openclacky 1.5.19 → 1.5.20
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 +46 -0
- data/README.md +1 -1
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- data/lib/clacky/agent/llm_caller.rb +16 -3
- data/lib/clacky/agent/message_compressor_helper.rb +8 -0
- data/lib/clacky/agent/session_serializer.rb +25 -2
- data/lib/clacky/agent.rb +100 -33
- data/lib/clacky/billing/platform_billing.rb +6 -5
- data/lib/clacky/cli.rb +2 -2
- data/lib/clacky/client.rb +7 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +77 -20
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/locales/en.rb +1 -1
- data/lib/clacky/locales/zh.rb +1 -1
- data/lib/clacky/message_history.rb +24 -1
- data/lib/clacky/providers.rb +17 -126
- data/lib/clacky/server/http_server.rb +120 -35
- data/lib/clacky/server/model_prices.rb +6 -3
- data/lib/clacky/server/session_registry.rb +21 -3
- data/lib/clacky/server/web_ui_controller.rb +49 -0
- data/lib/clacky/session_manager.rb +21 -5
- data/lib/clacky/utils/model_pricing.rb +114 -30
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +795 -147
- data/lib/clacky/web/app.js +82 -13
- data/lib/clacky/web/components/code-editor.js +6 -2
- data/lib/clacky/web/components/model-picker.js +89 -96
- data/lib/clacky/web/components/sidebar.js +6 -2
- data/lib/clacky/web/features/archive/store.js +182 -0
- data/lib/clacky/web/features/archive/view.js +399 -0
- data/lib/clacky/web/features/backup/view.js +12 -2
- data/lib/clacky/web/features/billing/view.js +84 -10
- data/lib/clacky/web/features/channels/view.js +75 -29
- data/lib/clacky/web/features/new-session/view.js +1 -0
- data/lib/clacky/web/features/trash/store.js +4 -4
- data/lib/clacky/web/features/trash/view.js +57 -9
- data/lib/clacky/web/features/workspace/view.js +421 -16
- data/lib/clacky/web/i18n.js +140 -44
- data/lib/clacky/web/index.html +209 -86
- data/lib/clacky/web/sessions.js +366 -137
- data/lib/clacky/web/settings.js +43 -24
- data/lib/clacky/web/ws-dispatcher.js +24 -8
- data/lib/clacky.rb +3 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a87f3bff7a9f13d77ae6ae11ccc67a67a6429d596d922309e3f3847469b240f4
|
|
4
|
+
data.tar.gz: 6f7043a63e27dfb922bac4e586ec19c6d0659efb75ba9ef7e1b522b1a16b9410
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 23cc0c1d6902579a09f2cb3c9b9f692c61dcb485daca62ff226ea6fa1c165afa141fbee8ee51de24e97ec4d7060bc872988c6f8b785575180f821a52e68acc26
|
|
7
|
+
data.tar.gz: d198a4ed2d6887094fe8f90fc1d13a429cd06008f47859acb7b334804d5d8dfc727f31dba6486ae02d11613bfd0dbe2f0004c83090a8e7f457d31891b4e29fae
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.5.20] - 2026-10-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Add a data management page with session archiving and a recycle bin
|
|
11
|
+
- Add a tab context menu and reopen tabs after a reload
|
|
12
|
+
- Preview audio and video files in the workspace (#634)
|
|
13
|
+
- Preview pending image attachments before sending (#629)
|
|
14
|
+
- Zoom and pan in the viewer's image preview
|
|
15
|
+
- Move the todo list into a session bar popover (#619)
|
|
16
|
+
- Add opt-in per-task feedback countdowns (#633 - @no7dw)
|
|
17
|
+
- Name a session automatically from its first message
|
|
18
|
+
- Show the active session title in the top header
|
|
19
|
+
- Show the QQ bind QR inline with expiry refresh and a sticky success state (#621)
|
|
20
|
+
- Add the oc-grok-4.7, abs-claude-haiku-5-5 and or-gemini-4-argon aliases
|
|
21
|
+
|
|
22
|
+
### Improved
|
|
23
|
+
- Lead model picker rows with their provider
|
|
24
|
+
- Retune the model series discounts and say where they come from
|
|
25
|
+
- Point the openclacky lite tier at claude-haiku-5-5
|
|
26
|
+
- Soften the segmented control hover
|
|
27
|
+
- Tidy the top-up label and the model row spacing
|
|
28
|
+
- Put the model remark under the API key and align it left
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- Pair replayed tool results with their own calls so a refreshed session no longer strands them in the wrong tool item (#640)
|
|
32
|
+
- Keep the assistant timestamp when a session is archived and replayed from chunk history (#639 - @BoyYangzai)
|
|
33
|
+
- Stop dropping attachment messages while a task is being processed (#628)
|
|
34
|
+
- Handle a missing workspace path in the directory picker (#623)
|
|
35
|
+
- Restore the settings tab after switching sessions (#626)
|
|
36
|
+
- Send model picker actions to the right settings tab (#624)
|
|
37
|
+
- Stop session action buttons from squeezing the session title (#625)
|
|
38
|
+
- Improve the mobile billing chart usability (#627)
|
|
39
|
+
- Stop truncating the navigator preview too early (#632)
|
|
40
|
+
- Refresh the Files tree after restoring a task from the timeline (#638)
|
|
41
|
+
- Checkpoint the latest task after a session reload (#637)
|
|
42
|
+
- Localize screenshot upload errors and retry after binding a device (#636)
|
|
43
|
+
- Persist run errors in the session history
|
|
44
|
+
- Stop pinning an auto-routed task to a dead lane when a failure carries no tier
|
|
45
|
+
- Reword network failures and link them to the key console
|
|
46
|
+
- Align the sidebar toggle buttons and unify their icons at 16px (#622)
|
|
47
|
+
- Fade the collapsed aside opener icon
|
|
48
|
+
|
|
49
|
+
### More
|
|
50
|
+
- Retire gemini-3.7-flash and drop the oc-minimax-m2.7 alias from the model picker
|
|
51
|
+
- Remove the OrcaRouter and Requesty provider presets
|
|
52
|
+
|
|
7
53
|
## [1.5.19] - 2026-10-10
|
|
8
54
|
|
|
9
55
|
### Added
|
data/README.md
CHANGED
|
@@ -205,7 +205,7 @@ $ openclacky
|
|
|
205
205
|
|
|
206
206
|
Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provider).
|
|
207
207
|
|
|
208
|
-
Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter
|
|
208
|
+
Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — or any custom endpoint.
|
|
209
209
|
|
|
210
210
|
## Coding use case
|
|
211
211
|
|
data/README_CN.md
CHANGED
|
@@ -189,7 +189,7 @@ $ openclacky
|
|
|
189
189
|
|
|
190
190
|
设置你的 **API Key**、**模型**和 **Base URL**(任意 OpenAI 兼容提供商)。
|
|
191
191
|
|
|
192
|
-
开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter
|
|
192
|
+
开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter**,或任意自定义端点。
|
|
193
193
|
|
|
194
194
|
## 代码开发场景
|
|
195
195
|
|
data/README_JA.md
CHANGED
|
@@ -194,7 +194,7 @@ $ openclacky
|
|
|
194
194
|
|
|
195
195
|
**API Key**、**Model**、**Base URL**(任意の OpenAI 互換プロバイダー)を設定します。
|
|
196
196
|
|
|
197
|
-
標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter
|
|
197
|
+
標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — または任意のカスタムエンドポイント。
|
|
198
198
|
|
|
199
199
|
## コーディングのユースケース
|
|
200
200
|
|
|
@@ -56,8 +56,15 @@ module Clacky
|
|
|
56
56
|
retries = 0
|
|
57
57
|
# Per-call tier attribution for auto routing: each retryable failure is
|
|
58
58
|
# charged to the tier the gateway actually routed that call to (echoed
|
|
59
|
-
# in X-Clacky-Routed-Tier).
|
|
60
|
-
#
|
|
59
|
+
# in X-Clacky-Routed-Tier). A tier-less failure is charged to the floor
|
|
60
|
+
# when the call went out as "auto": the gateway only stamps the tier
|
|
61
|
+
# header after it has routed the call, so a missing tier means the
|
|
62
|
+
# failure happened before routing (HTML error page, truncated body,
|
|
63
|
+
# rejection at the auth layer) — and the gateway defaults to the floor
|
|
64
|
+
# lane in exactly that case. Leaving it uncharged would keep
|
|
65
|
+
# agent_upstream_fails at 0 and pin the task to a dead floor lane until
|
|
66
|
+
# its retry budget ran out. Non-auto calls and unknown tier values are
|
|
67
|
+
# attributed to neither lane.
|
|
61
68
|
floor_fails = 0
|
|
62
69
|
upgrade_fails = 0
|
|
63
70
|
|
|
@@ -247,7 +254,7 @@ module Clacky
|
|
|
247
254
|
else
|
|
248
255
|
# Don't show_error here — let the outer rescue block handle it to avoid duplicates.
|
|
249
256
|
# Progress cleanup is the caller's responsibility (via its own ensure block).
|
|
250
|
-
raise AgentError.new("#{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message)
|
|
257
|
+
raise AgentError.new("#{I18n.t("llm.error.network_failed", retries: max_retries)}", raw_message: e.message, action_url: @client.account_url)
|
|
251
258
|
end
|
|
252
259
|
|
|
253
260
|
rescue RetryableError => e
|
|
@@ -257,6 +264,12 @@ module Clacky
|
|
|
257
264
|
case e.routed_tier
|
|
258
265
|
when "floor" then floor_fails += 1
|
|
259
266
|
when "upgrade" then upgrade_fails += 1
|
|
267
|
+
when nil then floor_fails += 1 if api_call_model == "auto"
|
|
268
|
+
else
|
|
269
|
+
if defined?(Clacky::Logger)
|
|
270
|
+
Clacky::Logger.warn("llm_caller.unknown_routed_tier",
|
|
271
|
+
tier: e.routed_tier, model: api_call_model)
|
|
272
|
+
end
|
|
260
273
|
end
|
|
261
274
|
|
|
262
275
|
# Probing failure: primary still down — renew cooling-off and retry with fallback.
|
|
@@ -641,6 +641,7 @@ module Clacky
|
|
|
641
641
|
lines << "## Assistant"
|
|
642
642
|
end
|
|
643
643
|
lines << ""
|
|
644
|
+
append_created_at_line(lines, msg)
|
|
644
645
|
# Include tool calls summary if present
|
|
645
646
|
# Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
|
|
646
647
|
if msg[:tool_calls]&.any?
|
|
@@ -733,6 +734,13 @@ module Clacky
|
|
|
733
734
|
lines << ""
|
|
734
735
|
end
|
|
735
736
|
|
|
737
|
+
def append_created_at_line(lines, msg)
|
|
738
|
+
return unless msg[:created_at].is_a?(Numeric)
|
|
739
|
+
|
|
740
|
+
lines << "_Created at: #{msg[:created_at]}_"
|
|
741
|
+
lines << ""
|
|
742
|
+
end
|
|
743
|
+
|
|
736
744
|
# Serialize persisted extension events into the chunk MD so they survive
|
|
737
745
|
# compression and can be replayed in place. Mirrors the "_Tool calls:"
|
|
738
746
|
# convention; parse_chunk_md_to_rounds reads it back.
|
|
@@ -17,6 +17,7 @@ module Clacky
|
|
|
17
17
|
@session_id = session_data[:session_id]
|
|
18
18
|
@name = session_data[:name] || ""
|
|
19
19
|
@pinned = session_data[:pinned] || false
|
|
20
|
+
@archived_at = session_data[:archived_at]
|
|
20
21
|
@history = MessageHistory.new(session_data[:messages] || [])
|
|
21
22
|
@input_queue = (session_data[:pending_inputs] || []).map do |entry|
|
|
22
23
|
entry.reject { |key, _| key == :steer_target }.merge(delivery: "queue")
|
|
@@ -75,6 +76,9 @@ module Clacky
|
|
|
75
76
|
@task_parents = raw_parents.each_with_object({}) { |(k, v), h| h[k.to_s.to_i] = v.to_i }
|
|
76
77
|
@current_task_id = (session_data.dig(:time_machine, :current_task_id) || 0).to_i
|
|
77
78
|
@active_task_id = (session_data.dig(:time_machine, :active_task_id) || 0).to_i
|
|
79
|
+
# Not persisted: assume the latest task's AFTER may be stale so the next
|
|
80
|
+
# task start re-checkpoints it instead of skipping it.
|
|
81
|
+
@latest_after_dirty = true
|
|
78
82
|
|
|
79
83
|
raw_meta = session_data.dig(:time_machine, :task_meta) || {}
|
|
80
84
|
@task_meta = raw_meta.each_with_object({}) do |(k, v), h|
|
|
@@ -99,7 +103,20 @@ module Clacky
|
|
|
99
103
|
last_error = session_data.dig(:stats, :last_error)
|
|
100
104
|
|
|
101
105
|
if last_status == "error" && last_error
|
|
102
|
-
|
|
106
|
+
real_users = @history.real_user_messages
|
|
107
|
+
task_tagged_history = real_users.any? { |message| !message[:task_id].nil? }
|
|
108
|
+
failed_turn_persisted = real_users.any? do |message|
|
|
109
|
+
message[:task_id].to_s == @current_task_id.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Modern sessions identify each user turn by task_id. Only schedule
|
|
113
|
+
# the legacy deferred rollback when the failed task's user message
|
|
114
|
+
# actually survived in history. Attachment setup can now roll its
|
|
115
|
+
# staged turn back before the error snapshot is saved; blindly
|
|
116
|
+
# removing the last user message after restore would then delete the
|
|
117
|
+
# preceding successful turn. Sessions without task ids keep the old
|
|
118
|
+
# fallback for backward compatibility.
|
|
119
|
+
@pending_error_rollback = true if !task_tagged_history || failed_turn_persisted
|
|
103
120
|
end
|
|
104
121
|
|
|
105
122
|
saved_reasoning = session_data.dig(:config, :reasoning_effort)
|
|
@@ -244,6 +261,9 @@ module Clacky
|
|
|
244
261
|
session_id: @session_id,
|
|
245
262
|
name: @name,
|
|
246
263
|
pinned: @pinned,
|
|
264
|
+
# Kept next to the other scalars so SessionManager's header-only
|
|
265
|
+
# reader can pick it up without parsing the message payload.
|
|
266
|
+
archived_at: @archived_at,
|
|
247
267
|
created_at: @created_at,
|
|
248
268
|
updated_at: stamp,
|
|
249
269
|
working_dir: @working_dir,
|
|
@@ -543,12 +563,15 @@ module Clacky
|
|
|
543
563
|
# Old: "_Tool calls: name1, name2_" (backward compat)
|
|
544
564
|
remaining_lines = []
|
|
545
565
|
pending_tool_entries = [] # [{name:, args:}]
|
|
566
|
+
created_at = nil
|
|
546
567
|
|
|
547
568
|
text.each_line do |line|
|
|
548
569
|
stripped = line.strip
|
|
549
570
|
if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
|
|
550
571
|
raw = m[1]
|
|
551
572
|
pending_tool_entries.concat(parse_tool_calls_line(raw))
|
|
573
|
+
elsif (m = stripped.match(/\A_Created at: (\d+(?:\.\d+)?)_\z/))
|
|
574
|
+
created_at = m[1].to_f
|
|
552
575
|
else
|
|
553
576
|
remaining_lines << line
|
|
554
577
|
end
|
|
@@ -557,7 +580,7 @@ module Clacky
|
|
|
557
580
|
# Flush any plain text
|
|
558
581
|
plain_text = remaining_lines.join.strip
|
|
559
582
|
unless plain_text.empty?
|
|
560
|
-
current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty
|
|
583
|
+
current_round[:events] << { role: "assistant", content: plain_text, interim: !pending_tool_entries.empty?, created_at: created_at }
|
|
561
584
|
end
|
|
562
585
|
|
|
563
586
|
# Emit one synthetic tool_calls message per detected tool
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -52,6 +52,9 @@ module Clacky
|
|
|
52
52
|
attr_accessor :pinned
|
|
53
53
|
attr_accessor :channel_info
|
|
54
54
|
attr_accessor :project_id
|
|
55
|
+
# ISO8601 stamp set when the user archives the session; nil = active.
|
|
56
|
+
# Archived sessions stay on disk but drop out of every normal list.
|
|
57
|
+
attr_accessor :archived_at
|
|
55
58
|
|
|
56
59
|
REASONING_EFFORTS = %w[low medium high xhigh max].freeze
|
|
57
60
|
MAX_VIDEO_UNDERSTANDING_BYTES = Utils::FileProcessor::MAX_FILE_BYTES
|
|
@@ -93,6 +96,7 @@ module Clacky
|
|
|
93
96
|
@session_id = session_id
|
|
94
97
|
@name = ""
|
|
95
98
|
@pinned = false
|
|
99
|
+
@archived_at = nil
|
|
96
100
|
@history = MessageHistory.new
|
|
97
101
|
@todos = [] # Store todos in memory
|
|
98
102
|
@iterations = 0
|
|
@@ -329,6 +333,14 @@ module Clacky
|
|
|
329
333
|
self
|
|
330
334
|
end
|
|
331
335
|
|
|
336
|
+
# Built-in run failures share the persisted event trail, without exposing
|
|
337
|
+
# built-in event types through the extension API.
|
|
338
|
+
def record_error_event(message, code: nil, top_up_url: nil, raw_message: nil)
|
|
339
|
+
data = { message: message, code: code, top_up_url: top_up_url, raw_message: raw_message }.compact
|
|
340
|
+
@history.append_ext_event({ type: "error", data: data })
|
|
341
|
+
self
|
|
342
|
+
end
|
|
343
|
+
|
|
332
344
|
# Resolve the model name the goal judge should use. Prefer the provider's
|
|
333
345
|
# lite model (cheap/fast for a one-line verdict); fall back to the primary
|
|
334
346
|
# model when no lite is resolvable.
|
|
@@ -485,7 +497,7 @@ module Clacky
|
|
|
485
497
|
@name = new_name.to_s.strip
|
|
486
498
|
end
|
|
487
499
|
|
|
488
|
-
def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil)
|
|
500
|
+
def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil, feedback: nil)
|
|
489
501
|
# Initialized here (not mid-body) because run's rescue/ensure are
|
|
490
502
|
# method-level and must be able to reference them on any exit path.
|
|
491
503
|
result = nil
|
|
@@ -522,6 +534,7 @@ module Clacky
|
|
|
522
534
|
|
|
523
535
|
# Start new task for Time Machine
|
|
524
536
|
task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
|
|
537
|
+
@feedback = feedback
|
|
525
538
|
run_context = { session_id: @session_id, run_id: task_id.to_s,
|
|
526
539
|
source: @source.to_s, input_created_at: created_at,
|
|
527
540
|
observed_at: Time.now.utc.iso8601(6), status: "running" }
|
|
@@ -928,6 +941,33 @@ module Clacky
|
|
|
928
941
|
private def append_user_input(user_input, files: nil, reference_contexts: nil,
|
|
929
942
|
display_text: nil, created_at: nil, references_display: nil,
|
|
930
943
|
task_id: @current_task_id)
|
|
944
|
+
# Resolve lightweight metadata first, then commit the accepted user turn
|
|
945
|
+
# before any attachment parser/OCR/sidecar work can block or fail. Session
|
|
946
|
+
# switching and graceful shutdown both read from @history, so the turn
|
|
947
|
+
# must already be authoritative while those slow operations are running.
|
|
948
|
+
skill_command = parse_skill_command(user_input)
|
|
949
|
+
skill_command_display = if skill_command[:found] && skill_command[:skill]
|
|
950
|
+
skill_command[:skill].display_name(Thread.current[:lang])
|
|
951
|
+
end
|
|
952
|
+
created_at ||= Time.now.to_f
|
|
953
|
+
message_id = SecureRandom.uuid
|
|
954
|
+
staged = Array(files).any?
|
|
955
|
+
staged_display_files = display_files_for(files)
|
|
956
|
+
message_metadata = {
|
|
957
|
+
role: "user", message_id: message_id, task_id: task_id, created_at: created_at,
|
|
958
|
+
display_text: display_text,
|
|
959
|
+
skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
|
|
960
|
+
skill_command_display: skill_command_display,
|
|
961
|
+
display_references: Array(references_display).empty? ? nil : references_display
|
|
962
|
+
}
|
|
963
|
+
if staged
|
|
964
|
+
@history.append(message_metadata.merge(
|
|
965
|
+
content: user_input,
|
|
966
|
+
display_files: staged_display_files.empty? ? nil : staged_display_files
|
|
967
|
+
))
|
|
968
|
+
staged_message_id = message_id
|
|
969
|
+
end
|
|
970
|
+
|
|
931
971
|
# Split files into vision images and disk files; downgrade oversized images to disk
|
|
932
972
|
image_files, disk_files = partition_files(Array(files))
|
|
933
973
|
vision_images, downgraded = resolve_vision_images(image_files)
|
|
@@ -999,37 +1039,21 @@ module Clacky
|
|
|
999
1039
|
# stored here — they recover from image_url blocks in user_content. Downgraded
|
|
1000
1040
|
# images (provider has no vision / too large / OCR'd) DO need path here so the
|
|
1001
1041
|
# UI can re-render them from the on-disk copy across session switches.
|
|
1002
|
-
display_files = all_disk_files
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1042
|
+
display_files = display_files_for(all_disk_files)
|
|
1043
|
+
resolved_message = message_metadata.merge(
|
|
1044
|
+
content: user_content,
|
|
1045
|
+
display_files: display_files.empty? ? nil : display_files
|
|
1046
|
+
)
|
|
1047
|
+
if staged
|
|
1048
|
+
replaced = @history.replace_user_message(
|
|
1049
|
+
message_id: message_id,
|
|
1050
|
+
message: resolved_message
|
|
1051
|
+
)
|
|
1052
|
+
raise AgentError, "Accepted user message disappeared during attachment processing" unless replaced
|
|
1053
|
+
else
|
|
1054
|
+
@history.append(resolved_message)
|
|
1012
1055
|
end
|
|
1013
1056
|
|
|
1014
|
-
# Resolved once here (not after append) so the user message can carry the
|
|
1015
|
-
# confirmed skill name: only a skill that actually dispatches gets marked,
|
|
1016
|
-
# so the UI never highlights a typo'd or unavailable command. The display
|
|
1017
|
-
# name is resolved against the client's language (Thread.current[:lang],
|
|
1018
|
-
# seeded from the WS message / X-Lang header) so the Web UI and third-party
|
|
1019
|
-
# clients can render a localized label without re-resolving the skill.
|
|
1020
|
-
skill_command = parse_skill_command(user_input)
|
|
1021
|
-
skill_command_display = if skill_command[:found] && skill_command[:skill]
|
|
1022
|
-
skill_command[:skill].display_name(Thread.current[:lang])
|
|
1023
|
-
end
|
|
1024
|
-
|
|
1025
|
-
created_at ||= Time.now.to_f
|
|
1026
|
-
@history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
|
|
1027
|
-
display_text: display_text,
|
|
1028
|
-
skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
|
|
1029
|
-
skill_command_display: skill_command_display,
|
|
1030
|
-
display_files: display_files.empty? ? nil : display_files,
|
|
1031
|
-
display_references: Array(references_display).empty? ? nil : references_display })
|
|
1032
|
-
|
|
1033
1057
|
# Inject disk file references as a system_injected message so:
|
|
1034
1058
|
# - LLM sees the file info (system_injected is NOT stripped from to_api)
|
|
1035
1059
|
# - replay_history skips it (next if ev[:system_injected]), keeping the user bubble clean
|
|
@@ -1144,6 +1168,36 @@ module Clacky
|
|
|
1144
1168
|
# skill-declared model not found) still stops the progress spinner.
|
|
1145
1169
|
inject_skill_command_as_assistant_message(skill_command, task_id)
|
|
1146
1170
|
|
|
1171
|
+
rescue Clacky::AgentInterrupted, StandardError
|
|
1172
|
+
# The staged turn exists only to make an accepted upload replayable while
|
|
1173
|
+
# attachment work is in progress. If that work crashes, remove this turn
|
|
1174
|
+
# and anything injected after it so the next request cannot inherit a
|
|
1175
|
+
# half-resolved attachment context. Parser failures returned as FileRef
|
|
1176
|
+
# metadata do not raise and therefore remain visible/actionable.
|
|
1177
|
+
@history.truncate_from_message(staged_message_id) if staged_message_id
|
|
1178
|
+
raise
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
# Build replay-only attachment metadata without parsing file contents.
|
|
1182
|
+
# @mention references render through display_references and must not also
|
|
1183
|
+
# appear as plain attachment badges.
|
|
1184
|
+
private def display_files_for(files)
|
|
1185
|
+
Array(files).filter_map do |file|
|
|
1186
|
+
next if file[:reference] || file["reference"]
|
|
1187
|
+
|
|
1188
|
+
name = file[:name] || file["name"]
|
|
1189
|
+
next unless name
|
|
1190
|
+
|
|
1191
|
+
mime_type = file[:mime_type] || file["mime_type"]
|
|
1192
|
+
type = file[:type] || file["type"]
|
|
1193
|
+
type ||= "image" if mime_type.to_s.start_with?("image/")
|
|
1194
|
+
{
|
|
1195
|
+
name: name,
|
|
1196
|
+
type: type || "file",
|
|
1197
|
+
path: file[:path] || file["path"],
|
|
1198
|
+
preview_path: file[:preview_path] || file["preview_path"]
|
|
1199
|
+
}
|
|
1200
|
+
end
|
|
1147
1201
|
end
|
|
1148
1202
|
|
|
1149
1203
|
def enqueue_input(content, delivery: :queue, **options)
|
|
@@ -1276,6 +1330,7 @@ module Clacky
|
|
|
1276
1330
|
end
|
|
1277
1331
|
entries.each do |entry|
|
|
1278
1332
|
options = entry[:options].dup
|
|
1333
|
+
options.delete(:feedback)
|
|
1279
1334
|
source = options.delete(:source) || :web
|
|
1280
1335
|
history_size = @history.size
|
|
1281
1336
|
begin
|
|
@@ -1691,9 +1746,20 @@ module Clacky
|
|
|
1691
1746
|
# can extract the questions and emit a "request_feedback" event
|
|
1692
1747
|
# (renders as a clickable card in the browser).
|
|
1693
1748
|
# Fallback UIs (terminal, IM channels) receive the formatted text message.
|
|
1694
|
-
@ui
|
|
1695
|
-
|
|
1696
|
-
|
|
1749
|
+
if @feedback && @ui.respond_to?(:wait_for_feedback)
|
|
1750
|
+
questions = Tools::AskUser.normalize_questions(args)
|
|
1751
|
+
question = questions.first
|
|
1752
|
+
label = if questions.one? && !question[:multi] && question[:recommended]
|
|
1753
|
+
question[:options][question[:recommended]]
|
|
1754
|
+
end
|
|
1755
|
+
answer, feedback_options = @ui.wait_for_feedback(timeout_seconds: @feedback[:timeout_seconds], question: question[:question], recommended_label: label) do
|
|
1756
|
+
@ui.show_tool_call(call[:name], call[:arguments])
|
|
1757
|
+
end
|
|
1758
|
+
check_stale!
|
|
1759
|
+
result[:awaiting_feedback] = awaiting_feedback = !answer
|
|
1760
|
+
enqueue_input("Answer: #{answer}", delivery: :steer, created_at: Time.now.to_f, **(feedback_options || {})) if answer
|
|
1761
|
+
elsif @config.permission_mode == :auto_approve
|
|
1762
|
+
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
1697
1763
|
# auto_approve means the agent runs unattended by default, but a
|
|
1698
1764
|
# human MAY be watching the terminal. Show a short interactive
|
|
1699
1765
|
# countdown: if the user steps in, hand control over and wait for
|
|
@@ -1716,6 +1782,7 @@ module Clacky
|
|
|
1716
1782
|
end
|
|
1717
1783
|
else
|
|
1718
1784
|
# confirm_all / confirm_safes — a human is present, truly wait for user input.
|
|
1785
|
+
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
1719
1786
|
awaiting_feedback = true
|
|
1720
1787
|
end
|
|
1721
1788
|
else
|
|
@@ -42,15 +42,16 @@ module Clacky
|
|
|
42
42
|
"abs-claude-sonnet-5" => "global.anthropic.claude-sonnet-5",
|
|
43
43
|
"abs-claude-sonnet-4-6" => "global.anthropic.claude-sonnet-4-6",
|
|
44
44
|
"abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
|
|
45
|
+
"abs-claude-haiku-5-5" => "global.anthropic.claude-haiku-5-5",
|
|
45
46
|
"abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
|
|
46
|
-
# GLM / Kimi /
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# unknown.
|
|
47
|
+
# GLM / Kimi / Grok via TokHub (oc-). The gateway books these under the
|
|
48
|
+
# alias itself, so the mapping is an identity — it exists so the billing UI
|
|
49
|
+
# can resolve the alias instead of dropping the record as unknown.
|
|
50
50
|
"oc-glm-5.3" => "oc-glm-5.3",
|
|
51
51
|
"oc-kimi-k3" => "oc-kimi-k3",
|
|
52
|
-
"oc-
|
|
52
|
+
"oc-grok-4.7" => "oc-grok-4.7",
|
|
53
53
|
# gemini chat (or-)
|
|
54
|
+
"or-gemini-4-argon" => "gemini-4-argon",
|
|
54
55
|
"or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
|
|
55
56
|
"or-gemini-3-8-flash" => "gemini-3.8-flash",
|
|
56
57
|
"or-gemini-3-7-flash" => "gemini-3.7-flash",
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -481,8 +481,8 @@ module Clacky
|
|
|
481
481
|
private def auto_name_session(agent, input)
|
|
482
482
|
return unless agent.history.empty?
|
|
483
483
|
|
|
484
|
-
auto_name = input.to_s.gsub(/\s+/, " ").strip[0,
|
|
485
|
-
auto_name += "…" if input.to_s.strip.length >
|
|
484
|
+
auto_name = input.to_s.gsub(/\s+/, " ").strip[0, 80]
|
|
485
|
+
auto_name += "…" if input.to_s.strip.length > 80
|
|
486
486
|
agent.rename(auto_name)
|
|
487
487
|
end
|
|
488
488
|
|
data/lib/clacky/client.rb
CHANGED
|
@@ -56,6 +56,13 @@ module Clacky
|
|
|
56
56
|
@read_timeout = read_timeout
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Console URL where the user can inspect credit / API key state. nil when
|
|
60
|
+
# the provider has no preset (custom base_url).
|
|
61
|
+
def account_url
|
|
62
|
+
preset = Providers.get(@provider_id)
|
|
63
|
+
preset && preset["website_url"]
|
|
64
|
+
end
|
|
65
|
+
|
|
59
66
|
# Returns true when the client is using the AWS Bedrock Converse API.
|
|
60
67
|
def bedrock?
|
|
61
68
|
@use_bedrock
|
|
@@ -112,6 +112,12 @@
|
|
|
112
112
|
"pub.screenshot.err": "Upload failed: {{msg}}",
|
|
113
113
|
"pub.screenshot.err.type": "Only PNG / JPG / GIF are allowed",
|
|
114
114
|
"pub.screenshot.err.size": "File size must be under 5 MB",
|
|
115
|
+
"pub.screenshot.err.unbound": "this device isn't linked to a platform account",
|
|
116
|
+
"pub.screenshot.err.missingFile": "no file received",
|
|
117
|
+
"pub.screenshot.err.extId": "missing extension ID",
|
|
118
|
+
"pub.screenshot.err.service": "the upload service is unavailable, please try again later",
|
|
119
|
+
"pub.screenshot.err.generic": "Upload failed, please try again",
|
|
120
|
+
"pub.screenshot.bind": "Authorize",
|
|
115
121
|
"pub.readme.edit.title": "Edit README",
|
|
116
122
|
"readme.pane.editor": "Markdown editor",
|
|
117
123
|
"readme.pane.preview": "Preview",
|
|
@@ -318,6 +324,12 @@
|
|
|
318
324
|
"pub.screenshot.err": "上传失败:{{msg}}",
|
|
319
325
|
"pub.screenshot.err.type": "仅支持 PNG / JPG / GIF 格式",
|
|
320
326
|
"pub.screenshot.err.size": "文件大小不能超过 5 MB",
|
|
327
|
+
"pub.screenshot.err.unbound": "设备未绑定平台账号",
|
|
328
|
+
"pub.screenshot.err.missingFile": "未收到文件",
|
|
329
|
+
"pub.screenshot.err.extId": "缺少扩展 ID",
|
|
330
|
+
"pub.screenshot.err.service": "上传服务暂不可用,请稍后重试",
|
|
331
|
+
"pub.screenshot.err.generic": "上传失败,请重试",
|
|
332
|
+
"pub.screenshot.bind": "去授权",
|
|
321
333
|
"pub.readme.edit.title": "编辑使用说明",
|
|
322
334
|
"readme.pane.editor": "Markdown 编写",
|
|
323
335
|
"readme.pane.preview": "预览",
|
|
@@ -651,15 +663,17 @@
|
|
|
651
663
|
|
|
652
664
|
const status = el("p", { class: "studio-modal-status", text: t("bind.body") });
|
|
653
665
|
const codeLine = el("p", { class: "studio-modal-code" });
|
|
654
|
-
const link = el("a", { class: "
|
|
666
|
+
const link = el("a", { class: "btn-primary", target: "_blank", rel: "noopener", text: t("bind.openLink") });
|
|
655
667
|
link.style.display = "none";
|
|
656
668
|
|
|
657
669
|
const modal = openModal({
|
|
658
670
|
title: t("bind.title"),
|
|
659
|
-
body: el("div", null, [status, codeLine
|
|
671
|
+
body: el("div", null, [status, codeLine]),
|
|
660
672
|
onClose: () => { polling = false; resolve(false); },
|
|
661
673
|
buttons: [{ label: t("bind.cancel"), keepOpen: false, onClick: () => { polling = false; resolve(false); } }],
|
|
662
674
|
});
|
|
675
|
+
// A real link (not a button) so it still opens when the browser blocks our popup.
|
|
676
|
+
modal.footer.appendChild(link);
|
|
663
677
|
|
|
664
678
|
function finish(ok) { polling = false; modal.close(); resolve(ok); }
|
|
665
679
|
|
|
@@ -1342,21 +1356,38 @@
|
|
|
1342
1356
|
|
|
1343
1357
|
const screenshotErr = el("p", { class: "studio-modal-status studio-modal-status-error", style: "display:none;margin-top:6px" });
|
|
1344
1358
|
screenshotsSection.appendChild(screenshotErr);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
screenshotErr.
|
|
1350
|
-
if (
|
|
1351
|
-
screenshotErr.
|
|
1352
|
-
screenshotErr.
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
return;
|
|
1359
|
+
|
|
1360
|
+
// Shows a localized error; passing retryFile appends an "Authorize" link that
|
|
1361
|
+
// reopens device binding and re-uploads that file once bound.
|
|
1362
|
+
function showScreenshotError(text, retryFile) {
|
|
1363
|
+
screenshotErr.textContent = text;
|
|
1364
|
+
if (retryFile) {
|
|
1365
|
+
screenshotErr.appendChild(document.createTextNode(" "));
|
|
1366
|
+
screenshotErr.appendChild(el("button", {
|
|
1367
|
+
type: "button",
|
|
1368
|
+
class: "studio-link-btn",
|
|
1369
|
+
text: t("pub.screenshot.bind"),
|
|
1370
|
+
onclick: () => bindThenUploadScreenshot(retryFile),
|
|
1371
|
+
}));
|
|
1359
1372
|
}
|
|
1373
|
+
screenshotErr.style.display = "";
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function screenshotErrorText(e) {
|
|
1377
|
+
if (e.status === 422 && e.message === "missing file") return t("pub.screenshot.err", { msg: t("pub.screenshot.err.missingFile") });
|
|
1378
|
+
if (e.status === 422 && e.message === "ext_id required") return t("pub.screenshot.err", { msg: t("pub.screenshot.err.extId") });
|
|
1379
|
+
if (e.status === 502) return t("pub.screenshot.err", { msg: t("pub.screenshot.err.service") });
|
|
1380
|
+
return t("pub.screenshot.err.generic");
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
async function bindThenUploadScreenshot(file) {
|
|
1384
|
+
screenshotErr.style.display = "none";
|
|
1385
|
+
if (await runBindingFlow()) return uploadScreenshot(file);
|
|
1386
|
+
showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.unbound") }), file);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async function uploadScreenshot(file) {
|
|
1390
|
+
let needsBinding = false;
|
|
1360
1391
|
uploadBtn.disabled = true;
|
|
1361
1392
|
uploadBtn.textContent = t("pub.screenshot.uploading");
|
|
1362
1393
|
screenshotErr.style.display = "none";
|
|
@@ -1366,7 +1397,12 @@
|
|
|
1366
1397
|
form.append("file", file);
|
|
1367
1398
|
const res = await fetch("/api/ext/ext-studio/screenshot", { method: "POST", body: form });
|
|
1368
1399
|
const json = await res.json();
|
|
1369
|
-
if (!res.ok || !json.ok)
|
|
1400
|
+
if (!res.ok || !json.ok) {
|
|
1401
|
+
const err = new Error(json.error || `Request failed (${res.status})`);
|
|
1402
|
+
err.status = res.status;
|
|
1403
|
+
err.data = json;
|
|
1404
|
+
throw err;
|
|
1405
|
+
}
|
|
1370
1406
|
// Insert  at cursor position in the editor.
|
|
1371
1407
|
// Ensure the image tag is on its own line by adding newlines if needed.
|
|
1372
1408
|
const mdImg = ``;
|
|
@@ -1382,12 +1418,33 @@
|
|
|
1382
1418
|
readmeArea.focus();
|
|
1383
1419
|
readmeArea.dispatchEvent(new Event("input"));
|
|
1384
1420
|
} catch (e) {
|
|
1385
|
-
|
|
1386
|
-
|
|
1421
|
+
if (e.status === 428 && e.data && e.data.needs_binding) {
|
|
1422
|
+
needsBinding = true;
|
|
1423
|
+
} else {
|
|
1424
|
+
console.error("[ext-studio] screenshot upload failed:", e);
|
|
1425
|
+
showScreenshotError(screenshotErrorText(e));
|
|
1426
|
+
}
|
|
1387
1427
|
} finally {
|
|
1388
1428
|
uploadBtn.disabled = false;
|
|
1389
1429
|
uploadBtn.textContent = t("pub.screenshot.upload");
|
|
1390
1430
|
}
|
|
1431
|
+
if (needsBinding) await bindThenUploadScreenshot(file);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
fileInput.addEventListener("change", () => {
|
|
1435
|
+
const file = fileInput.files[0];
|
|
1436
|
+
if (!file) return;
|
|
1437
|
+
fileInput.value = "";
|
|
1438
|
+
screenshotErr.style.display = "none";
|
|
1439
|
+
if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
|
|
1440
|
+
showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.type") }));
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
1444
|
+
showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.size") }));
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
uploadScreenshot(file);
|
|
1391
1448
|
});
|
|
1392
1449
|
|
|
1393
1450
|
box.appendChild(screenshotsSection);
|
|
@@ -2312,7 +2369,7 @@
|
|
|
2312
2369
|
.studio-input:focus { border-color: var(--color-accent-primary); outline: none; }
|
|
2313
2370
|
.studio-input-error { border-color: var(--color-error) !important; }
|
|
2314
2371
|
.studio-modal-code { margin: 0 0 8px; font-size: 13px; font-family: monospace; color: var(--color-text-primary); }
|
|
2315
|
-
.studio-modal-
|
|
2372
|
+
.studio-modal .modal-footer a.btn-primary { display: inline-flex; align-items: center; font-size: 14px; text-decoration: none; }
|
|
2316
2373
|
.studio-pub-success { margin-right: auto; font-size: 13px; font-weight: 500; color: var(--color-success); }
|
|
2317
2374
|
.studio-section { margin-bottom: 20px; }
|
|
2318
2375
|
.studio-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|