openclacky 1.5.0 → 1.5.2

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.
@@ -9,11 +9,12 @@ module Clacky
9
9
  "llm.error.403.model_not_allowed" => "当前模型不支持免费试用,请升级套餐或切换其他模型",
10
10
  "llm.error.403.api_key_revoked" => "API 密钥已被撤销,请前往控制台重新生成",
11
11
  "llm.error.403.api_key_expired" => "API 密钥已过期,请前往控制台重新生成",
12
- "llm.error.403.quota_exceeded" => "配额已用完,请升级套餐",
12
+ "llm.error.403.quota_exceeded" => "API Key 配额已超限,请在管理后台调整配额或关闭限制",
13
13
  "llm.error.403.access_denied" => "访问被拒绝,请检查 API 密钥权限",
14
14
  "llm.error.403.default" => "访问被拒绝",
15
15
  "llm.error.endpoint_not_found" => "API 端点不存在,请检查服务地址配置",
16
- "llm.error.rate_limit_429" => "请求过于频繁,请稍候重试",
16
+ "llm.error.rate_limit_429" => "请求过于频繁,请稍后重试",
17
+ "llm.error.quota_exhausted" => "API 配额已用尽(已达 Key 的用量上限),请前往控制台提升配额或升级套餐后继续",
17
18
  "llm.error.server_error" => "服务暂时不可用(%<status>d),正在重试...",
18
19
  "llm.error.unexpected" => "请求失败(%<status>d)",
19
20
  "llm.error.html_response" => "服务暂时不可用(收到 HTML 错误页),正在重试...",
@@ -21,6 +22,7 @@ module Clacky
21
22
  "llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次)",
22
23
  "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次)",
23
24
  "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)",
25
+ "llm.warn.switching_to_fallback_url" => "主节点无法连接,正在切换到备用节点:%<url>s",
24
26
  "platform.error.invalid_proof" => "许可证密钥无效,请检查后重试。",
25
27
  "platform.error.invalid_signature" => "请求签名无效。",
26
28
  "platform.error.nonce_replayed" => "检测到重复请求,请重试。",
@@ -28,7 +30,7 @@ module Clacky
28
30
  "platform.error.license_revoked" => "该许可证已被吊销,请联系客服。",
29
31
  "platform.error.license_expired" => "该许可证已过期,请续订后继续。",
30
32
  "platform.error.device_limit_reached" => "该许可证的设备数量已达上限。",
31
- "platform.error.device_revoked" => "该设备已从许可证中移除。",
33
+ "platform.error.device_revoked" => "该设备已从许可证中移除。如需重新绑定,请等待 15 分钟后再试。",
32
34
  "platform.error.invalid_license" => "未找到许可证密钥,请核对后重试。",
33
35
  "platform.error.device_not_found" => "设备未注册,请重新激活。",
34
36
  "platform.error.contributor_required" => "发布扩展需要先成为扩展贡献者。请登录平台,打开「我的扩展」页面,点击「成为扩展贡献者」即可开通(无需审核)。",
@@ -66,6 +66,7 @@ module Clacky
66
66
  system_text = system_messages.map { |m| extract_text(m[:content]) }.join("\n\n")
67
67
 
68
68
  api_messages = regular_messages.map { |msg| to_api_message(msg, caching_enabled) }
69
+ api_messages = merge_consecutive_tool_results(api_messages)
69
70
  api_tools = tools&.map { |t| to_api_tool(t) }
70
71
 
71
72
  if caching_enabled && api_tools&.any?
@@ -90,6 +91,34 @@ module Clacky
90
91
  %w[low medium high].include?(s) ? s : nil
91
92
  end
92
93
 
