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
@@ -392,6 +392,7 @@
392
392
  });
393
393
  if (res.ok) {
394
394
  state.footEl.textContent = res.message || t("tm.restored", "已恢复");
395
+ if (Clacky.Workspace) Clacky.Workspace.notifyTaskCompleted(state.sessionId);
395
396
  state.expanded = null;
396
397
  closeDetail(state);
397
398
  await loadHistory(state);
@@ -562,7 +562,7 @@ Confirm once more:
562
562
  ```
563
563
 
564
564
  On confirmation, use the `trash` tool (NOT raw `rm`) so the file lands in
565
- Recently Deleted and can be recovered via File Recall. Report what you did.
565
+ the Recycle Bin and can be recovered from there. Report what you did.
566
566
 
567
567
  ### C.7. Close
568
568
 
@@ -22,7 +22,7 @@ module Clacky
22
22
  "llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
23
23
  "llm.error.bad_request" => "Model request failed. Please switch to another model and retry",
24
24
  "llm.error.request_timeout" => "Request timed out after %<retries>d retries",
25
- "llm.error.network_failed" => "Network connection failed after %<retries>d retries",
25
+ "llm.error.network_failed" => "Network connection failed after %<retries>d retries. If the problem persists, check your account balance or API Key",
26
26
  "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries",
27
27
  "platform.error.invalid_proof" => "Invalid license key — please check and try again.",
28
28
  "platform.error.invalid_signature" => "Invalid request signature.",
@@ -22,7 +22,7 @@ module Clacky
22
22
  "llm.error.html_response" => "服务暂时不可用(收到 HTML 错误页),正在重试...",
23
23
  "llm.error.bad_request" => "请求模型出错,请暂时切换其他模型重试",
24
24
  "llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次)",
25
- "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次)",
25
+ "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次),若持续失败,请检查账户余额或 API Key",
26
26
  "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)",
27
27
  "platform.error.invalid_proof" => "许可证密钥无效,请检查后重试。",
28
28
  "platform.error.invalid_signature" => "请求签名无效。",
@@ -11,7 +11,7 @@ module Clacky
11
11
  class MessageHistory
12
12
  # Fields that are internal to the agent and must not be sent to the API.
13
13
  INTERNAL_FIELDS = %i[
14
- task_id created_at system_injected session_context memory_update
14
+ message_id task_id created_at system_injected session_context memory_update
15
15
  subagent_instructions subagent_result subagent_transcript token_usage
16
16
  compressed_summary chunk_path truncated transient
17
17
  chunk_index chunk_count ext_events skill_command skill_command_display
@@ -65,6 +65,18 @@ module Clacky
65
65
  self
66
66
  end
67
67
 
68
+ # Replace one real user turn after its attachments have been resolved.
69
+ # message_id is the stable identity; created_at remains presentation data.
70
+ def replace_user_message(message_id:, message:)
71
+ idx = @messages.index do |m|
72
+ m[:role] == "user" && !m[:system_injected] && m[:message_id] == message_id
73
+ end
74
+ return false unless idx
75
+
76
+ @messages[idx] = deep_sanitize_utf8(message)
77
+ true
78
+ end
79
+
68
80
  # Replace the entire message list (used by compression rebuild).
69
81
  def replace_all(new_messages)
70
82
  @messages = new_messages.map { |m| deep_sanitize_utf8(m) }
@@ -179,6 +191,17 @@ module Clacky
179
191
  truncate_from(idx)
180
192
  end
181
193
 
194
+ # Truncate history starting from the message with the given stable ID.
195
+ # Used to roll back an attachment turn that failed before resolution.
196
+ def truncate_from_message(message_id)
197
+ idx = @messages.index do |m|
198
+ m[:role] == "user" && !m[:system_injected] && m[:message_id] == message_id
199
+ end
200
+ return self unless idx
201
+
202
+ truncate_from(idx)
203
+ end
204
+
182
205
  # Roll back the history to just before the given message object.
183
206
  # Removes the message and anything appended after it.
184
207
  # Used to undo a failed speculative append (e.g. compression message that errored).
@@ -40,7 +40,6 @@ module Clacky
40
40
  "dsk-deepseek-v4-pro",
41
41
  "oc-glm-5.3",
42
42
  "oc-kimi-k3",
43
- "oc-minimax-m2.7",
44
43
  "abs-claude-fable-5-1",
45
44
  "abs-claude-fable-5",
46
45
  "abs-claude-opus-5-5",
@@ -50,17 +49,19 @@ module Clacky
50
49
  "abs-claude-sonnet-5-5",
51
50
  "abs-claude-sonnet-5",
52
51
  "abs-claude-sonnet-4-6",
52
+ "abs-claude-haiku-5-5",
53
53
  "abs-claude-haiku-4-5",
54
- "abs-gpt-6-astra",
55
54
  "abs-gpt-6.1-sol",
55
+ "abs-gpt-6-astra",
56
56
  "abs-gpt-6-sol",
57
57
  "abs-gpt-6-luna",
58
58
  "abs-gpt-5.6-sol",
59
59
  "abs-gpt-5.6-terra",
60
60
  "abs-gpt-5.6-luna",
61
+ "or-gemini-4-argon",
61
62
  "or-gemini-3-1-pro",
62
63
  "or-gemini-3-8-flash",
63
- "or-gemini-3-7-flash"
64
+ "oc-grok-4.7"
64
65
  ],
65
66
  # Image generation models served by the openclacky platform
66
67
  # gateway. The gateway exposes a standard OpenAI-compatible
@@ -112,12 +113,10 @@ module Clacky
112
113
  # audio parts). The gateway returns transcription text.
113
114
  "stt_models" => [
114
115
  "or-stt-gemini-3-8-flash",
115
- "or-stt-gemini-3-7-flash",
116
116
  "or-stt-gemini-1-5-pro"
117
117
  ],
118
118
  "stt_model_aliases" => {
119
119
  "or-stt-gemini-3-8-flash" => "Gemini 3.8 Flash STT",
120
- "or-stt-gemini-3-7-flash" => "Gemini 3.7 Flash STT",
121
120
  "or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
122
121
  },
123
122
  "default_stt_model" => "or-stt-gemini-3-8-flash",
@@ -126,12 +125,10 @@ module Clacky
126
125
  # parts). The gateway returns analysis text.
127
126
  "video_understanding_models" => [
128
127
  "or-gemini-3-8-flash",
129
- "or-gemini-3-7-flash",
130
128
  "or-gemini-3-1-pro"
131
129
  ],
132
130
  "video_understanding_model_aliases" => {
133
131
  "or-gemini-3-8-flash" => "Gemini 3.8 Flash",
134
- "or-gemini-3-7-flash" => "Gemini 3.7 Flash",
135
132
  "or-gemini-3-1-pro" => "Gemini 3.1 Pro"
136
133
  },
137
134
  "default_video_understanding_model" => "or-gemini-3-8-flash",
@@ -148,15 +145,12 @@ module Clacky
148
145
  # (it accepts image/audio/video input natively via OpenRouter).
149
146
  # oc-glm-5.3 is likewise text-only: its upstream rejects any content
150
147
  # block other than `type: "text"`, so images must never be sent
151
- # inline. oc-minimax-m2.7 is also text-only, but its upstream accepts
152
- # the image block and silently ignores it — a worse failure mode,
153
- # since the model then answers as if no image had been attached.
154
- # oc-kimi-k3 is natively multimodal and keeps vision=true.
148
+ # inline. oc-kimi-k3 and oc-grok-4.7 are natively multimodal and keep
149
+ # vision=true.
155
150
  "model_capabilities" => {
156
151
  "dsk-deepseek-flash" => { "vision" => true }.freeze,
157
152
  "dsk-deepseek-v4-pro" => { "vision" => false }.freeze,
158
- "oc-glm-5.3" => { "vision" => false }.freeze,
159
- "oc-minimax-m2.7" => { "vision" => false }.freeze
153
+ "oc-glm-5.3" => { "vision" => false }.freeze
160
154
  }.freeze,
161
155
  # Bedrock GPT models (abs-gpt-*) are served through the OpenAI
162
156
  # Responses API — their Chat Completions endpoint rejects function
@@ -173,15 +167,15 @@ module Clacky
173
167
  # no injection happens when the default model is already lite-class.
174
168
  "lite_models" => {
175
169
  "auto" => "dsk-deepseek-flash",
176
- "abs-claude-fable-5-1" => "abs-claude-haiku-4-5",
177
- "abs-claude-fable-5" => "abs-claude-haiku-4-5",
178
- "abs-claude-opus-5-5" => "abs-claude-haiku-4-5",
179
- "abs-claude-opus-5" => "abs-claude-haiku-4-5",
180
- "abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
181
- "abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
182
- "abs-claude-sonnet-5-5" => "abs-claude-haiku-4-5",
183
- "abs-claude-sonnet-5" => "abs-claude-haiku-4-5",
184
- "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
170
+ "abs-claude-fable-5-1" => "abs-claude-haiku-5-5",
171
+ "abs-claude-fable-5" => "abs-claude-haiku-5-5",
172
+ "abs-claude-opus-5-5" => "abs-claude-haiku-5-5",
173
+ "abs-claude-opus-5" => "abs-claude-haiku-5-5",
174
+ "abs-claude-opus-4-8" => "abs-claude-haiku-5-5",
175
+ "abs-claude-opus-4-7" => "abs-claude-haiku-5-5",
176
+ "abs-claude-sonnet-5-5" => "abs-claude-haiku-5-5",
177
+ "abs-claude-sonnet-5" => "abs-claude-haiku-5-5",
178
+ "abs-claude-sonnet-4-6" => "abs-claude-haiku-5-5",
185
179
  "abs-gpt-6-astra" => "abs-gpt-6-luna",
186
180
  "abs-gpt-6-sol" => "abs-gpt-6-luna",
187
181
  "abs-gpt-5.6-sol" => "abs-gpt-5.6-luna",
@@ -205,7 +199,7 @@ module Clacky
205
199
  "endpoint_variants" => [
206
200
  { "label" => "Primary (Global)", "label_key" => "settings.models.baseurl.variant.openclacky_primary", "base_url" => "https://api.openclacky.com" }.freeze
207
201
  ].freeze,
208
- "website_url" => "https://www.openclacky.com/ai-keys"
202
+ "website_url" => "https://www.openclacky.com/llm_keys"
209
203
  }.freeze,
210
204
 
211
205
 
@@ -677,107 +671,6 @@ module Clacky
677
671
  },
678
672
  "default_ocr_model" => "kimi-k2.7-code",
679
673
  "website_url" => "https://ollama.com/settings/keys"
680
- }.freeze,
681
-
682
- "orcarouter" => {
683
- "name" => "OrcaRouter",
684
- "base_url" => "https://api.orcarouter.ai/v1",
685
- "api" => "openai-completions",
686
- "default_model" => "openai/gpt-5.5",
687
- # Curated default lineup. OrcaRouter exposes 190+ models from OpenAI,
688
- # Anthropic, Google, DeepSeek, Qwen, MiniMax, Zhipu and others behind a
689
- # single OpenAI-compatible endpoint. Shipping a small list of the
690
- # mainstream Claude + GPT entries gives users a working dropdown out
691
- # of the box; users can still type any other OrcaRouter model id manually.
692
- "models" => [
693
- "anthropic/claude-sonnet-5",
694
- "anthropic/claude-opus-4.8",
695
- "anthropic/claude-haiku-4.5",
696
- "openai/gpt-5.5",
697
- "openai/gpt-5.4",
698
- "openai/gpt-5.4-mini",
699
- "google/gemini-3.5-flash",
700
- "deepseek/deepseek-v4-flash",
701
- "z-ai/glm-5.2"
702
- ],
703
- # Per-primary lite pairing — Claude family pairs with Haiku, GPT
704
- # family pairs with the mini variant. Mirrors the openrouter preset
705
- # so subagents on OrcaRouter get a sensible cheap/fast sidekick.
706
- "lite_models" => {
707
- "anthropic/claude-sonnet-5" => "anthropic/claude-haiku-4.5",
708
- "anthropic/claude-opus-4.8" => "anthropic/claude-haiku-4.5",
709
- "openai/gpt-5.5" => "openai/gpt-5.4-mini",
710
- "openai/gpt-5.4" => "openai/gpt-5.4-mini"
711
- },
712
- # Per-model API type overrides. OrcaRouter proxies Claude through a
713
- # native Anthropic /v1/messages endpoint (https://api.orcarouter.ai/v1/messages)
714
- # in addition to its OpenAI-compatible /chat/completions endpoint.
715
- # Routing "anthropic/*" via the native endpoint preserves cache_control
716
- # fidelity, matching what Claude Code CLI does internally (same rationale
717
- # as the openrouter preset). Non-Claude models keep the OpenAI shim.
718
- "model_api_overrides" => {
719
- /\Aanthropic\// => "anthropic-messages"
720
- }.freeze,
721
- # Most models on OrcaRouter are vision-capable; DeepSeek and GLM-5.2
722
- # are text-only.
723
- "capabilities" => { "vision" => true }.freeze,
724
- "model_capabilities" => {
725
- "deepseek/deepseek-v4-flash" => { "vision" => false }.freeze,
726
- "z-ai/glm-5.2" => { "vision" => false }.freeze
727
- }.freeze,
728
- "default_ocr_model" => "google/gemini-3.5-flash",
729
- "website_url" => "https://www.orcarouter.ai"
730
- }.freeze,
731
-
732
- "requesty" => {
733
- "name" => "Requesty",
734
- "base_url" => "https://router.requesty.ai/v1",
735
- "api" => "openai-completions",
736
- "default_model" => "claude-sonnet-4-6",
737
- # Curated default lineup of Requesty managed policies (GET /v1/models/managed).
738
- # A managed policy is a short model id that Requesty routes across several
739
- # upstream providers. Users can still type any other id manually, including
740
- # the full "vendor/model" catalogue ids (e.g. "openai/gpt-4o-mini").
741
- "models" => [
742
- "claude-sonnet-4-6",
743
- "claude-opus-4-8",
744
- "claude-opus-4-7",
745
- "claude-opus-4-6",
746
- "claude-haiku-4-5",
747
- "gpt-5.6-sol",
748
- "gpt-5.6-terra",
749
- "gpt-5.6-luna",
750
- "gpt-5.5",
751
- "gpt-5.4",
752
- "gpt-5.4-mini",
753
- "gemini-3.8-flash"
754
- ],
755
- # Per-primary lite pairing, same shape as the openrouter preset.
756
- "lite_models" => {
757
- "claude-sonnet-4-6" => "claude-haiku-4-5",
758
- "claude-opus-4-8" => "claude-haiku-4-5",
759
- "claude-opus-4-7" => "claude-haiku-4-5",
760
- "claude-opus-4-6" => "claude-haiku-4-5",
761
- "gpt-5.6-sol" => "gpt-5.6-luna",
762
- "gpt-5.6-terra" => "gpt-5.6-luna",
763
- "gpt-5.5" => "gpt-5.4-mini",
764
- "gpt-5.4" => "gpt-5.4-mini"
765
- },
766
- # Requesty also serves the native Anthropic /v1/messages endpoint, so
767
- # Claude models route there to preserve cache_control fidelity (same
768
- # rationale as the openrouter preset). Other models keep the OpenAI shim.
769
- "model_api_overrides" => {
770
- /\Aanthropic\// => "anthropic-messages",
771
- /\Aclaude[-.]/ => "anthropic-messages"
772
- }.freeze,
773
- # Same API key works on every regional endpoint; the EU one keeps
774
- # traffic inside the EU (Frankfurt).
775
- "endpoint_variants" => [
776
- { "label" => "Global", "base_url" => "https://router.requesty.ai/v1" }.freeze,
777
- { "label" => "EU", "base_url" => "https://router.eu.requesty.ai/v1", "region" => "eu" }.freeze
778
- ].freeze,
779
- "default_ocr_model" => "gemini-3.8-flash",
780
- "website_url" => "https://app.requesty.ai/api-keys"
781
674
  }.freeze
782
675
 
783
676
  }.freeze
@@ -797,8 +690,6 @@ module Clacky
797
690
  MIMO_ID = "mimo"
798
691
  VOLCENGINE_ARK_ID = "volcengine-ark"
799
692
  OLLAMA_ID = "ollama"
800
- ORCAROUTER_ID = "orcarouter"
801
- REQUESTY_ID = "requesty"
802
693
 
803
694
  MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
804
695
 
@@ -733,6 +733,7 @@ module Clacky
733
733
  when ["GET", "/api/trash/sessions"] then api_trash_sessions(req, res)
734
734
  when ["POST", "/api/trash/sessions/restore"] then api_trash_session_restore(req, res)
735
735
  when ["DELETE", "/api/trash/sessions"] then api_trash_sessions_delete(req, res)
736
+ when ["DELETE", "/api/archive/sessions"] then api_archive_sessions_empty(res)
736
737
  when ["GET", "/api/profile"] then api_profile_get(res)
737
738
  when ["PUT", "/api/profile"] then api_profile_put(req, res)
738
739
  when ["GET", "/api/memories"] then api_memories_list(res)
@@ -972,6 +973,7 @@ module Clacky
972
973
  date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
973
974
  type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
974
975
  exclude_type = query["exclude_type"].to_s.split(",").map(&:strip).reject(&:empty?).then { |v| v.empty? ? nil : v }
976
+ archived = %w[1 true yes].include?(query["archived"].to_s.strip.downcase)
975
977
  # Backward-compat: ?source=<x> and ?profile=coding → type
976
978
  type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
977
979
  type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
@@ -981,16 +983,23 @@ module Clacky
981
983
  # in the project section, so they must not eat into this page's quota.
982
984
  # Search (no before) keeps the original semantics: without a type filter
983
985
  # it may still match project sessions.
984
- exclude_project = before ? true : !!type
985
- sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project)
986
+ #
987
+ # The archive view is the exception: it groups archived sessions by
988
+ # project by hand, so project membership must survive both filters.
989
+ exclude_project = archived ? false : (before ? true : !!type)
990
+ sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project, archived: archived)
986
991
 
987
992
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
988
993
  has_more = non_pinned_part.size > limit
989
994
  non_pinned_part = non_pinned_part.first(limit)
990
995
  sessions = pinned_part + non_pinned_part
991
996
 
992
- json_response(res, 200, { sessions: sessions, has_more: has_more,
993
- groups: @registry.group_stats_all })
997
+ payload = { sessions: sessions, has_more: has_more, groups: @registry.group_stats_all }
998
+ # The archive page offers "delete all", so it needs the size of the whole
999
+ # archive — the paged `sessions` array only ever shows one page of it.
1000
+ payload[:archived_count] = @registry.archived_count if archived
1001
+
1002
+ json_response(res, 200, payload)
994
1003
  end
995
1004
 
996
1005
  # GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
@@ -4679,19 +4688,39 @@ module Clacky
4679
4688
  return
4680
4689
  end
4681
4690
 
4682
- # Support HTTP Range requests for video seeking
4691
+ # Support a single HTTP byte range for media seeking. Browsers normally
4692
+ # send an explicit or open-ended start, while other clients may use the
4693
+ # standard suffix form ("bytes=-N").
4683
4694
  range_header = req["Range"]
4684
4695
  if range_header && range_header =~ /\Abytes=(\d*)-(\d*)\z/
4685
- start_byte = ($1.empty? ? 0 : $1.to_i)
4686
- end_byte = ($2.empty? ? file_size - 1 : $2.to_i)
4687
- end_byte = [end_byte, file_size - 1].min
4696
+ first, last = $1, $2
4697
+ unsatisfiable = file_size.zero?
4698
+
4699
+ if first.empty?
4700
+ suffix_length = last.to_i
4701
+ unsatisfiable ||= suffix_length.zero?
4702
+ start_byte = [file_size - suffix_length, 0].max
4703
+ end_byte = file_size - 1
4704
+ else
4705
+ start_byte = first.to_i
4706
+ end_byte = last.empty? ? file_size - 1 : [last.to_i, file_size - 1].min
4707
+ unsatisfiable ||= start_byte >= file_size || end_byte < start_byte
4708
+ end
4688
4709
 
4689
- res.status = 206
4690
- res["Content-Type"] = mime
4691
- res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
4710
+ res["Content-Type"] = mime
4692
4711
  res["Accept-Ranges"] = "bytes"
4693
- res["Content-Length"] = (end_byte - start_byte + 1).to_s
4694
- IO.binread(path, end_byte - start_byte + 1, start_byte).then { |data| res.body = data }
4712
+ if unsatisfiable
4713
+ res.status = 416
4714
+ res["Content-Range"] = "bytes */#{file_size}"
4715
+ res["Content-Length"] = "0"
4716
+ res.body = ""
4717
+ else
4718
+ length = end_byte - start_byte + 1
4719
+ res.status = 206
4720
+ res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
4721
+ res["Content-Length"] = length.to_s
4722
+ res.body = IO.binread(path, length, start_byte)
4723
+ end
4695
4724
  else
4696
4725
  res.status = 200
4697
4726
  res["Content-Type"] = mime
@@ -6468,9 +6497,17 @@ module Clacky
6468
6497
  provider_id: m["provider_id"],
6469
6498
  provider_name: provider && provider["name"],
6470
6499
  provider_name_key: provider && provider["name_key"],
6500
+ # Where the row's provider manages keys / billing — the picker
6501
+ # links to it from the per-row top-up shortcut.
6502
+ website_url: provider && provider["website_url"],
6471
6503
  capabilities: m["capabilities"],
6472
6504
  remark: m["remark"],
6473
- type: m["type"]
6505
+ type: m["type"],
6506
+ # Sub-model list for this row: the models of the provider its
6507
+ # endpoint belongs to (same rule the session status bar uses), so
6508
+ # the picker can offer the switcher on every row and not only on
6509
+ # the session's current one.
6510
+ sub_models: provider_id ? Clacky::Providers.models(provider_id) : []
6474
6511
  }
6475
6512
  end
6476
6513
  # Sidecar capability configs (media/ocr) and auto-injected entries are
@@ -7264,6 +7301,11 @@ module Clacky
7264
7301
  body = parse_json_body(req)
7265
7302
  new_name = body["name"]&.to_s&.strip
7266
7303
  pinned = body["pinned"]
7304
+ archived = body["archived"]
7305
+
7306
+ if !archived.nil? && archived != true && archived != false
7307
+ return json_response(res, 400, { error: "archived must be true or false" })
7308
+ end
7267
7309
 
7268
7310
  return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
7269
7311
 
@@ -7279,6 +7321,11 @@ module Clacky
7279
7321
  if !pinned.nil?
7280
7322
  agent.pinned = pinned
7281
7323
  end
7324
+
7325
+ # Update archived status if provided
7326
+ if !archived.nil?
7327
+ agent.archived_at = archived ? Time.now.iso8601 : nil
7328
+ end
7282
7329
 
7283
7330
  # Save session data
7284
7331
  @session_manager.save(agent.to_session_data(updated_at: Time.now))
@@ -7287,11 +7334,13 @@ module Clacky
7287
7334
  update_data = { type: "session_updated", session_id: session_id }
7288
7335
  update_data[:name] = new_name if new_name && !new_name.empty?
7289
7336
  update_data[:pinned] = pinned unless pinned.nil?
7337
+ update_data[:archived_at] = agent.archived_at unless archived.nil?
7290
7338
  broadcast(session_id, update_data)
7291
7339
 
7292
7340
  response_data = { ok: true }
7293
7341
  response_data[:name] = new_name if new_name && !new_name.empty?
7294
7342
  response_data[:pinned] = pinned unless pinned.nil?
7343
+ response_data[:archived_at] = agent.archived_at unless archived.nil?
7295
7344
  json_response(res, 200, response_data)
7296
7345
  rescue => e
7297
7346
  json_response(res, 500, { error: e.message })
@@ -7540,6 +7589,23 @@ module Clacky
7540
7589
  json_response(res, 201, { session: @registry.snapshot(fork_id) })
7541
7590
  end
7542
7591
 
7592
+ # DELETE /api/archive/sessions
7593
+ # Empties the archive: every archived chat is soft-deleted and lands in
7594
+ # the recycle bin, never destroyed for good. Archiving keeps a chat out of
7595
+ # the sidebar, so clearing the archive means the user is done with all of
7596
+ # them. The toolbar's search and filters deliberately do not narrow this —
7597
+ # the button says "delete all", and the confirmation counts what this
7598
+ # call is about to take out.
7599
+ def api_archive_sessions_empty(res)
7600
+ ids = @registry.list(archived: true).map { |s| s[:id] }
7601
+
7602
+ # `list` just read these ids off disk, so skip the per-id existence
7603
+ # probe — `load` re-parses every session file for each call.
7604
+ ids.each { |session_id| soft_delete_session!(session_id, on_disk: true) }
7605
+
7606
+ json_response(res, 200, { ok: true, deleted_count: ids.size })
7607
+ end
7608
+
7543
7609
  def api_delete_session(session_id, res)
7544
7610
  # A session exists if it's either in the runtime registry OR on disk.
7545
7611
  # Old sessions that were never restored into memory this server run
@@ -7547,28 +7613,36 @@ module Clacky
7547
7613
  # must still be able to delete them. Previously this endpoint only
7548
7614
  # consulted @registry and returned 404 for disk-only sessions,
7549
7615
  # causing the "can't delete old sessions" bug.
7550
- in_registry = @registry.exist?(session_id)
7551
- on_disk = !@session_manager.load(session_id).nil?
7616
+ return json_response(res, 404, { error: "Session not found" }) unless soft_delete_session!(session_id)
7552
7617
 
7553
- unless in_registry || on_disk
7554
- return json_response(res, 404, { error: "Session not found" })
7555
- end
7618
+ json_response(res, 200, { ok: true })
7619
+ end
7620
+
7621
+ # Move one session to the recycle bin and tell everyone who can see it.
7622
+ # Returns false when the session is unknown — neither live nor on disk.
7623
+ #
7624
+ # `on_disk` skips the existence probe for callers that already scanned the
7625
+ # disk themselves: `SessionManager#load` re-parses every session file until
7626
+ # it finds a match, which is far too expensive to run per id in a bulk path.
7627
+ private def soft_delete_session!(session_id, on_disk: nil)
7628
+ in_registry = @registry.exist?(session_id)
7629
+ on_disk = !@session_manager.load(session_id).nil? if on_disk.nil?
7630
+ return false unless in_registry || on_disk
7556
7631
 
7557
- # Registry delete is best-effort — only meaningful when the session
7558
- # is actually live (cancels idle timer, interrupts the agent thread).
7559
- # For disk-only sessions this is a no-op and returns false, which is
7560
- # fine and no longer blocks the disk cleanup below.
7632
+ # Registry delete is best-effort — only meaningful when the session is
7633
+ # actually live (cancels idle timer, interrupts the agent thread). For
7634
+ # disk-only sessions this is a no-op and returns false, which is fine and
7635
+ # no longer blocks the disk cleanup below.
7561
7636
  @registry.delete(session_id) if in_registry
7562
7637
 
7563
7638
  # Soft-delete: move session to trash instead of permanently destroying it.
7564
7639
  @session_manager.soft_delete(session_id) if on_disk
7565
7640
 
7566
- # Notify any still-connected clients (mainly matters when the
7567
- # session was live, but harmless otherwise).
7641
+ # Notify any still-connected clients (mainly matters when the session was
7642
+ # live, but harmless otherwise) so other tabs drop the row too.
7568
7643
  broadcast(session_id, { type: "session_deleted", session_id: session_id })
7569
7644
  unsubscribe_all(session_id)
7570
-
7571
- json_response(res, 200, { ok: true })
7645
+ true
7572
7646
  end
7573
7647
 
7574
7648
  # Export a session bundle as a .zip download containing:
@@ -7767,7 +7841,14 @@ module Clacky
7767
7841
  raw_images = (msg["images"] || []).map do |data_url|
7768
7842
  { "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
7769
7843
  end
7770
- handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
7844
+ handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [], feedback: msg["feedback"]&.transform_keys(&:to_sym))
7845
+
7846
+ when "feedback_response"
7847
+ session_id = msg["session_id"] || conn.session_id
7848
+ @registry.get(session_id)&.dig(:ui)&.deliver_feedback(
7849
+ msg["content"].to_s, files: msg["files"] || [], display_text: msg["display_text"],
7850
+ references_display: msg["references"] || [],
7851
+ reference_contexts: build_reference_contexts(msg["references"] || []))
7771
7852
 
7772
7853
  when "confirmation"
7773
7854
  session_id = msg["session_id"] || conn.session_id
@@ -7867,11 +7948,12 @@ module Clacky
7867
7948
  end
7868
7949
  end
7869
7950
 
7870
- def handle_user_message(session_id, content, files = [], references: [])
7951
+ def handle_user_message(session_id, content, files = [], references: [], feedback: nil)
7871
7952
  return unless @registry.exist?(session_id)
7872
7953
 
7873
7954
  session = @registry.get(session_id)
7874
-
7955
+ feedback_options = feedback ? { feedback: feedback } : {}
7956
+
7875
7957
  mode = @agent_config.input_behavior
7876
7958
  queued = false
7877
7959
  queued_created_at = nil
@@ -7879,7 +7961,7 @@ module Clacky
7879
7961
  if s[:status] == :running && %w[queue steer].include?(mode)
7880
7962
  queued_created_at = Time.now.to_f
7881
7963
  s[:agent].enqueue_input(content, delivery: mode.to_sym, files: files, references_display: references,
7882
- reference_contexts: build_reference_contexts(references), created_at: queued_created_at)
7964
+ reference_contexts: build_reference_contexts(references), created_at: queued_created_at, **feedback_options)
7883
7965
  queued = true
