openclacky 1.5.7 → 1.5.8

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +2 -2
  4. data/README_CN.md +1 -1
  5. data/README_JA.md +1 -1
  6. data/lib/clacky/agent.rb +2 -3
  7. data/lib/clacky/agent_config.rb +40 -18
  8. data/lib/clacky/cli.rb +0 -1
  9. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +23 -10
  10. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +3 -2
  11. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  12. data/lib/clacky/extension/api_loader.rb +4 -11
  13. data/lib/clacky/extension/packager.rb +1 -1
  14. data/lib/clacky/extension/verifier.rb +1 -1
  15. data/lib/clacky/media/generator.rb +3 -3
  16. data/lib/clacky/message_history.rb +1 -1
  17. data/lib/clacky/providers.rb +2 -1
  18. data/lib/clacky/server/http_server.rb +60 -76
  19. data/lib/clacky/session_manager.rb +7 -0
  20. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  21. data/lib/clacky/ui2/components/input_area.rb +1 -0
  22. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  23. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  24. data/lib/clacky/ui2/line_editor.rb +1 -0
  25. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  26. data/lib/clacky/ui2/ui_controller.rb +1 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +134 -210
  29. data/lib/clacky/web/components/chat-navigator.js +1 -1
  30. data/lib/clacky/web/features/billing/store.js +3 -3
  31. data/lib/clacky/web/features/billing/view.js +5 -61
  32. data/lib/clacky/web/features/extensions/store.js +1 -1
  33. data/lib/clacky/web/features/extensions/view.js +28 -5
  34. data/lib/clacky/web/features/new-session/view.js +24 -0
  35. data/lib/clacky/web/i18n.js +20 -18
  36. data/lib/clacky/web/index.html +12 -13
  37. data/lib/clacky/web/projects.js +73 -282
  38. data/lib/clacky/web/settings.js +6 -1
  39. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c045233115364b3165e5ca43d7b45b898250996f952c2db8baacb4435404cc0
4
- data.tar.gz: 3617f770edb88eaf6361bb0e6398f2ab6519f950ce234c89d0e9bce4fed4d486
3
+ metadata.gz: efbdc6d887e5beea867023b733d023f236b0e0a0a813201cce3767deebe43fd2
4
+ data.tar.gz: 1abf4430324ddd28b1d1d6a6b82390eaa8c76dc026f31cc1c10ff7fdac6c7207
5
5
  SHA512:
6
- metadata.gz: 6e5943dfb3599650349c8e09359e3fc1467e7fa6126f78ccf4840d2d9eaa02e5c06b088dc02bbeb789d53e8ae5fc128656a6e193dd385951a627f2349222262d
7
- data.tar.gz: 8565520e6fef3cf12bc8580c537b04499a0f3b6068b2ff88d495dbec51f2feb70ef2e45713d0c1d1a06af0b1e4fffbdf4e3d4a2f414649943515b83d7a7f2946
6
+ metadata.gz: 6df9bcdd91aa229039baad046a06d471ae11367e78a594f655abffd808c3f9b14507f9fca32d99c4f3fbd13a3ab2743451826238bc8e55e9ddc0161546276bae
7
+ data.tar.gz: 53b8fd83915c5ee898237dc113099a7ea0e339961dac83b11d4b16285a1f2fb3b4581d1876f70c2a78608556b22760da3ba8fff7146af0b66c701d12e963005b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ 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.8] - 2026-08-12
8
+
9
+ ### Added
10
+ - Inline SVG icons (edit, clear sessions, delete) in project action menu (#474)
11
+ - New project button in project selector popover
12
+ - Verified badge for officially reviewed extensions in marketplace
13
+
14
+ ### Improved
15
+ - Skip full session/file scan when count is under cap (#470 - @shipinliang)
16
+ - Token estimation uses count() instead of scan().length (#471 - @shipinliang)
17
+ - Faster server startup
18
+ - Brand user extension UI (non-admin publish button grayed out, unverified banner hidden)
19
+ - Sidebar my-data section icons refreshed
20
+ - Section chevron icon size increased from 11px to 13px
21
+ - Project sidebar hover effects, folder default hints, project icon sizing
22
+ - Billing UI cleanup and global theme color adjustment
23
+ - Section header font and project group spacing tweaks
24
+ - Project menu labels updated
25
+ - Billing disclaimer about provider billing
26
+
27
+ ### Fixed
28
+ - Extension pack max zip size increased from 50 MB to 80 MB
29
+ - New session button in project area now correctly preselects the project
30
+ - Chat navigation popup no longer overlaps sidebar collapse button
31
+ - Sub-model config preserved when switching modes (auto/off/custom)
32
+ - minimax-m2.7 vision detection fixed
33
+ - Project icon no longer reverts to default after page refresh
34
+ - Load constant warning fixed
35
+
36
+ ### More
37
+ - README cost table formatting normalized
38
+ - Ambiguous cost phrasing clarified across READMEs (#475 - @chengoak)
39
+
7
40
  ## [1.5.7] - 2026-08-10
8
41
 
9
42
  ### Added
data/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  **The most Token-efficient open-source AI Agent.**
16
16
 
17
- OpenClacky matches Claude Code on capability at comparable cost, and saves significantly against other open-source agents (~50% vs OpenClaw, ~3× cheaper than Hermes). 100% open source (MIT), BYOK with any OpenAI-compatible model, built on two years of Agentic R&D and harness engineering.
17
+ OpenClacky matches Claude Code on capability at comparable cost, and saves significantly against other open-source agents (~50% vs OpenClaw, costs only ~1/3 of Hermes). 100% open source (MIT), BYOK with any OpenAI-compatible model, built on two years of Agentic R&D and harness engineering.
18
18
 
19
19
  > Website: https://www.openclacky.com/ · Backed by **MiraclePlus · ZhenFund · Sequoia China · Hillhouse Capital**
20
20
 
@@ -24,7 +24,7 @@ Same task, how much do you pay? Under comparable agent workloads, OpenClacky sav
24
24
 
25
25
  | Agent | Relative cost | Notes |
26
26
  |---|---|---|
27
- | **OpenClacky** | **~0.8** | 16 tools · ~100% cache hit · subagent routing |
27
+ | **OpenClacky** | **~0.8×** | 16 tools · ~100% cache hit · subagent routing |
28
28
  | Claude Code | 1.0× (baseline) | World-class harness, closed-source subscription |
29
29
  | OpenClaw | ~1.5× | Comparable harness agent |
30
30
  | Hermes | ~3× | 52 built-in tools — schema bloat ~3–4× |
data/README_CN.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  **最省 Token 的开源 AI Agent。**
12
12
 
13
- OpenClacky 在任务能力上对齐 Claude Code,成本相当,同时相比其他开源 Agent 有显著优势(约节省 50% vs OpenClaw,约便宜 vs Hermes)。100% 开源(MIT),支持 BYOK 接入任意 OpenAI 兼容模型,背后是两年 Agentic 研发与 Harness 工程积累。
13
+ OpenClacky 在任务能力上对齐 Claude Code,成本相当,同时相比其他开源 Agent 有显著优势(约节省 50% vs OpenClaw,成本约为 Hermes 1/3)。100% 开源(MIT),支持 BYOK 接入任意 OpenAI 兼容模型,背后是两年 Agentic 研发与 Harness 工程积累。
14
14
 
15
15
  > 官网:https://www.openclacky.com/ · 投资方:**奇绩创坛 · 真格基金 · 红杉中国 · 高瓴资本**
16
16
 
data/README_JA.md CHANGED
@@ -24,7 +24,7 @@ OpenClacky は Claude Code と同等の性能を同等のコストで実現し
24
24
 
25
25
  | エージェント | 相対コスト | 備考 |
26
26
  |---|---|---|
27
- | **OpenClacky** | **約 0.8** | 16 ツール · キャッシュヒット率約 100% · サブエージェントルーティング |
27
+ | **OpenClacky** | **約 0.8×** | 16 ツール · キャッシュヒット率約 100% · サブエージェントルーティング |
28
28
  | Claude Code | 1.0×(基準) | 世界クラスのハーネス、クローズドソースのサブスクリプション |
29
29
  | OpenClaw | 約 1.5× | 同等のハーネスエージェント |
30
30
  | Hermes | 約 3× | 52 個の組み込みツール — スキーマの肥大化が約 3〜4 倍 |
data/lib/clacky/agent.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require "securerandom"
4
4
  require "json"
5
5
  require "cgi"
6
- require "tty-prompt"
7
6
  require "set"
8
7
  require_relative "null_ui_controller"
9
8
  require_relative "utils/arguments_parser"
@@ -1392,7 +1391,7 @@ module Clacky
1392
1391
  # plain text, inflating token counts by 20-40x. The tool result carries a
1393
1392
  # plain-text description for the LLM; the actual image is delivered here.
1394
1393
  vision_supported = @config.current_model_supports?(:vision)
1395
- ocr_entry = vision_supported ? nil : @config.find_model_by_type("ocr")
1394
+ ocr_entry = vision_supported ? nil : @config.effective_ocr_entry
1396
1395
 
1397
1396
  tool_results.each do |tr|
1398
1397
  inject = tr[:image_inject]
@@ -1959,7 +1958,7 @@ module Clacky
1959
1958
  # OCR sidecar — only consulted when the primary doesn't see images.
1960
1959
  # When the sidecar entry has "primary"=>true, the primary itself can see,
1961
1960
  # so vision_supported was already true and we never enter the OCR branch.
1962
- ocr_entry = vision_supported ? nil : @config.find_model_by_type("ocr")
1961
+ ocr_entry = vision_supported ? nil : @config.effective_ocr_entry
1963
1962
 
1964
1963
  vision_images = [] # Array of { url:, name:, size_bytes:, path: }
1965
1964
  downgraded = []
@@ -633,16 +633,10 @@ module Clacky
633
633
  }.compact
634
634
  end
635
635
 
636
- # Find model by type (default or lite or media kind or ocr sidecar)
637
- # Returns the model hash or nil if not found.
638
- # For media kinds (image/video/audio): explicit user-configured (custom)
639
- # entries win; otherwise an auto-derived virtual entry is returned
640
- # based on the default model's provider — mirroring how lite is
641
- # virtually derived via #lite_model_config_for_current.
642
- # For "ocr": same custom→auto→nil pattern. Auto path first checks
643
- # whether the default model itself supports vision (zero-overhead path,
644
- # no sidecar needed); if not, derives from the provider's
645
- # default_ocr_model.
636
+ # Raw accessor - returns the stored model entry without mode-aware filtering.
637
+ # For media/ocr: does NOT check mode field, only legacy disabled flag.
638
+ # Mode-aware callers should use #effective_media_entry / #effective_ocr_entry.
639
+ # HTTP server uses this to read raw entries for saved_custom / api_key masking.
646
640
  def find_model_by_type(type)
647
641
  kind = type.to_s
648
642
  if Clacky::Providers::MEDIA_KINDS.include?(kind)
@@ -701,6 +695,38 @@ module Clacky
701
695
  @models.reject! { |m| m["auto_injected"] && Clacky::Providers::MEDIA_KINDS.include?(m["type"].to_s) }
702
696
  end
703
697
 
698
+ private def sidecar_off?(entry)
699
+ entry && (entry["disabled"] || entry["mode"] == "off")
700
+ end
701
+
702
+ private def sidecar_custom?(entry)
703
+ entry &&
704
+ entry["base_url"].to_s.strip != "" &&
705
+ entry["api_key"].to_s.strip != "" &&
706
+ entry["mode"] != "auto"
707
+ end
708
+
709
+ def effective_media_entry(kind)
710
+ kind = kind.to_s
711
+ raw_entry = @models.find { |m| m["type"] == kind }
712
+ return nil if sidecar_off?(raw_entry)
713
+
714
+ is_custom = sidecar_custom?(raw_entry)
715
+ override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
716
+
717
+ is_custom ? raw_entry : derive_media_model(kind, model_override: override_model)
718
+ end
719
+
720
+ def effective_ocr_entry
721
+ raw_entry = @models.find { |m| m["type"] == "ocr" }
722
+ return nil if sidecar_off?(raw_entry)
723
+
724
+ is_custom = sidecar_custom?(raw_entry)
725
+ override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
726
+
727
+ is_custom ? raw_entry : derive_ocr_model(model_override: override_model)
728
+ end
729
+
704
730
  # Derive an OCR sidecar model entry from the default model's provider.
705
731
  # Resolution order:
706
732
  # 1. If the default model itself supports vision → return the default
@@ -763,7 +789,7 @@ module Clacky
763
789
  kind = kind.to_s
764
790
  raw_entry = @models.find { |m| m["type"] == kind }
765
791
 
766
- if raw_entry && raw_entry["disabled"]
792
+ if sidecar_off?(raw_entry)
767
793
  default = find_model_by_type("default")
768
794
  default_provider = default && Clacky::Providers.resolve_provider(
769
795
  base_url: default["base_url"], api_key: default["api_key"]
@@ -782,9 +808,7 @@ module Clacky
782
808
  }
783
809
  end
784
810
 
785
- is_custom = raw_entry &&
786
- raw_entry["base_url"].to_s.strip != "" &&
787
- raw_entry["api_key"].to_s.strip != ""
811
+ is_custom = sidecar_custom?(raw_entry)
788
812
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
789
813
 
790
814
  entry = if is_custom
@@ -842,7 +866,7 @@ module Clacky
842
866
  )
843
867
  available = default_provider ? Clacky::Providers.ocr_models(default_provider) : []
844
868
 
845
- if raw_entry && raw_entry["disabled"]
869
+ if sidecar_off?(raw_entry)
846
870
  # A disabled OCR sidecar only means "no separate vision model"; it must
847
871
  # not override the fact that the chat model may handle images itself.
848
872
  anchor = current_model || default
@@ -872,9 +896,7 @@ module Clacky
872
896
  }
873
897
  end
874
898
 
875
- is_custom = raw_entry &&
876
- raw_entry["base_url"].to_s.strip != "" &&
877
- raw_entry["api_key"].to_s.strip != ""
899
+ is_custom = sidecar_custom?(raw_entry)
878
900
  override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
879
901
 
880
902
  entry = if is_custom
data/lib/clacky/cli.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "tty-prompt"
5
4
  require "fileutils"
6
5
  require_relative "ui2"
7
6
  require_relative "json_ui_controller"
@@ -148,6 +148,7 @@
148
148
  "extlist.unpublish.confirm": "Unpublish {{id}} from the marketplace?",
149
149
  "extlist.publishing": "Publishing…",
150
150
  "extlist.needLicense": "Publishing requires an activated user license.",
151
+ "extlist.brandLocked": "Publishing to the marketplace requires a creator license.",
151
152
  "extlist.newExt.label": "Create a new extension",
152
153
  "extlist.newExt.hint": "Opens an AI session that scaffolds and builds it for you.",
153
154
  "extlist.newExt.btn": "Create New Extension",
@@ -338,6 +339,7 @@
338
339
  "extlist.unpublish.confirm": "确定要从市场下架 {{id}} 吗?",
339
340
  "extlist.publishing": "发布中…",
340
341
  "extlist.needLicense": "发布需要已激活的用户授权。",
342
+ "extlist.brandLocked": "发布到市场需要创作者权限。",
341
343
  "extlist.newExt.label": "创建新扩展",
342
344
  "extlist.newExt.hint": "打开一个 AI 会话,帮你生成并开发它。",
343
345
  "extlist.newExt.btn": "创建新扩展",
@@ -1406,11 +1408,16 @@
1406
1408
  ));