94
+ # Merge consecutive tool_result user messages into a single user message.
95
+ # Anthropic requires all tool_results for an assistant turn to appear in
96
+ # a SINGLE user message immediately following the assistant. When an
97
+ # assistant calls multiple tools, the canonical format produces one
98
+ # role:"tool" message per result, which to_api_message converts into
99
+ # separate role:"user" messages — breaking the Anthropic contract.
100
+ private_class_method def self.merge_consecutive_tool_results(api_messages)
101
+ result = []
102
+ pending = nil
103
+
104
+ api_messages.each do |msg|
105
+ if msg[:role] == "user" && msg[:content].is_a?(Array) &&
106
+ msg[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
107
+ if pending
108
+ pending[:content] += msg[:content]
109
+ else
110
+ pending = msg
111
+ result << pending
112
+ end
113
+ else
114
+ pending = nil
115
+ result << msg
116
+ end
117
+ end
118
+
119
+ result
120
+ end
121
+
93
122
  # ── Response parsing ──────────────────────────────────────────────────────
94
123
 
95
124
  # Parse Anthropic API response into canonical internal format.
@@ -10,7 +10,7 @@ module Clacky
10
10
  # Fields that are internal to the agent and must not be sent to the API.
11
11
  INTERNAL_FIELDS = %i[
12
12
  task_id created_at system_injected session_context memory_update
13
- subagent_instructions subagent_result token_usage
13
+ subagent_instructions subagent_result subagent_transcript token_usage
14
14
  compressed_summary chunk_path truncated transient
15
15
  chunk_index chunk_count
16
16
  ].freeze
@@ -71,6 +71,20 @@ module Clacky
71
71
  self
72
72
  end
73
73
 
74
+ # Attach a key/value onto the most recent tool result message matching the
75
+ # given tool_call_id. Handles both OpenAI-style (role:"tool", tool_call_id)
76
+ # and Anthropic-style (role:"user" with tool_result blocks) messages.
77
+ # No-op if no matching message is found.
78
+ def attach_to_tool_result(tool_call_id, key, value)
79
+ msg = @messages.reverse.find do |m|
80
+ (m[:role] == "tool" && m[:tool_call_id] == tool_call_id) ||
81
+ (m[:role] == "user" && m[:content].is_a?(Array) &&
82
+ m[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" && b[:tool_use_id] == tool_call_id })
83
+ end
84
+ msg[key] = value if msg
85
+ self
86
+ end
87
+
74
88
  # Mutate the last message matching the predicate lambda in-place.
75
89
  # Used by execute_skill_with_subagent to update instruction messages.
76
90
  def mutate_last_matching(predicate, &block)
@@ -148,6 +148,18 @@ module Clacky
148
148
  "abs-claude-fable-5" => "abs-claude-opus-4-8",
149
149
  "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
150
150
  },
151
+ # Secondary gateway URL used when the primary base_url is unreachable
152
+ # after max retries. The model name stays the same — only the endpoint
153
+ # changes. Nil / absent means no URL fallback for this provider.
154
+ "fallback_base_url" => "https://llm.1024code.com",
155
+ # Two selectable endpoints exposed in the Base URL dropdown:
156
+ # Primary — global CDN, lowest latency for most regions.
157
+ # Secondary — China-optimised relay (1024code.com), useful when
158
+ # the primary is unreachable from mainland China.
159
+ "endpoint_variants" => [
160
+ { "label" => "Primary (Global)", "label_key" => "settings.models.baseurl.variant.openclacky_primary", "base_url" => "https://api.openclacky.com" }.freeze,
161
+ { "label" => "Secondary (China)", "label_key" => "settings.models.baseurl.variant.openclacky_secondary", "base_url" => "https://llm.1024code.com" }.freeze
162
+ ].freeze,
151
163
  "website_url" => "https://www.openclacky.com/ai-keys"
152
164
  }.freeze,
153
165
 
@@ -779,6 +791,16 @@ module Clacky
779
791
  preset&.dig("fallback_models", model)
780
792
  end
781
793
 
794
+ # Get the fallback base URL for a provider (used when primary endpoint is unreachable).
795
+ # Returns nil if the provider has no secondary gateway configured.
796
+ # @param provider_id [String] The provider identifier
797
+ # @return [String, nil] The fallback base URL or nil
798
+ def fallback_base_url(provider_id)
799
+ preset = PRESETS[provider_id]
800
+ url = preset&.dig("fallback_base_url")
801
+ url&.empty? ? nil : url
802
+ end
803
+
782
804
  # Find provider ID by base URL.
783
805
  # Matches if the given URL starts with the provider's base_url (after normalisation),
784
806
  # so both exact matches and sub-path variants (e.g. "/v1") are recognised.
@@ -103,6 +103,15 @@ module Clacky
103
103
  question: question, context: context, options: options }