7884
7966
  end
7885
7967
  end
@@ -7902,7 +7984,7 @@ module Clacky
7902
7984
  if old_thread && !old_thread.join(2)
7903
7985
  retained_created_at = Time.now.to_f
7904
7986
  session[:agent].enqueue_input(content, files: files, references_display: references,
7905
- reference_contexts: build_reference_contexts(references), created_at: retained_created_at)
7987
+ reference_contexts: build_reference_contexts(references), created_at: retained_created_at, **feedback_options)
7906
7988
  broadcast(session_id, { type: "input_enqueued", session_id: session_id, created_at: retained_created_at })
7907
7989
  broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.stoppingDelayed" })
7908
7990
  return
@@ -7916,8 +7998,8 @@ module Clacky
7916
7998
  # Auto-name the session from the first user message (before agent starts running).
7917
7999
  # Skip if the name looks like it was set by the user (not a system-generated "Session N").
7918
8000
  if agent.history.empty? && agent.name.match?(/\ASession \d+\z/)
7919
- auto_name = content.gsub(/\s+/, " ").strip[0, 30]
7920
- auto_name += "…" if content.strip.length > 30
8001
+ auto_name = content.gsub(/\s+/, " ").strip[0, 80]
8002
+ auto_name += "…" if content.strip.length > 80
7921
8003
  agent.rename(auto_name)
