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
|
@@ -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
|
-
|
|
565
|
+
the Recycle Bin and can be recovered from there. Report what you did.
|
|
566
566
|
|
|
567
567
|
### C.7. Close
|
|
568
568
|
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -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.",
|
data/lib/clacky/locales/zh.rb
CHANGED
|
@@ -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).
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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
|
-
"
|
|
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-
|
|
152
|
-
#
|
|
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-
|
|
177
|
-
"abs-claude-fable-5" => "abs-claude-haiku-
|
|
178
|
-
"abs-claude-opus-5-5" => "abs-claude-haiku-
|
|
179
|
-
"abs-claude-opus-5" => "abs-claude-haiku-
|
|
180
|
-
"abs-claude-opus-4-8" => "abs-claude-haiku-
|
|
181
|
-
"abs-claude-opus-4-7" => "abs-claude-haiku-
|
|
182
|
-
"abs-claude-sonnet-5-5" => "abs-claude-haiku-
|
|
183
|
-
"abs-claude-sonnet-5" => "abs-claude-haiku-
|
|
184
|
-
"abs-claude-sonnet-4-6" => "abs-claude-haiku-
|
|
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/
|
|
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
|
-
|
|
985
|
-
|
|
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
|
-
|
|
993
|
-
|
|
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
|
|
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
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
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
|
|
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
|
-
|
|
4694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
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
|
-
#
|
|
7559
|
-
#
|
|
7560
|
-
#
|
|
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
|
-
#
|
|
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,
|
|
7920
|
-
auto_name += "…" if content.strip.length >
|
|
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
|
-
|
|
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
|
|