104
104
  end
105
105
 
106
+ def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil)
107
+ @events << { type: "subagent_start", session_id: @session_id,
108
+ skill: skill, iterations: iterations, cost_usd: cost_usd }
109
+ end
110
+
111
+ def show_subagent_end
112
+ @events << { type: "subagent_end", session_id: @session_id }
113
+ end
114
+
106
115
  # Ignore all other UI methods (progress, errors, etc.) during history replay
107
116
  def method_missing(name, *args, **kwargs); end
108
117
  def respond_to_missing?(name, include_private = false); true; end
@@ -2267,19 +2276,23 @@ module Clacky
2267
2276
 
2268
2277
  Clacky::Logger.debug("[Brand] api_brand_status: expired=#{brand.expired?} grace_exceeded=#{brand.grace_period_exceeded?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"}")
2269
2278
 
2270
- warning = nil
2279
+ warning = nil
2280
+ warning_type = nil
2271
2281
  if brand.expired?
2272
- warning = "Your #{brand.product_name} license has expired. Please renew to continue."
2282
+ warning = "Your #{brand.product_name} license has expired. Please renew to continue."
2283
+ warning_type = "expired"
2273
2284
  elsif brand.grace_period_exceeded?
2274
- warning = "License server unreachable for more than 3 days. Please check your connection."
2285
+ warning = "License server unreachable for more than 3 days. Please check your connection."
2286
+ warning_type = "unreachable"
2275
2287
  elsif brand.license_expires_at && !brand.expired?
2276
2288
  days_remaining = ((brand.license_expires_at - Time.now.utc) / 86_400).ceil
2277
2289
  if days_remaining <= 7
2278
- warning = "Your #{brand.product_name} license expires in #{days_remaining} day#{"s" if days_remaining != 1}. Please renew soon."
2290
+ warning = "Your #{brand.product_name} license expires in #{days_remaining} day#{"s" if days_remaining != 1}. Please renew soon."
2291
+ warning_type = "expiring"
2279
2292
  end
2280
2293
  end
2281
2294
 
2282
- Clacky::Logger.debug("[Brand] api_brand_status: warning=#{warning.inspect}")
2295
+ Clacky::Logger.debug("[Brand] api_brand_status: warning=#{warning.inspect} warning_type=#{warning_type.inspect}")
2283
2296
 