1407
1409
 
1408
1410
  const bar = el("div", { class: "studio-actions" });
1409
- bar.appendChild(el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish }));
1411
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1412
+ const pubBtn = el("button", { class: "studio-btn studio-btn-primary studio-publish-btn", text: t("btn.publish"), onclick: doPublish });
1413
+ pubBtn.disabled = brandNonAdmin;
1414
+ if (brandNonAdmin) pubBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1415
+ bar.appendChild(pubBtn);
1410
1416
  if (typeof Brand !== "undefined" && Brand.userLicensed) {
1411
1417
  bar.appendChild(el("button", { class: "studio-btn studio-publish-brand-btn", text: t("btn.publishBrand"), onclick: doPublishBrand }));
1412
1418
  }
1413
- bar.appendChild(el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack }));
1419
+ const packBtn = el("button", { class: "studio-btn studio-btn-ghost studio-pack-btn", text: t("btn.pack"), onclick: doPack });
1420
+ bar.appendChild(packBtn);
1414
1421
  container.appendChild(bar);
1415
1422
 
1416
1423
  container.appendChild(el("div", { class: "studio-feedback" }));
@@ -1548,9 +1555,11 @@
1548
1555
  card.appendChild(meta);
1549
1556
 
1550
1557
  const actions = el("div", { class: "studio-actions" });
