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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +1 -1
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent/llm_caller.rb +16 -3
  7. data/lib/clacky/agent/message_compressor_helper.rb +8 -0
  8. data/lib/clacky/agent/session_serializer.rb +25 -2
  9. data/lib/clacky/agent.rb +100 -33
  10. data/lib/clacky/billing/platform_billing.rb +6 -5
  11. data/lib/clacky/cli.rb +2 -2
  12. data/lib/clacky/client.rb +7 -0
  13. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +77 -20
  14. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
  15. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  16. data/lib/clacky/locales/en.rb +1 -1
  17. data/lib/clacky/locales/zh.rb +1 -1
  18. data/lib/clacky/message_history.rb +24 -1
  19. data/lib/clacky/providers.rb +17 -126
  20. data/lib/clacky/server/http_server.rb +120 -35
  21. data/lib/clacky/server/model_prices.rb +6 -3
  22. data/lib/clacky/server/session_registry.rb +21 -3
  23. data/lib/clacky/server/web_ui_controller.rb +49 -0
  24. data/lib/clacky/session_manager.rb +21 -5
  25. data/lib/clacky/utils/model_pricing.rb +114 -30
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +795 -147
  28. data/lib/clacky/web/app.js +82 -13
  29. data/lib/clacky/web/components/code-editor.js +6 -2
  30. data/lib/clacky/web/components/model-picker.js +89 -96
  31. data/lib/clacky/web/components/sidebar.js +6 -2
  32. data/lib/clacky/web/features/archive/store.js +182 -0
  33. data/lib/clacky/web/features/archive/view.js +399 -0
  34. data/lib/clacky/web/features/backup/view.js +12 -2
  35. data/lib/clacky/web/features/billing/view.js +84 -10
  36. data/lib/clacky/web/features/channels/view.js +75 -29
  37. data/lib/clacky/web/features/new-session/view.js +1 -0
  38. data/lib/clacky/web/features/trash/store.js +4 -4
  39. data/lib/clacky/web/features/trash/view.js +57 -9
  40. data/lib/clacky/web/features/workspace/view.js +421 -16
  41. data/lib/clacky/web/i18n.js +140 -44
  42. data/lib/clacky/web/index.html +209 -86
  43. data/lib/clacky/web/sessions.js +366 -137
  44. data/lib/clacky/web/settings.js +43 -24
  45. data/lib/clacky/web/ws-dispatcher.js +24 -8
  46. data/lib/clacky.rb +3 -2
  47. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdaf1f7be658187d9515e3fcd1ca37a1ec8bdf04267b434a4d5722c3cbee3970
4
- data.tar.gz: a7a62edc84b707407fd06b186627b1a705a8e9977b60b88453e23759dc43f4b4
3
+ metadata.gz: a87f3bff7a9f13d77ae6ae11ccc67a67a6429d596d922309e3f3847469b240f4
4
+ data.tar.gz: 6f7043a63e27dfb922bac4e586ec19c6d0659efb75ba9ef7e1b522b1a16b9410
5
5
  SHA512:
6
- metadata.gz: 574f85b7317b21b2d231f009405275a6aa77df9758bd2bb9d861a948d1bf35f32d876c1c154fb87f8248786490734aab84a6568ba522e37e840913216510f504
7
- data.tar.gz: 6509de425a59612b5fad807c172affad49bee4da8b83d59ba9a204c3355814c1ef8f1b8ee233bee43755c624c3bbf4b9c7278378bf3b68e7faebcfc7096a6530
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 · OrcaRouter · Requesty** — or any custom endpoint.
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 · OrcaRouter · Requesty**,或任意自定义端点。
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 · OrcaRouter · Requesty** — または任意のカスタムエンドポイント。
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). Failures without a tier (network layer,
60
- # timeouts — the gateway never answered) are not attributed to either.
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
- @pending_error_rollback = true
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.filter_map do |f|
1003
- # @mention file/directory references are replayed from display_references
1004
- # (with mention badges), so skip them here to avoid double-rendering as
1005
- # plain attachment badges.
1006
- next if f[:reference] || f["reference"]
1007
- name = f[:name] || f["name"]
1008
- next unless name
1009
- { name: name, type: f[:type] || f["type"] || "file",
1010
- path: f[:path] || f["path"],
1011
- preview_path: f[:preview_path] || f["preview_path"] }
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&.show_tool_call(call[:name], call[:arguments])
1695
-
1696
- if @config.permission_mode == :auto_approve
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 / MiniMax via TokHub (oc-). The gateway books these under
47
- # the alias itself, so the mapping is an identity — it exists so the
48
- # billing UI can resolve the alias instead of dropping the record as
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-minimax-m2.7" => "oc-minimax-m2.7",
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, 30]
485
- auto_name += "…" if input.to_s.strip.length > 30
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: "studio-modal-link", target: "_blank", rel: "noopener", text: t("bind.openLink") });
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, link]),
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
- fileInput.addEventListener("change", async () => {
1346
- const file = fileInput.files[0];
1347
- if (!file) return;
1348
- fileInput.value = "";
1349
- screenshotErr.style.display = "none";
1350
- if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
1351
- screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.type") });
1352
- screenshotErr.style.display = "";
1353
- return;
1354
- }
1355
- if (file.size > 5 * 1024 * 1024) {
1356
- screenshotErr.textContent = t("pub.screenshot.err", { msg: t("pub.screenshot.err.size") });
1357
- screenshotErr.style.display = "";
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) throw new Error(json.error || "upload failed");
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 ![](url) 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 = `![](${json.url})`;
@@ -1382,12 +1418,33 @@
1382
1418
  readmeArea.focus();
1383
1419
  readmeArea.dispatchEvent(new Event("input"));
1384
1420
  } catch (e) {
1385
- screenshotErr.textContent = t("pub.screenshot.err", { msg: e.message });
1386
- screenshotErr.style.display = "";
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-link { display: inline-block; font-size: 13px; color: var(--color-accent-primary); }
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; }