7922
8004
  broadcast(session_id, { type: "session_renamed", session_id: session_id, name: auto_name })
7923
8005
  end
@@ -7960,7 +8042,7 @@ module Clacky
7960
8042
  # "Thinking..." forever. A web-originated turn bound to an IM channel gets a
7961
8043
  # card too, so tools/narration render like a native IM turn (#603).
7962
8044
  @channel_manager.channel_ui_for_session(session_id)&.start_task(reply_to: nil)
7963
- agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references)
8045
+ agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references, **feedback_options)
7964
8046
  end
7965
8047
  end
7966
8048
 
@@ -8227,11 +8309,14 @@ module Clacky
8227
8309
  if e.is_a?(Clacky::InsufficientCreditError) && e.provider_id
8228
8310
  preset = Clacky::Providers::PRESETS[e.provider_id]
8229
8311
  top_up_url = preset && preset["website_url"]
8312
+ elsif e.respond_to?(:action_url) && e.action_url
8313
+ top_up_url = e.action_url
8230
8314
  end
8231
8315
  user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
8232
8316
  raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
8233
8317
  next unless @registry.update_if_epoch(session_id, epoch, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
8234
8318
  broadcast_session_update(session_id)
8319
+ agent.record_error_event(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
8235
8320
  web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
8236
8321
  @session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message, updated_at: Time.now))