1558
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1551
1559
  const pub = el("button", { class: "studio-btn studio-btn-primary", text: published ? t("extlist.btn.update") : t("extlist.btn.publish") });
1552
- pub.disabled = !!ext.error_count;
1553
- pub.title = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "";
1560
+ pub.disabled = !!ext.error_count || brandNonAdmin;
1561
+ const pubTip = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : (brandNonAdmin ? t("extlist.brandLocked") : "");
1562
+ if (pubTip) pub.setAttribute("data-tooltip", pubTip);
1554
1563
  pub.addEventListener("click", () => doPublish(ext, cloudEntry ? cloudEntry.version : null));
1555
1564
  actions.appendChild(pub);
1556
1565
 
@@ -1558,7 +1567,7 @@
1558
1567
  const brandEntry = brandCloud.find((c) => c.id === ext.id);
1559
1568
  const brandPub = el("button", { class: "studio-btn", text: t("extlist.btn.publishBrand") });
1560
1569
  brandPub.disabled = !!ext.error_count;
1561
- brandPub.title = ext.error_count ? t("extlist.verify.errors", { n: ext.error_count }) : "";
1570
+ if (ext.error_count) brandPub.setAttribute("data-tooltip", t("extlist.verify.errors", { n: ext.error_count }));
1562
1571
  brandPub.addEventListener("click", () => doPublish(ext, brandEntry ? brandEntry.version : null, "self"));