2284
2297
  json_response(res, 200, {
2285
2298
  branded: true,
@@ -2288,6 +2301,7 @@ module Clacky
2288
2301
  homepage_url: brand.homepage_url,
2289
2302
  logo_url: brand.logo_url,
2290
2303
  warning: warning,
2304
+ warning_type: warning_type,
2291
2305
  test_mode: @brand_test,
2292
2306
  user_licensed: brand.user_licensed?,
2293
2307
  license_user_id: brand.license_user_id
@@ -2541,10 +2555,11 @@ module Clacky
2541
2555
  units
2542
2556
  end
2543
2557
 
2544
- # GET /api/store/extension?id=<slug-or-id>
2558
+ # GET /api/store/extension?id=<slug-or-id>[&source=brand]
2545
2559
  #
2546
- # Public detail for a single marketplace extension (contributes + version
2547
- # history). Anonymous, proxies BrandConfig#extension_detail!.
2560
+ # Detail for a single extension. Pass source=brand to query brand-private
2561
+ # extensions via the license-gated API; omit (or any other value) for the
2562
+ # public marketplace API.
2548
2563
  def api_store_extension_detail(req, res)
2549
2564
  id = req.query["id"].to_s
2550
2565
  if id.strip.empty?
@@ -2553,10 +2568,11 @@ module Clacky
2553
2568
  end
2554
2569
 
2555
2570
  brand = Clacky::BrandConfig.load
2556
- # Brand-private extensions (origin=self) are not visible via the public
2557
- # /api/v1/extensions/:id endpoint. Activated brand users must use the
2558
- # license-gated POST /api/v1/licenses/extension instead.
2559
- result = brand.activated? ? brand.brand_extension_detail!(id) : brand.extension_detail!(id)
2571
+ result = if req.query["source"] == "brand"
2572
+ brand.brand_extension_detail!(id)
2573
+ else
2574
+ brand.extension_detail!(id)
2575
+ end
2560
2576
 
2561
2577
  if result[:success] && result[:extension]
2562
2578
  ext = result[:extension]
@@ -5394,6 +5410,7 @@ module Clacky
5394
5410
  base_url: m["base_url"],
5395
5411
  api_key_masked: mask_api_key(m["api_key"]),
5396
5412
  anthropic_format: m["anthropic_format"] || false,
5413
+ provider_id: m["provider_id"],
5397
5414
  type: m["type"]
5398
5415
  }
5399
5416
  end
@@ -5602,7 +5619,8 @@ module Clacky
5602
5619
  "model" => model,
5603
5620
  "base_url" => base_url,
5604
5621
  "api_key" => api_key,
5605
- "anthropic_format" => body["anthropic_format"] || false
5622
+ "anthropic_format" => body["anthropic_format"] || false,
5623
+ "provider_id" => body["provider_id"].to_s.strip.then { |v| v.empty? ? nil : v }
5606
5624
  }
5607
5625
  type = body["type"].to_s
5608
5626
  unless type.empty?
@@ -5663,6 +5681,14 @@ module Clacky
5663
5681
  if body.key?("anthropic_format")
5664
5682
  target["anthropic_format"] = !!body["anthropic_format"]
5665
5683
  end
5684
+ if body.key?("provider_id")
5685
+ v = body["provider_id"].to_s.strip
5686
+ if v.empty?
5687
+ target.delete("provider_id")
5688
+ else
5689
+ target["provider_id"] = v
5690
+ end
5691
+ end
5666
5692
  if body.key?("api_key")
5667
5693
  new_key = body["api_key"].to_s
5668
5694
  # Only store a real, unmasked, non-empty value. This is the
@@ -5969,7 +5995,9 @@ module Clacky
5969
5995
 
5970
5996
  if model_name && !model_name.empty?
5971
5997
  info = agent.current_model_info
5972
- provider_id = info && Clacky::Providers.find_by_base_url(info[:base_url])
5998
+ # Prefer explicitly saved provider_id, fall back to base_url lookup
5999
+ provider_id = info&.dig(:provider_id).to_s.strip.then { |v| v.empty? ? nil : v }
6000
+ provider_id ||= (info && Clacky::Providers.find_by_base_url(info[:base_url]))
5973
6001
  allowed = provider_id ? Clacky::Providers.models(provider_id) : []
5974
6002
  if allowed.empty?
5975
6003
  return json_response(res, 400, { error: "Current model has no provider preset; sub-model switching unavailable" })
@@ -376,8 +376,10 @@ module Clacky
376
376
  # in any preset (e.g. self-hosted custom endpoints) — the WebUI
377
377
  # treats that as "no sub-model switcher available".
378
378
  private def sub_model_options_for(model_info)