8237
8322
  end
@@ -6,7 +6,9 @@ module Clacky
6
6
  # Resolves each model's input/output price (USD per 1M tokens) from
7
7
  # Clacky::ModelPricing plus a cost ratio vs the baseline model, so the
8
8
  # web UI never hardcodes prices - updating PRICING_TABLE is enough.
9
- # Prices are post-promotion; `discount` is present when a rule applied.
9
+ # Prices are post-promotion; `discount` is present when a rule applied,
10
+ # carrying both the rate and its reason ("channel" or "promo") so the UI
11
+ # can pick the right explanation.
10
12
  module ModelPrices
11
13
  BASELINE_MODEL = "claude-sonnet-5"
12
14
 
@@ -21,7 +23,8 @@ module Clacky
21
23
  pricing = Clacky::ModelPricing.get_pricing(name)
22
24
  pricing = Clacky::ModelPricing.resolve_deepseek_tier(pricing, now) if pricing && pricing[:deepseek]
23
25
  next unless pricing
24
- rate = Clacky::ModelPricing.discount_rate(name)
26
+ info = Clacky::ModelPricing.discount_info(name)
27
+ rate = info ? info[:rate] : 1.0
25
28
  # Rates are not powers of two, so the discounted product carries float
26
29
  # noise (3.0 * 0.95 == 2.8499999999999996) that would surface verbatim
27
30
  # in the picker tooltips. Four decimals is already finer than any
@@ -29,7 +32,7 @@ module Clacky
29
32
  pin = (pricing[:input][:default] * rate).round(4)
30
33
  pout = (pricing[:output][:default] * rate).round(4)
31
34
  entry = { in: pin, out: pout, ratio: (pin + pout) / base_total }
32
- entry[:discount] = { rate: rate } if rate < 1.0
35
+ entry[:discount] = { rate: rate, reason: info[:reason] } if rate < 1.0
33
36
  prices[name] = entry
34
37
  end
35
38