1563
1572
  actions.appendChild(brandPub);
1564
1573
  }
@@ -1635,11 +1644,15 @@
1635
1644
 
1636
1645
  if (headerActions) {
1637
1646
  headerActions.innerHTML = "";
1638
- headerActions.appendChild(el("button", {
1647
+ const brandNonAdmin = typeof Brand !== "undefined" && Brand.branded && !Brand.userLicensed;
1648
+ const newBtn = el("button", {
1639
1649
  class: "studio-btn studio-btn-primary",
1640
1650
  text: t("extlist.newExt.btn"),
1641
1651
  onclick: () => createExtension(t("extlist.newExt.seed")),
1642
- }));
1652
+ });
1653
+ newBtn.disabled = brandNonAdmin;
1654
+ if (brandNonAdmin) newBtn.setAttribute("data-tooltip", t("extlist.brandLocked"));
1655
+ headerActions.appendChild(newBtn);
1643
1656
  }
1644
1657
 
1645
1658
  container.appendChild(section("extlist.section.cloud", "extlist.section.cloudHint", cloud, cloudCard, "extlist.cloud.empty"));
@@ -1711,7 +1724,7 @@
1711
1724
  if (skill.local_present && skill.has_local_changes) {
1712
1725
  const btn = el("button", { class: "studio-btn studio-btn-primary", text: t("skills.btn.update") });
1713
1726
  btn.disabled = locked;
1714
- btn.title = locked ? t("skills.locked") : "";
1727
+ if (locked) btn.setAttribute("data-tooltip", t("skills.locked"));
1715
1728
  btn.addEventListener("click", () => doPublish(skill.name, btn, true));
1716
1729
  actions.appendChild(btn);
1717
1730
  } else if (skill.local_present) {
@@ -1738,7 +1751,7 @@
1738
1751
  const actions = el("div", { class: "studio-actions" });
1739
1752
  const btn = el("button", { class: "studio-btn studio-btn-primary", text: t("skills.btn.publish") });
1740
1753
  btn.disabled = locked;
1741
- btn.title = locked ? t("skills.locked") : "";
1754
+ if (locked) btn.setAttribute("data-tooltip", t("skills.locked"));
1742
1755
  btn.addEventListener("click", () => doPublish(skill.name, btn, false));
1743
1756
  actions.appendChild(btn);
1744
1757
  card.appendChild(actions);
@@ -1798,7 +1811,7 @@
1798
1811
  onclick: () => createSkill(null),
1799
1812
  });
1800
1813
  newBtn.disabled = locked;
1801
- newBtn.title = locked ? t("skills.locked") : "";
1814
+ if (locked) newBtn.setAttribute("data-tooltip", t("skills.locked"));
1802
1815
  headerActions.appendChild(newBtn);
1803
1816
  }
1804
1817
 
@@ -423,8 +423,9 @@ smallest change, re-run verify, repeat until clean — fix ONE issue at a time.
423
423
  - **`schema.unknown_contributes`** → a top-level key under `contributes:` is misspelled.
424
424
  **Do:** fix the spelling to one of `panels api skills agents channels patches hooks`.
425
425
  - **`schema.unknown_key`** → an unknown **top-level** key in `ext.yml`. **Do:** fix the
426
- spelling. Allowed top-level keys: `id name title description version origin author
427
- homepage license public license_required keywords contributes`.
426
+ spelling. Allowed top-level keys: `id name name_zh display_name display_name_zh title
427
+ description description_zh emoji version origin author homepage license public
428
+ license_required keywords contributes`.
428
429
  - **`schema.unknown_field`** → a unit has a field not allowed for its type. **Do:** delete
429
430
  or rename that field. Allowed fields per type (this is the authoritative list — do not
430
431
  invent others):
@@ -15,4 +15,4 @@ contributes:
15
15
  order: 1
16
16
  prompt: agents/general/system_prompt.md
17
17
  avatar: agents/general/avatar.png
18
- panels: [meeting/meeting]
18
+ panels: []
@@ -65,17 +65,10 @@ module Clacky
65
65
  existing = reload ? Clacky::ApiExtension.registry[ext_id] : nil
66
66
  existing&.reset_routes!
67
67
 
68
- if reload
69
- old_verbose = $VERBOSE
70
- $VERBOSE = nil
71
- begin
72
- load(handler_path)
73
- ensure
74
- $VERBOSE = old_verbose
75
- end
76
- else
77
- require(handler_path)
78
- end
68
+ # Wrapped load: each handler gets a fresh anonymous module, so two exts
69
+ # defining the same top-level class/constant names cannot clobber each
70
+ # other (and the `inherited` hook fires even on repeat loads).
71
+ load(handler_path, true)
79
72
 
80
73
  new_subclasses = Clacky::ApiExtension.pending_subclasses[before..] || []
81
74
  klass = new_subclasses.last || existing
@@ -16,7 +16,7 @@ module Clacky
16
16
  # optional URL fetch in `install`.
17
17
  module ExtensionPackager
18
18
  MANIFEST = "ext.yml"
19
- MAX_ZIP_SIZE = 50 * 1024 * 1024
19
+ MAX_ZIP_SIZE = 80 * 1024 * 1024
20
20
 
21
21
  # Network timeouts for the download step. `read_timeout` is an *idle*
22
22
  # timeout (resets on every chunk received), so a stalled connection with
@@ -16,7 +16,7 @@ module Clacky
16
16
  module ExtensionVerifier
17
17
  Issue = Struct.new(:ext, :unit, :level, :code, :message, :file, :hint, keyword_init: true)
18
18
 
19
- KNOWN_TOP_KEYS = %w[id name title description version origin author homepage license public license_required keywords contributes].freeze
19
+ KNOWN_TOP_KEYS = %w[id name name_zh display_name display_name_zh title description description_zh emoji version origin author homepage license public license_required keywords contributes].freeze
20
20
  KNOWN_CONTRIBUTES = %w[panels api skills agents channels patches hooks].freeze
21
21
 
22
22
  PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
@@ -49,17 +49,17 @@ module Clacky
49
49
 
50
50
  # @return [Hash, nil] the type=image model entry, or nil if not configured
51
51
  def image_model_entry
52
- @agent_config.find_model_by_type("image")
52
+ @agent_config.effective_media_entry("image")
53
53
  end
54
54
 
55
55
  # @return [Hash, nil] the type=video model entry, or nil if not configured
56
56
  def video_model_entry
57
- @agent_config.find_model_by_type("video")
57
+ @agent_config.effective_media_entry("video")
58
58
  end
59
59
 
60
60
  # @return [Hash, nil] the type=audio model entry, or nil if not configured
61
61
  def audio_model_entry
62
- @agent_config.find_model_by_type("audio")
62
+ @agent_config.effective_media_entry("audio")
63
63
  end
64
64
 
65
65
  def generate_image(prompt:, aspect_ratio: "landscape", output_dir: nil, **kwargs)
@@ -286,7 +286,7 @@ module Clacky
286
286
  private def estimate_content_tokens(content)
287
287
  case content
288
288
  when String
289
- ascii_chars = content.scan(/[ -~]/).length
289
+ ascii_chars = content.count(" -~")
290
290
  multibyte_chars = content.length - ascii_chars
291
291
  ((ascii_chars / 4.0) + (multibyte_chars / 1.5)).ceil
292
292
  when Array
@@ -472,7 +472,8 @@ module Clacky
472
472
  "model_capabilities" => {
473
473
  "glm-5.2" => { "vision" => false }.freeze,
474
474
  "deepseek-v4-pro" => { "vision" => false }.freeze,
475
- "deepseek-v4-flash" => { "vision" => false }.freeze
475
+ "deepseek-v4-flash" => { "vision" => false }.freeze,
476
+ "minimax-m2.7" => { "vision" => false }.freeze
476
477
  }.freeze,
477
478
  "default_ocr_model" => "doubao-seed-2.0-lite",
478
479
  "website_url" => "https://console.volcengine.com/ark/region:cn-beijing/overview"
@@ -184,7 +184,7 @@ module Clacky
184
184
  - 将大目标拆解为可执行的小步骤
185
185
  MD
186
186
 
187
- def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil, socket: nil, master_pid: nil)
187
+ def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil, projects_file: nil, socket: nil, master_pid: nil)
188
188
  @host = host
189
189
  @port = port
190
190
  @agent_config = agent_config
@@ -197,7 +197,7 @@ module Clacky
197
197
  @restart_script = File.expand_path($0)
198
198
  @restart_argv = ARGV.dup
199
199
  @session_manager = Clacky::SessionManager.new(sessions_dir: sessions_dir)
200
- @project_manager = Clacky::Server::ProjectManager.new
200
+ @project_manager = Clacky::Server::ProjectManager.new(projects_file: projects_file)
201
201
  @registry = SessionRegistry.new(
202
202
  session_manager: @session_manager,
203
203
  session_restorer: method(:build_session_from_data),
@@ -1840,6 +1840,9 @@ module Clacky
1840
1840
  requested_model: state["requested_model"],
1841
1841
  configured: state["configured"]
1842
1842
  }
1843
+ if entry && entry["base_url"] && state["source"] != "custom"
1844
+ out[t][:saved_custom] = { model: entry["model"], base_url: entry["base_url"] }
1845
+ end
1843
1846
  end
1844
1847
 
1845
1848
  # Surface what the current default model can offer, even when the
@@ -1949,50 +1952,78 @@ module Clacky
1949
1952
  end
1950
1953
 
1951
1954
  def api_update_media_config(kind, req, res)
1952
- body = parse_json_body(req) || {}
1955
+ update_sidecar_config(res, kind, parse_json_body(req) || {})
1956
+ end
1957
+
1958
+ private def update_sidecar_config(res, kind, body)
1953
1959
  source = body["source"].to_s
1954
1960
  unless %w[off auto custom].include?(source)
1955
1961
  return json_response(res, 422, { error: "invalid source" })
1956
1962
  end
1957
1963
 
1958
- @agent_config.models.reject! { |m| m["type"] == kind }
1964
+ existing = @agent_config.models.find { |m| m["type"] == kind }
1959
1965
 
1960
1966
  case source
1961
1967
  when "off"
1962
- @agent_config.models << {
1963
- "id" => SecureRandom.uuid,
1964
- "type" => kind,
1965
- "disabled" => true
1966
- }
1967
- when "auto"
1968
- override = body["model"].to_s.strip
1969
- unless override.empty?
1968
+ if existing
1969
+ existing["mode"] = "off"
1970
+ else
1970
1971
  @agent_config.models << {
1971
- "id" => SecureRandom.uuid,
1972
- "type" => kind,
1973
- "model" => override
1972
+ "id" => SecureRandom.uuid,
1973
+ "type" => kind,
1974
+ "mode" => "off"
1974
1975
  }
1975
1976
  end
1977
+ when "auto"
1978
+ override = body["model"].to_s.strip
1979
+ if existing
1980
+ existing.delete("disabled")
1981
+ existing["mode"] = "auto"
1982
+ existing["model"] = override unless override.empty?
1983
+ else
1984
+ unless override.empty?
1985
+ @agent_config.models << {
1986
+ "id" => SecureRandom.uuid,
1987
+ "type" => kind,
1988
+ "mode" => "auto",
1989
+ "model" => override
1990
+ }
1991
+ end
1992
+ end
1976
1993
  when "custom"
1977
1994
  model = body["model"].to_s.strip
1978
1995
  base_url = body["base_url"].to_s.strip
1979
1996
  api_key = body["api_key"].to_s
1980
- if model.empty? || base_url.empty? || api_key.empty? || api_key.include?("****")
1997
+ if api_key.empty? || api_key.include?("****")
1998
+ api_key = existing ? existing["api_key"].to_s : ""
1999
+ end
2000
+ if model.empty? || base_url.empty? || api_key.empty?
1981
2001
  return json_response(res, 422, { error: "model, base_url, api_key are required" })
1982
2002
  end
1983
2003
 
1984
- @agent_config.models << {
1985
- "id" => SecureRandom.uuid,
1986
- "model" => model,
1987
- "base_url" => base_url,
1988
- "api_key" => api_key,
1989
- "anthropic_format" => body["anthropic_format"] || false,
1990
- "type" => kind
1991
- }
2004
+ if existing
2005
+ existing["model"] = model
2006
+ existing["base_url"] = base_url
2007
+ existing["api_key"] = api_key
2008
+ existing["anthropic_format"] = body["anthropic_format"] || false
2009
+ existing["mode"] = "custom"
2010
+ existing.delete("disabled")
2011
+ else
2012
+ @agent_config.models << {
2013
+ "id" => SecureRandom.uuid,
2014
+ "model" => model,
2015
+ "base_url" => base_url,
2016
+ "api_key" => api_key,
2017
+ "anthropic_format" => body["anthropic_format"] || false,
2018
+ "type" => kind,
2019
+ "mode" => "custom"
2020
+ }
2021
+ end
1992
2022
  end
1993
2023
 
1994
2024
  @agent_config.save
1995
- json_response(res, 200, { ok: true, state: @agent_config.media_state(kind) })
2025
+ state = kind == "ocr" ? @agent_config.ocr_state : @agent_config.media_state(kind)
2026
+ json_response(res, 200, { ok: true, state: state })
1996
2027
  rescue => e
1997
2028
  json_response(res, 422, { error: e.message })
1998
2029
  end
@@ -2016,6 +2047,9 @@ module Clacky
2016
2047
  configured: state["configured"],
2017
2048
  primary: state["primary"] || false
2018
2049
  }
2050
+ if entry && entry["base_url"] && state["source"] != "custom"
2051
+ out[:saved_custom] = { model: entry["model"], base_url: entry["base_url"] }
2052
+ end
2019
2053
 
2020
2054
  # Auto-mode preview: surface what the OCR sidecar *would* be if the
2021
2055
  # user flipped to "auto" — derived from the same provider as the
@@ -2037,58 +2071,8 @@ module Clacky
2037
2071
  # PATCH /api/config/ocr
2038
2072
  # Body: { source: "off"|"auto"|"custom", model?, base_url?, api_key?,
2039
2073
  # anthropic_format? }
2040
- # Mirrors api_update_media_config but for the single "ocr" type.
2041
2074
  def api_update_ocr_config(req, res)
2042
- body = parse_json_body(req) || {}
2043
- source = body["source"].to_s
2044
- unless %w[off auto custom].include?(source)
2045
- return json_response(res, 422, { error: "invalid source" })
2046
- end
2047
-
2048
- @agent_config.models.reject! { |m| m["type"] == "ocr" }
2049
-
2050
- case source
2051
- when "off"
2052
- @agent_config.models << {
2053
- "id" => SecureRandom.uuid,
2054
- "type" => "ocr",
2055
- "disabled" => true
2056
- }
2057
- when "auto"
2058
- override = body["model"].to_s.strip
2059
- unless override.empty?
2060
- @agent_config.models << {
2061
- "id" => SecureRandom.uuid,
2062
- "type" => "ocr",
2063
- "model" => override
2064
- }
2065
- end
2066
- when "custom"
2067
- model = body["model"].to_s.strip
2068
- base_url = body["base_url"].to_s.strip
2069
- api_key = body["api_key"].to_s
2070
- if api_key.include?("****")
2071
- existing = @agent_config.models.find { |m| m["type"] == "ocr" && m["api_key"] }
2072
- api_key = existing ? existing["api_key"].to_s : ""
2073
- end
2074
- if model.empty? || base_url.empty? || api_key.empty?
2075
- return json_response(res, 422, { error: "model, base_url, api_key are required" })
2076
- end
2077
-
2078
- @agent_config.models << {
2079
- "id" => SecureRandom.uuid,
2080
- "model" => model,
2081
- "base_url" => base_url,
2082
- "api_key" => api_key,
2083
- "anthropic_format" => body["anthropic_format"] || false,
2084
- "type" => "ocr"
2085
- }
2086
- end
2087
-
2088
- @agent_config.save
2089
- json_response(res, 200, { ok: true, state: @agent_config.ocr_state })
2090
- rescue => e
2091
- json_response(res, 422, { error: e.message })
2075
+ update_sidecar_config(res, "ocr", parse_json_body(req) || {})
2092
2076
  end
2093
2077
 
2094
2078
  # POST /api/config/ocr/test