larkway 0.3.46 → 0.3.48

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.
@@ -17,6 +17,13 @@
17
17
 
18
18
  const ICONS = {
19
19
  check: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`,
20
+ copy: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`,
21
+ clock: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>`,
22
+ ext: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M9 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3"/></svg>`,
23
+ host: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>`,
24
+ up: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m8 12 4-4 4 4M12 8v8"/></svg>`,
25
+ q: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.4 2.3c-.8.3-.9 1-.9 1.7M12 16.5h.01"/></svg>`,
26
+ chevDown: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>`,
20
27
  x: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`,
21
28
  trash: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>`,
22
29
  info: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>`,
@@ -405,6 +412,12 @@ const state = {
405
412
  eventFilters: {},
406
413
  /** 最近事件面板是否展开全部,按 bot id 索引。 */
407
414
  eventShowAll: {},
415
+ /** 当前 UI 版本(/api/context)。 */
416
+ runningVersion: null,
417
+ /** 全局 larkway 版本检查结果(/api/update_version)。 */
418
+ updateVersion: null,
419
+ /** 更新任务进度快照(GET /api/update_version/status,BL-46 胶囊五态)。 */
420
+ updateJob: null,
408
421
  /** 当前详情表单是否有未保存改动(基础+高级 yaml 表单)。 */
409
422
  formDirty: false,
410
423
  /** 当前详情 memory 是否有未保存改动。 */
@@ -713,37 +726,30 @@ function lkBackendSelectHTML(value, containerId = "") {
713
726
  const sel = id === value;
714
727
  const isReady = _backendReady[id] !== false; // 默认就绪
715
728
  const isDefault = id === LK_BACKEND_DEFAULT;
716
- const selClass = sel ? " is-sel" : "";
717
729
  return (
718
730
  `<button type="button" role="radio" aria-checked="${sel}" data-bk-id="${esc(id)}"` +
719
- ` class="lk-bk-select-btn${selClass}"` +
720
- (containerId ? ` data-bk-container="${esc(containerId)}"` : "") + `>` +
721
- // 单选圈(选中=indigo)
722
- `<span class="lk-bk-radio">${sel ? `<span class="lk-bk-radio-dot"></span>` : ""}</span>` +
723
- // 字标 tile(中性)
724
- lkBackendMonoHTML(id, "xl") +
725
- `<div style="min-width:0;flex:1">` +
726
- `<div class="lk-bk-item-name">${esc(b.name)}` +
731
+ (containerId ? ` data-bk-container="${esc(containerId)}"` : "") +
732
+ ` class="bk2-opt${sel ? " is-active" : ""}">` +
733
+ `<span class="bk2-logo">${esc(b.mono)}</span>` +
734
+ `<span class="bk2-opt-meta">` +
735
+ `<span class="bk2-opt-name">${esc(b.name)}` +
727
736
  (isDefault ? `<span class="lk-bk-default-badge">默认</span>` : "") +
728
- `</div>` +
729
- `<div class="lk-bk-item-vendor">${esc(b.vendor)}</div>` +
730
- // 边界提示:未就绪(中性 slate,绝不染状态色)
731
- (!isReady
732
- ? `<div class="lk-bk-not-ready-hint">${ICONS.info}` +
733
- `本机还没就绪 —— 先在终端 <code>${esc(id)} login</code></div>`
734
- : "") +
735
- `</div>` +
736
- // 就绪/未就绪 badge(中性)
737
+ `</span>` +
738
+ `<span class="bk2-opt-sub">${esc(b.vendor)}` +
739
+ // 登录态微行:ready 探测来自 GET /api/backends(codex=login+runtime,
740
+ // claude 恒 true —— 细粒度登录态由能力体检卡负责,这里只做粗提示)
737
741
  (isReady
738
- ? `<span class="lk-bk-ready-badge">${ICONS.check} 本机就绪</span>`
739
- : `<span class="lk-bk-unready-badge">未就绪</span>`) +
742
+ ? `<span class="live-dot is-serving"></span>已就绪`
743
+ : `<span class="live-dot is-offline"></span>未就绪 · 终端 <code>${esc(id)} login</code>`) +
744
+ `</span></span>` +
745
+ `<span class="bk2-check">${sel ? ICONS.check : ""}</span>` +
740
746
  `</button>`
741
747
  );
742
748
  }).join("");
743
749
 