379
- return [] unless model_info && model_info[:base_url]
380
- provider_id = Clacky::Providers.find_by_base_url(model_info[:base_url])
379
+ return [] unless model_info
380
+ # Prefer explicitly saved provider_id, fall back to base_url lookup
381
+ provider_id = model_info[:provider_id].to_s.strip.then { |v| v.empty? ? nil : v }
382
+ provider_id ||= (model_info[:base_url] && Clacky::Providers.find_by_base_url(model_info[:base_url]))
381
383
  return [] unless provider_id
382
384
  Clacky::Providers.models(provider_id)
383
385
  end
data/lib/clacky/skill.rb CHANGED
@@ -537,6 +537,10 @@ module Clacky
537
537
  name_invalid = !valid_slug.call(@name) || @name.length > 64
538
538
 
539
539
  if name_invalid
540
+ # Preserve the original non-slug name as name_zh (display name) if not already set.
541
+ # e.g. name: "中英翻译" → name_zh = "中英翻译", name falls back to dir slug.
542
+ @name_zh ||= @name
543
+
540
544
  if valid_slug.call(dir_slug)
541
545
  # Recoverable: fall back to directory name, record a warning
542
546
  @warnings << "Invalid name '#{@name}' in metadata; using directory name '#{dir_slug}' instead."
@@ -77,6 +77,27 @@ module Clacky
77
77
  "InvokeSkill(#{skill})"
78
78
  end
79
79
 
80
+ # Format the tool result for the LLM.
81
+ # Converts the raw Hash into a markdown string so the LLM can directly
82
+ # read the subagent output without parsing a JSON wrapper. JSON-encoding
83
+ # the result as a nested string caused models to miss the "result" field
84
+ # and hallucinate that the subagent returned nothing.
85
+ # @param result [Hash, String] Tool execution result
86
+ # @return [String] LLM-friendly formatted result
87
+ def format_result_for_llm(result)
88
+ if result.is_a?(String)
89
+ result
90
+ elsif result[:error]
91
+ "Error: #{result[:error]}"
92
+ elsif result[:skill_type] == "subagent"
93
+ subagent_result = result[:result].to_s.strip
94
+ subagent_result = "(subagent produced no output)" if subagent_result.empty?
95
+ "Subagent executed successfully.\n\n#{subagent_result}"
96
+ else
97
+ "Skill content expanded"
98
+ end
99
+ end
100
+
80
101
  # Format the tool result for display
81
102
  # @param result [Hash] Tool execution result
82
103
  # @return [String] Formatted result summary
@@ -19,17 +19,18 @@ module Clacky
19
19
  # Responsibilities (applied to the `command` string BEFORE it is handed
20
20
  # to a shell / PTY for execution):
21
21
  #
22
- # 1. Block hard-dangerous commands: sudo, pkill clacky, eval, exec,
22
+ # 1. Block hard-dangerous commands: pkill clacky, eval, exec,
23
23
  # `...`, | sh, | bash,
24
24
  # redirect to /etc /usr /bin.
25
25
  # 2. Rewrite `curl ... | bash` → save script to a file for manual
26
26
  # review instead of exec.
27
- # 3. Protect credential/secret files: .env, .ssh/, .aws/ — block
27
+ # 3. Protect credential/secret files: .ssh/, .aws/ — block
28
28
  # writes to these only. Other
29
29
  # "project" files (Gemfile,
30
- # README.md, package.json, …)
31
- # are NOT protected — editing
32
- # them is a normal dev task.
30
+ # README.md, package.json,
31
+ # .env, …) are NOT protected —
32
+ # editing them is a normal dev
33
+ # task.
33
34
  #
34
35
  # Note on `rm`:
35
36
  # `rm` is NOT rewritten here — it's intercepted at runtime by a shell
@@ -135,7 +136,10 @@ module Clacky
135
136
  when /^curl.*\|\s*(sh|bash)/
136
137
  replace_curl_pipe_command(command)
137
138
  when /^sudo\s+/
138
- block_sudo_command(command)
139
+ # sudo is allowed — the user is in control and takes responsibility.
140
+ # Still log it for audit trail.
141
+ log_warning("sudo command executed: #{command}")
142
+ command
139
143
  when />\s*\/dev\/null\s*$/
