openclacky 1.5.2 → 1.5.3
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 +24 -0
- data/lib/clacky/agent_config.rb +36 -8
- data/lib/clacky/client.rb +7 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +10 -3
- data/lib/clacky/message_format/anthropic.rb +27 -1
- data/lib/clacky/message_format/open_ai.rb +69 -4
- data/lib/clacky/providers.rb +51 -6
- data/lib/clacky/server/http_server.rb +20 -12
- data/lib/clacky/utils/model_pricing.rb +8 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +149 -7
- data/lib/clacky/web/app.js +8 -3
- data/lib/clacky/web/components/code-editor.js +50 -12
- data/lib/clacky/web/features/billing/view.js +2 -1
- data/lib/clacky/web/features/extensions/store.js +15 -6
- data/lib/clacky/web/features/extensions/view.js +8 -4
- data/lib/clacky/web/features/workspace/store.js +9 -0
- data/lib/clacky/web/features/workspace/view.js +8 -1
- data/lib/clacky/web/i18n.js +4 -0
- data/lib/clacky/web/index.html +1 -2
- data/lib/clacky/web/settings.js +8 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04d2d81c4aaa380d4600066802c78e1137bdad1cb16c3e5e47e32e69ede15da6
|
|
4
|
+
data.tar.gz: 9a332e0305e96bae97799ce707ef492b24735e33ef8da70a390c06a10ac11ab4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 435b6c1bd6aac4c2916cf172e5d46dece9a5707d1b18119d4744d29293c1f7b8fb112709718d24b8b9b3a871bfa228294a282525d9b6f7ed14550b89eaaa2793
|
|
7
|
+
data.tar.gz: 90599042f3d933db9b7681c33d539937585cd85542d635696ea159056aa47dbe34f86971bd1db6541c4dee28ee73c66e8f4648d91d8f3b1897b179ce9a2b00fc
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ 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.3] - 2026-07-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Per-model max output token limits (MODEL_MAX_OUTPUT) applied automatically when sending requests
|
|
11
|
+
- GLM-5.2 added to preset model lineup with pricing table
|
|
12
|
+
- MiMo-V2.5 thinking mode support with max_completion_tokens
|
|
13
|
+
- reasoning_effort mapped to GLM thinking mode and Kimi-K3 thinking levels
|
|
14
|
+
- File-tree save support added to CodeEditor with markdown split-preview panel
|
|
15
|
+
- Unlisted badge and source=brand routing for brand-private extensions
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
- Markdown preview styles refined: hr, inline images, tables, modal sizing
|
|
19
|
+
- OpenAI client refactored with token_field_for and apply_reasoning_params helpers
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- URL fallback state (base URL) now resets correctly when switching models in quick switch
|
|
23
|
+
- Kimi K3 now uses correct thinking:{type:'enabled'} format for Anthropic client
|
|
24
|
+
- MiMo preset aligned with V2.5 lineup and Token Plan endpoint
|
|
25
|
+
- ext-studio sessions no longer incorrectly tagged as setup sessions (C-5722)
|
|
26
|
+
- localhost URLs no longer trigger OpenClacky failover (find_by_base_url guard)
|
|
27
|
+
- API key toggle icon default and click states corrected
|
|
28
|
+
- Share button on billing page now uses SVG icon instead of emoji
|
|
29
|
+
- Share button hover border color uses accent-primary token
|
|
30
|
+
|
|
7
31
|
## [1.5.2] - 2026-07-27
|
|
8
32
|
|
|
9
33
|
### Added
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -476,7 +476,13 @@ module Clacky
|
|
|
476
476
|
@current_model_id = id
|
|
477
477
|
@current_model_index = index
|
|
478
478
|
|
|
479
|
-
|
|
479
|
+
if previous_id != id
|
|
480
|
+
@session_model_overlay = nil
|
|
481
|
+
# Reset URL fallback when switching models — the new model has its own
|
|
482
|
+
# base_url and the old fallback endpoint is irrelevant (or even wrong).
|
|
483
|
+
@url_fallback_active = false
|
|
484
|
+
@url_fallback_base_url = nil
|
|
485
|
+
end
|
|
480
486
|
|
|
481
487
|
true
|
|
482
488
|
end
|
|
@@ -492,9 +498,16 @@ module Clacky
|
|
|
492
498
|
index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
|
|
493
499
|
return false if index.nil?
|
|
494
500
|
|
|
501
|
+
previous_id = @current_model_id
|
|
495
502
|
@current_model_id = @models[index]["id"]
|
|
496
503
|
@current_model_index = index
|
|
497
504
|
|
|
505
|
+
if previous_id != @current_model_id
|
|
506
|
+
# Reset URL fallback when switching models — same rationale as switch_model_by_id.
|
|
507
|
+
@url_fallback_active = false
|
|
508
|
+
@url_fallback_base_url = nil
|
|
509
|
+
end
|
|
510
|
+
|
|
498
511
|
true
|
|
499
512
|
end
|
|
500
513
|
|
|
@@ -1061,19 +1074,34 @@ module Clacky
|
|
|
1061
1074
|
# probe or reset it automatically, keeping the logic simple.
|
|
1062
1075
|
|
|
1063
1076
|
# Look up the fallback base URL for the current model's provider.
|
|
1064
|
-
# Returns nil if
|
|
1077
|
+
# Returns nil if:
|
|
1078
|
+
# - provider cannot be determined
|
|
1079
|
+
# - provider is not openclacky (URL failover is only supported for the
|
|
1080
|
+
# OpenClacky gateway; other providers have no secondary endpoint)
|
|
1081
|
+
# - current base_url is already the fallback URL (user picked the
|
|
1082
|
+
# Secondary/China node directly — switching to the same URL does nothing)
|
|
1065
1083
|
# @return [String, nil]
|
|
1066
1084
|
def fallback_base_url_for_current_provider
|
|
1067
1085
|
m = current_model
|
|
1068
1086
|
return nil unless m
|
|
1069
1087
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
return nil
|
|
1088
|
+
# Use strict URL matching to identify the provider — no inference.
|
|
1089
|
+
# resolve_provider would incorrectly return "openclacky" for localhost
|
|
1090
|
+
# addresses and clacky-* api keys, triggering unwanted failover.
|
|
1091
|
+
# find_by_base_url only matches registered preset URLs, so localhost
|
|
1092
|
+
# and other non-openclacky endpoints correctly return nil.
|
|
1093
|
+
provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
|
|
1094
|
+
return nil unless provider_id == "openclacky"
|
|
1095
|
+
|
|
1096
|
+
fallback_url = Clacky::Providers.fallback_base_url(provider_id)
|
|
1097
|
+
return nil unless fallback_url
|
|
1098
|
+
|
|
1099
|
+
# Don't offer a fallback when the current base_url is already the
|
|
1100
|
+
# fallback endpoint — switching to the same URL accomplishes nothing.
|
|
1101
|
+
current_base = m["base_url"].to_s.chomp("/")
|
|
1102
|
+
return nil if current_base == fallback_url.to_s.chomp("/")
|
|
1075
1103
|
|
|
1076
|
-
|
|
1104
|
+
fallback_url
|
|
1077
1105
|
end
|
|
1078
1106
|
|
|
1079
1107
|
# Activate the URL fallback: override base_url with the secondary gateway.
|
data/lib/clacky/client.rb
CHANGED
|
@@ -337,6 +337,13 @@ module Clacky
|
|
|
337
337
|
# ── OpenAI request / response ─────────────────────────────────────────────
|
|
338
338
|
|
|
339
339
|
def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil, capability_model: nil)
|
|
340
|
+
# Override max_tokens when the model declares a higher output ceiling
|
|
341
|
+
# in Providers::MODEL_MAX_OUTPUT. Without this, strong models (GLM-5.2,
|
|
342
|
+
# Kimi-K3, MiMo-V2.5) are throttled to the 16K global default.
|
|
343
|
+
model_for_limit = capability_model || model
|
|
344
|
+
model_limit = Providers.max_output_for(model_for_limit)
|
|
345
|
+
max_tokens = model_limit if model_limit
|
|
346
|
+
|
|
340
347
|
# Apply cache_control markers to messages when caching is enabled.
|
|
341
348
|
# OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
|
|
342
349
|
messages = apply_message_caching(messages) if caching_enabled
|
|
@@ -362,7 +362,7 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
362
362
|
post "/develop" do
|
|
363
363
|
idea = presence(json_body["idea"])
|
|
364
364
|
name = idea ? "扩展开发: #{idea[0, 40]}" : "扩展开发"
|
|
365
|
-
sid = create_session(name: name, prompt: idea, profile: "ext-developer"
|
|
365
|
+
sid = create_session(name: name, prompt: idea, profile: "ext-developer")
|
|
366
366
|
json(ok: true, session_id: sid)
|
|
367
367
|
end
|
|
368
368
|
|
|
@@ -1957,7 +1957,7 @@
|
|
|
1957
1957
|
.studio-form-status { font-size: 12px; color: var(--color-text-tertiary); }
|
|
1958
1958
|
.studio-btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
1959
1959
|
.studio-readme-overlay { align-items: center; justify-content: center; }
|
|
1960
|
-
.studio-readme-modal { width:
|
|
1960
|
+
.studio-readme-modal { max-width: 1280px; width: 98%; height: 90vh; max-height: 960px; border-radius: var(--radius-md, 8px); margin: 0; padding: 24px 28px 20px; display: flex; flex-direction: column; }
|
|
1961
1961
|
.studio-readme-modal .studio-modal-title { margin-bottom: 16px; flex-shrink: 0; }
|
|
1962
1962
|
.studio-readme-split { display: flex; gap: 14px; flex: 1; min-height: 0; margin-bottom: 18px; }
|
|
1963
1963
|
.studio-readme-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
@@ -1965,13 +1965,20 @@
|
|
|
1965
1965
|
.studio-readme-textarea { flex: 1; resize: none; font-family: monospace; font-size: 12.5px; line-height: 1.65; border-radius: var(--radius-sm); }
|
|
1966
1966
|
.studio-readme-preview { flex: 1; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 12px 16px; background: var(--color-bg-secondary); font-size: 13px; line-height: 1.75; }
|
|
1967
1967
|
.studio-readme-preview > *:first-child { margin-top: 0; }
|
|
1968
|
-
.studio-readme-preview img { max-width: 100%; height: auto; display: block; border-radius: var(--radius-sm); }
|
|
1969
1968
|
.studio-readme-screenshots { border-top: 1px solid var(--color-border-primary); padding-top: 14px; margin-bottom: 6px; }
|
|
1970
1969
|
.studio-readme-screenshots-row { display: flex; align-items: center; gap: 10px; }
|
|
1971
1970
|
.studio-readme-screenshots-hint { font-size: 11px; color: var(--color-text-muted); }
|
|
1972
1971
|
.studio-readme-card { margin-top: 8px; }
|
|
1973
1972
|
.studio-readme-card-preview { max-height: 200px; overflow-y: auto; font-size: 13px; }
|
|
1974
|
-
.extension-readme-body img { max-width: min(800px, 100%); height: auto; }
|
|
1973
|
+
.extension-readme-body img { max-width: min(800px, 100%); height: auto; display: inline; vertical-align: middle; }
|
|
1974
|
+
.studio-readme-preview img { max-width: 100%; height: auto; display: inline; vertical-align: middle; }
|
|
1975
|
+
.studio-readme-preview hr, .extension-readme-body hr { border: none; border-top: 1px solid var(--color-border-primary); margin: 1.25em 0; }
|
|
1976
|
+
.studio-readme-preview table, .extension-readme-body table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.8125rem; }
|
|
1977
|
+
.studio-readme-preview th, .studio-readme-preview td, .extension-readme-body th, .extension-readme-body td { border: 1px solid var(--color-border-primary); padding: 0.375em 0.75em; text-align: left; }
|
|
1978
|
+
.studio-readme-preview th, .extension-readme-body th { background: rgba(128,128,128,0.15); font-weight: 600; }
|
|
1979
|
+
.studio-readme-preview pre, .extension-readme-body pre { background: rgba(128,128,128,0.15); border-radius: 6px; padding: 0.75em 1em; overflow-x: auto; margin: 0.625em 0; }
|
|
1980
|
+
.studio-readme-preview code, .extension-readme-body code { font-family: monospace; font-size: 0.8125em; background: rgba(128,128,128,0.15); border-radius: 4px; padding: 0.15em 0.4em; }
|
|
1981
|
+
.studio-readme-preview pre code, .extension-readme-body pre code { background: none; padding: 0; }
|
|
1975
1982
|
.studio-link-btn { background: none; border: none; padding: 0; font-size: 12px; color: var(--color-accent-primary); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
1976
1983
|
.studio-link-btn:hover { color: var(--color-accent-hover); }
|
|
1977
1984
|
`;
|
|
@@ -77,7 +77,33 @@ module Clacky
|
|
|
77
77
|
body[:system] = system_text unless system_text.empty?
|
|
78
78
|
body[:tools] = api_tools if api_tools&.any?
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
# Kimi (Moonshot Coding Plan) models routed through the Anthropic
|
|
81
|
+
# /v1/messages format expect thinking:{type:"enabled"} — the native
|
|
82
|
+
# Anthropic "adaptive" type is Claude-specific and is silently ignored
|
|
83
|
+
# by the Kimi backend, so thinking never actually activates on K3.
|
|
84
|
+
#
|
|
85
|
+
# K3 additionally supports a "max" effort level (strongest reasoning)
|
|
86
|
+
# beyond the standard low/medium/high triple. The generic
|
|
87
|
+
# normalized_effort() helper drops "max" because it only whitelists
|
|
88
|
+
# the standard three levels, making K3's signature strongest-reasoning
|
|
89
|
+
# mode unreachable through this format adapter.
|
|
90
|
+
#
|
|
91
|
+
# This branch only changes K3's wire format; every other model keeps
|
|
92
|
+
# using the adaptive path below unchanged. When reasoning_effort is
|
|
93
|
+
# nil/empty we leave the body untouched (provider default), and we
|
|
94
|
+
# never emit output_config for "on" so the backend can pick its own
|
|
95
|
+
# default — both behaviours match the official kimi CLI.
|
|
96
|
+
if model.to_s.match?(/\A(kimi-)?k3/i)
|
|
97
|
+
effort_str = reasoning_effort.to_s
|
|
98
|
+
k3_effort =
|
|
99
|
+
case effort_str
|
|
100
|
+
when "max", "xhigh" then "max"
|
|
101
|
+
when "high", "medium", "low" then effort_str
|
|
102
|
+
else nil
|
|
103
|
+
end
|
|
104
|
+
body[:thinking] = { type: "enabled" }
|
|
105
|
+
body[:output_config] = { effort: k3_effort } if k3_effort
|
|
106
|
+
elsif (effort = normalized_effort(reasoning_effort))
|
|
81
107
|
body[:thinking] = { type: "adaptive" }
|
|
82
108
|
body[:output_config] = { effort: effort }
|
|
83
109
|
end
|
|
@@ -47,7 +47,7 @@ module Clacky
|
|
|
47
47
|
def build_request_body(messages, model, tools, max_tokens, caching_enabled, vision_supported: true, reasoning_effort: nil)
|
|
48
48
|
api_messages = messages.map { |msg| normalize_message_content(msg, vision_supported: vision_supported) }
|
|
49
49
|
|
|
50
|
-
body = { model: model,
|
|
50
|
+
body = { model: model, token_field_for(model) => max_tokens, messages: api_messages }
|
|
51
51
|
|
|
52
52
|
if tools&.any?
|
|
53
53
|
if caching_enabled
|
|
@@ -59,9 +59,7 @@ module Clacky
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
body[:reasoning_effort] = reasoning_effort.to_s
|
|
64
|
-
end
|
|
62
|
+
apply_reasoning_params(body, model, reasoning_effort)
|
|
65
63
|
|
|
66
64
|
body
|
|
67
65
|
end
|
|
@@ -196,6 +194,73 @@ module Clacky
|
|
|
196
194
|
|
|
197
195
|
# ── Private helpers ───────────────────────────────────────────────────────
|
|
198
196
|
|
|
197
|
+
# Returns the token-limit field name for the given model.
|
|
198
|
+
# Most OpenAI-compatible APIs use :max_tokens; MiMo-V2.5 (Xiaomi) uses
|
|
199
|
+
# :max_completion_tokens to cap the combined thinking + answer length.
|
|
200
|
+
private_class_method def self.token_field_for(model)
|
|
201
|
+
model.to_s.match?(/^mimo-v2/i) ? :max_completion_tokens : :max_tokens
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Apply model-specific reasoning / thinking parameters to the request body.
|
|
205
|
+
#
|
|
206
|
+
# Different model families expose extended reasoning through different API
|
|
207
|
+
# fields. We map the shared internal reasoning_effort value to each
|
|
208
|
+
# family's native parameters in-place.
|
|
209
|
+
#
|
|
210
|
+
# Zero side-effects: when reasoning_effort is nil/empty the body is
|
|
211
|
+
# unchanged, preserving the provider default for all models.
|
|
212
|
+
private_class_method def self.apply_reasoning_params(body, model, reasoning_effort)
|
|
213
|
+
effort_str = reasoning_effort.to_s
|
|
214
|
+
|
|
215
|
+
if model.to_s.match?(/^glm-[45]/i)
|
|
216
|
+
# GLM (Zhipu / Z.ai) supports a native top-level "thinking" field
|
|
217
|
+
# ({type: "enabled"|"disabled"}) plus a restricted reasoning_effort
|
|
218
|
+
# that only accepts "max" or "high". Other effort levels collapse
|
|
219
|
+
# to "high". Send both fields so GLM activates thinking correctly.
|
|
220
|
+
if %w[off nothink disabled].include?(effort_str)
|
|
221
|
+
body[:thinking] = { type: "disabled" }
|
|
222
|
+
elsif !effort_str.empty?
|
|
223
|
+
glm_effort =
|
|
224
|
+
case effort_str
|
|
225
|
+
when "max", "xhigh" then "max"
|
|
226
|
+
when "high" then "high"
|
|
227
|
+
when "medium", "low" then "high" # GLM collapses these to "high"
|
|
228
|
+
else "max"
|
|
229
|
+
end
|
|
230
|
+
body[:thinking] = { type: "enabled" }
|
|
231
|
+
body[:reasoning_effort] = glm_effort
|
|
232
|
+
end
|
|
233
|
+
elsif model.to_s.match?(/^kimi-k3/i)
|
|
234
|
+
# Kimi K3 reasoning_effort only accepts low/high/max (default max).
|
|
235
|
+
# Thinking is always enabled and cannot be turned off; "off" maps
|
|
236
|
+
# to the lowest intensity "low". Unsupported effort levels (medium,
|
|
237
|
+
# xhigh) would be rejected or ignored by the server.
|
|
238
|
+
if %w[off nothink disabled].include?(effort_str)
|
|
239
|
+
body[:reasoning_effort] = "low"
|
|
240
|
+
elsif !effort_str.empty?
|
|
241
|
+
body[:reasoning_effort] =
|
|
242
|
+
case effort_str
|
|
243
|
+
when "max", "xhigh" then "max"
|
|
244
|
+
when "high" then "high"
|
|
245
|
+
when "medium", "low" then "low"
|
|
246
|
+
else "max"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
elsif model.to_s.match?(/^mimo-v2/i)
|
|
250
|
+
# MiMo-V2.5 (Xiaomi) controls reasoning via a top-level
|
|
251
|
+
# thinking:{type:...} field rather than reasoning_effort. Map the
|
|
252
|
+
# internal reasoning_effort value to thinking on/off so MiMo does
|
|
253
|
+
# not receive an unsupported parameter.
|
|
254
|
+
if %w[off nothink disabled].include?(effort_str)
|
|
255
|
+
body[:thinking] = { type: "disabled" }
|
|
256
|
+
elsif !effort_str.empty?
|
|
257
|
+
body[:thinking] = { type: "enabled" }
|
|
258
|
+
end
|
|
259
|
+
elsif reasoning_effort && !effort_str.empty?
|
|
260
|
+
body[:reasoning_effort] = effort_str
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
199
264
|
private_class_method def self.parse_tool_calls(raw)
|
|
200
265
|
return nil if raw.nil? || raw.empty?
|
|
201
266
|
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -348,13 +348,30 @@ module Clacky
|
|
|
348
348
|
"base_url" => "https://api.xiaomimimo.com/v1",
|
|
349
349
|
"api" => "openai-completions",
|
|
350
350
|
"default_model" => "mimo-v2.5-pro",
|
|
351
|
-
|
|
352
|
-
#
|
|
351
|
+
# The MiMo-V2 family (mimo-v2-pro / mimo-v2-omni) was retired on
|
|
352
|
+
# 2026-06-30 and the model ids are no longer accepted by the API. The
|
|
353
|
+
# current lineup is the V2.5 series:
|
|
354
|
+
# - mimo-v2.5-pro: text reasoning flagship, no vision
|
|
355
|
+
# - mimo-v2.5: native omni-modal (image/video/audio/text), vision-capable
|
|
356
|
+
# Source: https://platform.xiaomimimo.com/docs/zh-CN/model
|
|
357
|
+
"models" => ["mimo-v2.5-pro", "mimo-v2.5"],
|
|
353
358
|
"capabilities" => { "vision" => false }.freeze,
|
|
354
359
|
"model_capabilities" => {
|
|
355
|
-
"mimo-v2
|
|
360
|
+
"mimo-v2.5" => { "vision" => true }.freeze
|
|
356
361
|
}.freeze,
|
|
357
|
-
"default_ocr_model" => "mimo-v2
|
|
362
|
+
"default_ocr_model" => "mimo-v2.5",
|
|
363
|
+
# Xiaomi serves the same V2.5 lineup from two billing endpoints: the
|
|
364
|
+
# pay-as-you-go API (api.xiaomimimo.com) and the Token Plan subscription
|
|
365
|
+
# endpoint (token-plan-cn.xiaomimimo.com). Both accept identical model
|
|
366
|
+
# ids and share one capability profile, so a single preset with
|
|
367
|
+
# endpoint_variants recognises both. Without this, users on the Token
|
|
368
|
+
# Plan endpoint fell through to "unknown provider" and the conservative
|
|
369
|
+
# vision=true default applied to every model — sending images to the
|
|
370
|
+
# text-only mimo-v2.5-pro, which rejects image input.
|
|
371
|
+
"endpoint_variants" => [
|
|
372
|
+
{ "label" => "Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mimo_payg", "base_url" => "https://api.xiaomimimo.com/v1", "region" => "cn" }.freeze,
|
|
373
|
+
{ "label" => "Token Plan", "label_key" => "settings.models.baseurl.variant.mimo_token_plan", "base_url" => "https://token-plan-cn.xiaomimimo.com/v1", "region" => "cn" }.freeze
|
|
374
|
+
].freeze,
|
|
358
375
|
"website_url" => "https://platform.xiaomimimo.com/"
|
|
359
376
|
}.freeze,
|
|
360
377
|
|
|
@@ -362,8 +379,8 @@ module Clacky
|
|
|
362
379
|
"name" => "GLM (Z.ai / Zhipu)",
|
|
363
380
|
"base_url" => "https://open.bigmodel.cn/api/paas/v4",
|
|
364
381
|
"api" => "openai-completions",
|
|
365
|
-
"default_model" => "glm-5.
|
|
366
|
-
"models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
382
|
+
"default_model" => "glm-5.2",
|
|
383
|
+
"models" => ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
367
384
|
# Zhipu / Z.ai expose four functionally-equivalent endpoints:
|
|
368
385
|
# two regional sites (mainland open.bigmodel.cn + international api.z.ai)
|
|
369
386
|
# each with a general-billing and a Coding-Plan subpath. They share the
|
|
@@ -520,6 +537,22 @@ module Clacky
|
|
|
520
537
|
|
|
521
538
|
MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
|
|
522
539
|
|
|
540
|
+
# Per-model maximum output token limits.
|
|
541
|
+
#
|
|
542
|
+
# The Agent global default (@max_tokens = 16_384) is tuned for the lowest
|
|
543
|
+
# common denominator. Strong reasoning models (GLM-5.2, Kimi-K3, MiMo-V2.5)
|
|
544
|
+
# support 64K–128K output but are artificially throttled to 16K, causing
|
|
545
|
+
# truncation on long reasoning chains and code generation.
|
|
546
|
+
#
|
|
547
|
+
# Entries are matched top-to-bottom; the first match wins. Models not
|
|
548
|
+
# listed here fall back to the global default.
|
|
549
|
+
MODEL_MAX_OUTPUT = [
|
|
550
|
+
{ pattern: /glm/i, limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
|
|
551
|
+
{ pattern: /kimi-k3/i, limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
|
|
552
|
+
{ pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
|
|
553
|
+
{ pattern: /mimo/i, limit: 32_768 } # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
|
|
554
|
+
].freeze
|
|
555
|
+
|
|
523
556
|
class << self
|
|
524
557
|
# Check if a provider preset exists
|
|
525
558
|
# @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
|
|
@@ -559,6 +592,18 @@ module Clacky
|
|
|
559
592
|
preset&.dig("api")
|
|
560
593
|
end
|
|
561
594
|
|
|
595
|
+
# Resolve the per-model maximum output token limit.
|
|
596
|
+
# Returns nil when no MODEL_MAX_OUTPUT entry matches — callers should
|
|
597
|
+
# then fall back to the global default (@max_tokens).
|
|
598
|
+
#
|
|
599
|
+
# @param model_name [String] The model name (e.g. "glm-5.2")
|
|
600
|
+
# @return [Integer, nil] The max output limit, or nil if undeclared
|
|
601
|
+
def max_output_for(model_name)
|
|
602
|
+
return nil if model_name.nil? || model_name.to_s.empty?
|
|
603
|
+
entry = MODEL_MAX_OUTPUT.find { |e| model_name.to_s.match?(e[:pattern]) }
|
|
604
|
+
entry&.[](:limit)
|
|
605
|
+
end
|
|
606
|
+
|
|
562
607
|
# Resolve the API type for a specific provider+model pair.
|
|
563
608
|
#
|
|
564
609
|
# Resolution order:
|
|
@@ -2492,11 +2492,11 @@ module Clacky
|
|
|
2492
2492
|
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2493
2493
|
"hub_active" => market&.dig("hub_active"),
|
|
2494
2494
|
"download_count" => market&.dig("download_count").to_i,
|
|
2495
|
-
#
|
|
2496
|
-
#
|
|
2497
|
-
#
|
|
2498
|
-
#
|
|
2499
|
-
"unlisted" => market.nil?
|
|
2495
|
+
# Mark as unlisted when:
|
|
2496
|
+
# - market is nil (extension no longer exists on the platform), OR
|
|
2497
|
+
# - platform batch API explicitly returned unlisted:true (brand-private
|
|
2498
|
+
# extension removed from all distributions but not yet soft-deleted).
|
|
2499
|
+
"unlisted" => market.nil? || market["unlisted"] == true,
|
|
2500
2500
|
"layer" => container[:layer].to_s,
|
|
2501
2501
|
"installed" => true,
|
|
2502
2502
|
"removable" => true,
|
|
@@ -2582,7 +2582,7 @@ module Clacky
|
|
|
2582
2582
|
"installed" => !container.nil?,
|
|
2583
2583
|
"installed_version" => container&.dig(:version),
|
|
2584
2584
|
"removable" => container && container[:layer] == :installed,
|
|
2585
|
-
"disabled" => container ? container[:disabled] == true : false
|
|
2585
|
+
"disabled" => container ? container[:disabled] == true : false,
|
|
2586
2586
|
)
|
|
2587
2587
|
json_response(res, 200, { ok: true, extension: ext })
|
|
2588
2588
|
else
|
|
@@ -2609,9 +2609,7 @@ module Clacky
|
|
|
2609
2609
|
"homepage" => market ? (market["homepage"] || "") : container[:homepage].to_s,
|
|
2610
2610
|
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2611
2611
|
"hub_active" => market&.dig("hub_active"),
|
|
2612
|
-
|
|
2613
|
-
# marketplace (origin != "self") but is no longer listed there.
|
|
2614
|
-
"unlisted" => market.nil? && container[:origin].to_s != "self",
|
|
2612
|
+
"unlisted" => market.nil? || market["unlisted"] == true,
|
|
2615
2613
|
"installed" => true,
|
|
2616
2614
|
"removable" => true,
|
|
2617
2615
|
"disabled" => container[:disabled] == true,
|
|
@@ -3785,9 +3783,10 @@ module Clacky
|
|
|
3785
3783
|
|
|
3786
3784
|
# POST /api/file-action
|
|
3787
3785
|
# Unified file action endpoint — open locally or download.
|
|
3788
|
-
# Body: { path: String, action: "open" | "download" }
|
|
3786
|
+
# Body: { path: String, action: "open" | "download" | "save" }
|
|
3789
3787
|
# open: opens the file with the OS default handler (local deployments).
|
|
3790
3788
|
# download: returns the file as a download (remote deployments).
|
|
3789
|
+
# save: writes content back to the file. Body must include { content: String }.
|
|
3791
3790
|
def api_file_action(req, res)
|
|
3792
3791
|
body = parse_json_body(req)
|
|
3793
3792
|
path = body["path"]
|
|
@@ -3801,7 +3800,10 @@ module Clacky
|
|
|
3801
3800
|
linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
3802
3801
|
linux_path = File.expand_path(linux_path)
|
|
3803
3802
|
|
|
3804
|
-
|
|
3803
|
+
# For save action, the file may not exist yet — skip the existence check.
|
|
3804
|
+
unless action == "save"
|
|
3805
|
+
return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
|
|
3806
|
+
end
|
|
3805
3807
|
|
|
3806
3808
|
case action
|
|
3807
3809
|
when "open"
|
|
@@ -3816,8 +3818,14 @@ module Clacky
|
|
|
3816
3818
|
when "display-path"
|
|
3817
3819
|
display = Utils::EnvironmentDetector.linux_to_win_path(linux_path)
|
|
3818
3820
|
json_response(res, 200, { ok: true, path: display })
|
|
3821
|
+
when "save"
|
|
3822
|
+
content = body["content"]
|
|
3823
|
+
return json_response(res, 400, { error: "content is required" }) if content.nil?
|
|
3824
|
+
FileUtils.mkdir_p(File.dirname(linux_path))
|
|
3825
|
+
File.write(linux_path, content, encoding: "UTF-8")
|
|
3826
|
+
json_response(res, 200, { ok: true })
|
|
3819
3827
|
else
|
|
3820
|
-
json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download'
|
|
3828
|
+
json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download', 'display-path' or 'save'" })
|
|
3821
3829
|
end
|
|
3822
3830
|
rescue => e
|
|
3823
3831
|
json_response(res, 500, { ok: false, error: e.message })
|
|
@@ -472,6 +472,12 @@ module Clacky
|
|
|
472
472
|
# endpoints don't charge separately for cache writes (Z.ai's page lists
|
|
473
473
|
# "Cached Input Storage: Limited-time Free"), so bill writes at the
|
|
474
474
|
# regular input miss rate for safe "displayed ≤ actual" behaviour.
|
|
475
|
+
"glm-5.2" => {
|
|
476
|
+
input: { default: 1.40, over_200k: 1.40 },
|
|
477
|
+
output: { default: 4.40, over_200k: 4.40 },
|
|
478
|
+
cache: { write: 1.40, read: 0.26 }
|
|
479
|
+
},
|
|
480
|
+
|
|
475
481
|
"glm-5.1" => {
|
|
476
482
|
input: { default: 1.40, over_200k: 1.40 },
|
|
477
483
|
output: { default: 4.40, over_200k: 4.40 },
|
|
@@ -786,6 +792,8 @@ module Clacky
|
|
|
786
792
|
# (mainland bigmodel.cn vs intl z.ai) the user configured.
|
|
787
793
|
# Strict anchored match so unrelated strings like "glm-5-x-foo"
|
|
788
794
|
# don't silently borrow a nearby model's rate.
|
|
795
|
+
when /^glm-5\.2$/i
|
|
796
|
+
"glm-5.2"
|
|
789
797
|
when /^glm-5\.1$/i
|
|
790
798
|
"glm-5.1"
|
|
791
799
|
when /^glm-5v-turbo$/i
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -5376,7 +5376,7 @@ body {
|
|
|
5376
5376
|
}
|
|
5377
5377
|
.field-input-row .field-input { flex: 1; }
|
|
5378
5378
|
.btn-toggle-key {
|
|
5379
|
-
background: var(--color-
|
|
5379
|
+
background: var(--color-bg-primary);
|
|
5380
5380
|
border: 1px solid var(--color-border-primary);
|
|
5381
5381
|
border-radius: 6px;
|
|
5382
5382
|
color: var(--color-text-secondary);
|
|
@@ -5384,8 +5384,19 @@ body {
|
|
|
5384
5384
|
padding: 0.25rem 0.5rem;
|
|
5385
5385
|
cursor: pointer;
|
|
5386
5386
|
flex-shrink: 0;
|
|
5387
|
+
display: flex;
|
|
5388
|
+
align-items: center;
|
|
5389
|
+
justify-content: center;
|
|
5390
|
+
}
|
|
5391
|
+
.btn-toggle-key:hover {
|
|
5392
|
+
color: var(--color-text-primary);
|
|
5393
|
+
background: var(--color-bg-hover);
|
|
5394
|
+
}
|
|
5395
|
+
.btn-toggle-key:active {
|
|
5396
|
+
background: var(--color-bg-hover);
|
|
5397
|
+
filter: brightness(0.94);
|
|
5398
|
+
transform: scale(0.95);
|
|
5387
5399
|
}
|
|
5388
|
-
.btn-toggle-key:hover { color: var(--color-text-primary); }
|
|
5389
5400
|
.field-checkbox {
|
|
5390
5401
|
width: 1rem;
|
|
5391
5402
|
height: 1rem;
|
|
@@ -5778,14 +5789,15 @@ body {
|
|
|
5778
5789
|
max-height: 90vh;
|
|
5779
5790
|
display: flex;
|
|
5780
5791
|
flex-direction: column;
|
|
5781
|
-
overflow:
|
|
5792
|
+
/* Do NOT use overflow:hidden here — it clips absolutely-positioned dropdowns
|
|
5793
|
+
inside the modal (e.g. the provider custom-select). Border-radius still
|
|
5794
|
+
applies because there is no overflow to clip. */
|
|
5795
|
+
overflow: visible;
|
|
5782
5796
|
}
|
|
5783
5797
|
.model-edit-modal-content {
|
|
5784
5798
|
max-width: 500px;
|
|
5785
5799
|
}
|
|
5786
|
-
|
|
5787
|
-
overflow-y: auto;
|
|
5788
|
-
}
|
|
5800
|
+
/* overflow override for model-edit modal is applied after .modal-content .modal-body below */
|
|
5789
5801
|
.modal-content .modal-header {
|
|
5790
5802
|
display: flex;
|
|
5791
5803
|
align-items: center;
|
|
@@ -5823,6 +5835,12 @@ body {
|
|
|
5823
5835
|
gap: 0.875rem;
|
|
5824
5836
|
padding: 1.25rem;
|
|
5825
5837
|
}
|
|
5838
|
+
/* model-edit modal: allow absolutely-positioned dropdowns to escape the body.
|
|
5839
|
+
Must come after .modal-content .modal-body to win the cascade. */
|
|
5840
|
+
.modal-content.model-edit-modal-content .modal-body {
|
|
5841
|
+
overflow: visible;
|
|
5842
|
+
padding-bottom: 0;
|
|
5843
|
+
}
|
|
5826
5844
|
.modal-content .modal-footer {
|
|
5827
5845
|
display: flex;
|
|
5828
5846
|
align-items: center;
|
|
@@ -6206,6 +6224,9 @@ body {
|
|
|
6206
6224
|
font-size: 0.75rem;
|
|
6207
6225
|
flex: 1;
|
|
6208
6226
|
}
|
|
6227
|
+
.model-test-result:empty {
|
|
6228
|
+
display: none;
|
|
6229
|
+
}
|
|
6209
6230
|
.result-ok { color: var(--color-success); }
|
|
6210
6231
|
.result-fail { color: var(--color-error); }
|
|
6211
6232
|
.result-testing { color: var(--color-text-secondary); }
|
|
@@ -8170,6 +8191,127 @@ body {
|
|
|
8170
8191
|
border-radius: 4px;
|
|
8171
8192
|
}
|
|
8172
8193
|
|
|
8194
|
+
/* ── Markdown split-pane layout ─────────────────────────────────────────── */
|
|
8195
|
+
.code-editor-modal--md {
|
|
8196
|
+
max-width: 1280px;
|
|
8197
|
+
width: 98%;
|
|
8198
|
+
height: 90vh;
|
|
8199
|
+
max-height: 960px;
|
|
8200
|
+
}
|
|
8201
|
+
.code-editor-body--split {
|
|
8202
|
+
display: flex;
|
|
8203
|
+
flex-direction: row;
|
|
8204
|
+
overflow: hidden;
|
|
8205
|
+
}
|
|
8206
|
+
.code-editor-split-left {
|
|
8207
|
+
flex: 1;
|
|
8208
|
+
min-width: 0;
|
|
8209
|
+
overflow: hidden;
|
|
8210
|
+
border-right: 1px solid var(--color-border-primary);
|
|
8211
|
+
display: flex;
|
|
8212
|
+
flex-direction: column;
|
|
8213
|
+
}
|
|
8214
|
+
.code-editor-split-left .cm-editor {
|
|
8215
|
+
height: 100%;
|
|
8216
|
+
flex: 1;
|
|
8217
|
+
}
|
|
8218
|
+
.code-editor-split-left > .cm-editor {
|
|
8219
|
+
height: 100%;
|
|
8220
|
+
}
|
|
8221
|
+
.code-editor-split-divider {
|
|
8222
|
+
display: none;
|
|
8223
|
+
}
|
|
8224
|
+
.code-editor-split-right {
|
|
8225
|
+
flex: 1;
|
|
8226
|
+
min-width: 0;
|
|
8227
|
+
display: flex;
|
|
8228
|
+
flex-direction: column;
|
|
8229
|
+
overflow: hidden;
|
|
8230
|
+
}
|
|
8231
|
+
.code-editor-markdown-preview {
|
|
8232
|
+
flex: 1;
|
|
8233
|
+
overflow-y: auto;
|
|
8234
|
+
padding: 0 1.25rem;
|
|
8235
|
+
font-size: 0.875rem;
|
|
8236
|
+
line-height: 1.7;
|
|
8237
|
+
color: var(--color-text-primary);
|
|
8238
|
+
}
|
|
8239
|
+
.code-editor-markdown-preview h1,
|
|
8240
|
+
.code-editor-markdown-preview h2,
|
|
8241
|
+
.code-editor-markdown-preview h3,
|
|
8242
|
+
.code-editor-markdown-preview h4 {
|
|
8243
|
+
margin-top: 1.25em;
|
|
8244
|
+
margin-bottom: 0.5em;
|
|
8245
|
+
font-weight: 600;
|
|
8246
|
+
}
|
|
8247
|
+
.code-editor-markdown-preview h1:first-child,
|
|
8248
|
+
.code-editor-markdown-preview h2:first-child,
|
|
8249
|
+
.code-editor-markdown-preview h3:first-child { margin-top: 0; }
|
|
8250
|
+
.code-editor-markdown-preview h1 { font-size: 1.375rem; }
|
|
8251
|
+
.code-editor-markdown-preview h2 { font-size: 1.125rem; border-bottom: 1px solid var(--color-border); padding-bottom: 0.25em; }
|
|
8252
|
+
.code-editor-markdown-preview h3 { font-size: 1rem; }
|
|
8253
|
+
.code-editor-markdown-preview p { margin: 0.5em 0; }
|
|
8254
|
+
.code-editor-markdown-preview ul,
|
|
8255
|
+
.code-editor-markdown-preview ol { padding-left: 1.5em; margin: 0.5em 0; }
|
|
8256
|
+
.code-editor-markdown-preview li { margin: 0.2em 0; }
|
|
8257
|
+
.code-editor-markdown-preview code {
|
|
8258
|
+
font-family: var(--font-mono, 'SF Mono', 'Fira Code', monospace);
|
|
8259
|
+
font-size: 0.8125em;
|
|
8260
|
+
background: rgba(128, 128, 128, 0.15);
|
|
8261
|
+
border-radius: 4px;
|
|
8262
|
+
padding: 0.15em 0.4em;
|
|
8263
|
+
}
|
|
8264
|
+
.code-editor-markdown-preview pre {
|
|
8265
|
+
background: rgba(128, 128, 128, 0.15);
|
|
8266
|
+
border-radius: 8px;
|
|
8267
|
+
padding: 0.875em;
|
|
8268
|
+
overflow-x: auto;
|
|
8269
|
+
margin: 0.625em 0;
|
|
8270
|
+
}
|
|
8271
|
+
.code-editor-markdown-preview pre code {
|
|
8272
|
+
background: none;
|
|
8273
|
+
padding: 0;
|
|
8274
|
+
font-size: 0.8125rem;
|
|
8275
|
+
}
|
|
8276
|
+
.code-editor-markdown-preview blockquote {
|
|
8277
|
+
border-left: 3px solid var(--color-accent-primary, #7c3aed);
|
|
8278
|
+
margin: 0.75em 0;
|
|
8279
|
+
padding: 0.25em 1em;
|
|
8280
|
+
color: var(--color-text-secondary);
|
|
8281
|
+
}
|
|
8282
|
+
.code-editor-markdown-preview a {
|
|
8283
|
+
color: var(--color-accent-primary);
|
|
8284
|
+
text-decoration: none;
|
|
8285
|
+
}
|
|
8286
|
+
.code-editor-markdown-preview a:hover { text-decoration: underline; }
|
|
8287
|
+
.code-editor-markdown-preview img {
|
|
8288
|
+
display: inline;
|
|
8289
|
+
vertical-align: middle;
|
|
8290
|
+
max-width: 100%;
|
|
8291
|
+
height: auto;
|
|
8292
|
+
}
|
|
8293
|
+
.code-editor-markdown-preview hr {
|
|
8294
|
+
border: none;
|
|
8295
|
+
border-top: 1px solid var(--color-border-primary);
|
|
8296
|
+
margin: 1.25em 0;
|
|
8297
|
+
}
|
|
8298
|
+
.code-editor-markdown-preview table {
|
|
8299
|
+
border-collapse: collapse;
|
|
8300
|
+
width: 100%;
|
|
8301
|
+
margin: 0.75em 0;
|
|
8302
|
+
font-size: 0.8125rem;
|
|
8303
|
+
}
|
|
8304
|
+
.code-editor-markdown-preview th,
|
|
8305
|
+
.code-editor-markdown-preview td {
|
|
8306
|
+
border: 1px solid var(--color-border-primary);
|
|
8307
|
+
padding: 0.375em 0.75em;
|
|
8308
|
+
text-align: left;
|
|
8309
|
+
}
|
|
8310
|
+
.code-editor-markdown-preview th {
|
|
8311
|
+
background: rgba(128, 128, 128, 0.15);
|
|
8312
|
+
font-weight: 600;
|
|
8313
|
+
}
|
|
8314
|
+
|
|
8173
8315
|
/* ── Skills sidebar section ──────────────────────────────────────────────── */
|
|
8174
8316
|
#skill-list-items { padding: 0 0.5rem 0.5rem; display: flex; flex-direction: column; gap: 2px; }
|
|
8175
8317
|
|
|
@@ -11055,7 +11197,7 @@ body.setup-mode[data-theme="dark"] {
|
|
|
11055
11197
|
.billing-share-btn:hover {
|
|
11056
11198
|
background: var(--color-bg-tertiary);
|
|
11057
11199
|
color: var(--color-text-primary);
|
|
11058
|
-
border-color: var(--color-
|
|
11200
|
+
border-color: var(--color-accent-primary);
|
|
11059
11201
|
}
|
|
11060
11202
|
.billing-clear-popup {
|
|
11061
11203
|
position: absolute;
|
data/lib/clacky/web/app.js
CHANGED
|
@@ -97,7 +97,11 @@ const Router = (() => {
|
|
|
97
97
|
if (h === "mcp") return { view: "mcp", params: {} };
|
|
98
98
|
if (h === "extensions") return { view: "extensions", params: {} };
|
|
99
99
|
const mExtDetail = h.match(/^extensions\/(.+)$/);
|
|
100
|
-
if (mExtDetail)
|
|
100
|
+
if (mExtDetail) {
|
|
101
|
+
const [detailId, qs] = mExtDetail[1].split("?");
|
|
102
|
+
const source = new URLSearchParams(qs || "").get("source") || null;
|
|
103
|
+
return { view: "extensions", params: { detailId, source } };
|
|
104
|
+
}
|
|
101
105
|
if (h === "trash") return { view: "trash", params: {} };
|
|
102
106
|
if (h === "profile") return { view: "profile", params: {} };
|
|
103
107
|
// Legacy: #memories redirects to #profile (memories are now merged into
|
|
@@ -256,8 +260,9 @@ const Router = (() => {
|
|
|
256
260
|
case "extensions":
|
|
257
261
|
$("extensions-panel").style.display = "flex";
|
|
258
262
|
if (params.detailId) {
|
|
259
|
-
|
|
260
|
-
|
|
263
|
+
const sourceQs = params.source ? `?source=${encodeURIComponent(params.source)}` : "";
|
|
264
|
+
_setHash(`extensions/${params.detailId}${sourceQs}`);
|
|
265
|
+
Extensions.onPanelShow({ detailId: params.detailId, source: params.source });
|
|
261
266
|
} else {
|
|
262
267
|
_setHash("extensions");
|
|
263
268
|
Extensions.onPanelShow();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* global CM */
|
|
1
|
+
/* global CM, marked */
|
|
2
2
|
/**
|
|
3
3
|
* CodeEditor — a reusable wrapper around CodeMirror 6.
|
|
4
4
|
*
|
|
@@ -31,16 +31,20 @@
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function _detectLanguage(filename) {
|
|
34
|
-
if (!filename) return
|
|
34
|
+
if (!filename) return null;
|
|
35
35
|
const ext = filename.split(".").pop().toLowerCase();
|
|
36
36
|
const map = { md: "markdown", markdown: "markdown" };
|
|
37
|
-
return map[ext] ||
|
|
37
|
+
return map[ext] || null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function _isDark() {
|
|
41
41
|
return document.documentElement.getAttribute("data-theme") === "dark";
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function _isMarkdown(language) {
|
|
45
|
+
return language === "markdown" || language === "md";
|
|
46
|
+
}
|
|
47
|
+
|
|
44
48
|
function _buildExtensions(opts) {
|
|
45
49
|
const extensions = [
|
|
46
50
|
CM.lineNumbers(),
|
|
@@ -86,9 +90,24 @@
|
|
|
86
90
|
}]));
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
if (opts.onChange) {
|
|
94
|
+
extensions.push(CM.EditorView.updateListener.of((update) => {
|
|
95
|
+
if (update.docChanged) opts.onChange(update.state.doc.toString());
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
return extensions;
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
function _renderMarkdownHtml(text) {
|
|
103
|
+
if (typeof marked !== "undefined") {
|
|
104
|
+
try {
|
|
105
|
+
return marked.parse(text, { breaks: true, gfm: true });
|
|
106
|
+
} catch (_) { /* fall through */ }
|
|
107
|
+
}
|
|
108
|
+
return `<pre>${text.replace(/</g, "<")}</pre>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
92
111
|
function open(opts) {
|
|
93
112
|
const {
|
|
94
113
|
content = "",
|
|
@@ -101,6 +120,7 @@
|
|
|
101
120
|
|
|
102
121
|
const kind = opts.kind || (opts.filename ? _fileKind(opts.filename) : "text");
|
|
103
122
|
const language = opts.language || _detectLanguage(opts.filename);
|
|
123
|
+
const isMd = _isMarkdown(language) && kind !== "image";
|
|
104
124
|
|
|
105
125
|
let overlay = document.getElementById("code-editor-overlay");
|
|
106
126
|
if (overlay) overlay.remove();
|
|
@@ -110,8 +130,8 @@
|
|
|
110
130
|
overlay.className = "modal-overlay";
|
|
111
131
|
|
|
112
132
|
const cancelLabel = I18n.t("modal.cancel");
|
|
113
|
-
const closeLabel
|
|
114
|
-
const saveLabel
|
|
133
|
+
const closeLabel = I18n.t("modal.close");
|
|
134
|
+
const saveLabel = I18n.t("modal.save");
|
|
115
135
|
|
|
116
136
|
const isReadOnlyOrImage = readOnly || kind === "image";
|
|
117
137
|
const footerActions = isReadOnlyOrImage
|
|
@@ -119,12 +139,14 @@
|
|
|
119
139
|
: `<button class="btn btn-secondary code-editor-cancel">${cancelLabel}</button><button class="btn btn-primary code-editor-save">${saveLabel}</button>`;
|
|
120
140
|
|
|
121
141
|
overlay.innerHTML = `
|
|
122
|
-
<div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}">
|
|
142
|
+
<div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}${isMd ? " code-editor-modal--md" : ""}">
|
|
123
143
|
<div class="code-editor-header">
|
|
124
144
|
<h3 class="code-editor-title"></h3>
|
|
125
145
|
<button class="code-editor-close" title="${closeLabel}">×</button>
|
|
126
146
|
</div>
|
|
127
|
-
<div class="code-editor-body"
|
|
147
|
+
<div class="code-editor-body${isMd ? " code-editor-body--split" : ""}">
|
|
148
|
+
${isMd ? `<div class="code-editor-split-left"></div><div class="code-editor-split-divider"></div><div class="code-editor-split-right"><div class="code-editor-markdown-preview"></div></div>` : ""}
|
|
149
|
+
</div>
|
|
128
150
|
<div class="code-editor-footer">
|
|
129
151
|
<span class="code-editor-status"></span>
|
|
130
152
|
<div class="code-editor-actions">${footerActions}</div>
|
|
@@ -134,8 +156,8 @@
|
|
|
134
156
|
document.body.appendChild(overlay);
|
|
135
157
|
overlay.querySelector(".code-editor-title").textContent = title;
|
|
136
158
|
|
|
137
|
-
const body
|
|
138
|
-
const status
|
|
159
|
+
const body = overlay.querySelector(".code-editor-body");
|
|
160
|
+
const status = overlay.querySelector(".code-editor-status");
|
|
139
161
|
const closeBtn = overlay.querySelector(".code-editor-close");
|
|
140
162
|
const cancelBtn = overlay.querySelector(".code-editor-cancel");
|
|
141
163
|
const saveBtn = overlay.querySelector(".code-editor-save");
|
|
@@ -147,7 +169,6 @@
|
|
|
147
169
|
|
|
148
170
|
closeBtn.addEventListener("click", close);
|
|
149
171
|
if (cancelBtn) cancelBtn.addEventListener("click", close);
|
|
150
|
-
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
|
151
172
|
|
|
152
173
|
if (kind === "image") {
|
|
153
174
|
body.classList.add("code-editor-body--image");
|
|
@@ -159,7 +180,21 @@
|
|
|
159
180
|
return { close };
|
|
160
181
|
}
|
|
161
182
|
|
|
162
|
-
|
|
183
|
+
// Editor mount target: left pane for markdown split, body itself otherwise
|
|
184
|
+
const editorParent = isMd ? overlay.querySelector(".code-editor-split-left") : body;
|
|
185
|
+
const previewPane = isMd ? overlay.querySelector(".code-editor-markdown-preview") : null;
|
|
186
|
+
|
|
187
|
+
function updatePreview(text) {
|
|
188
|
+
if (previewPane) previewPane.innerHTML = _renderMarkdownHtml(text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const editorOpts = {
|
|
192
|
+
language: language || "markdown",
|
|
193
|
+
readOnly,
|
|
194
|
+
onSave: null,
|
|
195
|
+
_getContent: null,
|
|
196
|
+
onChange: isMd ? updatePreview : null,
|
|
197
|
+
};
|
|
163
198
|
const getContent = () => view.state.doc.toString();
|
|
164
199
|
editorOpts._getContent = getContent;
|
|
165
200
|
editorOpts.onSave = onSave ? () => doSave() : null;
|
|
@@ -169,9 +204,12 @@
|
|
|
169
204
|
doc: content,
|
|
170
205
|
extensions: _buildExtensions(editorOpts),
|
|
171
206
|
}),
|
|
172
|
-
parent:
|
|
207
|
+
parent: editorParent,
|
|
173
208
|
});
|
|
174
209
|
|
|
210
|
+
// Render initial preview
|
|
211
|
+
if (isMd) updatePreview(content);
|
|
212
|
+
|
|
175
213
|
async function doSave() {
|
|
176
214
|
if (!onSave) return;
|
|
177
215
|
if (saveBtn) saveBtn.disabled = true;
|
|
@@ -138,7 +138,8 @@ const BillingView = (() => {
|
|
|
138
138
|
<div class="billing-period-group">${periodBtns}</div>
|
|
139
139
|
<select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
|
|
140
140
|
<button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
|
|
141
|
-
|
|
141
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><polyline points="16 6 12 2 8 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="2" x2="12" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
142
|
+
${I18n.t('billing.share.btn') || 'Share scorecard'}
|
|
142
143
|
</button>
|
|
143
144
|
<div class="billing-clear-container">
|
|
144
145
|
<button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
|
|
@@ -178,14 +178,15 @@ const ExtensionsStore = (() => {
|
|
|
178
178
|
},
|
|
179
179
|
|
|
180
180
|
/** Open the detail view for one extension (fetches contributes + versions). */
|
|
181
|
-
async loadDetail(id) {
|
|
181
|
+
async loadDetail(id, source) {
|
|
182
182
|
if (!id) return;
|
|
183
183
|
_detail = null;
|
|
184
184
|
_detailLoading = true;
|
|
185
185
|
_detailError = null;
|
|
186
186
|
_emit("extensions:detail");
|
|
187
187
|
try {
|
|
188
|
-
const
|
|
188
|
+
const isBrand = source === "brand" || _filterBrand;
|
|
189
|
+
const url = isBrand
|
|
189
190
|
? "/api/store/extension?id=" + encodeURIComponent(id) + "&source=brand"
|
|
190
191
|
: "/api/store/extension?id=" + encodeURIComponent(id);
|
|
191
192
|
const res = await fetch(url);
|
|
@@ -250,10 +251,18 @@ const ExtensionsStore = (() => {
|
|
|
250
251
|
async install(id) {
|
|
251
252
|
if (!id) return;
|
|
252
253
|
try {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
// Prefer the already-loaded detail (avoids a second round-trip and correctly
|
|
255
|
+
// handles brand-private extensions whose detail was fetched with &source=brand).
|
|
256
|
+
let ext;
|
|
257
|
+
if (_detail && String(_detail.id) === String(id)) {
|
|
258
|
+
ext = _detail;
|
|
259
|
+
} else {
|
|
260
|
+
const source = _filterBrand ? "&source=brand" : "";
|
|
261
|
+
const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id) + source);
|
|
262
|
+
const detailData = await detailRes.json();
|
|
263
|
+
if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
|
|
264
|
+
ext = detailData.extension;
|
|
265
|
+
}
|
|
257
266
|
const download_url = ext.download_url;
|
|
258
267
|
if (!download_url) throw new Error("No download URL available");
|
|
259
268
|
const res = await fetch("/api/store/extension/install", {
|
|
@@ -121,7 +121,8 @@ const ExtensionsView = (() => {
|
|
|
121
121
|
|
|
122
122
|
const card = document.createElement("div");
|
|
123
123
|
card.className = "extension-card extension-card-clickable";
|
|
124
|
-
card.dataset.extId
|
|
124
|
+
card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
|
|
125
|
+
card.dataset.extOrigin = ext.origin || "";
|
|
125
126
|
card.innerHTML = `
|
|
126
127
|
<div class="extension-card-main">
|
|
127
128
|
${emojiHtml}
|
|
@@ -474,8 +475,11 @@ const ExtensionsView = (() => {
|
|
|
474
475
|
const card = e.target.closest(".extension-card-clickable");
|
|
475
476
|
if (card && card.dataset.extId) {
|
|
476
477
|
const router = window.Clacky && window.Clacky.Router;
|
|
477
|
-
|
|
478
|
-
|
|
478
|
+
// Use origin stored on the card; origin="self" means brand-private extension.
|
|
479
|
+
const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
|
|
480
|
+
const source = isBrand ? "brand" : null;
|
|
481
|
+
if (router) router.navigate("extensions", { detailId: card.dataset.extId, source });
|
|
482
|
+
else Extensions.loadDetail(card.dataset.extId, source);
|
|
479
483
|
}
|
|
480
484
|
});
|
|
481
485
|
}
|
|
@@ -506,7 +510,7 @@ const ExtensionsView = (() => {
|
|
|
506
510
|
const detailId = opts && opts.detailId;
|
|
507
511
|
if (detailId) {
|
|
508
512
|
Extensions.load();
|
|
509
|
-
Extensions.loadDetail(detailId);
|
|
513
|
+
Extensions.loadDetail(detailId, opts && opts.source);
|
|
510
514
|
} else {
|
|
511
515
|
Extensions.closeDetail();
|
|
512
516
|
Extensions.load();
|
|
@@ -102,6 +102,15 @@ const WorkspaceStore = (() => {
|
|
|
102
102
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
103
103
|
return resp.text();
|
|
104
104
|
},
|
|
105
|
+
|
|
106
|
+
async saveFileText(entry, content) {
|
|
107
|
+
const resp = await fetch("/api/file-action", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({ path: _absPath(entry.path), action: "save", content })
|
|
111
|
+
});
|
|
112
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
113
|
+
},
|
|
105
114
|
};
|
|
106
115
|
|
|
107
116
|
return Workspace;
|
|
@@ -226,7 +226,14 @@ const WorkspaceView = (() => {
|
|
|
226
226
|
}
|
|
227
227
|
try {
|
|
228
228
|
const text = await Workspace.fetchFileText(entry);
|
|
229
|
-
CodeEditor.open({
|
|
229
|
+
CodeEditor.open({
|
|
230
|
+
filename: entry.name,
|
|
231
|
+
title: entry.name,
|
|
232
|
+
content: text,
|
|
233
|
+
onSave: async (content) => {
|
|
234
|
+
await Workspace.saveFileText(entry, content);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
230
237
|
} catch (err) {
|
|
231
238
|
console.error("preview failed:", err);
|
|
232
239
|
Modal.toast(t("workspace.previewFailed"), "error");
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -669,6 +669,8 @@ const I18n = (() => {
|
|
|
669
669
|
"settings.models.baseurl.variant.ark_agent": "Agent Plan",
|
|
670
670
|
"settings.models.baseurl.variant.openclacky_primary": "Primary (Global)",
|
|
671
671
|
"settings.models.baseurl.variant.openclacky_secondary": "Secondary (China)",
|
|
672
|
+
"settings.models.baseurl.variant.mimo_payg": "Pay-as-you-go",
|
|
673
|
+
"settings.models.baseurl.variant.mimo_token_plan": "Token Plan",
|
|
672
674
|
"settings.models.btn.save": "Save",
|
|
673
675
|
"settings.models.btn.saving": "Saving…",
|
|
674
676
|
"settings.models.btn.saved": "Saved ✓",
|
|
@@ -1671,6 +1673,8 @@ const I18n = (() => {
|
|
|
1671
1673
|
"settings.models.baseurl.variant.ark_agent": "Agent Plan",
|
|
1672
1674
|
"settings.models.baseurl.variant.openclacky_primary": "主节点(全球)",
|
|
1673
1675
|
"settings.models.baseurl.variant.openclacky_secondary": "备用节点(中国)",
|
|
1676
|
+
"settings.models.baseurl.variant.mimo_payg": "按量付费",
|
|
1677
|
+
"settings.models.baseurl.variant.mimo_token_plan": "Token Plan",
|
|
1674
1678
|
"settings.models.btn.save": "保存",
|
|
1675
1679
|
"settings.models.btn.saving": "保存中…",
|
|
1676
1680
|
"settings.models.btn.saved": "已保存 ✓",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -1311,8 +1311,7 @@
|
|
|
1311
1311
|
<input type="password" id="model-modal-apikey" class="field-input api-key-input" placeholder="sk-..." autocomplete="off">
|
|
1312
1312
|
<button class="btn-toggle-key" id="model-modal-toggle-key" type="button" title="Show/hide key">
|
|
1313
1313
|
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1314
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="
|
|
1315
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
|
1314
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
|
1316
1315
|
</svg>
|
|
1317
1316
|
</button>
|
|
1318
1317
|
</div>
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -528,8 +528,14 @@ const Settings = (() => {
|
|
|
528
528
|
|
|
529
529
|
// Toggle API key visibility
|
|
530
530
|
document.getElementById("model-modal-toggle-key").addEventListener("click", () => {
|
|
531
|
-
const input
|
|
532
|
-
|
|
531
|
+
const input = document.getElementById("model-modal-apikey");
|
|
532
|
+
const eyeSvg = document.getElementById("model-modal-toggle-key").querySelector("svg");
|
|
533
|
+
const isPassword = input.type === "password";
|
|
534
|
+
input.type = isPassword ? "text" : "password";
|
|
535
|
+
// Switch icon: eye-off ↔ eye
|
|
536
|
+
eyeSvg.innerHTML = isPassword
|
|
537
|
+
? `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>`
|
|
538
|
+
: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>`;
|
|
533
539
|
});
|
|
534
540
|
|
|
535
541
|
// Model dropdown functionality
|