openclacky 1.5.13 → 1.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
|
@@ -308,38 +308,69 @@ module Clacky
|
|
|
308
308
|
end
|
|
309
309
|
|
|
310
310
|
ext = mime_type.split(";").first.split("/").last.then { |e| e == "mpeg" ? "mp3" : e }
|
|
311
|
-
filename = "chunk.#{ext}"
|
|
312
|
-
audio_data = Base64.decode64(audio_base64)
|
|
313
|
-
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
|
|
314
|
-
# A multipart body is a byte stream: build it in binary so UTF-8 text
|
|
315
|
-
# parts (e.g. a non-ASCII vocabulary prompt) don't clash with the
|
|
316
|
-
# ASCII-8BIT audio bytes.
|
|
317
|
-
body = "".b
|
|
318
|
-
body << "--#{boundary}\r\n".b
|
|
319
|
-
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n".b
|
|
320
|
-
body << "Content-Type: #{mime_type.split(';').first}\r\n\r\n".b
|
|
321
|
-
body << audio_data.b
|
|
322
|
-
body << "\r\n--#{boundary}\r\n".b
|
|
323
|
-
body << "Content-Disposition: form-data; name=\"model\"\r\n\r\n".b
|
|
324
|
-
body << @model.to_s.b
|
|
325
|
-
unless prompt.to_s.strip.empty?
|
|
326
|
-
body << "\r\n--#{boundary}\r\n".b
|
|
327
|
-
body << "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n".b
|
|
328
|
-
body << prompt.to_s.strip.b
|
|
329
|
-
end
|
|
330
|
-
body << "\r\n--#{boundary}--\r\n".b
|
|
331
311
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
312
|
+
# OpenRouter caps multipart uploads at 25 MB and redirects larger files
|
|
313
|
+
# to base64 JSON via input_audio. Plain OpenAI-compatible providers keep
|
|
314
|
+
# multipart (incl. the openclacky gateway, which only parses multipart
|
|
315
|
+
# on its /audio/transcriptions today — enable the JSON flavor there once
|
|
316
|
+
# the server supports it).
|
|
317
|
+
if provider_id == Clacky::Providers::OPENROUTER_ID
|
|
318
|
+
ext = "m4a" if ext == "mp4" # OpenRouter wants the audio format, not the container
|
|
319
|
+
payload = {
|
|
320
|
+
model: @model,
|
|
321
|
+
input_audio: {
|
|
322
|
+
data: audio_base64, # raw base64 bytes, NOT a data URI
|
|
323
|
+
format: ext
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
payload[:prompt] = prompt.to_s.strip unless prompt.to_s.strip.empty?
|
|
327
|
+
|
|
328
|
+
begin
|
|
329
|
+
response = stt_json_connection.post("audio/transcriptions") do |req|
|
|
330
|
+
req.headers["Content-Type"] = "application/json"
|
|
331
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
332
|
+
req.body = JSON.generate(payload)
|
|
333
|
+
end
|
|
334
|
+
rescue Faraday::Error => e
|
|
335
|
+
return transcription_error_response(
|
|
336
|
+
error: "HTTP request failed: #{e.message}",
|
|
337
|
+
error_type: "network_error", provider: provider_id
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
else
|
|
341
|
+
filename = "chunk.#{ext}"
|
|
342
|
+
audio_data = Base64.decode64(audio_base64)
|
|
343
|
+
boundary = "----FormBoundary#{SecureRandom.hex(8)}"
|
|
344
|
+
# A multipart body is a byte stream: build it in binary so UTF-8 text
|
|
345
|
+
# parts (e.g. a non-ASCII vocabulary prompt) don't clash with the
|
|
346
|
+
# ASCII-8BIT audio bytes.
|
|
347
|
+
body = "".b
|
|
348
|
+
body << "--#{boundary}\r\n".b
|
|
349
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n".b
|
|
350
|
+
body << "Content-Type: #{mime_type.split(';').first}\r\n\r\n".b
|
|
351
|
+
body << audio_data.b
|
|
352
|
+
body << "\r\n--#{boundary}\r\n".b
|
|
353
|
+
body << "Content-Disposition: form-data; name=\"model\"\r\n\r\n".b
|
|
354
|
+
body << @model.to_s.b
|
|
355
|
+
unless prompt.to_s.strip.empty?
|
|
356
|
+
body << "\r\n--#{boundary}\r\n".b
|
|
357
|
+
body << "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n".b
|
|
358
|
+
body << prompt.to_s.strip.b
|
|
359
|
+
end
|
|
360
|
+
body << "\r\n--#{boundary}--\r\n".b
|
|
361
|
+
|
|
362
|
+
begin
|
|
363
|
+
response = stt_connection.post("audio/transcriptions") do |req|
|
|
364
|
+
req.headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
365
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
366
|
+
req.body = body
|
|
367
|
+
end
|
|
368
|
+
rescue Faraday::Error => e
|
|
369
|
+
return transcription_error_response(
|
|
370
|
+
error: "HTTP request failed: #{e.message}",
|
|
371
|
+
error_type: "network_error", provider: provider_id
|
|
372
|
+
)
|
|
337
373
|
end
|
|
338
|
-
rescue Faraday::Error => e
|
|
339
|
-
return transcription_error_response(
|
|
340
|
-
error: "HTTP request failed: #{e.message}",
|
|
341
|
-
error_type: "network_error", provider: provider_id
|
|
342
|
-
)
|
|
343
374
|
end
|
|
344
375
|
|
|
345
376
|
unless response.success?
|
|
@@ -479,6 +510,15 @@ module Clacky
|
|
|
479
510
|
end
|
|
480
511
|
end
|
|
481
512
|
|
|
513
|
+
# STT JSON body carries a large base64 payload; upstream may need longer
|
|
514
|
+
# to decode/transcribe it than the 30s multipart path.
|
|
515
|
+
private def stt_json_connection
|
|
516
|
+
Faraday.new(url: normalized_base_url) do |f|
|
|
517
|
+
f.options.timeout = 120
|
|
518
|
+
f.options.open_timeout = 10
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
482
522
|
private def vu_connection
|
|
483
523
|
Faraday.new(url: normalized_base_url) do |f|
|
|
484
524
|
f.options.timeout = 60
|
|
@@ -26,6 +26,8 @@ module Clacky
|
|
|
26
26
|
# the same Clacky proxy on a different endpoint. The *model prefix* is
|
|
27
27
|
# the source of truth for which upstream format the proxy expects:
|
|
28
28
|
#
|
|
29
|
+
# abs-gpt-* → OpenAI-compatible (POST /chat/completions) — GPT models
|
|
30
|
+
# on Bedrock use the OpenAI endpoint, not Converse
|
|
29
31
|
# abs-* → Bedrock Converse (POST /model/{id}/converse)
|
|
30
32
|
# dsk-* → OpenAI-compatible (POST /chat/completions)
|
|
31
33
|
# or-* → OpenAI-compatible (POST /chat/completions)
|
|
@@ -38,7 +40,10 @@ module Clacky
|
|
|
38
40
|
# will route correctly without touching this file.
|
|
39
41
|
def self.bedrock_api_key?(api_key, model)
|
|
40
42
|
return true if api_key.to_s.start_with?("ABSK")
|
|
41
|
-
model.to_s
|
|
43
|
+
model_str = model.to_s
|
|
44
|
+
# abs-gpt-* is served through Bedrock's OpenAI-compatible endpoint.
|
|
45
|
+
return false if model_str.start_with?("abs-gpt-")
|
|
46
|
+
model_str.start_with?("abs-")
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
module_function
|
data/lib/clacky/prompts/base.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
- Ask clarifying questions if requirements are unclear.
|
|
4
4
|
- Break down complex tasks into manageable steps.
|
|
5
5
|
- **USE TOOLS to create/modify files** — don't just return content.
|
|
6
|
-
- When the user asks to send/download a file or you generate one for them, append `[filename](file
|
|
6
|
+
- When the user asks to send/download a file or you generate one for them, append `[filename](file:///absolute/path/to/file)` at the end of your reply. Use the exact absolute path the write tool returned — don't rewrite or abbreviate it with `~` (keep non-ASCII characters and spaces literal); never URL-encode it.
|
|
7
7
|
|
|
8
8
|
## Tool Usage Rules
|
|
9
9
|
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -29,8 +29,12 @@ module Clacky
|
|
|
29
29
|
"name" => "OpenClacky",
|
|
30
30
|
"base_url" => "https://api.openclacky.com",
|
|
31
31
|
"api" => "bedrock",
|
|
32
|
-
"default_model" => "
|
|
32
|
+
"default_model" => "or-gemini-3-8-flash",
|
|
33
33
|
"models" => [
|
|
34
|
+
"abs-gpt-6-astra",
|
|
35
|
+
"abs-gpt-5.6-sol",
|
|
36
|
+
"abs-gpt-5.6-terra",
|
|
37
|
+
"abs-gpt-5.6-luna",
|
|
34
38
|
"abs-claude-fable-5-1",
|
|
35
39
|
"abs-claude-fable-5",
|
|
36
40
|
"abs-claude-opus-5",
|
|
@@ -41,6 +45,7 @@ module Clacky
|
|
|
41
45
|
"abs-claude-sonnet-4-6",
|
|
42
46
|
"abs-claude-sonnet-4-5",
|
|
43
47
|
"abs-claude-haiku-4-5",
|
|
48
|
+
"dsk-deepseek-flash",
|
|
44
49
|
"dsk-deepseek-v4-pro",
|
|
45
50
|
"dsk-deepseek-v4-flash",
|
|
46
51
|
"dsk-deepseek-v4-flash-vision-exp",
|
|
@@ -134,7 +139,7 @@ module Clacky
|
|
|
134
139
|
# Default OCR sidecar — used when the primary model is text-only.
|
|
135
140
|
# Candidates are derived from the provider's vision-capable models;
|
|
136
141
|
# this just picks the cheap+fast default to surface in "auto" mode.
|
|
137
|
-
"default_ocr_model" => "
|
|
142
|
+
"default_ocr_model" => "or-gemini-3-8-flash",
|
|
138
143
|
# Provider-level default: the Claude family served here is vision-capable.
|
|
139
144
|
"capabilities" => { "vision" => true }.freeze,
|
|
140
145
|
# Model-level overrides: DeepSeek models routed through this provider
|
|
@@ -143,10 +148,17 @@ module Clacky
|
|
|
143
148
|
# to disk refs. Gemini 3.1 Pro keeps the provider-default vision=true
|
|
144
149
|
# (it accepts image/audio/video input natively via OpenRouter).
|
|
145
150
|
"model_capabilities" => {
|
|
151
|
+
"dsk-deepseek-flash" => { "vision" => true }.freeze,
|
|
146
152
|
"dsk-deepseek-v4-pro" => { "vision" => false }.freeze,
|
|
147
153
|
"dsk-deepseek-v4-flash" => { "vision" => false }.freeze,
|
|
148
154
|
"dsk-deepseek-v4-flash-vision-exp" => { "vision" => true }.freeze
|
|
149
155
|
}.freeze,
|
|
156
|
+
# Bedrock GPT models (abs-gpt-*) are served through the OpenAI
|
|
157
|
+
# Responses API — their Chat Completions endpoint rejects function
|
|
158
|
+
# tools for reasoning models (GPT-6 Astra).
|
|
159
|
+
"model_api_overrides" => {
|
|
160
|
+
/\Aabs-gpt-/ => "openai-responses"
|
|
161
|
+
}.freeze,
|
|
150
162
|
# Per-primary lite pairing: keys are "strong" primary models, values
|
|
151
163
|
# are the lite sidekick to auto-inject when that primary is the
|
|
152
164
|
# default. Lite is consumed by some subagents for cheap/fast work;
|
|
@@ -163,6 +175,8 @@ module Clacky
|
|
|
163
175
|
"abs-claude-sonnet-5" => "abs-claude-haiku-4-5",
|
|
164
176
|
"abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
|
|
165
177
|
"abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
|
|
178
|
+
"abs-gpt-5.6-sol" => "abs-gpt-5.6-luna",
|
|
179
|
+
"abs-gpt-5.6-terra" => "abs-gpt-5.6-luna",
|
|
166
180
|
"dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
|
|
167
181
|
"or-gemini-3-1-pro" => "or-gemini-3-6-flash"
|
|
168
182
|
},
|
|
@@ -266,14 +280,17 @@ module Clacky
|
|
|
266
280
|
# deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
|
|
267
281
|
# and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
|
|
268
282
|
"models" => [
|
|
283
|
+
"deepseek-flash",
|
|
269
284
|
"deepseek-v4-pro",
|
|
270
285
|
"deepseek-v4-flash",
|
|
271
286
|
"deepseek-v4-flash-vision-exp",
|
|
272
287
|
],
|
|
273
288
|
# DeepSeek V4 API is text-only across all models, except the
|
|
274
|
-
# flash-vision-exp variant which accepts image input.
|
|
289
|
+
# flash-vision-exp variant which accepts image input. V4.1 Flash
|
|
290
|
+
# (deepseek-flash) is natively multimodal.
|
|
275
291
|
"capabilities" => { "vision" => false }.freeze,
|
|
276
292
|
"model_capabilities" => {
|
|
293
|
+
"deepseek-flash" => { "vision" => true }.freeze,
|
|
277
294
|
"deepseek-v4-flash-vision-exp" => { "vision" => true }.freeze
|
|
278
295
|
}.freeze,
|
|
279
296
|
"website_url" => "https://platform.deepseek.com/api_keys"
|
|
@@ -420,6 +437,7 @@ module Clacky
|
|
|
420
437
|
"api" => "openai-completions",
|
|
421
438
|
"default_model" => "gpt-5.5",
|
|
422
439
|
"models" => [
|
|
440
|
+
"gpt-6-astra",
|
|
423
441
|
"gpt-5.6-sol",
|
|
424
442
|
"gpt-5.6-terra",
|
|
425
443
|
"gpt-5.6-luna",
|
|
@@ -703,6 +721,23 @@ module Clacky
|
|
|
703
721
|
|
|
704
722
|
}.freeze
|
|
705
723
|
|
|
724
|
+
# Canonical provider identifiers. Mirror the PRESETS keys above; keep both
|
|
725
|
+
# in sync when adding or renaming a provider.
|
|
726
|
+
OPENCLACKY_ID = "openclacky"
|
|
727
|
+
OPENROUTER_ID = "openrouter"
|
|
728
|
+
DEEPSEEKV4_ID = "deepseekv4"
|
|
729
|
+
GLM_ID = "glm"
|
|
730
|
+
KIMI_ID = "kimi"
|
|
731
|
+
KIMI_CODING_ID = "kimi-coding"
|
|
732
|
+
MINIMAX_ID = "minimax"
|
|
733
|
+
ANTHROPIC_ID = "anthropic"
|
|
734
|
+
OPENAI_ID = "openai"
|
|
735
|
+
QWEN_ID = "qwen"
|
|
736
|
+
MIMO_ID = "mimo"
|
|
737
|
+
VOLCENGINE_ARK_ID = "volcengine-ark"
|
|
738
|
+
OLLAMA_ID = "ollama"
|
|
739
|
+
ORCAROUTER_ID = "orcarouter"
|
|
740
|
+
|
|
706
741
|
MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
|
|
707
742
|
|
|
708
743
|
# Per-model maximum output token limits.
|
|
@@ -496,7 +496,7 @@ module Clacky
|
|
|
496
496
|
when :add
|
|
497
497
|
new_model = show_model_edit_form(nil, test_callback: test_callback)
|
|
498
498
|
if new_model
|
|
499
|
-
anthropic_format = new_model[:provider] ==
|
|
499
|
+
anthropic_format = new_model[:provider] == Clacky::Providers::ANTHROPIC_ID
|
|
500
500
|
current_config.add_model(
|
|
501
501
|
model: new_model[:model],
|
|
502
502
|
api_key: new_model[:api_key],
|
data/lib/clacky/search_config.rb
CHANGED
|
@@ -6,10 +6,10 @@ require "fileutils"
|
|
|
6
6
|
module Clacky
|
|
7
7
|
# Search provider selection, backed by ~/.clacky/search.yml.
|
|
8
8
|
#
|
|
9
|
-
# The built-in DuckDuckGo/Bing
|
|
10
|
-
# default. Pointing `provider` at a script in ~/.clacky/searchers/ routes
|
|
9
|
+
# The built-in Parallel/DuckDuckGo/Bing providers need no configuration and
|
|
10
|
+
# stay the default. Pointing `provider` at a script in ~/.clacky/searchers/ routes
|
|
11
11
|
# web_search through an API-backed engine instead, which is what users who
|
|
12
|
-
# want
|
|
12
|
+
# want a provider-specific API or policy controls reach for.
|
|
13
13
|
#
|
|
14
14
|
# search.yml schema:
|
|
15
15
|
# provider: tavily — basename of a script in ~/.clacky/searchers/;
|
|
@@ -272,7 +272,7 @@ module Clacky
|
|
|
272
272
|
end
|
|
273
273
|
|
|
274
274
|
# Handle built-in commands
|
|
275
|
-
if text&.match?(KNOWN_COMMAND)
|
|
275
|
+
if text&.match?(KNOWN_COMMAND)
|
|
276
276
|
handle_command(adapter, event, text)
|
|
277
277
|
return
|
|
278
278
|
end
|
|
@@ -366,7 +366,7 @@ module Clacky
|
|
|
366
366
|
key = channel_key(event)
|
|
367
367
|
|
|
368
368
|
case text
|
|
369
|
-
when
|
|
369
|
+
when %r{\A/([??]|h|help)\z}i
|
|
370
370
|
adapter.send_text(chat_id, COMMAND_HELP)
|
|
371
371
|
|
|
372
372
|
when "/new", "/clear"
|
|
@@ -506,15 +506,18 @@ module Clacky
|
|
|
506
506
|
list_sessions(adapter, chat_id)
|
|
507
507
|
|
|
508
508
|
else
|
|
509
|
-
adapter.send_text(chat_id, "Unknown command. Type
|
|
509
|
+
adapter.send_text(chat_id, "Unknown command. Type /? for help.")
|
|
510
510
|
end
|
|
511
511
|
end
|
|
512
512
|
|
|
513
|
-
|
|
513
|
+
# "/?" is anchored with \z instead of \b because \b never matches after
|
|
514
|
+
# "?" (not a word character), which would let "/?extra" through.
|
|
515
|
+
# "?" is accepted too since Chinese IMEs produce the full-width form.
|
|
516
|
+
KNOWN_COMMAND = %r{\A/(?:[??]\z|(?:h|help|new|clear|model|skills|bind|stop|unbind|status|list)\b)}i
|
|
514
517
|
|
|
515
518
|
COMMAND_HELP = <<~HELP.strip
|
|
516
519
|
Commands:
|
|
517
|
-
|
|
520
|
+
/? / /h / /help - show this help
|
|
518
521
|
/new / /clear - start a new session
|
|
519
522
|
/model - show current model, cards & quick-switch list
|
|
520
523
|
/model <n> - switch card by number
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
|
|
5
|
+
require_relative "../utils/encoding"
|
|
6
|
+
|
|
5
7
|
module Clacky
|
|
6
8
|
module Server
|
|
7
9
|
# Read-mostly git operations scoped to a session's working directory, backing
|
|
@@ -14,9 +16,15 @@ module Clacky
|
|
|
14
16
|
|
|
15
17
|
# Run a git subcommand in `dir` with argv-style args (no shell). Returns
|
|
16
18
|
# [stdout, stderr, success_bool]. Never raises on git failure.
|
|
19
|
+
#
|
|
20
|
+
# `core.quotePath=false` keeps git from octal-escaping non-ASCII paths, so
|
|
21
|
+
# the parsers below (and the path comparisons in `restore`) see the same
|
|
22
|
+
# bytes the caller passed in. Output is then normalised to UTF-8 because
|
|
23
|
+
# git inherits the locale, and under a non-UTF-8 locale the raw bytes come
|
|
24
|
+
# back tagged US-ASCII, where String#split would raise on multibyte paths.
|
|
17
25
|
def git(dir, *args)
|
|
18
|
-
out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
|
|
19
|
-
[out, err, status.success?]
|
|
26
|
+
out, err, status = Open3.capture3("git", "-C", dir.to_s, "-c", "core.quotePath=false", *args)
|
|
27
|
+
[Utils::Encoding.to_utf8(out), Utils::Encoding.to_utf8(err), status.success?]
|
|
20
28
|
rescue StandardError => e
|
|
21
29
|
["", e.message, false]
|
|
22
30
|
end
|
|
@@ -20,6 +20,7 @@ require_relative "session_registry"
|
|
|
20
20
|
require_relative "project_manager"
|
|
21
21
|
require_relative "git_panel"
|
|
22
22
|
require_relative "dir_picker"
|
|
23
|
+
require_relative "preview"
|
|
23
24
|
require_relative "web_ui_controller"
|
|
24
25
|
require_relative "model_prices"
|
|
25
26
|
require_relative "scheduler"
|
|
@@ -102,7 +103,7 @@ module Clacky
|
|
|
102
103
|
|
|
103
104
|
# Rewrite local image paths to /api/local-image proxy URLs for browser rendering
|
|
104
105
|
rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
105
|
-
ev = { type: "assistant_message", session_id: @session_id, content: rewritten }
|
|
106
|
+
ev = { type: "assistant_message", session_id: @session_id, content: rewritten, interim: interim }
|
|
106
107
|
ev[:created_at] = created_at if created_at
|
|
107
108
|
@events << stamp(ev)
|
|
108
109
|
end
|
|
@@ -168,6 +169,7 @@ module Clacky
|
|
|
168
169
|
# GET /** → static files served from lib/clacky/web/ directory
|
|
169
170
|
class HttpServer
|
|
170
171
|
include DirPicker
|
|
172
|
+
include Preview
|
|
171
173
|
|
|
172
174
|
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
173
175
|
# How long shutdown waits for each agent thread to unwind before falling
|
|
@@ -496,6 +498,12 @@ module Clacky
|
|
|
496
498
|
self.send(:serve_agent_avatar, req, res)
|
|
497
499
|
elsif req.path.start_with?("/ext_ui/")
|
|
498
500
|
self.send(:serve_ext_ui, req, res)
|
|
501
|
+
elsif req.path.start_with?("/preview/p/")
|
|
502
|
+
self.send(:serve_preview_proxy, req, res)
|
|
503
|
+
elsif req.path.start_with?("/preview/f/")
|
|
504
|
+
self.send(:serve_preview_local, req, res)
|
|
505
|
+
elsif req.path == "/preview" || req.path.start_with?("/preview/")
|
|
506
|
+
self.send(:serve_preview, req, res)
|
|
499
507
|
else
|
|
500
508
|
file_handler.service(req, res)
|
|
501
509
|
res["Cache-Control"] = "no-store"
|
|
@@ -722,6 +730,9 @@ module Clacky
|
|
|
722
730
|
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
723
731
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
724
732
|
when ["POST", "/api/file-action"] then api_file_action(req, res)
|
|
733
|
+
when ["GET", "/api/file/apps"] then api_file_apps(req, res)
|
|
734
|
+
when ["GET", "/api/file/default-app"] then api_file_default_app(req, res)
|
|
735
|
+
when ["GET", "/api/app-icon"] then api_app_icon(req, res)
|
|
725
736
|
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
726
737
|
when ["POST", "/api/media/image"] then api_media_image(req, res)
|
|
727
738
|
when ["POST", "/api/media/video"] then api_media_video(req, res)
|
|
@@ -3471,7 +3482,7 @@ module Clacky
|
|
|
3471
3482
|
keys = []
|
|
3472
3483
|
@agent_config.models.each do |m|
|
|
3473
3484
|
next unless m.is_a?(Hash)
|
|
3474
|
-
next unless @agent_config.provider_id_for(m) ==
|
|
3485
|
+
next unless @agent_config.provider_id_for(m) == Clacky::Providers::OPENCLACKY_ID
|
|
3475
3486
|
|
|
3476
3487
|
key = m["api_key"].to_s.strip
|
|
3477
3488
|
keys << key unless key.empty?
|
|
@@ -4423,12 +4434,87 @@ module Clacky
|
|
|
4423
4434
|
json_response(res, 500, { ok: false, error: e.message })
|
|
4424
4435
|
end
|
|
4425
4436
|
|
|
4437
|
+
# GET /api/file/apps?path=/path/to/file.pptx
|
|
4438
|
+
# Lists installed applications that declare support for the file's
|
|
4439
|
+
# extension, so the Web UI can offer "open with" choices for formats it
|
|
4440
|
+
# cannot preview inline. macOS uses MacAppDetector, WSL uses
|
|
4441
|
+
# WindowsAppDetector; other OSes answer with an empty list.
|
|
4442
|
+
def api_file_apps(req, res)
|
|
4443
|
+
raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
4444
|
+
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
4445
|
+
|
|
4446
|
+
path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
|
|
4447
|
+
ext = File.extname(path).downcase.sub(/\A\./, "")
|
|
4448
|
+
|
|
4449
|
+
apps = if ext.empty?
|
|
4450
|
+
[]
|
|
4451
|
+
elsif Utils::EnvironmentDetector.os_type == :wsl
|
|
4452
|
+
Utils::WindowsAppDetector.apps_for_ext(ext)
|
|
4453
|
+
else
|
|
4454
|
+
Utils::MacAppDetector.apps_for_ext(ext)
|
|
4455
|
+
end
|
|
4456
|
+
json_response(res, 200, { ok: true, apps: apps })
|
|
4457
|
+
rescue => e
|
|
4458
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
4459
|
+
end
|
|
4460
|
+
|
|
4461
|
+
# GET /api/file/default-app?path=...
|
|
4462
|
+
# Returns the application the OS would use to open the file.
|
|
4463
|
+
def api_file_default_app(req, res)
|
|
4464
|
+
raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
4465
|
+
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
4466
|
+
|
|
4467
|
+
path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
|
|
4468
|
+
app = if File.extname(path).empty?
|
|
4469
|
+
nil
|
|
4470
|
+
elsif Utils::EnvironmentDetector.os_type == :wsl
|
|
4471
|
+
Utils::WindowsAppDetector.default_app_for(path)
|
|
4472
|
+
else
|
|
4473
|
+
Utils::MacAppDetector.default_app_for(path)
|
|
4474
|
+
end
|
|
4475
|
+
json_response(res, 200, { ok: true, app: app })
|
|
4476
|
+
rescue => e
|
|
4477
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
4478
|
+
end
|
|
4479
|
+
|
|
4480
|
+
# GET /api/app-icon?path=/Applications/Keynote.app (macOS)
|
|
4481
|
+
# GET /api/app-icon?path=C:\...\app.exe (WSL)
|
|
4482
|
+
# Serves an application's icon as a PNG. macOS converts the bundle .icns
|
|
4483
|
+
# via sips; WSL extracts the .exe associated icon via PowerShell. Both are
|
|
4484
|
+
# cached on disk.
|
|
4485
|
+
def api_app_icon(req, res)
|
|
4486
|
+
raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
4487
|
+
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
4488
|
+
|
|
4489
|
+
png = if Utils::EnvironmentDetector.os_type == :wsl
|
|
4490
|
+
Utils::WindowsAppDetector.icon_png(raw_path)
|
|
4491
|
+
else
|
|
4492
|
+
Utils::MacAppDetector.icon_png(Utils::EnvironmentDetector.resolve_local_path(raw_path))
|
|
4493
|
+
end
|
|
4494
|
+
unless png
|
|
4495
|
+
# Never let a failed lookup stick in the browser cache — it would keep
|
|
4496
|
+
# serving a 404 even after the detector is fixed/updated.
|
|
4497
|
+
res["Cache-Control"] = "no-store"
|
|
4498
|
+
return json_response(res, 404, { error: "icon not found" })
|
|
4499
|
+
end
|
|
4500
|
+
|
|
4501
|
+
res.status = 200
|
|
4502
|
+
res["Content-Type"] = "image/png"
|
|
4503
|
+
res["Cache-Control"] = "public, max-age=604800"
|
|
4504
|
+
res["Content-Length"] = File.size(png).to_s
|
|
4505
|
+
res.body = File.binread(png)
|
|
4506
|
+
rescue => e
|
|
4507
|
+
json_response(res, 500, { error: e.message })
|
|
4508
|
+
end
|
|
4509
|
+
|
|
4426
4510
|
# POST /api/file-action
|
|
4427
4511
|
# Unified file action endpoint — open locally or download.
|
|
4428
|
-
# Body: { path: String, action: "open" | "download" | "save" }
|
|
4429
|
-
# open:
|
|
4430
|
-
#
|
|
4431
|
-
#
|
|
4512
|
+
# Body: { path: String, action: "open" | "open_with" | "download" | "save" }
|
|
4513
|
+
# open: opens the file with the OS default handler (local deployments).
|
|
4514
|
+
# open_with: opens the file with the app named in body.app (the app must
|
|
4515
|
+
# be one returned by /api/file/apps).
|
|
4516
|
+
# download: returns the file as a download (remote deployments).
|
|
4517
|
+
# save: writes content back to the file. Body must include { content: String }.
|
|
4432
4518
|
def api_file_action(req, res)
|
|
4433
4519
|
body = parse_json_body(req)
|
|
4434
4520
|
path = body["path"]
|
|
@@ -4452,6 +4538,15 @@ module Clacky
|
|
|
4452
4538
|
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
4453
4539
|
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
4454
4540
|
json_response(res, 200, { ok: true })
|
|
4541
|
+
when "open_with"
|
|
4542
|
+
result = if Utils::EnvironmentDetector.os_type == :wsl
|
|
4543
|
+
Utils::WindowsAppDetector.open_with(linux_path, body["app"].to_s)
|
|
4544
|
+
else
|
|
4545
|
+
Utils::MacAppDetector.open_with(linux_path, body["app"].to_s)
|
|
4546
|
+
end
|
|
4547
|
+
return json_response(res, 400, { error: "unknown application" }) if result.nil?
|
|
4548
|
+
return json_response(res, 500, { error: "failed to open with application" }) unless result
|
|
4549
|
+
json_response(res, 200, { ok: true })
|
|
4455
4550
|
when "reveal"
|
|
4456
4551
|
Utils::EnvironmentDetector.reveal_file(linux_path)
|
|
4457
4552
|
json_response(res, 200, { ok: true })
|
|
@@ -4467,7 +4562,7 @@ module Clacky
|
|
|
4467
4562
|
File.write(linux_path, content, encoding: "UTF-8")
|
|
4468
4563
|
json_response(res, 200, { ok: true })
|
|
4469
4564
|
else
|
|
4470
|
-
json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download', 'display-path' or 'save'" })
|
|
4565
|
+
json_response(res, 400, { error: "invalid action. Must be 'open', 'open_with', 'reveal', 'download', 'display-path' or 'save'" })
|
|
4471
4566
|
end
|
|
4472
4567
|
rescue => e
|
|
4473
4568
|
json_response(res, 500, { ok: false, error: e.message })
|
|
@@ -5226,7 +5321,10 @@ module Clacky
|
|
|
5226
5321
|
next unless File.exist?(full)
|
|
5227
5322
|
{
|
|
5228
5323
|
name: name,
|
|
5229
|
-
|
|
5324
|
+
# Root-level entries must NOT get a leading "/" — the client treats
|
|
5325
|
+
# slash-prefixed paths as absolute and passes them to file-action
|
|
5326
|
+
# verbatim, which then fails to find "<root>/<name>".
|
|
5327
|
+
path: rel.empty? ? name : "#{rel}/#{name}".gsub(%r{/+}, "/"),
|
|
5230
5328
|
type: is_dir ? "dir" : "file",
|
|
5231
5329
|
size: is_dir ? nil : (File.size(full) rescue nil)
|
|
5232
5330
|
}
|
|
@@ -5358,11 +5456,22 @@ module Clacky
|
|
|
5358
5456
|
return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
|
|
5359
5457
|
end
|
|
5360
5458
|
|
|
5459
|
+
raw = File.read(skill_md)
|
|
5460
|
+
frontmatter_match = raw.match(/\A---\n.*?\n---[ \t]*\n?/m)
|
|
5461
|
+
body = frontmatter_match ? raw[frontmatter_match.end(0)..].sub(/\A\n+/, "") : raw
|
|
5462
|
+
|
|
5361
5463
|
json_response(res, 200, {
|
|
5362
5464
|
ok: true,
|
|
5363
5465
|
name: skill.identifier,
|
|
5364
|
-
content:
|
|
5365
|
-
path: skill_md
|
|
5466
|
+
content: raw,
|
|
5467
|
+
path: skill_md,
|
|
5468
|
+
fields: {
|
|
5469
|
+
name: skill.identifier,
|
|
5470
|
+
name_zh: skill.name_zh.to_s,
|
|
5471
|
+
description: skill.description.to_s,
|
|
5472
|
+
description_zh: skill.description_zh.to_s,
|
|
5473
|
+
body: body
|
|
5474
|
+
}
|
|
5366
5475
|
})
|
|
5367
5476
|
end
|
|
5368
5477
|
|
|
@@ -5375,13 +5484,19 @@ module Clacky
|
|
|
5375
5484
|
return json_response(res, 403, { ok: false, error: "System skills cannot be edited" })
|
|
5376
5485
|
end
|
|
5377
5486
|
|
|
5378
|
-
data
|
|
5379
|
-
content = data["content"].to_s
|
|
5487
|
+
data = parse_json_body(req)
|
|
5380
5488
|
skill_md = File.join(skill.directory.to_s, "SKILL.md")
|
|
5381
5489
|
unless File.exist?(skill_md)
|
|
5382
5490
|
return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
|
|
5383
5491
|
end
|
|
5384
5492
|
|
|
5493
|
+
fields = data["fields"]
|
|
5494
|
+
content = if fields.is_a?(Hash)
|
|
5495
|
+
Clacky::Skill.update_frontmatter_fields(File.read(skill_md), fields)
|
|
5496
|
+
else
|
|
5497
|
+
data["content"].to_s
|
|
5498
|
+
end
|
|
5499
|
+
|
|
5385
5500
|
File.write(skill_md, content)
|
|
5386
5501
|
@skill_loader.load_all
|
|
5387
5502
|
json_response(res, 200, { ok: true, name: skill.identifier })
|
|
@@ -6761,15 +6876,47 @@ module Clacky
|
|
|
6761
6876
|
|
|
6762
6877
|
unless agent
|
|
6763
6878
|
Clacky::Logger.warn("[messages] agent is nil", session_id: session_id)
|
|
6879
|
+
if query["navigation"] == "1"
|
|
6880
|
+
return json_response(res, 200, { sources: [], total: 0 })
|
|
6881
|
+
end
|
|
6882
|
+
if query["previews"]
|
|
6883
|
+
return json_response(res, 200, { previews: [] })
|
|
6884
|
+
end
|
|
6885
|
+
if query["preview"]
|
|
6886
|
+
return json_response(res, 409, { error: "History location is no longer available" })
|
|
6887
|
+
end
|
|
6764
6888
|
return json_response(res, 200, { events: [], has_more: false })
|
|
6765
6889
|
end
|
|
6766
6890
|
|
|
6767
6891
|
# Collect events emitted by replay_history via a lightweight collector UI
|
|
6892
|
+
if query["navigation"] == "1"
|
|
6893
|
+
return json_response(res, 200, agent.history_navigation)
|
|
6894
|
+
end
|
|
6895
|
+
if query["previews"]
|
|
6896
|
+
ids = JSON.parse(query["previews"])
|
|
6897
|
+
raise ArgumentError, "History locations must be an array" unless ids.is_a?(Array)
|
|
6898
|
+
|
|
6899
|
+
return json_response(res, 200, { previews: agent.history_navigation_previews(ids: ids) })
|
|
6900
|
+
end
|
|
6901
|
+
if query["preview"]
|
|
6902
|
+
return json_response(res, 200, agent.history_navigation_preview(id: query["preview"]))
|
|
6903
|
+
end
|
|
6768
6904
|
collected = []
|
|
6769
6905
|
collector = HistoryCollector.new(session_id, collected)
|
|
6770
|
-
|
|
6906
|
+
if query["window"] == "1"
|
|
6907
|
+
result = agent.replay_history_window(collector, limit: limit, around: query["around"],
|
|
6908
|
+
before_id: query["before_id"], after_id: query["after_id"])
|
|
6909
|
+
ids = result.delete(:round_ids)
|
|
6910
|
+
collected.select { |event| event[:type] == "history_user_message" }.each_with_index do |event, index|
|
|
6911
|
+
event[:round_id] = ids[index]
|
|
6912
|
+
end
|
|
6913
|
+
else
|
|
6914
|
+
result = agent.replay_history(collector, limit: limit, before: before)
|
|
6915
|
+
end
|
|
6771
6916
|
|
|
6772
|
-
json_response(res, 200, { events: collected
|
|
6917
|
+
json_response(res, 200, { events: collected }.merge(result))
|
|
6918
|
+
rescue ArgumentError, JSON::ParserError => e
|
|
6919
|
+
json_response(res, 409, { error: e.message })
|
|
6773
6920
|
end
|
|
6774
6921
|
|
|
6775
6922
|
# ── Project API ───────────────────────────────────────────────────────────
|