744
750
  return (
745
751
  `<div class="lk-bk-select">` +
746
- `<div class="lk-bk-select-group" role="radiogroup" aria-label="选择底座">` +
752
+ `<div class="bk2-choice" role="radiogroup" aria-label="选择底座">` +
747
753
  items +
748
754
  `</div>` +
749
755
  // 「会增长的列表」的可视暗示
@@ -761,23 +767,17 @@ function lkBackendSelectHTML(value, containerId = "") {
761
767
  * @param {function(string):void} onChange 选中新 id 时调用
762
768
  */
763
769
  function wireLkBackendSelect(container, onChange) {
764
- container.querySelectorAll(".lk-bk-select-btn").forEach((btn) => {
770
+ container.querySelectorAll(".bk2-opt").forEach((btn) => {
765
771
  btn.addEventListener("click", () => {
766
772
  const id = btn.dataset.bkId;
767
773
  if (!id) return;
768
- // 更新 is-sel + aria-checked + 单选圈
769
- container.querySelectorAll(".lk-bk-select-btn").forEach((b) => {
774
+ // 更新 is-active + aria-checked + 右侧对勾
775
+ container.querySelectorAll(".bk2-opt").forEach((b) => {
770
776
  const sel = b.dataset.bkId === id;
771
- b.classList.toggle("is-sel", sel);
777
+ b.classList.toggle("is-active", sel);
772
778
  b.setAttribute("aria-checked", String(sel));
773
- const dot = b.querySelector(".lk-bk-radio-dot");
774
- if (sel && !dot) {
775
- const radio = b.querySelector(".lk-bk-radio");
776
- if (radio) radio.innerHTML = `<span class="lk-bk-radio-dot"></span>`;
777
- } else if (!sel && dot) {
778
- const radio = b.querySelector(".lk-bk-radio");
779
- if (radio) radio.innerHTML = "";
780
- }
779
+ const check = b.querySelector(".bk2-check");
780
+ if (check) check.innerHTML = sel ? ICONS.check : "";
781
781
  });
782
782
  onChange(id);
783
783
  });
@@ -798,122 +798,633 @@ function lkHexA(hex, alpha) {
798
798
  }
799
799
 
800
800
  // ---------------------------------------------------------------------------
801
- // Model / Effort 覆盖(perf plan 批 C 旋钮)—— 挂在底座卡片下方,因为可选的
802
- // model 建议项直接取决于选了哪个底座。
801
+ // Model / Effort 覆盖(底座卡 v2,能力体检设计稿范围 B)—— 单一控件 + 菜单内
802
+ // 自定义逃生口,替代 input+datalist+chips 三件套(原生 datalist 会按输入过滤
803
+ // 建议,填过值后下拉「看起来是空的」,用户两次被坑;chips 只是补丁)。
804
+ // 表单契约不变:隐藏 input#ac-model[name=model] / #ac-effort[name=effort],
805
+ // 可视控件写值后 dispatch bubbling input,走 bkCard 现有脏检测。
803
806
  // ---------------------------------------------------------------------------
804
807
 
805
808
  /**
806
- * model 输入的建议项(datalist,非枚举)。按底座区分,因为两家模型名字完全不同:
807
- * claude --model 接受的别名;codex 是 OpenAI 模型 slug(取自本机
808
- * ~/.codex/models_cache.json 当前可见模型,按 priority 升序的前三个:
809
- * gpt-5.5(默认/最强)· gpt-5.4-mini(更快更省)· gpt-5.3-codex(编程特化),
810
- * 不是瞎猜的名字)。input 本身允许自由输入任意值 —— 两家模型都在演进,建议项
811
- * 只是省得手打,不是白名单。
809
+ * model 建议项(菜单全量常显,不按输入过滤)。按底座区分;d 是一句定位描述,
810
+ * 帮人选而不是白名单 —— 菜单底部永远有「输入自定义模型名…」逃生口。
811
+ * codex 项取自本机 ~/.codex/models_cache.json 当前可见模型(非瞎猜)。
812
812
  * @param {string} backendId
813
- * @returns {Array<[string,string]>} [value, label][]
813
+ * @returns {Array<{v:string,d:string}>}
814
814
  */
815
815
  function modelSuggestionsForBackend(backendId) {
816
816
  if (backendId === "codex") {
817
817
  return [
818
- ["gpt-5.5", "gpt-5.5 · 当前默认,最强"],
819
- ["gpt-5.4-mini", "gpt-5.4-mini · 更快更省"],
820
- ["gpt-5.3-codex", "gpt-5.3-codex · 编程特化"],
818
+ { v: "gpt-5.5", d: "日常首选 · 能力与速度均衡" },
819
+ { v: "gpt-5.4-mini", d: "便宜快 · 轻量任务" },
820
+ { v: "gpt-5.3-codex", d: "代码特化 · 重构与评审" },
821
821
  ];
822
822
  }
823
823
  return [
824
- ["claude-sonnet-5", "claude-sonnet-5 · Sonnet 5,快/省"],
825
- ["claude-opus-4-8", "claude-opus-4-8 · Opus 4.8"],
826
- ["claude-fable-5", "claude-fable-5 · Fable 5,最强"],
827
- ["sonnet", "sonnet · 别名=最新 Sonnet"],
828
- ["opus", "opus · 别名=最新 Opus"],
824
+ { v: "claude-sonnet-5", d: "Sonnet 5 · 快/省" },
825
+ { v: "claude-opus-4-8", d: "Opus 4.8" },
826
+ { v: "claude-fable-5", d: "Fable 5 · 最强" },
827
+ { v: "sonnet", d: "别名 = 最新 Sonnet" },
828
+ { v: "opus", d: "别名 = 最新 Opus" },
829
829
  ];
830
830
  }
831
831
 
832
832
  /**
833
- * effort <select> 的 <option> HTML。两家底座共用同一套 value(low/medium/high/max)
834
- * ——claude 直接透传给 --effort;codex 经 codexEffortFromLarkway 映射成它自己的
835
- * low/medium/high/xhigh(即官方 Codex 客户端 Reasoning 选择器里的
836
- * Light/Medium/High/Extra High,已用 model/list 实测确认)。value 保持一致只是
837
- * 为了 larkway 配置层统一,label 按底座换成对应措辞,避免用户以为 codex 也有个
838
- * 叫 "max" 的档位。
839
- * @param {string} effortVal
833
+ * effort 档位:value 两家统一(low/medium/high/max,claude 直接透传 --effort;
834
+ * codex 经 codexEffortFromLarkway 映射),label 按底座换措辞,避免用户以为
835
+ * codex 也有个叫 "max" 的档位。首项 "" = 默认(不覆盖)。
840
836
  * @param {string} backendId
841
837
  */
842
- function effortOptionsHTML(effortVal, backendId) {
838
+ function effortLevelsForBackend(backendId) {
843
839
  const labels = backendId === "codex"
844
- ? { low: "low (Light)", medium: "medium (Medium)", high: "high (High)", max: "max (Extra High)" }
840
+ ? { low: "Light", medium: "Medium", high: "High", max: "Extra High" }
845
841
  : { low: "low", medium: "medium", high: "high", max: "max" };
846
842
  return [
847
- ["", "默认(不覆盖)"],
848
- ["low", labels.low],
849
- ["medium", labels.medium],
850
- ["high", labels.high],
851
- ["max", labels.max],
852
- ].map(([v, label]) => `<option value="${esc(v)}"${v === effortVal ? " selected" : ""}>${esc(label)}</option>`).join("");
843
+ { v: "", label: "默认" },
844
+ { v: "low", label: labels.low },
845
+ { v: "medium", label: labels.medium },
846
+ { v: "high", label: labels.high },
847
+ { v: "max", label: labels.max },
848
+ ];
849
+ }
850
+
851
+ /** model 值 → 定位描述;空值/自定义值给对应措辞。 */
852
+ function modelDescFor(backendId, val) {
853
+ if (!val) return "不覆盖 —— 跟随底座全局设置";
854
+ const hit = modelSuggestionsForBackend(backendId).find((m) => m.v === val);
855
+ return hit ? hit.d : "自定义模型名";
856
+ }
857
+
858
+ /** Model 收起态单一控件(select 形态,点开自定义菜单)。 */
859
+ function modelSelectClosedHTML(backendId, val) {
860
+ return (
861
+ `<button type="button" class="bk2-select" id="bk2-model-btn" aria-haspopup="listbox">` +
862
+ `<code>${esc(val || "底座默认")}</code>` +
863
+ `<span class="bk2-sel-desc">${esc(modelDescFor(backendId, val))}</span>` +
864
+ ICONS.chevDown +
865
+ `</button>`
866
+ );
867
+ }
868
+
869
+ /** Model 展开菜单:默认项 + 全量建议(带定位描述)+ 自定义逃生口。 */
870
+ function modelMenuHTML(backendId, currentVal) {
871
+ const item = (v, d) =>
872
+ `<div class="bk2-mi${v === "" ? " is-default" : ""}${v === (currentVal || "") ? " is-sel" : ""}" role="option" data-model-pick="${esc(v)}">` +
873
+ `<code>${esc(v || "底座默认")}</code><span>${esc(d)}</span>` +
874
+ (v === (currentVal || "") ? ICONS.check : "") +
875
+ `</div>`;
876
+ return (
877
+ item("", "不覆盖 —— 跟随底座全局设置") +
878
+ modelSuggestionsForBackend(backendId).map((m) => item(m.v, m.d)).join("") +
879
+ `<div class="bk2-mi-custom" role="option" data-model-custom>${ICONS.edit}输入自定义模型名…</div>`
880
+ );
881
+ }
882
+
883
+ /** Effort 分段控件内部按钮组。 */
884
+ function effortSegHTML(backendId, effortVal) {
885
+ return effortLevelsForBackend(backendId)
886
+ .map((l) =>
887
+ `<button type="button" data-effort-value="${esc(l.v)}"` +
888
+ (l.v === (effortVal || "") ? ` class="is-active"` : "") +
889
+ `>${esc(l.label)}</button>`)
890
+ .join("");
891
+ }
892
+
893
+ /** 覆盖区收起时的一行摘要。 */
894
+ function bk2AdvSummaryHTML(backendId, modelVal, effortVal) {
895
+ if (!modelVal && !effortVal) return `<i>跟随底座默认</i>`;
896
+ const effortLabel = effortLevelsForBackend(backendId).find((l) => l.v === (effortVal || ""))?.label;
897
+ const parts = [];
898
+ if (modelVal) parts.push(`<code>${esc(modelVal)}</code>`);
899
+ if (effortVal) parts.push(`<code>${esc(effortLabel || effortVal)}</code>`);
900
+ return parts.join(`<i>·</i>`);
853
901
  }
854
902
 
855
903
  /**
856
- * Model / Effort 覆盖块的 HTML —— 嵌进「用哪个底座驱动它」卡片(lk-bk-card),
857
- * 紧跟在 lkBackendSelectHTML(...) 后面。只在编辑态渲染(调用方 buildDetailHTML
858
- * 只服务已存在的 bot);新建流程走 /api/onboard/finalize,那条路径尚不转发
859
- * 这两个字段,建完再来编辑页配即可。
904
+ * Model / Effort 覆盖块(bk2-adv)—— 嵌进底座卡(第一卡)body。只在编辑态
905
+ * 渲染;新建流程走 /api/onboard/finalize,那条路径不转发这两个字段。
906
+ * 无覆盖时默认收起成一行摘要,有覆盖时展开。
860
907
  * @param {{model?:string, effort?:string, backend?:string}} bot
861
908
  */
862
909
  function buildModelEffortHTML(bot) {
863
910
  const modelVal = bot.model ?? "";
864
911
  const effortVal = bot.effort ?? "";
865
912
  const backendId = bot.backend || LK_BACKEND_DEFAULT;
866
- const modelListHTML = modelSuggestionsForBackend(backendId)
867
- .map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`)
868
- .join("");
913
+ const hasOverride = Boolean(modelVal || effortVal);
869
914
  return (
870
- `<div class="ac-field" id="lk-bk-model-effort" style="margin-top:14px;padding-top:14px;border-top:1px solid var(--border)">` +
871
- `<label class="ac-label" for="ac-model">Model / Effort 覆盖 <span class="ac-optional">可选</span></label>` +
872
- `<p class="ac-hint">不覆盖 = 用账号默认;降低 effort 会牺牲判断深度。</p>` +
873
- `<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">` +
874
- `<input list="ac-model-list" id="ac-model" name="model" class="ac-input" style="width:210px" value="${esc(modelVal)}" placeholder="默认(不覆盖)" autocomplete="off" />` +
875
- `<datalist id="ac-model-list">${modelListHTML}</datalist>` +
876
- `<span id="ac-effort-field">` +
877
- `<select id="ac-effort" name="effort" class="ac-input" style="width:150px">${effortOptionsHTML(effortVal, backendId)}</select>` +
878
- `</span>` +
915
+ `<div class="bk2-adv${hasOverride ? " is-open" : ""}" id="bk2-adv" data-backend="${esc(backendId)}">` +
916
+ `<button type="button" class="bk2-adv-head" id="bk2-adv-toggle" aria-expanded="${hasOverride}">` +
917
+ `<span class="bk2-adv-label">Model / Effort 覆盖</span>` +
918
+ `<span class="ac-optional">可选 · 默认跟随底座</span>` +
919
+ `<span class="bk2-adv-sum" id="bk2-adv-sum">${bk2AdvSummaryHTML(backendId, modelVal, effortVal)}</span>` +
920
+ `<span class="bk2-adv-chev">${ICONS.chevDown}</span>` +
921
+ `</button>` +
922
+ `<div class="bk2-adv-body">` +
923
+ `<div class="bk2-adv-grid">` +
924
+ `<div class="ac-field bk2-model-field" id="bk2-model-field">` +
925
+ modelSelectClosedHTML(backendId, modelVal) +
926
+ `<div class="bk2-menu" id="bk2-model-menu" hidden>${modelMenuHTML(backendId, modelVal)}</div>` +
927
+ `<div id="bk2-model-custom" hidden>` +
928
+ `<input type="text" class="ac-input ac-mono" id="bk2-model-custom-input" spellcheck="false" autocomplete="off" placeholder="模型名,如 gpt-6-preview" />` +
929
+ `<p class="ac-hint" style="margin-top:7px">任意模型名都行 —— 建议列表不是白名单,新模型出了直接填。</p>` +
930
+ `<button type="button" class="bk2-backlink" id="bk2-model-back">${ICONS.chevDown} 返回建议列表</button>` +
879
931
  `</div>` +
880
- // 常驻快选 chips:datalist 会按当前输入值过滤建议(输入框已填某个模型时,
881
- // 下拉只剩它自己,像"选项丢了"——2026-07-08 mini 实测用户困惑),chips 不受
882
- // 输入值影响,永远列出全部建议;点一下=填入并触发 input(保存 dirty 检测)。
883
- `<div id="ac-model-chips" style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">${modelChipsHTML(backendId)}</div>` +
932
+ `</div>` +
933
+ `<div class="ac-field"><div class="bk2-seg" id="bk2-effort-seg">${effortSegHTML(backendId, effortVal)}</div></div>` +
934
+ `</div>` +
935
+ `<p class="ac-hint" id="bk2-adv-hint">${bk2AdvHintText(backendId)}</p>` +
936
+ `</div>` +
937
+ `<input type="hidden" id="ac-model" name="model" value="${esc(modelVal)}" />` +
938
+ `<input type="hidden" id="ac-effort" name="effort" value="${esc(effortVal)}" />` +
884
939
  `</div>`
885
940
  );
886
941
  }
887
942
 
888
- /** 常驻模型快选 chips 的 HTML(见 buildModelEffortHTML 里的调用注释)。 */
889
- function modelChipsHTML(backendId) {
890
- return modelSuggestionsForBackend(backendId)
891
- .map(([v, label]) =>
892
- `<button type="button" class="ac-model-chip" data-model-value="${esc(v)}" title="${esc(label)}" ` +
893
- `style="font-size:12px;padding:3px 10px;border:1px solid var(--border);border-radius:999px;background:var(--bg,transparent);cursor:pointer">${esc(v)}</button>`)
894
- .join("");
943
+ /** 覆盖区底部提示文案(按底座措辞)。 */
944
+ function bk2AdvHintText(backendId) {
945
+ return backendId === "codex"
946
+ ? "建议不是白名单 —— 列表底部可输入任意模型名;切到 Claude Code 时档位显示 low / medium / high / max。"
947
+ : "建议不是白名单 —— 列表底部可输入任意模型名;切到 Codex 时档位显示 Light / Medium / High / Extra High。";
895
948
  }
896
949
 
897
950
  /**
898
- * 底座切换时实时刷新 model 建议项 + effort 选项的措辞。effort 两家底座都生效
899
- * (见 effortOptionsHTML 顶部注释),这里只换 label,不再隐藏控件。
951
+ * 底座切换时实时刷新 model 菜单/收起控件描述 + effort 分段措辞 + 提示文案。
952
+ * 隐藏 input 里的当前值保留(值域跨底座本就允许自由填)
900
953
  * @param {HTMLElement} panel
901
954
  * @param {string} backendId
902
955
  */
903
956
  function refreshModelEffortForBackend(panel, backendId) {
904
- const datalist = panel.querySelector("#ac-model-list");
905
- if (datalist) {
906
- datalist.innerHTML = modelSuggestionsForBackend(backendId)
907
- .map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`)
908
- .join("");
957
+ const adv = panel.querySelector("#bk2-adv");
958
+ if (!adv) return;
959
+ adv.dataset.backend = backendId;
960
+ const modelVal = panel.querySelector("#ac-model")?.value ?? "";
961
+ const effortVal = panel.querySelector("#ac-effort")?.value ?? "";
962
+ const menu = panel.querySelector("#bk2-model-menu");
963
+ if (menu) menu.innerHTML = modelMenuHTML(backendId, modelVal);
964
+ const btn = panel.querySelector("#bk2-model-btn");
965
+ if (btn) btn.outerHTML = modelSelectClosedHTML(backendId, modelVal);
966
+ const seg = panel.querySelector("#bk2-effort-seg");
967
+ if (seg) seg.innerHTML = effortSegHTML(backendId, effortVal);
968
+ const sum = panel.querySelector("#bk2-adv-sum");
969
+ if (sum) sum.innerHTML = bk2AdvSummaryHTML(backendId, modelVal, effortVal);
970
+ const hint = panel.querySelector("#bk2-adv-hint");
971
+ if (hint) hint.textContent = bk2AdvHintText(backendId);
972
+ }
973
+
974
+ /**
975
+ * bk2-adv 全部交互(委托到 bkCard,重渲染菜单/分段不掉线):
976
+ * 覆盖区折叠、model 菜单开合/选择/自定义逃生口、effort 分段。
977
+ * 值写进隐藏 input 后 dispatch bubbling input → bkCard 脏检测。
978
+ * @param {HTMLElement} panel 详情面板根
979
+ * @param {HTMLElement} bkCard 底座卡根(#lk-bk-card)
980
+ */
981
+ function wireModelEffort(panel, bkCard) {
982
+ const setHidden = (idSel, val) => {
983
+ const input = panel.querySelector(idSel);
984
+ if (!input) return;
985
+ input.value = val;
986
+ input.dispatchEvent(new Event("input", { bubbles: true }));
987
+ };
988
+ const currentBackend = () => panel.querySelector("#bk2-adv")?.dataset.backend || LK_BACKEND_DEFAULT;
989
+ const refreshSummary = () => {
990
+ const sum = panel.querySelector("#bk2-adv-sum");
991
+ if (sum) sum.innerHTML = bk2AdvSummaryHTML(
992
+ currentBackend(),
993
+ panel.querySelector("#ac-model")?.value ?? "",
994
+ panel.querySelector("#ac-effort")?.value ?? "",
995
+ );
996
+ };
997
+
998
+ bkCard.addEventListener("click", (e) => {
999
+ const el = e.target instanceof Element ? e.target : null;
1000
+ if (!el) return;
1001
+
1002
+ // 覆盖区折叠/展开
1003
+ const toggle = el.closest("#bk2-adv-toggle");
1004
+ if (toggle) {
1005
+ const adv = panel.querySelector("#bk2-adv");
1006
+ if (adv) {
1007
+ const open = adv.classList.toggle("is-open");
1008
+ toggle.setAttribute("aria-expanded", String(open));
1009
+ if (!open) panel.querySelector("#bk2-model-menu")?.setAttribute("hidden", "");
1010
+ }
1011
+ return;
1012
+ }
1013
+
1014
+ // model 收起控件 → 菜单开合
1015
+ if (el.closest("#bk2-model-btn")) {
1016
+ const menu = panel.querySelector("#bk2-model-menu");
1017
+ if (menu) menu.hidden = !menu.hidden;
1018
+ return;
1019
+ }
1020
+
1021
+ // 菜单项选择
1022
+ const pick = el.closest("[data-model-pick]");
1023
+ if (pick) {
1024
+ const val = pick.getAttribute("data-model-pick") ?? "";
1025
+ setHidden("#ac-model", val);
1026
+ const backendId = currentBackend();
1027
+ const btn = panel.querySelector("#bk2-model-btn");
1028
+ if (btn) btn.outerHTML = modelSelectClosedHTML(backendId, val);
1029
+ const menu = panel.querySelector("#bk2-model-menu");
1030
+ if (menu) {
1031
+ menu.innerHTML = modelMenuHTML(backendId, val);
1032
+ menu.hidden = true;
1033
+ }
1034
+ refreshSummary();
1035
+ return;
1036
+ }
1037
+
1038
+ // 自定义逃生口:换成自由输入
1039
+ if (el.closest("[data-model-custom]")) {
1040
+ const menu = panel.querySelector("#bk2-model-menu");
1041
+ if (menu) menu.hidden = true;
1042
+ const btn = panel.querySelector("#bk2-model-btn");
1043
+ const custom = panel.querySelector("#bk2-model-custom");
1044
+ const input = panel.querySelector("#bk2-model-custom-input");
1045
+ if (btn) btn.hidden = true;
1046
+ if (custom) custom.hidden = false;
1047
+ if (input instanceof HTMLInputElement) {
1048
+ input.value = panel.querySelector("#ac-model")?.value ?? "";
1049
+ input.focus();
1050
+ }
1051
+ return;
1052
+ }
1053
+
1054
+ // 自定义态返回建议列表
1055
+ if (el.closest("#bk2-model-back")) {
1056
+ const custom = panel.querySelector("#bk2-model-custom");
1057
+ if (custom) custom.hidden = true;
1058
+ const backendId = currentBackend();
1059
+ const val = panel.querySelector("#ac-model")?.value ?? "";
1060
+ const btn = panel.querySelector("#bk2-model-btn");
1061
+ if (btn) {
1062
+ btn.outerHTML = modelSelectClosedHTML(backendId, val);
1063
+ panel.querySelector("#bk2-model-btn")?.removeAttribute("hidden");
1064
+ }
1065
+ const menu = panel.querySelector("#bk2-model-menu");
1066
+ if (menu) menu.innerHTML = modelMenuHTML(backendId, val);
1067
+ refreshSummary();
1068
+ return;
1069
+ }
1070
+
1071
+ // effort 分段
1072
+ const segBtn = el.closest("[data-effort-value]");
1073
+ if (segBtn) {
1074
+ const val = segBtn.getAttribute("data-effort-value") ?? "";
1075
+ setHidden("#ac-effort", val);
1076
+ panel.querySelectorAll("#bk2-effort-seg button").forEach((b) => {
1077
+ b.classList.toggle("is-active", b === segBtn);
1078
+ });
1079
+ refreshSummary();
1080
+ return;
1081
+ }
1082
+ });
1083
+
1084
+ // 自定义输入实时写进隐藏 input(同一条脏检测路)
1085
+ bkCard.addEventListener("input", (e) => {
1086
+ const el = e.target;
1087
+ if (el instanceof HTMLInputElement && el.id === "bk2-model-custom-input") {
1088
+ const hidden = panel.querySelector("#ac-model");
1089
+ if (hidden && hidden.value !== el.value.trim()) {
1090
+ hidden.value = el.value.trim();
1091
+ hidden.dispatchEvent(new Event("input", { bubbles: true }));
1092
+ }
1093
+ refreshSummary();
1094
+ }
1095
+ });
1096
+
1097
+ // 点菜单外任意处收起菜单
1098
+ document.addEventListener("click", (e) => {
1099
+ const menu = panel.querySelector("#bk2-model-menu");
1100
+ if (!menu || menu.hidden) return;
1101
+ const el = e.target instanceof Element ? e.target : null;
1102
+ if (el && !el.closest("#bk2-model-field")) menu.hidden = true;
1103
+ });
1104
+ }
1105
+
1106
+ // ---------------------------------------------------------------------------
1107
+ // 能力体检卡(能力体检设计稿范围 A)—— bot 详情页第五卡。
1108
+ // 三组检查:工具(/api/runtime/requirements 现成数据,botIds 过滤)· 凭据
1109
+ // (GET /api/bot/:id/health-scan)· 应用权限(无法自动检测 → 基线清单 +
1110
+ // console 深链对照)。两维正交:颜色管严重度(红=必需缺失/黄=过旧/中性=建议),
1111
+ // 「全局」徽章管作用域。面板零写操作 —— 检测、清单、深链、重检,四个动作。
1112
+ // ---------------------------------------------------------------------------
1113
+
1114
+ /** 工具最低版本要求(有真实故障案例才进这张表,别当版本洁癖用)。 */
1115
+ const LK_TOOL_MIN_VERSIONS = {
1116
+ "lark-cli": {
1117
+ min: "1.0.56",
1118
+ why: "新版修复了 keychain 凭据回落 —— 旧版会间歇读不到登录态,<b>bot 看起来像掉线</b>。",
1119
+ },
1120
+ };
1121
+
1122
+ /** "1.0.42" 风格版本比较:a < b 时 true。解析不了按 false(不误报过旧)。 */
1123
+ function hcVersionLt(a, b) {
1124
+ const pa = String(a).split(".").map((n) => parseInt(n, 10));
1125
+ const pb = String(b).split(".").map((n) => parseInt(n, 10));
1126
+ if (pa.some(isNaN) || pb.some(isNaN)) return false;
1127
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1128
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1129
+ if (d !== 0) return d < 0;
1130
+ }
1131
+ return false;
1132
+ }
1133
+
1134
+ /** 从 `--version` 输出里抠出 x.y.z。 */
1135
+ function hcExtractVersion(text) {
1136
+ return String(text ?? "").match(/\d+\.\d+\.\d+/)?.[0] ?? null;
1137
+ }
1138
+
1139
+ const HC_GLOBAL_BADGE =
1140
+ `<span class="hc-global" title="主机级 —— 修好一处,所有助手生效。真相源在右上服务指示灯。">${ICONS.host}全局</span>`;
1141
+
1142
+ /** 检查项行(设计稿 row() 的落地版)。 */
1143
+ function hcRowHTML(o) {
1144
+ const badges =
1145
+ (o.ver ? `<span class="hc-ver">${esc(o.ver)}</span>` : "") +
1146
+ `<span class="hc-tag">${esc(o.tag)}</span>` +
1147
+ (o.global ? HC_GLOBAL_BADGE : "");
1148
+ const scopes = o.scopes
1149
+ ? `<div class="hc-scopes">${o.scopes
1150
+ .map((sc) => `<div class="hc-scope"><code>${esc(sc.scope)}</code><span>${esc(sc.reason)}</span></div>`)
1151
+ .join("")}</div>`
1152
+ : "";
1153
+ let act = "";
1154
+ if (o.copy) {
1155
+ act =
1156
+ `<div class="hc-actline"><button type="button" class="hc-act" data-hc-copy="${esc(o.copy)}">${ICONS.copy} ${esc(o.copyLabel || "复制安装命令")}</button>` +
1157
+ `<code class="hc-cmd">${esc(o.copy)}</code></div>`;
1158
+ }
1159
+ if (o.link) {
1160
+ act =
1161
+ `<div class="hc-actline"><button type="button" class="hc-act" data-hc-link="${esc(o.linkHref || "")}">${esc(o.link)} ${ICONS.ext}</button></div>` +
1162
+ (o.pubnote ? `<div class="hc-pubnote">${ICONS.warn}<span>${o.pubnote}</span></div>` : "");
1163
+ }
1164
+ return (
1165
+ `<div class="hc-row"><span class="hc-ico ${o.ico}">${o.glyph}</span>` +
1166
+ `<div class="hc-row-main"><div class="hc-row-title"><span class="hc-name">${esc(o.name)}</span>${badges}</div>` +
1167
+ `<p class="hc-impact">${o.impact}</p>${scopes}${act}</div></div>`
1168
+ );
1169
+ }
1170
+
1171
+ /**
1172
+ * 把工具(runtime requirements)+ 凭据(health-scan)归拢成渲染模型。
1173
+ * @returns {{required:string[], recommended:string[], unknown:string[], passed:Array<{name:string,tag:string,note:string}>}}
1174
+ */
1175
+ function hcComputeRows(id, bot, scan) {
1176
+ const required = [];
1177
+ const recommended = [];
1178
+ const unknown = [];
1179
+ const passed = [];
1180
+
1181
+ // ── 工具组(主机级;botIds 过滤出与本助手相关的) ──
1182
+ // kind==="secret" 的项(如 Git token env)由凭据组(health-scan)负责,
1183
+ // 这里跳过避免同一事实出现两行。
1184
+ const reqs = (state.requirements?.requirements ?? []).filter(
1185
+ (r) => Array.isArray(r.botIds) && r.botIds.includes(id) && r.kind !== "secret",
1186
+ );
1187
+ for (const r of reqs) {
1188
+ const label = r.command || r.label || r.id;
1189
+ if (!r.ok) {
1190
+ const row = hcRowHTML({
1191
+ ico: r.severity === "required" ? "is-miss" : "is-rec",
1192
+ glyph: r.severity === "required" ? ICONS.x : ICONS.up,
1193
+ name: `${label} ${r.kind === "secret" ? "未配置" : "未安装"}`,
1194
+ tag: r.kind === "secret" ? "凭据" : "工具",
1195
+ global: r.kind !== "secret",
1196
+ impact: esc(r.reason || `本助手依赖 ${label}。`),
1197
+ copy: r.installHint || undefined,
1198
+ });
1199
+ (r.severity === "required" ? required : recommended).push(row);
1200
+ continue;
1201
+ }
1202
+ // 已安装:查「过旧」(有真实故障案例的最低版本表)
1203
+ const minReq = LK_TOOL_MIN_VERSIONS[r.command];
1204
+ const ver = hcExtractVersion(r.version);
1205
+ if (minReq && ver && hcVersionLt(ver, minReq.min)) {
1206
+ required.push(hcRowHTML({
1207
+ ico: "is-old",
1208
+ glyph: ICONS.clock,
1209
+ name: `${label} 过旧`,
1210
+ ver: `v${ver} → 需 ≥ ${minReq.min}`,
1211
+ tag: "工具",
1212
+ global: true,
1213
+ impact: minReq.why,
1214
+ copy: `npm install -g ${r.command}@latest`,
1215
+ copyLabel: "复制升级命令",
1216
+ }));
1217
+ continue;
1218
+ }
1219
+ passed.push({ name: `${label} 已就绪`, tag: r.kind === "secret" ? "凭据" : "工具",
1220
+ note: ver ? `v${ver} · 探测正常。` : "探测正常。" });
1221
+ }
1222
+
1223
+ // ── 凭据组(health-scan) ──
1224
+ const backendName = lkBackend(bot.backend || LK_BACKEND_DEFAULT).name;
1225
+ for (const c of scan?.credentials ?? []) {
1226
+ if (c.status === "unknown") {
1227
+ unknown.push(
1228
+ `<b>${esc(c.label)}</b>:${esc(c.hint || "暂时探测不到 —— 修好前置项后点重新体检,这项会自动补上。")}`,
1229
+ );
1230
+ continue;
1231
+ }
1232
+ if (c.status === "fail") {
1233
+ const copyById = {
1234
+ "backend-login": {
1235
+ name: `${backendName} 未登录`,
1236
+ impact: `主机上底座 CLI 没登录 —— <b>本助手收到任务也跑不起来</b>。${esc(c.hint || "")}`,
1237
+ },
1238
+ "lark-profile": {
1239
+ name: "lark-cli profile 不可用",
1240
+ impact: `bot 身份读不出来 —— agent <b>读不了飞书文档、拉不了话题历史、评论不了任务</b>。${esc(c.hint || "")}`,
1241
+ },
1242
+ "git-token": {
1243
+ name: "Git 访问令牌未配置",
1244
+ impact: esc(c.hint || "配好后 clone / 推送 / 开 MR 才走这个 bot 自己的账号。"),
1245
+ },
1246
+ };
1247
+ const cc = copyById[c.id] || { name: c.label, impact: esc(c.hint || "") };
1248
+ const row = hcRowHTML({
1249
+ ico: c.severity === "required" ? "is-miss" : "is-rec",
1250
+ glyph: c.severity === "required" ? ICONS.x : ICONS.up,
1251
+ name: cc.name,
1252
+ tag: "凭据",
1253
+ global: !!c.global,
1254
+ impact: cc.impact,
1255
+ });
1256
+ (c.severity === "required" ? required : recommended).push(row);
1257
+ continue;
1258
+ }
1259
+ const okNoteById = {
1260
+ "backend-login": `${backendName} 订阅登录态正常,底座随时可拉起。`,
1261
+ "lark-profile": `绑定到本应用 ${bot.app_id ? bot.app_id.slice(0, 12) + "…" : ""},能读飞书文档、拉话题历史(keychain 可读)。`,
1262
+ "git-token": "存于本机环境,clone / 推送可用。",
1263
+ };
1264
+ passed.push({
1265
+ name: c.id === "backend-login" ? `${backendName} 已登录` : `${c.label} 已配置`,
1266
+ tag: "凭据",
1267
+ note: okNoteById[c.id] || "探测正常。",
1268
+ });
909
1269
  }
910
- const chips = panel.querySelector("#ac-model-chips");
911
- if (chips) chips.innerHTML = modelChipsHTML(backendId);
912
- const effortSelect = panel.querySelector("#ac-effort");
913
- if (effortSelect) {
914
- const currentVal = effortSelect.value;
915
- effortSelect.innerHTML = effortOptionsHTML(currentVal, backendId);
1270
+
1271
+ return { required, recommended, unknown, passed };
1272
+ }
1273
+
1274
+ /** 体检卡头部。 */
1275
+ function hcHeadHTML(countText, stampHtml) {
1276
+ return (
1277
+ `<div class="hc-head"><span class="hc-shield">${ICONS.shield}</span>` +
1278
+ `<div class="hc-meta"><div class="hc-title-row"><h3 class="hc-title">能力体检</h3>` +
1279
+ `<span class="hc-headcount">${countText}</span></div>` +
1280
+ `<p class="hc-desc">这个助手现在能不能好好干活 —— 工具、凭据、应用权限一次看清。只检测、只指路,开通动作都在你手里完成。</p></div>` +
1281
+ `<div class="hc-head-right"><button type="button" class="hc-refresh" data-hc-recheck>${ICONS.refresh}<span>重新体检</span></button>` +
1282
+ `<span class="hc-stamp">${stampHtml}</span></div></div>`
1283
+ );
1284
+ }
1285
+
1286
+ /** 体检卡整卡 HTML。scan=null 表示接口失败(降级为无法检测)。 */
1287
+ function buildHealthCardHTML(id, bot, scan) {
1288
+ const rows = hcComputeRows(id, bot, scan);
1289
+ const requiredTotal = rows.required.length + rows.passed.length; // 粗计数:通过的都算必需侧
1290
+ const stampTime = scan?.checkedAt ? new Date(scan.checkedAt) : new Date();
1291
+ const hh = String(stampTime.getHours()).padStart(2, "0");
1292
+ const mm = String(stampTime.getMinutes()).padStart(2, "0");
1293
+ const isStale = scan?.checkedAt ? Date.now() - scan.checkedAt > 10 * 60 * 1000 : false;
1294
+ const stamp = isStale
1295
+ ? `<span class="is-stale">${hh}:${mm} 检测 · 结果可能过时</span>`
1296
+ : `${hh}:${mm} 检测 · 缓存 10 分钟`;
1297
+
1298
+ // ✅ 全过(无必需缺失/无建议/无 unknown)→ 一行收敛(权限对照段仍在,默认折叠)
1299
+ const allClear = rows.required.length === 0 && rows.recommended.length === 0 && rows.unknown.length === 0;
1300
+
1301
+ const permissions = scan?.permissions;
1302
+ const permSeg = permissions
1303
+ ? `<div class="hc-seg" data-hc-seg>` +
1304
+ `<button type="button" class="hc-seg-head" data-hc-toggle><span class="hc-count hc-count-rec">应用权限 · 对照清单</span>` +
1305
+ `<span class="hc-seg-sub">${esc(permissions.unknownReason || "无法自动检测,请人工对照")}</span>` +
1306
+ `<span class="hc-seg-chev">${ICONS.chevDown}</span></button>` +
1307
+ `<div class="hc-seg-rows">` +
1308
+ hcRowHTML({
1309
+ ico: "is-rec",
1310
+ glyph: ICONS.q,
1311
+ name: "基线权限清单(逐项对照)",
1312
+ tag: "权限",
1313
+ impact: "缺哪项、降级哪个功能,逐项如下 —— 开通在飞书开放平台完成,这里只指路。",
1314
+ scopes: permissions.baseline ?? [],
1315
+ link: "打开开放平台权限页",
1316
+ linkHref: permissions.consoleUrl || "",
1317
+ pubnote: "勾选开通后记得 <b>「创建版本 → 发布」</b> —— 权限发版后才生效。发完回来点「重新体检」。",
1318
+ }) +
1319
+ `</div></div>`
1320
+ : "";
1321
+
1322
+ if (allClear) {
1323
+ return (
1324
+ `<section class="hc" id="hc-card" data-hc-root>` +
1325
+ `<div class="hc-okline">` +
1326
+ `<span class="hc-ico is-ok">${ICONS.check}</span>` +
1327
+ `<div class="hc-ok-main"><span class="hc-ok-title">一切就绪</span>` +
1328
+ `<span class="hc-ok-sub">必需 ${requiredTotal} 项全部通过 · ${hh}:${mm} 检测 —— 应用权限清单可展开对照</span></div>` +
1329
+ `<button type="button" class="hc-refresh-ghost" data-hc-recheck>${ICONS.refresh}<span>重新体检</span></button>` +
1330
+ `</div>` +
1331
+ `<div class="hc-body" style="border-top:1px solid var(--border)">${permSeg}</div>` +
1332
+ `</section>`
1333
+ );
916
1334
  }
1335
+
1336
+ const reqSeg = rows.required.length
1337
+ ? `<div class="hc-seg is-open">` +
1338
+ `<div class="hc-seg-head"><span class="hc-count hc-count-req">${rows.required.length} 项必须处理</span>` +
1339
+ `<span class="hc-seg-sub">缺了这些,它干不了活或功能降级</span></div>` +
1340
+ `<div class="hc-seg-rows">${rows.required.join("")}</div></div>`
1341
+ : `<div class="hc-seg is-open">` +
1342
+ `<div class="hc-seg-head"><span class="hc-count hc-count-ok">必需项全部通过</span>` +
1343
+ `<span class="hc-seg-sub">干活没问题 —— 下面是按它的配置给的加强建议</span></div></div>`;
1344
+
1345
+ const recSeg = rows.recommended.length
1346
+ ? `<div class="hc-seg is-open" data-hc-seg>` +
1347
+ `<button type="button" class="hc-seg-head" data-hc-toggle><span class="hc-count hc-count-rec">${rows.recommended.length} 项建议</span>` +
1348
+ `<span class="hc-seg-sub">不影响当前功能 —— 按这个助手的配置给的提醒</span>` +
1349
+ `<span class="hc-seg-chev">${ICONS.chevDown}</span></button>` +
1350
+ `<div class="hc-seg-rows">${rows.recommended.join("")}</div></div>`
1351
+ : "";
1352
+
1353
+ const unkLine = rows.unknown.length
1354
+ ? `<div class="hc-unk">${ICONS.q}<span><b>${rows.unknown.length} 项暂时无法检测</b> —— ${rows.unknown.join(";")}</span></div>`
1355
+ : "";
1356
+
1357
+ const passedBlock = rows.passed.length
1358
+ ? `<button type="button" class="hc-passed" data-hc-toggle-passed>${ICONS.check}<span class="hc-passed-label">其余 ${rows.passed.length} 项已通过</span>` +
1359
+ `<span class="hc-passed-sum">${esc(rows.passed.map((prow) => prow.name).join(" · "))}</span>` +
1360
+ `<span class="hc-seg-chev">${ICONS.chevDown}</span></button>` +
1361
+ `<div class="hc-passed-list" data-hc-passed-list>` +
1362
+ rows.passed
1363
+ .map((prow) => hcRowHTML({ ico: "is-ok", glyph: ICONS.check, name: prow.name, tag: prow.tag, impact: esc(prow.note) }))
1364
+ .join("") +
1365
+ `</div>`
1366
+ : "";
1367
+
1368
+ const countText = rows.required.length
1369
+ ? `必需 ${requiredTotal} · 待处理 ${rows.required.length}`
1370
+ : `必需 ${requiredTotal} 全部通过${rows.recommended.length ? ` · 建议 ${rows.recommended.length}` : ""}`;
1371
+
1372
+ return (
1373
+ `<section class="hc" id="hc-card" data-hc-root>` +
1374
+ hcHeadHTML(countText, stamp) +
1375
+ `<div class="hc-body">${reqSeg}${recSeg}${permSeg}${unkLine}${passedBlock}</div>` +
1376
+ `</section>`
1377
+ );
1378
+ }
1379
+
1380
+ /** 拉数据 + 渲染 + 接线(重检/折叠/复制/深链)。 */
1381
+ async function initHealthCard(panel, id, bot, opts = {}) {
1382
+ const slot = panel.querySelector("#hc-slot");
1383
+ if (!slot) return;
1384
+ const method = opts.force ? "POST" : "GET";
1385
+ let scan = null;
1386
+ try {
1387
+ const res = await api(method, `/api/bot/${encodeURIComponent(id)}/health-scan`);
1388
+ if (res.ok) scan = res.json;
1389
+ } catch { /* 降级:凭据组缺席,工具组仍可渲染 */ }
1390
+ slot.innerHTML = buildHealthCardHTML(id, bot, scan);
1391
+
1392
+ if (slot._hcWired) return; // 委托监听只接一次(重渲染不掉线)
1393
+ slot._hcWired = true;
1394
+ slot.addEventListener("click", async (e) => {
1395
+ const el = e.target instanceof Element ? e.target : null;
1396
+ if (!el) return;
1397
+ const recheck = el.closest("[data-hc-recheck]");
1398
+ if (recheck && !recheck.classList.contains("is-loading")) {
1399
+ recheck.classList.add("is-loading");
1400
+ const label = recheck.querySelector("span");
1401
+ if (label) label.textContent = "检测中…";
1402
+ await refreshRuntimeRequirements();
1403
+ await initHealthCard(panel, id, bot, { force: true });
1404
+ return;
1405
+ }
1406
+ const cp = el.closest("[data-hc-copy]");
1407
+ if (cp) {
1408
+ try { navigator.clipboard?.writeText(cp.getAttribute("data-hc-copy") ?? ""); } catch { /* 忽略 */ }
1409
+ const old = cp.innerHTML;
1410
+ cp.innerHTML = `${ICONS.check} 已复制`;
1411
+ setTimeout(() => { cp.innerHTML = old; }, 1200);
1412
+ return;
1413
+ }
1414
+ const lk = el.closest("[data-hc-link]");
1415
+ if (lk) {
1416
+ const href = lk.getAttribute("data-hc-link");
1417
+ if (href) window.open(href, "_blank", "noopener");
1418
+ return;
1419
+ }
1420
+ const tg = el.closest("[data-hc-toggle]");
1421
+ if (tg) { tg.closest("[data-hc-seg]")?.classList.toggle("is-open"); return; }
1422
+ const tp = el.closest("[data-hc-toggle-passed]");
1423
+ if (tp) {
1424
+ tp.classList.toggle("is-open");
1425
+ tp.parentElement?.querySelector("[data-hc-passed-list]")?.classList.toggle("is-open");
1426
+ }
1427
+ });
917
1428
  }
918
1429
 
919
1430
  // ---------------------------------------------------------------------------
@@ -939,6 +1450,21 @@ function isBackendMismatch(runningBackend, configuredBackend) {
939
1450
  return runningBackend !== configuredBackend;
940
1451
  }
941
1452
 
1453
+ /**
1454
+ * 「已保存,尚未生效」待生效状态条(底座卡 v2 / BL-17 升格版)。
1455
+ * 左侧 amber 细色条语义,不整块黄底;indigo 实心按钮 = 真会执行的重启。
1456
+ * @param {string} runningBackend
1457
+ * @param {string} configuredBackend
1458
+ */
1459
+ function backendPendingBarHTML(runningBackend, configuredBackend) {
1460
+ return (
1461
+ `<div class="bk2-pending"><span class="bk2-pending-dot"></span>` +
1462
+ `<div class="bk2-pending-text"><b>已保存,尚未生效</b>` +
1463
+ `<span>运行中:${esc(lkBackend(runningBackend).name)} · 已保存:${esc(lkBackend(configuredBackend).name)} —— 重启后按新配置干活</span></div>` +
1464
+ `<button type="button" class="bk2-restart" data-bk2-restart>${ICONS.refresh} 重启服务生效</button></div>`
1465
+ );
1466
+ }
1467
+
942
1468
  /**
943
1469
  * 生成「运行中 X · 已存 Y,重启服务生效」持久 badge 的 HTML。
944
1470
  * 位置:名册行(sm)和详情区(md)复用此函数,由 size 控制尺寸。
@@ -2268,6 +2794,12 @@ function buildHeroInner(id, bot, f) {
2268
2794
 
2269
2795
  // 触点②:hero kicker 行,紧跟眉批加 backend chip(mono + vendor)
2270
2796
  const backendKickerChip = lkBackendChipHTML(bot.backend || LK_BACKEND_DEFAULT, { mono: true, vendor: true });
2797
+ // BL-17 呼应 badge:配置待生效(与底座卡 bk2-pending 状态条同源同灭)
2798
+ const heroPendingBadge =
2799
+ state.bridge?.running &&
2800
+ isBackendMismatch(state.runningBackends?.[id] ?? null, bot.backend || LK_BACKEND_DEFAULT)
2801
+ ? `<span class="hero-badge is-pending" title="配置已保存但服务仍按旧配置运行 —— 重启服务后生效">${ICONS.warn} 配置待生效 · 需重启</span>`
2802
+ : "";
2271
2803
 
2272
2804
  // ── BL-18:hero 状态面板(重启中=冷静 sky 面板 / 超时=红色告警) ────────────────
2273
2805
  // 重启窗口内隐藏「修复/重启」类动作(别诱导重复点),下方配置区保持可读。
@@ -2290,6 +2822,7 @@ function buildHeroInner(id, bot, f) {
2290
2822
  `<div class="hero-eyebrow-row">` +
2291
2823
  `<span class="hero-eyebrow">${eyebrowLabel} ${num} 个</span>` +
2292
2824
  backendKickerChip +
2825
+ heroPendingBadge +
2293
2826
  editBadge +
2294
2827
  heroDelBtn +
2295
2828
  `</div>` +
@@ -2547,7 +3080,7 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
2547
3080
  // ─── 层一:定义 ───
2548
3081
  `<section class="ac-layer">` +
2549
3082
  `<div class="ac-layer-head">` +
2550
- `<span class="ac-index">一</span>` +
3083
+ `<span class="ac-index">二</span>` +
2551
3084
  `<div class="ac-layer-meta">` +
2552
3085
  `<h3 class="ac-layer-title">Agent 的定义</h3>` +
2553
3086
  `<p class="ac-layer-role">它是谁、干嘛的、怎么干活 —— 任何 bot 都要。纯对话 / 自带知识答疑的 bot,只配这一层就够。</p>` +
@@ -2592,7 +3125,7 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
2592
3125
  // ─── 层二:权限 ───
2593
3126
  `<section class="ac-layer ac-layer-collapsible" id="ac-perm-layer" data-open="${codeAccess ? "1" : "0"}">` +
2594
3127
  `<div class="ac-layer-head ac-layer-head-toggle" id="ac-perm-toggle" role="button" tabindex="0" aria-expanded="${codeAccess}">` +
2595
- `<span class="ac-index">二</span>` +
3128
+ `<span class="ac-index">三</span>` +
2596
3129
  `<div class="ac-layer-meta">` +
2597
3130
  `<div class="ac-layer-title-row">` +
2598
3131
  `<h3 class="ac-layer-title">Agent 的权限</h3>` +
@@ -2664,7 +3197,7 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
2664
3197
  // ─── 层三:约束 ───
2665
3198
  `<section class="ac-layer ac-layer-collapsible" id="ac-adv-layer" data-open="0">` +
2666
3199
  `<div class="ac-layer-head ac-layer-head-toggle" id="ac-adv-toggle" role="button" tabindex="0" aria-expanded="false">` +
2667
- `<span class="ac-index">三</span>` +
3200
+ `<span class="ac-index">四</span>` +
2668
3201
  `<div class="ac-layer-meta">` +
2669
3202
  `<div class="ac-layer-title-row">` +
2670
3203
  `<h3 class="ac-layer-title">行为约束</h3>` +
@@ -3343,19 +3876,24 @@ function buildDetailHTML(id, bot, memContent) {
3343
3876
  // 触点④(编辑表单顶部):底座选择卡
3344
3877
  const _configuredBk4 = bot.backend || LK_BACKEND_DEFAULT;
3345
3878
  const _runningBk4 = state.runningBackends[id] ?? null;
3346
- // BL-17:底座不一致 badge(详情区,md)— 只在 bridge 在跑时显示
3347
- const bkMismatchDetail =
3879
+ // BL-17 升格:「已保存未生效」从灰字提示升为黄色待生效状态条(bk2-pending)
3880
+ // —— 只在 bridge 在跑且运行态≠配置态时显示;hero 状态行同步出现呼应 badge。
3881
+ const pendingBar =
3348
3882
  state.bridge?.running && isBackendMismatch(_runningBk4, _configuredBk4)
3349
- ? `<div style="margin-top:10px">${backendMismatchBadgeHTML(_runningBk4, _configuredBk4, "md")}` +
3350
- `<span style="margin-left:8px;font-size:12px;color:var(--muted)">右上角重启服务后生效</span></div>`
3883
+ ? backendPendingBarHTML(_runningBk4, _configuredBk4)
3351
3884
  : "";
3352
- const backendCardHTML = `<div class="lk-bk-card" id="lk-bk-card">` +
3353
- `<h4 class="lk-bk-card-title">${ICONS.box} 用哪个底座驱动它</h4>` +
3354
- `<p class="lk-bk-card-desc">底座决定这个助手背后跑哪个 CLI agent。默认 Codex;切换后需重启服务生效。</p>` +
3885
+ // 底座卡 = 配置卡家族第一卡(与 ac-layer 同壳,编号「一」):底座是最先要
3886
+ // 定的事,体检卡(第五卡)末尾验能力 —— 因果顺序自然(能力体检设计稿决策①)。
3887
+ const backendCardHTML = `<section class="ac-layer" id="lk-bk-card">` +
3888
+ `<div class="ac-layer-head"><span class="ac-index">一</span>` +
3889
+ `<div class="ac-layer-meta"><div class="ac-layer-title-row">` +
3890
+ `<h3 class="ac-layer-title">用哪个底座驱动它</h3></div>` +
3891
+ `<p class="ac-layer-role">决定它拿哪家模型思考与干活。默认 Codex;改动保存后要重启服务才生效。</p></div></div>` +
3892
+ `<div class="ac-layer-body">` +
3355
3893
  lkBackendSelectHTML(_configuredBk4, `bk-edit-${id}`) +
3356
3894
  buildModelEffortHTML(bot) +
3357
- bkMismatchDetail +
3358
- `</div>`;
3895
+ `<div id="bk2-pending-slot">${pendingBar}</div>` +
3896
+ `</div></section>`;
3359
3897
 
3360
3898
  return `
3361
3899
  <!-- 编辑式 hero band(健康=indigo soft 渐变,异常=status soft 渐变) -->
@@ -3380,6 +3918,9 @@ function buildDetailHTML(id, bot, memContent) {
3380
3918
  <div class="ac-panel-wrap" id="ac-panel-wrap">
3381
3919
  ${acPanelHTML}
3382
3920
  </div>
3921
+
3922
+ <!-- 第五卡:能力体检(异步填充;保存 bar 是 sticky 的,不受 DOM 顺序影响) -->
3923
+ <div id="hc-slot"><section class="hc"><div class="hc-okline"><span class="spinner"></span><span class="hc-ok-sub">正在体检 —— 工具 · 凭据 · 应用权限…</span></div></section></div>
3383
3924
  </div>
3384
3925
  `;
3385
3926
  }
@@ -3548,25 +4089,26 @@ function wireDetailEvents(panel, id, bot) {
3548
4089
  toast(`底座已切换为 ${lkBackend(newId).name}(保存后生效)`, "info");
3549
4090
  });
3550
4091
 
3551
- // model(input)/effort(select)挂在 lk-bk-card 里、在 #ac-panel 之外 ——
4092
+ // model/effort 隐藏 input 挂在底座卡里、在 #ac-panel 之外 ——
3552
4093
  // #ac-panel 的 input 脏检测监听器(见上)碰不到它们,这里单独接一份。
3553
4094
  bkCard.addEventListener("input", () => {
3554
4095
  state.formDirty = acFormSnapshot(panel) !== panel._acBaseline;
3555
4096
  updateFormDirty(panel);
3556
4097
  });
3557
4098
 
3558
- // 模型快选 chips:点一下 = 填入 model 输入框(委托监听,chips 会随底座
3559
- // 切换整体重渲染);dispatch input 让上面的脏检测同一条路生效。
4099
+ // bk2 覆盖区全部交互(菜单/分段/自定义逃生口,委托监听重渲染不掉线)
4100
+ wireModelEffort(panel, bkCard);
4101
+
4102
+ // 待生效状态条的「重启服务生效」(indigo 实心 = 真会执行)
3560
4103
  bkCard.addEventListener("click", (e) => {
3561
- const chip = e.target instanceof Element ? e.target.closest(".ac-model-chip") : null;
3562
- if (!chip) return;
3563
- const input = panel.querySelector("#ac-model");
3564
- if (!(input instanceof HTMLInputElement)) return;
3565
- input.value = chip.getAttribute("data-model-value") ?? "";
3566
- input.dispatchEvent(new Event("input", { bubbles: true }));
4104
+ const btn = e.target instanceof Element ? e.target.closest("[data-bk2-restart]") : null;
4105
+ if (btn) doFixAction("restart", btn);
3567
4106
  });
3568
4107
  }
3569
4108
 
4109
+ // 第五卡:能力体检(异步拉数据渲染;失败降级为工具组-only)
4110
+ void initHealthCard(panel, id, bot);
4111
+
3570
4112
  // 详情 hero 删除按钮
3571
4113
  const btnHeroDel = panel.querySelector("#btn-hero-del");
3572
4114
  if (btnHeroDel) {
@@ -4708,6 +5250,162 @@ function renderOnboardError(msg) {
4708
5250
  modal.querySelector("#ob2-err-retry")?.addEventListener("click", () => openOnboardModal());
4709
5251
  }
4710
5252
 
5253
+ /**
5254
+ * 版本胶囊五态(BL-46,能力体检设计稿范围 C):
5255
+ * 最新(中性安静,不可点)→ 可更新(indigo 实心 = 会执行)→ 更新中(阶段,
5256
+ * 图标旋转)→ 失败(可点,展开错误 + 重试 + 复制日志)→ 已装待重启(黄)。
5257
+ * larkway 版本信息只住在这颗胶囊里,体检卡不重复(DRY)。
5258
+ * 「已装待重启」的诚实语义:updater 已重启 bridge;仍是旧版的是本管理页的
5259
+ * 后端进程(larkway ui),它不随更新重启 —— hover/点击都把这两个事实讲清。
5260
+ */
5261
+ function renderBrandVersion() {
5262
+ const btn = document.getElementById("brand-ver");
5263
+ if (!btn) return;
5264
+
5265
+ const info = state.updateVersion;
5266
+ const current = state.runningVersion ?? info?.currentVersion;
5267
+ const latest = info?.latestVersion;
5268
+ const job = state.updateJob;
5269
+
5270
+ btn.classList.remove("is-update-available", "is-busy", "is-fail", "is-installed");
5271
+ btn.disabled = false;
5272
+
5273
+ // ③ 更新中:阶段不给百分比,图标旋转示活
5274
+ if (job?.status === "running") {
5275
+ btn.classList.add("is-busy");
5276
+ btn.disabled = true;
5277
+ const stage = job.phase ? `${job.phase} ${job.phaseIndex ?? 1}/${job.phaseCount ?? 3}` : "…";
5278
+ btn.innerHTML = `<span class="cap-spin">${ICONS.refresh}</span>更新中 · ${esc(stage)}`;
5279
+ btn.title = "正在更新 Larkway(下载安装 → 重启服务 → 收尾)";
5280
+ return;
5281
+ }
5282
+
5283
+ // ④ 更新失败:胶囊留在失败态可点,展开错误 + 重试 + 复制日志
5284
+ if (job?.status === "failed") {
5285
+ btn.classList.add("is-fail");
5286
+ btn.innerHTML = `${ICONS.x}更新失败 · 点击处理`;
5287
+ btn.title = job.error || "更新失败";
5288
+ return;
5289
+ }
5290
+
5291
+ // ⑤ 已装待重启:两个事实都写进态里
5292
+ if (job?.status === "done") {
5293
+ btn.classList.add("is-installed");
5294
+ btn.innerHTML = `已装 v${esc(latest ?? "?")} · 管理页待重启`;
5295
+ btn.title =
5296
+ `bridge 已按新版重启;本管理页后端(larkway ui)运行中仍是 v${current ?? "?"}。\n` +
5297
+ `刷新页面加载新版前端;如遇接口异常,手动重启 larkway ui。`;
5298
+ return;
5299
+ }
5300
+
5301
+ const updateAvailable = Boolean(info?.updateAvailable && latest);
5302
+ // ② 可更新:indigo 实心 = 点了真会执行(确认后)
5303
+ if (updateAvailable) {
5304
+ btn.classList.add("is-update-available");
5305
+ btn.textContent = `v${latest}·可更新`;
5306
+ btn.title = current ? `当前 v${current},点击更新到 v${latest}` : `点击更新到 v${latest}`;
5307
+ return;
5308
+ }
5309
+
5310
+ // ① 最新:中性安静,不可点
5311
+ btn.disabled = true;
5312
+ btn.textContent = current ? `v${current}` : "";
5313
+ btn.title = "Larkway 版本";
5314
+ }
5315
+
5316
+ async function refreshUpdateVersion() {
5317
+ const res = await api("GET", "/api/update_version");
5318
+ if (!res.ok) return;
5319
+ state.updateVersion = res.json;
5320
+ renderBrandVersion();
5321
+ }
5322
+
5323
+ /** 轮询更新任务进度直到终态;终态各自收尾。 */
5324
+ async function pollUpdateJob() {
5325
+ for (;;) {
5326
+ await new Promise((r) => setTimeout(r, 1500));
5327
+ const res = await api("GET", "/api/update_version/status");
5328
+ if (!res.ok) continue;
5329
+ state.updateJob = res.json;
5330
+ renderBrandVersion();
5331
+ if (res.json?.status === "done") {
5332
+ toast("larkway 已更新,bridge 已重启。刷新页面加载新版前端。", "ok");
5333
+ await refreshUpdateVersion();
5334
+ await refreshRuntimeRequirements();
5335
+ await refreshBridgeStatus();
5336
+ await pollStatus();
5337
+ return;
5338
+ }
5339
+ if (res.json?.status === "failed") {
5340
+ toast(`更新失败:${res.json?.error ?? "未知错误"} —— 点版本胶囊可重试/复制日志`, "error");
5341
+ return;
5342
+ }
5343
+ }
5344
+ }
5345
+
5346
+ /** 失败态点击:展开错误条(重试 + 复制日志)。 */
5347
+ function showUpdateFailBar() {
5348
+ document.getElementById("cap-fail-bar")?.remove();
5349
+ const job = state.updateJob;
5350
+ const bar = document.createElement("div");
5351
+ bar.className = "cap-fail-bar";
5352
+ bar.id = "cap-fail-bar";
5353
+ bar.innerHTML =
5354
+ `${ICONS.warn}<code>${esc(job?.error ?? "更新失败")}</code>` +
5355
+ `<button type="button" class="hc-act" id="cap-retry">${ICONS.refresh} 重试</button>` +
5356
+ `<button type="button" class="hc-act" id="cap-copy-log">${ICONS.copy} 复制日志</button>` +
5357
+ `<button type="button" class="hc-act" id="cap-dismiss">${ICONS.x}</button>`;
5358
+ document.querySelector(".topbar")?.insertAdjacentElement("afterend", bar);
5359
+ bar.querySelector("#cap-retry")?.addEventListener("click", () => {
5360
+ bar.remove();
5361
+ void startUpdateJob();
5362
+ });
5363
+ bar.querySelector("#cap-copy-log")?.addEventListener("click", () => {
5364
+ try { navigator.clipboard?.writeText((job?.log ?? []).join("\n")); } catch { /* 忽略 */ }
5365
+ toast("日志已复制", "ok");
5366
+ });
5367
+ bar.querySelector("#cap-dismiss")?.addEventListener("click", () => bar.remove());
5368
+ }
5369
+
5370
+ /** 发起更新任务(POST)并进入轮询;409(已在更新)直接转入轮询。 */
5371
+ async function startUpdateJob() {
5372
+ const res = await api("POST", "/api/update_version");
5373
+ if (!res.ok && res.status !== 409) {
5374
+ toast(`更新启动失败:${res.json?.error ?? res.status}`, "error");
5375
+ return;
5376
+ }
5377
+ state.updateJob = { status: "running", phase: "准备", phaseIndex: 1, phaseCount: 3 };
5378
+ renderBrandVersion();
5379
+ void pollUpdateJob();
5380
+ }
5381
+
5382
+ async function doUpdateVersion() {
5383
+ const job = state.updateJob;
5384
+ if (job?.status === "running") return;
5385
+ if (job?.status === "failed") { showUpdateFailBar(); return; }
5386
+ if (job?.status === "done") {
5387
+ toast("已装新版 —— 刷新页面加载新版前端;如遇接口异常,手动重启 larkway ui。", "info");
5388
+ return;
5389
+ }
5390
+
5391
+ const info = state.updateVersion;
5392
+ if (!info?.updateAvailable) return;
5393
+
5394
+ const confirmed = await confirmDialog({
5395
+ title: "更新 Larkway?",
5396
+ body:
5397
+ `将执行 npm i -g larkway@latest,并重启 bridge。\n\n` +
5398
+ `⚠️ 重启会中断所有正在进行中的任务(不会自动重试,需重新 @)。\n` +
5399
+ `建议确认没有 bot 正在干活时再更新。\n\n` +
5400
+ `当前全局版本:v${info.currentVersion ?? "unknown"}\n` +
5401
+ `最新版本:v${info.latestVersion ?? "latest"}`,
5402
+ confirmText: "确认更新",
5403
+ confirmDanger: true,
5404
+ });
5405
+ if (!confirmed) return;
5406
+ await startUpdateJob();
5407
+ }
5408
+
4711
5409
  // ---------------------------------------------------------------------------
4712
5410
  // 接线 + 启动
4713
5411
  // ---------------------------------------------------------------------------
@@ -4720,6 +5418,9 @@ function wireEvents() {
4720
5418
  document.getElementById("btn-add")?.addEventListener("click", () => {
4721
5419
  openOnboardModal();
4722
5420
  });
5421
+ document.getElementById("brand-ver")?.addEventListener("click", () => {
5422
+ void doUpdateVersion();
5423
+ });
4723
5424
 
4724
5425
  // 同步预览 modal 背景点击关闭
4725
5426
  document.getElementById("modal-backdrop")?.addEventListener("click", (e) => {
@@ -4765,8 +5466,8 @@ async function boot() {
4765
5466
  if (ctxRes.ok) {
4766
5467
  state.mode = "local";
4767
5468
  const ver = ctxRes.json?.version;
4768
- const verEl = document.getElementById("brand-ver");
4769
- if (verEl && ver) verEl.textContent = "v" + ver;
5469
+ if (ver) state.runningVersion = ver;
5470
+ renderBrandVersion();
4770
5471
  // 填充本机 hostname badge(浏览器侧地址,本机即 127.0.0.1)
4771
5472
  const hostEl = document.getElementById("ctx-host");
4772
5473
  const host = location.hostname || "";
@@ -4774,6 +5475,16 @@ async function boot() {
4774
5475
  }
4775
5476
 
4776
5477
  renderContextSwitch();
5478
+ void refreshUpdateVersion();
5479
+ // 页面在更新中途被刷新时,接上进行中/终态的更新任务(胶囊态不丢)
5480
+ void (async () => {
5481
+ const res = await api("GET", "/api/update_version/status");
5482
+ if (res.ok && res.json?.status && res.json.status !== "idle") {
5483
+ state.updateJob = res.json;
5484
+ renderBrandVersion();
5485
+ if (res.json.status === "running") void pollUpdateJob();
5486
+ }
5487
+ })();
4777
5488
 
4778
5489
  // 拉 backend 注册表(驱动底座选择就绪态;失败静默)
4779
5490
  void loadBackends();