140
144
  allow_dev_null_redirect(command)
141
145
  when /^(mv|cp|mkdir|touch|echo)\s+/
@@ -175,10 +179,6 @@ module Clacky
175
179
  end
176
180
  end
177
181
 
178
- def block_sudo_command(_command)
179
- raise SecurityError, "sudo commands are not allowed for security reasons"
180
- end
181
-
182
182
  def allow_dev_null_redirect(command)
183
183
  command
184
184
  end
@@ -273,8 +273,6 @@ module Clacky
273
273
  SECRET_WRITE_PATTERNS = [
274
274
  %r{(?:\A|/)\.ssh/},
275
275
  %r{(?:\A|/)\.aws/},
276
- /(?:\A|\/)\.env(?:\.|\z)/,
277
- /\.env\z/
278
276
  ].freeze
279
277
 
280
278
  def validate_secret_write(path)
@@ -319,7 +317,7 @@ module Clacky
319
317
  end
320
318
 
321
319
  private :replace_chmod_command,
322
- :replace_curl_pipe_command, :block_sudo_command,
320
+ :replace_curl_pipe_command,
323
321
  :allow_dev_null_redirect, :validate_and_allow,
324
322
  :validate_general_command,
325
323
  :validate_file_path, :validate_secret_write,
@@ -11,6 +11,8 @@ module Clacky
11
11
  # @param files [Array<Hash>] extracted file refs: [{ name:, path:, inline: }]
12
12
  def show_assistant_message(content, files:); end
13
13
  def show_feedback_request(question, context, options); end
14
+ def show_subagent_start(skill: nil, iterations: nil, cost_usd: nil); end
15
+ def show_subagent_end; end
14
16
  def show_tool_call(name, args); end
15
17
  def show_tool_result(result); end
16
18
  def show_tool_stdout(lines); end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.0"
4
+ VERSION = "1.5.2"
5
5
  end
@@ -2173,6 +2173,40 @@ body {
2173
2173
  margin-top: -0.625rem;
2174
2174
  padding-left: 0.125rem;
2175
2175
  }
