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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb5f68cdd24e5d619fa22b79c643953e6c00f54400d7e89d95a9c99373328a55
4
- data.tar.gz: 5bc17c114482d78e13d59d5f6b194caffbf9456015bce6082d3bbc63bdf951a2
3
+ metadata.gz: 04d2d81c4aaa380d4600066802c78e1137bdad1cb16c3e5e47e32e69ede15da6
4
+ data.tar.gz: 9a332e0305e96bae97799ce707ef492b24735e33ef8da70a390c06a10ac11ab4
5
5
  SHA512:
6
- metadata.gz: 55044dd2a193d5ed5e0b64aefee284fec18cb036369482d55936e7c3c01d8740aecee16e0ca75f1e5d1cc643a4d70d66005f1f5eb61d3bfc02a6152ef3b4155d
7
- data.tar.gz: 86de30d32d826921245ea60abd5229665eb82445b68f68e6a68f90b519d4b0dd900b077a738c82b65a1d78a3bc974b3d938986445e9149f074b94095ead044c9
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
@@ -476,7 +476,13 @@ module Clacky
476
476
  @current_model_id = id
477
477
  @current_model_index = index
478
478
 
479
- @session_model_overlay = nil if previous_id != id
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 the provider has no secondary gateway configured.
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
- provider_id = Clacky::Providers.resolve_provider(
1071
- base_url: m["base_url"],
1072
- api_key: m["api_key"]
1073
- )
1074
- return nil unless provider_id
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
- Clacky::Providers.fallback_base_url(provider_id)
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", source: :setup)
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: min(1200px, calc(100vw - 48px)); height: calc(100vh - 80px); max-width: calc(100vw - 48px); border-radius: var(--radius-md, 8px); margin: 0; padding: 24px 28px 20px; display: flex; flex-direction: column; }
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
- if (effort = normalized_effort(reasoning_effort))
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, max_tokens: max_tokens, messages: api_messages }
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
- if reasoning_effort && !reasoning_effort.to_s.empty?
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
 
@@ -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
- "models" => ["mimo-v2.5-pro", "mimo-v2-pro", "mimo-v2-omni"],
352
- # MiMo-V2-Pro is text-only; MiMo-V2-Omni supports vision (omni = multimodal).
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-omni" => { "vision" => true }.freeze
360
+ "mimo-v2.5" => { "vision" => true }.freeze
356
361
  }.freeze,
357
- "default_ocr_model" => "mimo-v2-omni",
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.1",
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
- # Only mark as unlisted when the extension was published to the
2496
- # marketplace (origin != "self") but is no longer listed there.
2497
- # Self-authored extensions (origin: self) have never been published
2498
- # and should never show the "unlisted" badge.
2499
- "unlisted" => market.nil? && container[:origin].to_s != "self",
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
- # Only mark as unlisted when the extension was published to the
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
- return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
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' or 'display-path'" })
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.2"
4
+ VERSION = "1.5.3"
5
5
  end
@@ -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-border-secondary);
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: hidden;
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
- .model-edit-modal-content .modal-body {
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-text-secondary);
11200
+ border-color: var(--color-accent-primary);
11059
11201
  }
11060
11202
  .billing-clear-popup {
11061
11203
  position: absolute;
@@ -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) return { view: "extensions", params: { detailId: mExtDetail[1] } };
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
- _setHash(`extensions/${params.detailId}`);
260
- Extensions.onPanelShow({ detailId: params.detailId });
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 "markdown";
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] || "markdown";
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, "&lt;")}</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 = I18n.t("modal.close");
114
- const saveLabel = I18n.t("modal.save");
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}">&times;</button>
126
146
  </div>
127
- <div class="code-editor-body"></div>
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 = overlay.querySelector(".code-editor-body");
138
- const status = overlay.querySelector(".code-editor-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
- const editorOpts = { language, readOnly, onSave: null, _getContent: null };
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: body,
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
- 📤 ${I18n.t('billing.share.btn') || 'Share scorecard'}
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 url = _filterBrand
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
- const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
254
- const detailData = await detailRes.json();
255
- if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
256
- const ext = detailData.extension;
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 = ext.id != null ? String(ext.id) : (ext.name || "");
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
- if (router) router.navigate("extensions", { detailId: card.dataset.extId });
478
- else Extensions.loadDetail(card.dataset.extId);
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({ filename: entry.name, title: entry.name, content: text, readOnly: true });
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");
@@ -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": "已保存 ✓",
@@ -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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
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>
@@ -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 = document.getElementById("model-modal-apikey");
532
- input.type = input.type === "password" ? "text" : "password";
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy