openclacky 1.3.9 → 1.3.11

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.
@@ -66,9 +66,8 @@ module Clacky
66
66
  raise Error, "extension #{ext_id.inspect} already installed at #{target} (pass force: true to overwrite)"
67
67
  end
68
68
 
69
- FileUtils.mkdir_p(installed_dir)
70
- FileUtils.rm_rf(target) if Dir.exist?(target)
71
- FileUtils.cp_r(container_src, target)
69
+ FileUtils.mkdir_p(target)
70
+ FileUtils.cp_r(Dir.glob("#{container_src}/*"), target)
72
71
 
73
72
  Clacky::ExtensionLoader.invalidate_cache!
74
73
  units = verify_installed(ext_id, installed_dir)
@@ -5,7 +5,7 @@ module Clacky
5
5
  EN = {
6
6
  "llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
7
7
  "llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
8
- "llm.error.invalid_api_key" => "Invalid API key, please check your configuration",
8
+ "llm.error.invalid_api_key" => "API key is invalid or expired, please update it in Settings",
9
9
  "llm.error.403.model_not_allowed" => "This model is not available on your current plan",
10
10
  "llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
11
11
  "llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
@@ -5,7 +5,7 @@ module Clacky
5
5
  ZH = {
6
6
  "llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
7
7
  "llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
8
- "llm.error.invalid_api_key" => "API 密钥无效,请检查配置",
8
+ "llm.error.invalid_api_key" => "API Key 无效或已过期,请到设置中重新配置",
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 密钥已过期,请前往控制台重新生成",
@@ -3,7 +3,7 @@
3
3
  - Ask clarifying questions if requirements are unclear.
4
4
  - Break down complex tasks into manageable steps.
5
5
  - **USE TOOLS to create/modify files** — don't just return content.
6
- - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply.
6
+ - When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply. Use the raw file path as-is (keep non-ASCII characters and spaces literal); never URL-encode it.
7
7
 
8
8
  ## Tool Usage Rules
9
9
 
@@ -117,6 +117,7 @@ module Clacky
117
117
  WEB_ROOT = File.expand_path("../web", __dir__)
118
118
  EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
119
119
  EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
120
+ OSS_CDN_BASE = "https://oss.1024code.com/openclacky"
120
121
 
121
122
  # Default SOUL.md written when the user skips the onboard conversation.
122
123
  # A richer version is created by the Agent during the soul_setup phase.
@@ -476,8 +477,10 @@ module Clacky
476
477
  elsif path.start_with?("/api/backup/download") || path == "/api/backup/run" || path == "/api/backup/restore"
477
478
  # Building/extracting a tar.gz of ~/.clacky can take a while.
478
479
  120
480
+ elsif path == "/api/store/extension/install"
481
+ 300
479
482
  else
480
- 10
483
+ 30
481
484
  end
482
485
  Timeout.timeout(timeout_sec) do
483
486
  _dispatch_rest(req, res)
@@ -538,7 +541,8 @@ module Clacky
538
541
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
539
542
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
540
543
  when ["GET", "/api/store/skills"] then api_store_skills(res)
541
- when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
544
+ when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
545
+ when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
542
546
  when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
543
547
  when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
544
548
  when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
@@ -2332,10 +2336,14 @@ module Clacky
2332
2336
  result = brand.search_extensions!(query: req.query["q"], sort: req.query["sort"])
2333
2337
 
2334
2338
  if result[:success]
2335
- installed = installed_extension_slugs
2339
+ installed = installed_extension_containers
2336
2340
  extensions = Array(result[:extensions]).map do |ext|
2337
2341
  slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
2338
- ext.merge("installed" => installed.include?(slug))
2342
+ container = installed[slug]
2343
+ ext.merge(
2344
+ "installed" => !container.nil?,
2345
+ "installed_version" => container&.dig(:version)
2346
+ )
2339
2347
  end
2340
2348
  json_response(res, 200, { ok: true, extensions: extensions })
2341
2349
  else
@@ -2347,6 +2355,68 @@ module Clacky
2347
2355
  end
2348
2356
  end
2349
2357
 
2358
+ # GET /api/store/extensions/installed
2359
+ #
2360
+ # Returns all locally installed extensions (all layers: builtin, installed,
2361
+ # local) regardless of whether they are still listed on the marketplace.
2362
+ def api_store_extensions_installed(res)
2363
+ result = Clacky::ExtensionLoader.load_all
2364
+ disabled = Clacky::ExtensionLoader.disabled_ids
2365
+
2366
+ local_entries = Array(result&.containers).filter_map do |ext_id, container|
2367
+ next unless container[:layer] == :installed
2368
+
2369
+ [ext_id, container]
2370
+ end.to_h
2371
+
2372
+ market_by_slug = fetch_batch_market_data(local_entries.keys)
2373
+
2374
+ extensions = local_entries.map do |ext_id, container|
2375
+ market = market_by_slug[ext_id]
2376
+ {
2377
+ "id" => ext_id,
2378
+ "name" => market ? (market["name"] || ext_id) : ext_id,
2379
+ "name_zh" => market&.dig("name_zh"),
2380
+ "name_en" => market&.dig("name_en"),
2381
+ "slug" => ext_id,
2382
+ "version" => market ? (market["version"] || container[:version]) : container[:version],
2383
+ "installed_version" => container[:version],
2384
+ "description" => market&.dig("description"),
2385
+ "author" => market&.dig("author"),
2386
+ "icon_url" => market&.dig("icon_url"),
2387
+ "units" => market&.dig("units"),
2388
+ "homepage" => market ? (market["homepage"] || "") : "",
2389
+ "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2390
+ "hub_active" => market&.dig("hub_active"),
2391
+ "download_count" => market&.dig("download_count").to_i,
2392
+ "unlisted" => market.nil?,
2393
+ "layer" => container[:layer].to_s,
2394
+ "installed" => true,
2395
+ "removable" => true,
2396
+ "disabled" => disabled.include?(ext_id),
2397
+ }
2398
+ end
2399
+
2400
+ json_response(res, 200, { ok: true, extensions: extensions })
2401
+ rescue StandardError => e
2402
+ json_response(res, 500, { ok: false, error: e.message })
2403
+ end
2404
+
2405
+ private def fetch_batch_market_data(slugs)
2406
+ return {} if slugs.empty?
2407
+
2408
+ client = Clacky::PlatformHttpClient.new
2409
+ slugs.each_slice(50).each_with_object({}) do |batch, result|
2410
+ ids_param = batch.join(",")
2411
+ response = client.get("/api/v1/extensions/batch?ids=#{ids_param}")
2412
+ next unless response[:success]
2413
+
2414
+ Array(response.dig(:data, "extensions")).each { |ext| result[ext["name"]] = ext }
2415
+ end
2416
+ rescue StandardError
2417
+ {}
2418
+ end
2419
+
2350
2420
  # Slugs of every extension container currently loaded (any layer), used to
2351
2421
  # flag "installed" on the public marketplace catalog.
2352
2422
  def installed_extension_slugs
@@ -2356,6 +2426,13 @@ module Clacky
2356
2426
  Set.new
2357
2427
  end
2358
2428
 
2429
+ def installed_extension_containers
2430
+ result = Clacky::ExtensionLoader.load_all
2431
+ Array(result&.containers).filter_map { |id, c| [id, c] if c[:layer] == :installed }.to_h
2432
+ rescue StandardError
2433
+ {}
2434
+ end
2435
+
2359
2436
  # GET /api/store/extension?id=<slug-or-id>
2360
2437
  #
2361
2438
  # Public detail for a single marketplace extension (contributes + version
@@ -2375,13 +2452,40 @@ module Clacky
2375
2452
  slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
2376
2453
  container = extension_container(slug)
2377
2454
  ext = ext.merge(
2378
- "installed" => !container.nil?,
2379
- "removable" => container && container[:layer] == :installed,
2380
- "disabled" => container ? container[:disabled] == true : false
2455
+ "installed" => !container.nil?,
2456
+ "installed_version" => container&.dig(:version),
2457
+ "removable" => container && container[:layer] == :installed,
2458
+ "disabled" => container ? container[:disabled] == true : false
2381
2459
  )
2382
2460
  json_response(res, 200, { ok: true, extension: ext })
2383
2461
  else
2384
- json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
2462
+ container = extension_container(id)
2463
+ if container && container[:layer] == :installed
2464
+ market = fetch_batch_market_data([id])[id]
2465
+ ext = {
2466
+ "id" => id,
2467
+ "name" => market ? (market["name"] || id) : id,
2468
+ "name_zh" => market&.dig("name_zh"),
2469
+ "name_en" => market&.dig("name_en"),
2470
+ "slug" => id,
2471
+ "version" => market ? (market["version"] || container[:version]) : container[:version],
2472
+ "installed_version" => container[:version],
2473
+ "description" => market&.dig("description"),
2474
+ "author" => market&.dig("author"),
2475
+ "icon_url" => market&.dig("icon_url"),
2476
+ "units" => market&.dig("units"),
2477
+ "homepage" => market ? (market["homepage"] || "") : "",
2478
+ "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2479
+ "hub_active" => market&.dig("hub_active"),
2480
+ "unlisted" => market.nil?,
2481
+ "installed" => true,
2482
+ "removable" => true,
2483
+ "disabled" => container[:disabled] == true,
2484
+ }
2485
+ json_response(res, 200, { ok: true, extension: ext })
2486
+ else
2487
+ json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
2488
+ end
2385
2489
  end
2386
2490
  end
2387
2491
 
@@ -2432,6 +2536,7 @@ module Clacky
2432
2536
 
2433
2537
  Clacky::ExtensionPackager.install(download_url, force: true)
2434
2538
  Clacky::ExtensionLoader.invalidate_cache!
2539
+ Clacky::Telemetry.extension_install!(name) unless name.empty?
2435
2540
  json_response(res, 200, { ok: true, name: name })
2436
2541
  rescue Clacky::ExtensionPackager::Error => e
2437
2542
  json_response(res, 422, { ok: false, error: e.message })
@@ -2899,7 +3004,7 @@ module Clacky
2899
3004
  require "net/http"
2900
3005
  require "uri"
2901
3006
 
2902
- oss_base = "https://oss.1024code.com/openclacky"
3007
+ oss_base = OSS_CDN_BASE
2903
3008
  latest_url = "#{oss_base}/latest.txt"
2904
3009
 
2905
3010
  Clacky::Logger.info("[Upgrade] Non-official source — fetching latest version from OSS CDN")
@@ -3071,11 +3176,12 @@ module Clacky
3071
3176
  end
3072
3177
 
3073
3178
  # Query the latest openclacky version.
3074
- # Strategy: try RubyGems official REST API first (most accurate, not affected by mirror lag),
3075
- # then fall back to `gem list -r` (respects user's configured gem source).
3076
- # Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
3179
+ # Strategy: OSS CDN latest.txt first (fast, CDN-accelerated), then RubyGems API,
3180
+ # then fall back to `gem list -r` as a last resort.
3077
3181
  private def fetch_latest_version_from_gem
3078
- fetch_latest_version_from_rubygems_api || fetch_latest_version_from_gem_command
3182
+ fetch_oss_latest_version("#{OSS_CDN_BASE}/latest.txt") ||
3183
+ fetch_latest_version_from_rubygems_api ||
3184
+ fetch_latest_version_from_gem_command
3079
3185
  end
3080
3186
 
3081
3187
  # Try RubyGems official REST API — fast and always up-to-date.
@@ -91,6 +91,15 @@ module Clacky
91
91
  fire_and_forget("/api/v1/telemetry/share", payload.compact)
92
92
  end
93
93
 
94
+ # Called after a marketplace extension is successfully installed.
95
+ # Increments the extension's download_count on the platform.
96
+ def extension_install!(slug)
97
+ return unless enabled?
98
+ return if slug.to_s.strip.empty?
99
+
100
+ fire_and_forget("/api/v1/telemetry/extension_install", { slug: slug })
101
+ end
102
+
94
103
  # ── private helpers ────────────────────────────────────────────────
95
104
 
96
105
  private def enabled?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.9"
4
+ VERSION = "1.3.11"
5
5
  end
@@ -1874,9 +1874,7 @@ body {
1874
1874
  flex-direction: column;
1875
1875
  gap: 1.25rem;
1876
1876
  width: 100%;
1877
- max-width: 60rem;
1878
- margin: 0 auto;
1879
- padding: 5.5rem 1.5rem 1rem;
1877
+ padding: 6rem max(1.5rem, calc(50% - 30rem)) 1rem;
1880
1878
  }
1881
1879
  .new-session-composer {
1882
1880
  position: relative;
@@ -2007,6 +2005,11 @@ body {
2007
2005
  font-size: 0.78125rem;
2008
2006
  color: var(--color-text-tertiary);
2009
2007
  line-height: 1.4;
2008
+ flex: 1;
2009
+ overflow: hidden;
2010
+ display: -webkit-box;
2011
+ -webkit-line-clamp: 2;
2012
+ -webkit-box-orient: vertical;
2010
2013
  }
2011
2014
  .agent-card-badge {
2012
2015
  position: absolute;
@@ -2026,7 +2029,6 @@ body {
2026
2029
  color: var(--color-accent-primary);
2027
2030
  }
2028
2031
  .agent-card-author {
2029
- margin-top: 0.25rem;
2030
2032
  font-size: 0.72rem;
2031
2033
  color: var(--color-text-tertiary);
2032
2034
  opacity: 0.8;
@@ -7070,6 +7072,12 @@ body {
7070
7072
  }
7071
7073
 
7072
7074
  /* ── Extension Market ─────────────────────────────────────────────────── */
7075
+ #extensions-panel {
7076
+ flex: 1;
7077
+ display: flex;
7078
+ flex-direction: column;
7079
+ overflow: hidden;
7080
+ }
7073
7081
  #extensions-body {
7074
7082
  flex: 1;
7075
7083
  overflow-y: auto;
@@ -7169,7 +7177,7 @@ body {
7169
7177
  .extension-card:hover { border-color: var(--color-text-muted); }
7170
7178
  .extension-card-main {
7171
7179
  display: flex;
7172
- align-items: flex-start;
7180
+ align-items: center;
7173
7181
  gap: 0.75rem;
7174
7182
  }
7175
7183
  .extension-emoji {
@@ -7181,7 +7189,7 @@ body {
7181
7189
  .extension-title {
7182
7190
  display: flex;
7183
7191
  align-items: center;
7184
- gap: 0.375rem;
7192
+ gap: 0.5rem;
7185
7193
  margin-bottom: 0.25rem;
7186
7194
  flex-wrap: wrap;
7187
7195
  }
@@ -7195,17 +7203,31 @@ body {
7195
7203
  color: var(--color-text-secondary);
7196
7204
  background: var(--color-bg-tertiary, rgba(255,255,255,0.05));
7197
7205
  border-radius: 4px;
7198
- padding: 0.0625rem 0rem;
7206
+ padding: 0.0625rem 0.375rem;
7199
7207
  }
7200
7208
  .extension-units {
7201
7209
  font-size: 0.6875rem;
7202
7210
  color: var(--color-text-muted);
7203
7211
  }
7204
7212
  .extension-installed {
7205
- font-size: 0.6875rem;
7213
+ font-size: 0.625rem;
7206
7214
  color: var(--color-success, #16a34a);
7207
7215
  background: var(--color-success-bg, rgba(22,163,74,0.12));
7208
7216
  border-radius: 4px;
7217
+ padding: 0.2rem 0.375rem;
7218
+ }
7219
+ .extension-updatable {
7220
+ font-size: 0.625rem;
7221
+ color: var(--color-button-primary);
7222
+ background: color-mix(in srgb, var(--color-button-primary) 12%, transparent);
7223
+ border-radius: 4px;
7224
+ padding: 0.2rem 0.375rem;
7225
+ }
7226
+ .extension-unlisted {
7227
+ font-size: 0.6875rem;
7228
+ color: var(--color-text-secondary);
7229
+ background: var(--color-surface-2, rgba(0,0,0,0.06));
7230
+ border-radius: 4px;
7209
7231
  padding: 0.0625rem 0.375rem;
7210
7232
  }
7211
7233
  .extension-desc {
@@ -7222,6 +7244,16 @@ body {
7222
7244
  font-size: 0.6875rem;
7223
7245
  color: var(--color-text-muted);
7224
7246
  }
7247
+ .extension-author {
7248
+ font-size: 0.75rem;
7249
+ color: var(--color-text-muted);
7250
+ font-weight: 400;
7251
+ }
7252
+ .extension-installs {
7253
+ font-size: 0.75rem;
7254
+ color: var(--color-text-muted);
7255
+ font-weight: 400;
7256
+ }
7225
7257
  .extension-meta {
7226
7258
  display: flex;
7227
7259
  align-items: center;
@@ -7324,26 +7356,67 @@ body {
7324
7356
  margin-top: 0.125rem;
7325
7357
  }
7326
7358
  .extension-version-item {
7327
- padding: 0.625rem 0.875rem;
7359
+ padding: 0.75rem 1rem;
7328
7360
  border-bottom: 1px solid var(--color-border-primary);
7329
7361
  }
7330
7362
  .extension-version-item:last-child { border-bottom: none; }
7331
7363
  .extension-version-row {
7332
7364
  display: flex;
7333
- align-items: center;
7365
+ align-items: baseline;
7334
7366
  gap: 0.5rem;
7335
7367
  }
7368
+ .extension-version-item:has(.extension-version-notes) .extension-version-row {
7369
+ padding-bottom: 0.5rem;
7370
+ margin-bottom: 0.5rem;
7371
+ border-bottom: 1px solid var(--color-border-primary);
7372
+ }
7373
+ .extension-version-row .extension-version {
7374
+ font-size: 0.9375rem;
7375
+ font-weight: 700;
7376
+ color: var(--color-text-primary);
7377
+ background: none;
7378
+ padding: 0;
7379
+ border-radius: 0;
7380
+ }
7336
7381
  .extension-version-date {
7337
7382
  font-size: 0.6875rem;
7338
7383
  color: var(--color-text-muted);
7339
7384
  }
7340
7385
  .extension-version-notes {
7341
- font-size: 0.75rem;
7386
+ font-size: 0.8125rem;
7342
7387
  color: var(--color-text-secondary);
7343
- line-height: 1.5;
7344
- margin-top: 0.25rem;
7345
- white-space: pre-wrap;
7388
+ line-height: 1.6;
7389
+ margin-top: 0.5rem;
7346
7390
  }
7391
+ .extension-version-notes h1,
7392
+ .extension-version-notes h2,
7393
+ .extension-version-notes h3 {
7394
+ font-size: 0.8125rem;
7395
+ font-weight: 600;
7396
+ color: var(--color-text-primary);
7397
+ margin: 0.75rem 0 0.25rem;
7398
+ }
7399
+ .extension-version-notes h1 {
7400
+ font-size: 0.9375rem;
7401
+ }
7402
+ .extension-version-notes h1:first-child,
7403
+ .extension-version-notes h2:first-child,
7404
+ .extension-version-notes h3:first-child {
7405
+ margin-top: 0;
7406
+ }
7407
+ .extension-version-notes ul,
7408
+ .extension-version-notes ol {
7409
+ margin: 0.25rem 0;
7410
+ padding-left: 1.25rem;
7411
+ }
7412
+ .extension-version-notes li {
7413
+ margin: 0.1rem 0;
7414
+ }
7415
+ .extension-version-notes p {
7416
+ margin: 0.25rem 0;
7417
+ }
7418
+ .extension-version-notes p:first-child { margin-top: 0; }
7419
+ .extension-version-notes p:last-child { margin-bottom: 0; }
7347
7420
  .extension-detail-loading { display: flex; flex-direction: column; gap: 0.5rem; }
7348
7421
 
7349
7422
  .extension-detail-actions {
@@ -7385,6 +7458,18 @@ body {
7385
7458
  opacity: 0.5;
7386
7459
  cursor: not-allowed;
7387
7460
  }
7461
+ .extension-action-update {
7462
+ background: var(--color-button-primary);
7463
+ color: var(--color-button-primary-text);
7464
+ border-color: transparent;
7465
+ }
7466
+ .extension-action-update:hover {
7467
+ background: var(--color-button-primary-hover);
7468
+ }
7469
+ .extension-action-update:disabled {
7470
+ opacity: 0.5;
7471
+ cursor: not-allowed;
7472
+ }
7388
7473
  .extension-action-disable,
7389
7474
  .extension-action-enable {
7390
7475
  color: var(--color-text-tertiary);
@@ -66,6 +66,7 @@ const ExtensionsStore = (() => {
66
66
 
67
67
  /** Fetch the catalog from the server for the current query + sort. */
68
68
  async load() {
69
+ if (_filterInstalled) return;
69
70
  _loading = true;
70
71
  _error = null;
71
72
  _emit("extensions:loading");
@@ -77,9 +78,9 @@ const ExtensionsStore = (() => {
77
78
  const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
78
79
  const data = await res.json();
79
80
  _allExtensions = data.extensions || [];
80
- _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
81
81
  _error = data.warning || null;
82
82
  _loading = false;
83
+ _extensions = _allExtensions;
83
84
  _emit("extensions:changed", { extensions: _extensions, warning: _error });
84
85
  } catch (e) {
85
86
  console.error("[Extensions] load failed", e);
@@ -95,6 +96,7 @@ const ExtensionsStore = (() => {
95
96
  /** Set the search text and reload. */
96
97
  setQuery(query) {
97
98
  _query = (query || "").trim();
99
+ if (_filterInstalled) return Extensions.setFilterInstalled(true);
98
100
  return Extensions.load();
99
101
  },
100
102
 
@@ -104,11 +106,30 @@ const ExtensionsStore = (() => {
104
106
  return Extensions.load();
105
107
  },
106
108
 
107
- /** Toggle the "installed only" filter (client-side, no new network request). */
108
- setFilterInstalled(onlyInstalled) {
109
+ /** Toggle the "installed only" filter fetches from local store when enabled. */
110
+ async setFilterInstalled(onlyInstalled) {
109
111
  _filterInstalled = !!onlyInstalled;
110
- _extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
111
- _emit("extensions:changed", { extensions: _extensions, warning: _error });
112
+ if (_filterInstalled) {
113
+ _loading = true;
114
+ _emit("extensions:loading");
115
+ try {
116
+ const res = await fetch("/api/store/extensions/installed");
117
+ const data = await res.json();
118
+ const all = data.extensions || [];
119
+ _extensions = _query
120
+ ? all.filter(e => [e.name, e.name_zh, e.description].some(
121
+ f => f && f.toLowerCase().includes(_query.toLowerCase())))
122
+ : all;
123
+ } catch (e) {
124
+ console.error("[Extensions] load installed failed", e);
125
+ _extensions = [];
126
+ } finally {
127
+ _loading = false;
128
+ }
129
+ _emit("extensions:changed", { extensions: _extensions, warning: _error });
130
+ } else {
131
+ return Extensions.load();
132
+ }
112
133
  },
113
134
 
114
135
  /** Open the detail view for one extension (fetches contributes + versions). */
@@ -158,8 +179,7 @@ const ExtensionsStore = (() => {
158
179
  });
159
180
  const data = await res.json();
160
181
  if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
161
- await Extensions.loadDetail(id);
162
- Extensions.load();
182
+ location.reload();
163
183
  } catch (e) {
164
184
  console.error("[Extensions] setEnabled failed", e);
165
185
  _detailError = e.message;
@@ -184,7 +204,7 @@ const ExtensionsStore = (() => {
184
204
  });
185
205
  const data = await res.json();
186
206
  if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
187
- await Extensions.loadDetail(id);
207
+ location.reload();
188
208
  } catch (e) {
189
209
  console.error("[Extensions] install failed", e);
190
210
  _detailError = e.message;
@@ -192,6 +212,11 @@ const ExtensionsStore = (() => {
192
212
  }
193
213
  },
194
214
 
215
+ /** Update an installed extension to the latest marketplace version. */
216
+ async update(id) {
217
+ return Extensions.install(id);
218
+ },
219
+
195
220
  /** Remove an installed extension, then return to the list. */
196
221
  async uninstall(id) {
197
222
  if (!id) return;
@@ -203,7 +228,7 @@ const ExtensionsStore = (() => {
203
228
  });
204
229
  const data = await res.json();
205
230
  if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
206
- await Extensions.loadDetail(id);
231
+ location.reload();
207
232
  } catch (e) {
208
233
  console.error("[Extensions] uninstall failed", e);
209
234
  _detailError = e.message;