2176
+
2177
+ /* ── Starter Prompts ── */
2178
+ .new-session-starter-prompts {
2179
+ margin-top: 0.25rem;
2180
+ }
2181
+ .ns-starter-label {
2182
+ font-size: 0.75rem;
2183
+ color: var(--color-text-tertiary);
2184
+ margin-bottom: 0.5rem;
2185
+ }
2186
+ .ns-starter-list {
2187
+ display: flex;
2188
+ flex-direction: column;
2189
+ gap: 0.375rem;
2190
+ }
2191
+ .ns-starter-item {
2192
+ display: block;
2193
+ width: 100%;
2194
+ text-align: left;
2195
+ padding: 0.5rem 0.75rem;
2196
+ border: 1px solid var(--color-border-primary);
2197
+ border-radius: 0.5rem;
2198
+ background: var(--color-bg-primary);
2199
+ color: var(--color-text-secondary);
2200
+ font-size: 0.8125rem;
2201
+ line-height: 1.45;
2202
+ cursor: pointer;
2203
+ transition: border-color 0.14s, background-color 0.14s, color 0.14s;
2204
+ }
2205
+ .ns-starter-item:hover {
2206
+ border-color: color-mix(in srgb, var(--color-accent-primary) 40%, var(--color-border-primary));
2207
+ background: var(--color-bg-hover);
2208
+ color: var(--color-text-primary);
2209
+ }
2176
2210
  .ns-panels-label {
2177
2211
  width: 100%;
2178
2212
  font-size: 0.75rem;
@@ -2637,7 +2671,6 @@ body {
2637
2671
  .session-banner { flex-shrink: 0; }
2638
2672
  .session-composer {
2639
2673
  flex-shrink: 0;
2640
- margin-bottom: -9px;
2641
2674
  display: flex;
2642
2675
  padding-inline: 24px;
2643
2676
  }
@@ -8967,10 +9000,11 @@ body.setup-mode[data-theme="dark"] {
8967
9000
  padding-top: 0.75rem;
8968
9001
  border-top: 1px solid var(--color-border-primary);
8969
9002
  }
8970
- .badge-active { color: var(--color-success); }
8971
- .badge-expired { color: var(--color-error); }
8972
- .badge-expiring { color: var(--color-warning); }
8973
- .badge-inactive { color: var(--color-text-secondary); }
9003
+ .badge-active { color: var(--color-success); }
9004
+ .badge-expired { color: var(--color-error); }
9005
+ .badge-expiring { color: var(--color-warning); }
9006
+ .badge-unreachable { color: var(--color-text-secondary); }
9007
+ .badge-inactive { color: var(--color-text-secondary); }
8974
9008
 
8975
9009
  /* Support QR code */
8976
9010
  .brand-support-contact {
@@ -10908,6 +10942,19 @@ body.setup-mode[data-theme="dark"] {
10908
10942
  flex-wrap: wrap;
10909
10943
  }
10910
10944
 
10945
+ /* ── Disclaimer notice ──────────────────────────────────────────────── */
10946
+ .billing-disclaimer {
10947
+ display: flex;
10948
+ align-items: center;
10949
+ gap: 0.4rem;
10950
+ font-size: 0.75rem;
10951
+ color: var(--color-text-tertiary);
10952
+ padding: 0.4rem 0.75rem;
10953
+ background: var(--color-bg-secondary);
10954
+ border: 1px solid var(--color-border-primary);
10955
+ border-radius: 6px;
10956
+ }
10957
+
10911
10958
  /* ── Period Button Group ─────────────────────────────────────────────── */
10912
10959
  .billing-period-group {
10913
10960
  display: flex;
@@ -11084,10 +11131,10 @@ body.setup-mode[data-theme="dark"] {
11084
11131
  justify-content: center;
11085
11132
  flex-shrink: 0;
11086
11133
  }
11087
- .billing-stat-icon-cost { background: color-mix(in srgb, #10b981 10%, transparent); color: #10b981; }
11088
- .billing-stat-icon-tokens { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; }
11134
+ .billing-stat-icon-cost { background: color-mix(in srgb, var(--color-accent-primary) 10%, transparent); color: var(--color-accent-primary); }
11135
+ .billing-stat-icon-tokens { background: color-mix(in srgb, #2075ffbf 10%, transparent); color: #2075ffbf; }
11089
11136
  .billing-stat-icon-requests { background: color-mix(in srgb, #f59e0b 10%, transparent); color: #f59e0b; }
11090
- .billing-stat-icon-cache { background: color-mix(in srgb, var(--color-accent-primary) 10%, transparent); color: var(--color-accent-primary); }
11137
+ .billing-stat-icon-cache { background: color-mix(in srgb, #10b981 10%, transparent); color: #10b981; }
11091
11138
  .billing-stat-icon svg {
11092
11139
  width: 18px;
11093
11140
  height: 18px;
@@ -11318,7 +11365,7 @@ body.setup-mode[data-theme="dark"] {
11318
11365
  align-items: stretch;
11319
11366
  }
11320
11367
  .billing-cache-hit {
11321
- background: #3b82f6;
11368
+ background: #2075ffbf;
11322
11369
  width: 100%;
11323
11370
  border-radius: 2px 2px 0 0;
11324
11371
  transition: height 0.2s ease;
@@ -11346,7 +11393,7 @@ body.setup-mode[data-theme="dark"] {
11346
11393
 
11347
11394
  /* Legend colors — indigo + emerald */
11348
11395
  .billing-legend-total { background: var(--color-accent-primary); }
11349
- .billing-legend-cache-hit { background: #3b82f6; }
11396
+ .billing-legend-cache-hit { background: #2075ffbf; }
11350
11397
  .billing-legend-cache-miss{ background: #f59e0b; }
11351
11398
  .billing-legend-output { background: #34d399; }
11352
11399
 
@@ -11397,7 +11444,7 @@ body.setup-mode[data-theme="dark"] {
11397
11444
  flex-shrink: 0;
11398
11445
  }
11399
11446
  .tooltip-total { background: var(--color-accent-primary); }
11400
- .tooltip-cache-hit { background: #3b82f6; }
11447
+ .tooltip-cache-hit { background: #2075ffbf; }
11401
11448
  .tooltip-cache-miss{ background: #f59e0b; }
11402
11449
  .tooltip-output { background: #34d399; }.tooltip-label {
11403
11450
  flex: 1;
@@ -11472,7 +11519,7 @@ body.setup-mode[data-theme="dark"] {
11472
11519
  background: #34d399;
11473
11520
  }
11474
11521
  .billing-bar-cache-read {
11475
- background: #3b82f6;
11522
+ background: #2075ffbf;
11476
11523
  }
11477
11524
  .billing-bar-cache-miss {
11478
11525
  background: #f59e0b;
@@ -11951,7 +11998,7 @@ body.setup-mode[data-theme="dark"] {
11951
11998
  }
11952
11999
 
11953
12000
  .swatch-indigo { background: #4f46e5; }
11954
- .swatch-aurora-blue { background: #3B82F6; }
12001
+ .swatch-aurora-blue { background: #2075ffbf; }
11955
12002
  .swatch-forest-green { background: #10B981; }
11956
12003
  .swatch-sunrise-orange { background: #F59E0B; }
11957
12004
  .swatch-rose-violet { background: #8B5CF6; }
@@ -12172,7 +12219,7 @@ body.setup-mode[data-theme="dark"] {
12172
12219
  color: var(--color-accent-primary);
12173
12220
  }
12174
12221
  .billing-cell-hit {
12175
- color: #3b82f6;
12222
+ color: #2075ffbf;
12176
12223
  }
12177
12224
  .billing-cell-miss {
12178
12225
  color: #f59e0b;
@@ -152,6 +152,11 @@ const BillingView = (() => {
152
152
  </div>
153
153
  </div>
154
154
 
155
+ <div class="billing-disclaimer">
156
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.4"/><path d="M8 7v4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><circle cx="8" cy="5" r="0.7" fill="currentColor"/></svg>
157
+ ${I18n.t("billing.disclaimer")}
158
+ </div>
159
+
155
160
  <div class="billing-stats-row">
156
161
  <div class="billing-stat-card">
157
162
  <div class="billing-stat-icon billing-stat-icon-cost">
@@ -158,7 +158,11 @@ const ExtensionsStore = (() => {
158
158
  try {
159
159
  const res = await fetch("/api/store/extensions/brand");
160
160
  const data = await res.json();
161
- _extensions = data.extensions || [];
161
+ const all = data.extensions || [];
162
+ _extensions = _query
163
+ ? all.filter(e => [e.name, e.name_zh, e.description].some(
164
+ f => f && f.toLowerCase().includes(_query.toLowerCase())))
165
+ : all;
162
166
  _error = data.warning || null;
163
167
  _loading = false;
164
168
  _emit("extensions:changed", { extensions: _extensions, warning: _error });
@@ -181,7 +185,10 @@ const ExtensionsStore = (() => {
181
185
  _detailError = null;
182
186
  _emit("extensions:detail");
183
187
  try {
184
- const res = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
188
+ const url = _filterBrand
189
+ ? "/api/store/extension?id=" + encodeURIComponent(id) + "&source=brand"
190
+ : "/api/store/extension?id=" + encodeURIComponent(id);
191
+ const res = await fetch(url);
185
192
  const data = await res.json();
186
193
  if (res.ok && data.ok && data.extension) {
187
194
  _detail = data.extension;