codebee 0.1.5 → 0.1.7

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.
package/app/ui/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  const $ = (id) => document.getElementById(id);
5
- const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {} };
5
+ const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {} };
6
6
 
7
7
  /* ---------------------------------------------------------- 任务类型(流程) */
8
8
  async function loadFlows() {
@@ -97,10 +97,17 @@ function onTypeChange() {
97
97
  const engine = flow ? flow.engine : "code";
98
98
  const isReview = engine === "review";
99
99
  const isDirect = engine === "direct";
100
- const codeOnly = $("f-code-only"), reviewOnly = $("f-review-only");
100
+ const codeOnly = $("f-code-only"), reviewOnly = $("f-review-only");
101
101
  // 直连:验证命令与评审参数都不适用,两块一起收起(目标+附件即全部输入)
102
- if (codeOnly) codeOnly.classList.toggle("hidden", isReview || isDirect);
103
- if (reviewOnly) reviewOnly.classList.toggle("hidden", !isReview);
102
+ if (codeOnly) codeOnly.classList.toggle("hidden", isReview || isDirect);
103
+ if (reviewOnly) reviewOnly.classList.toggle("hidden", !isReview);
104
+ const bibleCreate = $("bible-create-field");
105
+ const isSerialReview = isReview && !!(flow && flow.serial);
106
+ if (bibleCreate) bibleCreate.classList.toggle("hidden", !isSerialReview);
107
+ const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible"].forEach((id) => {
108
+ const el = $(id);
109
+ if (el) el.value = "";
110
+ });
104
111
  const goal = $("f-goal");
105
112
  if (goal && flow && flow.goal_hint) goal.placeholder = t(flow.goal_hint);
106
113
  if (isReview && flow) {
@@ -110,12 +117,16 @@ function onTypeChange() {
110
117
  const saved = ($("f-rubric").value || "").trim();
111
118
  if (!saved && flow.rubric) $("f-rubric").value = flow.rubric.join(", ");
112
119
  // 连载参数预填(用户可改/可清空 = 单稿件模式)
113
- if (flow.serial) {
114
- if (!$("f-chapters").value) $("f-chapters").value = flow.serial.chapters || "";
115
- if (!$("f-words-per-ch").value) $("f-words-per-ch").value = flow.serial.words_per_chapter || "";
116
- }
117
- }
120
+ if (flow.serial) {
121
+ if (!$("f-chapters").value) $("f-chapters").value = flow.serial.chapters || "";
122
+ if (!$("f-words-per-ch").value) $("f-words-per-ch").value = flow.serial.words_per_chapter || "";
123
+ } else clearSerialFields();
124
+ } else {
125
+ // 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
126
+ clearSerialFields();
127
+ }
118
128
  renderImplSelects();
129
+ refreshEstimate();
119
130
  }
120
131
 
121
132
  /* 快捷类型 chips(Composer 框下方):内置流程一键选定,选中态跟 #f-type 走 */
@@ -149,6 +160,28 @@ function cmpGreeting() {
149
160
  : "晚上好呀,有什么想让我帮忙的吗");
150
161
  }
151
162
 
163
+ /* 开跑前成本预估(借鉴 omnigent 的 pre-run estimate):读台账同类任务给量级参考。
164
+ * 无样本/请求失败都保持隐藏;快速切类型用序号丢弃过期响应。 */
165
+ let _estSeq = 0;
166
+ async function refreshEstimate() {
167
+ const el = $("cmp-estimate");
168
+ const flowId = ($("f-type") || {}).value || "";
169
+ if (!el) return;
170
+ const seq = ++_estSeq;
171
+ try {
172
+ const d = await api("/api/usage/estimate?type=" + encodeURIComponent(flowId) + "&days=90");
173
+ if (seq !== _estSeq) return;
174
+ if (!d || !(d.samples > 0)) { el.classList.add("hidden"); el.textContent = ""; return; }
175
+ const tk = "≈" + fmtTok(d.median_tokens) + " tokens";
176
+ const tail = "(" + t("近{0}天 · {1}次同类", d.days || 90, d.samples) + ")";
177
+ const cost = (d.median_cost_usd || 0) > 0 ? " · ≈$" + (d.median_cost_usd).toFixed(2) : "";
178
+ el.textContent = t("预估:≈{0}{1}", tk, cost) + tail;
179
+ el.classList.remove("hidden");
180
+ } catch (e) {
181
+ if (seq === _estSeq) { el.classList.add("hidden"); el.textContent = ""; }
182
+ }
183
+ }
184
+
152
185
  /* ---------------------------------------------------------- 本机身份与鉴权 */
153
186
  function clientId() {
154
187
  let id = localStorage.getItem("orch.client");
@@ -567,7 +600,9 @@ function renderHealthBanner(health) {
567
600
  el.className = "health-banner alerting";
568
601
  el.title = alerts.map((p) =>
569
602
  p.provider + (p.model ? " · " + p.model : "") +
570
- t(":连续失败 ") + p.consecutive_failures + t(" 次(") + (p.last_error || t("未知错误")) + t(")")
603
+ (p.static
604
+ ? t(":") + (p.last_error || t("未知错误"))
605
+ : t(":连续失败 ") + p.consecutive_failures + t(" 次(") + (p.last_error || t("未知错误")) + t(")"))
571
606
  ).join("\n");
572
607
  if (!_healthBeeped) { _healthBeeped = true; beepAttention(); }
573
608
  }
@@ -575,7 +610,7 @@ function renderHealthBanner(health) {
575
610
  function healthBannerText(health) {
576
611
  return ((health && health.alerts) || []).map((p) => {
577
612
  const m = p.model ? " · " + p.model : "";
578
- return "⚠ " + p.provider + m + " " + t("连接异常");
613
+ return "⚠ " + p.provider + m + " " + (p.static ? t("绑定链失效") : t("连接异常"));
579
614
  }).join(t(" "));
580
615
  }
581
616
 
@@ -584,19 +619,21 @@ function healthBannerClick() {
584
619
  if (!alerts.length) return;
585
620
  const rows = alerts.map((p) => {
586
621
  const model = p.model ? " · " + p.model : "";
587
- return "<div style='margin-bottom:10px'>" +
588
- "<b>" + esc(p.provider) + (p.model ? " · " + esc(p.model) : "") + "</b>" +
622
+ const statLine = p.static ? "" :
589
623
  "<div style='opacity:.75;font-size:12px;margin-top:2px'>" +
590
624
  t("连续失败") + " " + p.consecutive_failures + " " + t("次 · 首次失败 ") + (p.first_fail_at || "-") +
591
- "</div>" +
625
+ "</div>";
626
+ return "<div style='margin-bottom:10px'>" +
627
+ "<b>" + esc(p.provider) + (p.model ? " · " + esc(p.model) : "") + "</b>" +
628
+ statLine +
592
629
  "<div style='color:#dc2626;font-size:12px;margin-top:2px;word-break:break-all'>" +
593
630
  esc(p.last_error || "") + "</div></div>";
594
631
  }).join("");
595
632
  const pid0 = alerts[0].provider_id || "";
596
633
  const model0 = alerts[0].model || "";
597
634
  const foot =
598
- (model0 ? "<button class='btn ghost' onclick=\"healthDisableModel('" + esc(pid0) + "','" + esc(model0) + "')\">" + t("禁用该模型") + "</button>" : "") +
599
- "<button class='btn ghost' onclick=\"healthDisableProvider('" + esc(pid0) + "')\">" + t("禁用该厂商") + "</button>" +
635
+ (pid0 && model0 ? "<button class='btn ghost' onclick=\"healthDisableModel('" + esc(pid0) + "','" + esc(model0) + "')\">" + t("禁用该模型") + "</button>" : "") +
636
+ (pid0 ? "<button class='btn ghost' onclick=\"healthDisableProvider('" + esc(pid0) + "')\">" + t("禁用该厂商") + "</button>" : "") +
600
637
  "<button class='btn ghost' onclick=\"healthOp('silence')\">" + t("静默本次告警") + "</button>" +
601
638
  "<button class='btn ghost' onclick=\"healthOp('reset')\">" + t("手动标记恢复") + "</button>" +
602
639
  "<button class='primary' onclick='closeModal()'>" + t("关闭") + "</button>";
@@ -640,6 +677,7 @@ async function healthDisableModel(pid, model) {
640
677
  S.provSig = "";
641
678
  closeModal(); render(); loadOrchestrator();
642
679
  toast(t("已禁用模型 {0} · {1},链降级自动跳过;绑定页可重新启用").replace("{0}", pid).replace("{1}", model));
680
+ autoRebindSoon();
643
681
  } catch (e) {
644
682
  toast(t("操作失败:") + e.message, true);
645
683
  }
@@ -663,6 +701,7 @@ async function healthDisableProvider(pid) {
663
701
  S.provSig = "";
664
702
  closeModal(); render(); loadOrchestrator();
665
703
  toast(t("已禁用厂商 {0}:链降级自动跳过,绑定页可重新启用").replace("{0}", pid));
704
+ autoRebindSoon();
666
705
  } catch (e) {
667
706
  toast(t("操作失败:") + e.message, true);
668
707
  }
@@ -848,19 +887,19 @@ function renderBindings() {
848
887
  }).join("");
849
888
  const bound = provs.find((p) => p.id === b.provider_id);
850
889
  const offWarn = bound && bound.enabled === false
851
- ? '<p class="hint warn">' + t("该供应商已停用:编排时不会注入它,将回落为 CLI 默认配置(模型链也不会生效)。") + '</p>' : "";
890
+ ? '<p class="hint warn">' + t("该供应商已停用:编排时不会注入它,模型链因此失效时相关步骤会直接判失败。") + '</p>' : "";
852
891
  const protoWarn = bound && !bindable.some((p) => p.id === bound.id)
853
892
  ? '<p class="hint warn">' + t("该供应商协议为 ") + esc(protoLabel(bound)) +
854
- t(",当前没有可注入的 CLI,编排时会回落为 CLI 默认配置。") + '</p>' : "";
855
- // 供应商停用但链上勾选了它家模型:链条目在解析时也会被整条跳过,
856
- // 链空了照样回落本机默认——这是 2026-09-16 配额事故的隐藏形态,单说
857
- // 「供应商下拉」警告不够,链本身的死活也要点名。
893
+ t(",当前没有可注入的 CLI,编排时相关步骤会直接判失败。") + '</p>' : "";
894
+ // 供应商停用但链上勾选了它家模型:链条目在解析时也会被整条跳过,链空了
895
+ // 相关步骤会直接判失败(2026-09-17 起;此前的静默回落本机默认正是 2026-09-16
896
+ // 配额事故的隐藏形态)——单说「供应商下拉」警告不够,链本身的死活也要点名。
858
897
  const chainDead = bindChain(b).some((c2) => c2.p && (() => {
859
898
  const pv = provs.find((p) => p.id === c2.p);
860
899
  return !pv || pv.enabled === false;
861
900
  })());
862
901
  const chainWarn = (!bound || bound.enabled !== false) && chainDead
863
- ? '<p class="hint warn">' + t("模型链里有已停用/已删除的供应商:这些条目解析时会被跳过,链可能因此整体失效。") + '</p>' : "";
902
+ ? '<p class="hint warn">' + t("模型链里有已停用/已删除的供应商:这些条目解析时会被跳过,链可能因此整体失效,相关步骤将判失败。") + '</p>' : "";
864
903
  return '<div class="card"><div class="head"><span class="name">' + esc(c.name) + "</span>" +
865
904
  '<span class="tag">' + esc(c.orch_kind) + "</span></div>" +
866
905
  '<div class="field"><label>' + t("供应商") + '</label><select id="bindprov-' + esc(c.id) + '">' + opts + "</select></div>" +
@@ -964,6 +1003,7 @@ async function batchProvOp(op) {
964
1003
  try {
965
1004
  await api("/api/models/provider-op", { method: "POST",
966
1005
  body: JSON.stringify({ ids, op }) });
1006
+ if (op !== "duplicate") autoRebindSoon();
967
1007
  } catch (e) { toast(t("操作失败:") + e.message, true); }
968
1008
  S.selProvs = {};
969
1009
  S.modelsSig = null;
@@ -1150,6 +1190,7 @@ async function keyOpCall(pid, op, keyId, key, label) {
1150
1190
  try {
1151
1191
  await api("/api/models/key-op", { method: "POST",
1152
1192
  body: JSON.stringify({ provider_id: pid, op, key_id: keyId, key, label }) });
1193
+ autoRebindSoon(); // 密钥启停/删除/解冻会改变厂商「推荐可用」判定,自动补绑一次
1153
1194
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1154
1195
  // 操作期间轮询照跑(uiPrompt 弹框不阻塞轮询),S.modelsSig 还是旧值:必须
1155
1196
  // 清签名强制重绘,否则 KEY 写盘成功界面也看不到。
@@ -1359,6 +1400,7 @@ async function modelOp(pid, name, op) {
1359
1400
  try {
1360
1401
  await api("/api/models/model-op", { method: "POST",
1361
1402
  body: JSON.stringify({ provider_id: pid, name, op }) });
1403
+ autoRebindSoon(); // 模型停用/启用/删除会改变推荐源与死链判定,自动补绑一次
1362
1404
  const s = modelSel(pid); // 单行操作后同步清掉该行勾选
1363
1405
  if (s[name]) { delete modelSelSet(pid)[name]; }
1364
1406
  } catch (e) { toast(t("操作失败:") + e.message, true); }
@@ -1425,6 +1467,7 @@ async function batchModelOp(pid, op) {
1425
1467
  try {
1426
1468
  await api("/api/models/model-op", { method: "POST",
1427
1469
  body: JSON.stringify({ provider_id: pid, names, op }) });
1470
+ autoRebindSoon();
1428
1471
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1429
1472
  clearModelSel(pid);
1430
1473
  poll();
@@ -1443,6 +1486,7 @@ async function toggleProviderEnabled(pid, enabled) {
1443
1486
  try {
1444
1487
  await api("/api/models/provider-op", { method: "POST",
1445
1488
  body: JSON.stringify({ ids: [pid], op: off ? "enable" : "disable" }) });
1489
+ autoRebindSoon();
1446
1490
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1447
1491
  S.modelsSig = null;
1448
1492
  poll();
@@ -1466,7 +1510,8 @@ async function refreshAllModels() {
1466
1510
  btn.disabled = false; btn.classList.remove("loading");
1467
1511
  btn.title = t("全部重新拉取模型列表");
1468
1512
  }, 1500);
1469
- setTimeout(poll, 2500); setTimeout(poll, 8000);
1513
+ setTimeout(poll, 2500);
1514
+ setTimeout(autoRebindSoon, 8000); // 模型列表落盘后推荐源才完整,补绑一次
1470
1515
  }
1471
1516
 
1472
1517
  async function refreshProviderModels(id) {
@@ -1477,7 +1522,7 @@ async function refreshProviderModels(id) {
1477
1522
  else toast(t("已获取模型列表 · wire 适配测试后台进行中"));
1478
1523
  } catch (e) { toast(t("获取失败:") + e.message, true); }
1479
1524
  poll();
1480
- setTimeout(poll, 4000); // 后台探测落盘后补一轮,适配徽标及时出现
1525
+ setTimeout(autoRebindSoon, 4000); // 模型列表落盘后补绑一次(后台 wire 探测同步进行)
1481
1526
  }
1482
1527
 
1483
1528
  /* 供应商编辑卡:协议可选「自动」;显式选定时它是主协议(用于需要唯一协议的
@@ -1533,6 +1578,7 @@ async function delProvider(id) {
1533
1578
  try {
1534
1579
  await api("/api/models/provider-op", { method: "POST",
1535
1580
  body: JSON.stringify({ ids: [id], op: "delete" }) });
1581
+ autoRebindSoon();
1536
1582
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1537
1583
  if (S.selModels) delete S.selModels[id];
1538
1584
  if (S.selProvs) delete S.selProvs[id];
@@ -1596,6 +1642,7 @@ async function doAddProvider() {
1596
1642
  S.modelsSig = null;
1597
1643
  closeModal();
1598
1644
  poll();
1645
+ autoRebindSoon(); // 新增即启用:马上给空链/死链一次推荐机会
1599
1646
  if (p && body.api_key) refreshProviderModels(p.id); // 有密钥才自动拉模型列表
1600
1647
  } catch (e) {
1601
1648
  res.textContent = t("保存失败:") + e.message;
@@ -1691,7 +1738,7 @@ async function doImport() {
1691
1738
  btn.textContent = t("完成");
1692
1739
  btn.disabled = false;
1693
1740
  btn.onclick = closeModal;
1694
- if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); }
1741
+ if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); autoRebindSoon(); }
1695
1742
  } catch (e) {
1696
1743
  $("import-result").innerHTML = '<div class="msg bad">' + t("导入失败:") + esc(e.message) + "</div>";
1697
1744
  btn.disabled = false; btn.textContent = t("导入选中");
@@ -1756,18 +1803,39 @@ function renderImplSelects() {
1756
1803
  }
1757
1804
  }
1758
1805
 
1759
- async function createTask() {
1760
- const msg = $("create-msg");
1806
+ async function createTask() {
1807
+ if (S.creatingTask) return;
1808
+ S.creatingTask = true;
1809
+ const submit = $("btn-create");
1810
+ if (submit) {
1811
+ submit.disabled = true;
1812
+ submit.setAttribute("aria-busy", "true");
1813
+ }
1814
+ const resetSubmit = () => {
1815
+ S.creatingTask = false;
1816
+ if (submit) {
1817
+ submit.disabled = false;
1818
+ submit.removeAttribute("aria-busy");
1819
+ }
1820
+ };
1821
+ const msg = $("create-msg");
1761
1822
  msg.className = "msg"; msg.textContent = t("提交中…");
1762
1823
  const payload = {
1763
1824
  type: $("f-type").value,
1764
1825
  mode: $("f-mode").value,
1765
1826
  title: $("f-title").value.trim(),
1766
1827
  goal: $("f-goal").value.trim(),
1767
- context: $("f-context").value.trim(),
1768
- workdir: $("f-workdir").value.trim(),
1769
- };
1770
- if (!payload.workdir) delete payload.workdir; // 留空 → 服务端用「默认保存路径」(编排设置可改)
1828
+ context: $("f-context").value.trim(),
1829
+ workdir: $("f-workdir").value.trim(),
1830
+ };
1831
+ if (!payload.goal) {
1832
+ msg.className = "msg err";
1833
+ msg.textContent = t("请先填写目标");
1834
+ $("f-goal").focus();
1835
+ resetSubmit();
1836
+ return;
1837
+ }
1838
+ if (!payload.workdir) delete payload.workdir; // 留空 → 服务端用「默认保存路径」(编排设置可改)
1771
1839
  else unhideSideDir(payload.workdir); // 在已移除的目录新建任务 → 自动恢复显示
1772
1840
  if (payload.mode === "manual") payload.implementer = $("f-impl").value;
1773
1841
  // 附件与代码版本:任务创建时服务端把待提交附件移入 _attachments/ 并注入上下文
@@ -1785,7 +1853,8 @@ async function createTask() {
1785
1853
  payload.resume = { agent: resumeAgent, session: sid,
1786
1854
  preview: opt ? opt.textContent : "", project: (hit && hit.project) || "" };
1787
1855
  }
1788
- const _eng = (flowById(payload.type) || {}).engine;
1856
+ const flow = flowById(payload.type) || {};
1857
+ const _eng = flow.engine;
1789
1858
  if (_eng === "code") {
1790
1859
  payload.verify_command = $("f-verify").value.trim();
1791
1860
  } else if (_eng === "direct") {
@@ -1797,11 +1866,18 @@ async function createTask() {
1797
1866
  const rubric = $("f-rubric").value.trim();
1798
1867
  if (rubric) payload.rubric = rubric.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
1799
1868
  const ch = parseInt($("f-chapters").value, 10);
1800
- if (ch >= 2) payload.serial = {
1801
- chapters: ch,
1802
- words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
1803
- variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
1804
- };
1869
+ if (ch >= 2) {
1870
+ payload.serial = {
1871
+ chapters: ch,
1872
+ words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
1873
+ variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
1874
+ };
1875
+ } else if (flow.serial) {
1876
+ // 显式 null 覆盖 serial_novel 的流程默认,空章节就是单稿件。
1877
+ payload.serial = null;
1878
+ }
1879
+ const initialBible = ($("f-bible") || {}).value ? $("f-bible").value.trim() : "";
1880
+ if (initialBible && flow.serial && payload.serial) payload.story_bible = initialBible;
1805
1881
  const critics = Array.from($("f-critics").querySelectorAll("input:checked")).map((i) => i.value);
1806
1882
  if (critics.length) payload.critics = critics;
1807
1883
  }
@@ -1813,11 +1889,14 @@ async function createTask() {
1813
1889
  try { await refreshState(); } catch (e) { /* 刷失败等轮询兜底 */ }
1814
1890
  jumpToRun(r.run_id);
1815
1891
  $("f-goal").value = "";
1892
+ if ($("f-bible")) $("f-bible").value = "";
1816
1893
  S.atts = []; renderAttachChips(); // 附件已移交任务待提交区,清空本地列表
1817
- } catch (e) {
1818
- msg.className = "msg err"; msg.textContent = e.message;
1819
- }
1820
- }
1894
+ } catch (e) {
1895
+ msg.className = "msg err"; msg.textContent = e.message;
1896
+ } finally {
1897
+ resetSubmit();
1898
+ }
1899
+ }
1821
1900
 
1822
1901
  /* ---------------------------------------------------------- 附件(截图/文件) */
1823
1902
  /* 待提交附件:选中/粘贴即上传到服务端 pending 区,创建任务时随 payload 落盘
@@ -2087,44 +2166,59 @@ function openCtxMenu(x, y, items) {
2087
2166
  };
2088
2167
  }
2089
2168
 
2090
- function closeCtxMenu() {
2091
- const menu = $("ctx-menu");
2092
- if (menu && !menu.classList.contains("hidden")) menu.classList.add("hidden");
2093
- }
2094
-
2095
- function bindCtxMenus() {
2169
+ function closeCtxMenu() {
2170
+ const menu = $("ctx-menu");
2171
+ if (menu && !menu.classList.contains("hidden")) menu.classList.add("hidden");
2172
+ }
2173
+
2174
+ /* 任务行的「⋯」与右键共用同一组动作,避免两个入口越改越不一样。
2175
+ * det 是 .stask 元素;菜单位置由调用方提供,缺省时贴在行右下角。 */
2176
+ function taskContextItems(det) {
2177
+ const taskId = det && det.dataset ? (det.dataset.task || "") : "";
2178
+ const runId = det && det.dataset ? (det.dataset.run || "") : "";
2179
+ const items = [];
2180
+ if (runId) items.push({ label: t("打开详情"), fn: () => sideOpenRun(runId) });
2181
+ if (taskId) {
2182
+ items.push("-");
2183
+ items.push({ label: t("打开工作目录"), fn: () => revealPath("tasks", taskId, true) });
2184
+ items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2185
+ if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2186
+ const st = det.dataset.status || "";
2187
+ if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2188
+ items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2189
+ fn: () => newFromTask(taskId) });
2190
+ const sTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
2191
+ if (sTask && sTask.serial && st !== "running" && st !== "queued")
2192
+ items.push({ label: t("继续连载(新任务)"), fn: () => continueSerial(taskId) });
2193
+ items.push({ label: t("重命名任务"), fn: () => renameTask(taskId) });
2194
+ const archived = archivedTaskIds().has(taskId);
2195
+ items.push({ label: archived ? t("取消归档") : t("归档"), fn: () => archiveTask(taskId, !archived) });
2196
+ items.push({ label: t("删除任务"), danger: true, fn: () => deleteTask(taskId) });
2197
+ } else if (runId) {
2198
+ items.push("-");
2199
+ items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2200
+ items.push({ label: t("删除记录"), danger: true, fn: () => deleteRun(runId) });
2201
+ }
2202
+ return items;
2203
+ }
2204
+
2205
+ window.openTaskActions = function (det, ev) {
2206
+ if (!det) return;
2207
+ const r = det.getBoundingClientRect();
2208
+ const x = ev && Number.isFinite(ev.clientX) ? ev.clientX : r.right - 8;
2209
+ const y = ev && Number.isFinite(ev.clientY) ? ev.clientY : r.bottom;
2210
+ openCtxMenu(x, y, taskContextItems(det));
2211
+ };
2212
+
2213
+ function bindCtxMenus() {
2096
2214
  // 侧栏任务树:右键任务 → 详情/目录/重试/重命名/归档/删除;管理类运行 → 详情/目录/删除记录
2097
2215
  $("side-tasks").addEventListener("contextmenu", (e) => {
2098
2216
  // .stask 在 .sdir 内部:先判任务行,点到才算文件夹自己的菜单
2099
- const det = e.target.closest(".stask");
2100
- if (det) {
2101
- e.preventDefault();
2102
- const taskId = det.dataset.task || "", runId = det.dataset.run || "";
2103
- const items = [];
2104
- if (runId) items.push({ label: t("打开详情"), fn: () => sideOpenRun(runId) });
2105
- if (taskId) {
2106
- items.push("-");
2107
- items.push({ label: t("打开工作目录"), fn: () => revealPath("tasks", taskId, true) });
2108
- items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2109
- if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2110
- const st = det.dataset.status || "";
2111
- if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2112
- // 与详情页同一套说法:失败/取消叫「编辑重试」,其余叫「基于此任务新建」
2113
- items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2114
- fn: () => newFromTask(taskId) });
2115
- const sTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
2116
- if (sTask && sTask.serial && st !== "running" && st !== "queued")
2117
- items.push({ label: t("继续连载(新任务)"), fn: () => continueSerial(taskId) });
2118
- items.push({ label: t("重命名任务"), fn: () => renameTask(taskId) });
2119
- items.push({ label: archivedTaskIds().has(taskId) ? t("取消归档") : t("归档"), fn: () => archiveTask(taskId, !archivedTaskIds().has(taskId)) });
2120
- items.push({ label: t("删除任务"), danger: true, fn: () => deleteTask(taskId) });
2121
- } else if (runId) {
2122
- items.push("-");
2123
- items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2124
- items.push({ label: t("删除记录"), danger: true, fn: () => deleteRun(runId) });
2125
- }
2126
- openCtxMenu(e.clientX, e.clientY, items);
2127
- return;
2217
+ const det = e.target.closest(".stask");
2218
+ if (det) {
2219
+ e.preventDefault();
2220
+ openCtxMenu(e.clientX, e.clientY, taskContextItems(det));
2221
+ return;
2128
2222
  }
2129
2223
  // 文件夹行(ZCode 桌面端式样):新建任务到该目录 / 打开工作目录 / 复制路径 / 移除。
2130
2224
  // 「其他」是兜底展示(无主运行的聚合),不是真实目录,不弹菜单。
@@ -2789,17 +2883,19 @@ function renderSideTasks() {
2789
2883
  // 二级任务行:单行即全部——点击主栏直开任务详情。
2790
2884
  // 内嵌步骤层已砍:步骤/运行明细在任务详情里更全,侧栏只留状态字形 + 徽章 + 相对时间。
2791
2885
  // 右键菜单仍吃 data-task / data-run;主栏开着某任务详情时行保持高亮。
2792
- const row = (g) => {
2793
- if (g.active) g.status = "running";
2794
- const sel = (g.key === S.detailTaskKey || g.runIds.indexOf(S.detailRunId) >= 0) ? " active" : "";
2795
- return '<div class="stask' + sel + (g.archived ? " archived" : "") + '" tabindex="0" role="button" data-key="' + esc(g.key) +
2796
- '" data-task="' + esc(g.taskId || "") + '" data-run="' + esc(g.runIds[0] || "") +
2797
- '" data-status="' + esc(g.status || "") + '">' + staskGlyph(g.status) +
2798
- '<span class="t">' + esc(g.title) + "</span>" +
2799
- (g.archived ? '<span class="sbadge archb">' + t("已归档") + "</span>" : "") +
2800
- (g.verdict ? '<span class="sbadge">' + t("待裁决") + "</span>" : "") +
2801
- '<span class="tm">' + esc(relTime(g.time)) + "</span></div>";
2802
- };
2886
+ const row = (g) => {
2887
+ if (g.active) g.status = "running";
2888
+ const sel = (g.key === S.detailTaskKey || g.runIds.indexOf(S.detailRunId) >= 0) ? " active" : "";
2889
+ return '<div class="stask' + sel + (g.archived ? " archived" : "") + '" tabindex="0" role="button" data-key="' + esc(g.key) +
2890
+ '" data-task="' + esc(g.taskId || "") + '" data-run="' + esc(g.runIds[0] || "") +
2891
+ '" data-status="' + esc(g.status || "") + '">' + staskGlyph(g.status) +
2892
+ '<span class="t">' + esc(g.title) + "</span>" +
2893
+ (g.archived ? '<span class="sbadge archb">' + t("已归档") + "</span>" : "") +
2894
+ (g.verdict ? '<span class="sbadge">' + t("待裁决") + "</span>" : "") +
2895
+ '<span class="tm">' + esc(relTime(g.time)) + "</span>" +
2896
+ '<button class="stask-more" type="button" aria-haspopup="menu" aria-label="' + esc(t("任务操作")) + '" title="' + esc(t("任务操作")) +
2897
+ '" onclick="event.stopPropagation();openTaskActions(this.closest(\'.stask\'), event)">⋯</button></div>';
2898
+ };
2803
2899
  // 一级文件夹行:折叠箭头 + 目录图标 + 末段名(hover 显全路径)+ 任务计数徽标
2804
2900
  box.innerHTML = dirs.map((d) => {
2805
2901
  const isOrphan = d.dir === ORPHAN;
@@ -2949,7 +3045,7 @@ function drawTaskDetail(key, runs) {
2949
3045
  if (sig === S.taskSig) return;
2950
3046
  S.taskSig = sig;
2951
3047
  const latest = runs[0]; // runs 新→旧
2952
- const chrono = runs.slice().reverse(); // 展示按时间正序
3048
+ const ordered = runs.slice(); // 详情按最近运行优先,方便排查
2953
3049
  const totalSteps = runs.reduce((a, r) => a + (r.steps || []).length, 0);
2954
3050
  const active = runs.some((r) => r.status === "running" || r.status === "queued");
2955
3051
  const st = active ? "running" : latest.status;
@@ -2963,7 +3059,9 @@ function drawTaskDetail(key, runs) {
2963
3059
  $("btn-retry").classList.toggle("hidden", !(latest.task_id && (latest.status === "failed" || latest.status === "cancelled")));
2964
3060
  setEditRetry(latest.task_id, latest.status,
2965
3061
  !!((S.state || {}).tasks || []).some((x) => x.id === latest.task_id));
2966
- S.lastRun = latest;
3062
+ S.lastRun = latest;
3063
+ renderRunOutcome(latest, runs);
3064
+ renderDetailOverview(latest, runs, bmTask);
2967
3065
  // 指挥区指向最新的活跃运行(无则隐藏整个指挥区,避免终态任务误导用户以为指令还能生效)
2968
3066
  const activeRun = runs.find((r) => r.status === "queued" || r.status === "running");
2969
3067
  // 活跃运行(含排队中)显示取消按钮;取消目标钉在活跃 run——任务级详情没有
@@ -2988,20 +3086,23 @@ function drawTaskDetail(key, runs) {
2988
3086
  $("rd-plan").classList.add("hidden");
2989
3087
  const statusTxt = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") };
2990
3088
  let html = "";
2991
- chrono.forEach((r, i) => {
2992
- html += '<div class="run-sep"><span class="rs-i">' + t("第 ") + (i + 1) + "/" + chrono.length + t(" 次运行") + '</span>' +
3089
+ ordered.forEach((r, i) => {
3090
+ const runNo = runs.length - i;
3091
+ html += '<div class="run-sep"><span class="rs-i">' + t("第 ") + runNo + "/" + runs.length + t(" 次运行") + '</span>' +
2993
3092
  '<span class="rs-t">' + esc(String(r.created_at || "").slice(5, 16)) + "</span>" +
2994
3093
  '<span class="chip ' + esc(r.status || "") + '">' + (statusTxt[r.status] || r.status || "") + "</span>" +
2995
3094
  (r.error ? '<span class="rs-err" title="' + esc(r.error.slice(0, 200)) + '">' + esc(r.error.slice(0, 60)) + "</span>" : "") +
2996
3095
  "</div>";
2997
- html += (r.steps || []).map((s) =>
2998
- '<div class="step" onclick="toggleLog(\'' + esc(r.id) + "', '" + esc(s.log) + '\')" title="' + esc(s.note || "") + '">' +
3096
+ html += (r.steps || []).slice().reverse().map((s) =>
3097
+ '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(r.id) + '" data-log="' + esc(s.log || "") + '" title="' + esc(s.note || "") + '">' +
2999
3098
  '<span class="n">' + String(s.n).padStart(2, "0") + "</span>" +
3000
3099
  '<span class="role">' + esc(s.role) + "</span>" +
3001
3100
  '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3002
3101
  '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
3003
3102
  '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3004
- statusChip(s.status) + "</div>"
3103
+ statusChip(s.status) +
3104
+ (s.log ? '<button class="step-log-btn" type="button" data-log-run="' + esc(r.id) + '" data-log-rel="' + esc(s.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button>" : "") +
3105
+ "</div>"
3005
3106
  ).join("");
3006
3107
  });
3007
3108
  $("rd-steps").innerHTML = html || '<div class="empty">' + t("尚无步骤") + '</div>';
@@ -3144,7 +3245,7 @@ function applyStepFocus() {
3144
3245
  el.classList.add("flash");
3145
3246
  try { el.scrollIntoView({ block: "center", behavior: "smooth" }); }
3146
3247
  catch (e) { el.scrollIntoView(); }
3147
- if (el.dataset.log) toggleLog(S.detailRunId, el.dataset.log);
3248
+ if (el.dataset.log) toggleLog(el.dataset.runId || S.detailRunId, el.dataset.log);
3148
3249
  setTimeout(() => el.classList.remove("flash"), 1800);
3149
3250
  }
3150
3251
 
@@ -3178,6 +3279,18 @@ function applyRdTabs() {
3178
3279
  });
3179
3280
  document.querySelectorAll("#run-detail .rd-pane").forEach((p) =>
3180
3281
  p.classList.toggle("hidden", p.dataset.pane !== S.rdTab));
3282
+ document.querySelectorAll("#rd-tabs .rd-tab").forEach((b) => {
3283
+ const active = b.dataset.tab === S.rdTab && !b.classList.contains("hidden");
3284
+ b.setAttribute("aria-selected", active ? "true" : "false");
3285
+ b.tabIndex = active ? 0 : -1;
3286
+ });
3287
+ document.querySelectorAll("#run-detail .rd-pane").forEach((p) =>
3288
+ p.setAttribute("aria-hidden", p.dataset.pane === S.rdTab ? "false" : "true"));
3289
+ const focusedTab = document.activeElement;
3290
+ if (focusedTab && focusedTab.matches && focusedTab.matches("#rd-tabs .rd-tab.hidden")) {
3291
+ const nextTab = document.querySelector('#rd-tabs .rd-tab[data-tab="' + S.rdTab + '"]:not(.hidden)');
3292
+ if (nextTab) nextTab.focus({ preventScroll: true });
3293
+ }
3181
3294
  // 对话页签不放日志抽屉:会盖住贴底输入条(手动切来/自动选卡都覆盖)
3182
3295
  if (S.rdTab === "chat" && !$("rd-log").classList.contains("hidden")) window.rdLogClose();
3183
3296
  renderChatNav();
@@ -3253,13 +3366,16 @@ function rdTabsSync(ctx) {
3253
3366
  const sig = (S.detailTaskKey || S.detailRunId || "") + "|" + (ctx.status || "") +
3254
3367
  "|" + (ctx.gitState || "") + "|" + (ctx.running ? 1 : 0) + "|" + (chatAvail ? 1 : 0);
3255
3368
  if (S.rdTabSig !== sig) {
3256
- S.rdTabSig = sig;
3257
- const avail = rdTabAvail();
3258
- // 直连任务:对话就是全部内容,默认落「对话」;其余流程保持既有落位。
3259
- // 没跑到终态时成果分区是空的,落进去只见「运行结束后生成」白板——
3260
- // 运行中先落蜂巢/步骤,收尾后自动选卡会再切到成果
3261
- const finishing = !ctx.running && ["done", "failed", "cancelled", "timeout"].includes(ctx.status);
3262
- S.rdTab = (avail.chat ? "chat" : null)
3369
+ S.rdTabSig = sig;
3370
+ const avail = rdTabAvail();
3371
+ const direct = chatAvail && chatEngineIsDirect(S.lastRun);
3372
+ // 直连任务的主问题是「继续聊什么、上一轮回答是什么」——默认把对话
3373
+ // 放在第一视线;编排/代码任务才默认落蜂巢,先看阶段与在岗步骤。
3374
+ // 没跑到终态时成果分区是空的,避免打开详情先看到白板。
3375
+ const finishing = !ctx.running && ["done", "failed", "cancelled", "timeout"].includes(ctx.status);
3376
+ S.rdTab = (direct && avail.chat ? "chat" : null)
3377
+ || (avail.hive ? "hive" : null)
3378
+ || (avail.chat ? "chat" : null)
3263
3379
  || (ctx.running ? (avail.hive ? "hive" : "steps")
3264
3380
  : (ctx.gitState === "isolated" && avail.git ? "git"
3265
3381
  : (ctx.hasResult && finishing ? "result" : "steps")));
@@ -3299,7 +3415,9 @@ async function renderRunDetail() {
3299
3415
  $("btn-continue").classList.toggle("hidden",
3300
3416
  !(rcTask && rcTask.serial && run.status !== "running" && run.status !== "queued"));
3301
3417
  setEditRetry(run.task_id, run.status, !!rcTask);
3302
- S.lastRun = run;
3418
+ S.lastRun = run;
3419
+ renderRunOutcome(run);
3420
+ renderDetailOverview(run, [run], rcTask);
3303
3421
  const bp = $("btn-pause");
3304
3422
  if (bp) { bp.classList.toggle("hidden", !active);
3305
3423
  bp.textContent = run.paused ? t("继续执行") : t("暂停"); }
@@ -3315,16 +3433,20 @@ async function renderRunDetail() {
3315
3433
  '<span class="stat tasksum hidden" id="rd-meta-task"></span>';
3316
3434
  if (S.detailSide) fillMetaTask(S.detailSide.stats || {}); // 缓存命中:轮询重画不闪丢累计组
3317
3435
  renderPlan(run);
3318
- $("rd-steps").innerHTML = (run.steps || []).map((s) =>
3319
- '<div class="step" data-n="' + Number(s.n) + '" data-log="' + esc(s.log || "") +
3320
- '" onclick="toggleLog(\'' + esc(run.id) + "', '" + esc(s.log) + '\')" title="' + esc(s.note || "") + '">' +
3436
+ // 详情页优先展示最近一步,排查运行中的任务时无需滚到底部;
3437
+ // 蜂巢泳道仍按原始流程顺序呈现,避免破坏阶段语义。
3438
+ $("rd-steps").innerHTML = (run.steps || []).slice().reverse().map((s) =>
3439
+ '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(run.id) + '" data-log="' + esc(s.log || "") +
3440
+ '" title="' + esc(s.note || "") + '">' +
3321
3441
  '<span class="n">' + String(s.n).padStart(2, "0") + "</span>" +
3322
3442
  '<span class="role">' + esc(s.role) + "</span>" +
3323
- '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3324
- '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
3325
- '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3326
- statusChip(s.status) + "</div>"
3327
- ).join("") || '<div class="empty">' + t("尚无步骤") + '</div>';
3443
+ '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3444
+ '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
3445
+ '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3446
+ statusChip(s.status) +
3447
+ (s.log ? '<button class="step-log-btn" type="button" data-log-run="' + esc(run.id) + '" data-log-rel="' + esc(s.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button>" : "") +
3448
+ "</div>"
3449
+ ).join("") || '<div class="empty">' + t("尚无步骤") + '</div>';
3328
3450
  applyStepFocus();
3329
3451
  // 报告
3330
3452
  if (run.status === "done" || run.report) {
@@ -4531,9 +4653,10 @@ function bindInspector() {
4531
4653
  const det = e.target.closest(".stask");
4532
4654
  if (det) sideRowActivate(det);
4533
4655
  });
4534
- $("side-tasks").addEventListener("keydown", (e) => {
4535
- if (e.key !== "Enter" && e.key !== " ") return;
4536
- const det = e.target.closest(".stask");
4656
+ $("side-tasks").addEventListener("keydown", (e) => {
4657
+ if (e.key !== "Enter" && e.key !== " ") return;
4658
+ if (e.target.closest("button, a, input, select, textarea, summary")) return;
4659
+ const det = e.target.closest(".stask");
4537
4660
  if (det) { e.preventDefault(); sideRowActivate(det); }
4538
4661
  });
4539
4662
  }
@@ -4553,14 +4676,13 @@ function artifactsChips(runId, files) {
4553
4676
  return files.map((f) => {
4554
4677
  const isTxt = FP_TXT.has(_fpExt(f.name)); // 文本/代码都有弹窗预览(按代码格式展示)
4555
4678
  return '<span class="fc-row">' +
4556
- '<a class="file-chip" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4557
- encodeURIComponent(f.name) + '" target="_blank" rel="noopener" ' +
4558
- 'title="' + esc(f.name + " · " + fmtSize(f.size)) + '" onclick="artPopup(\'' + esc(runId) + "', '" +
4559
- esc(f.name) + "', " + (Number(f.size) || 0) + ');return false">' +
4679
+ '<a class="file-chip artifact-file-open" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4680
+ encodeURIComponent(f.name) + '" target="_blank" rel="noopener" ' +
4681
+ 'title="' + esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4560
4682
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
4561
4683
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>" +
4562
- (isTxt ? '<a class="file-chip prev" title="' + esc(t("查看内容")) +
4563
- '" onclick="artPopup(\'' + esc(runId) + '\', \'' + esc(f.name) + '\', ' + (Number(f.size) || 0) + ')">' +
4684
+ (isTxt ? '<a class="file-chip prev artifact-file-preview" title="' + esc(t("查看内容")) +
4685
+ '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4564
4686
  '<svg class="ico" aria-hidden="true"><use href="#i-book"/></svg>' + t("预览") + "</a>" : "") +
4565
4687
  "</span>";
4566
4688
  }).join("");
@@ -4611,12 +4733,14 @@ window.copyText = function (t) {
4611
4733
  } catch (e) { /* 剪贴板不可用则忽略 */ }
4612
4734
  };
4613
4735
 
4614
- let currentLog = null;
4615
-
4616
- /* 当前打开的日志是否仍属于这批步骤(重画后日志路径消失说明步骤被重建,才收起) */
4617
- function stepsMatch(runs, rel) {
4618
- return (runs || []).some((r) => (r.steps || []).some((s) => s.log === rel));
4619
- }
4736
+ let currentLog = null; // { runId, rel }:同一路径在不同轮次也必须能切换
4737
+
4738
+ /* 当前打开的日志是否仍属于这批步骤(重画后日志路径消失说明步骤被重建,才收起) */
4739
+ function stepsMatch(runs, current) {
4740
+ if (!current) return false;
4741
+ return (runs || []).some((r) => String(r.id || "") === String(current.runId || "") &&
4742
+ (r.steps || []).some((s) => s.log === current.rel));
4743
+ }
4620
4744
 
4621
4745
  function renderPlan(run) {
4622
4746
  const box = $("rd-plan");
@@ -4677,22 +4801,24 @@ window.rdLogClose = function () {
4677
4801
  if (st) st.textContent = "";
4678
4802
  };
4679
4803
 
4680
- async function toggleLog(runId, rel) {
4681
- const box = $("rd-log"), pre = $("rd-log-text");
4682
- if (currentLog === rel && !box.classList.contains("hidden")) {
4804
+ async function toggleLog(runId, rel) {
4805
+ const box = $("rd-log"), pre = $("rd-log-text");
4806
+ if (currentLog && currentLog.runId === runId && currentLog.rel === rel && !box.classList.contains("hidden")) {
4683
4807
  window.rdLogClose(); return;
4684
4808
  }
4685
4809
  try {
4686
4810
  const r = await api("/api/runs/" + encodeURIComponent(runId) + "/log?step=" + encodeURIComponent(rel) + "&pretty=1");
4687
4811
  // 拉取期间可能切详情:过期日志不落盘(A 的步骤输出写进 B 详情的抽屉)。
4688
4812
  // 检查器蜂巢/步骤点开日志同款:run 属检查器当前 run 也算在场
4689
- const owned = S.detailRunId === runId ||
4690
- (S.detailTaskKey && S.lastRun && S.lastRun.id === runId) ||
4691
- ((S.inspData || {}).run || {}).id === runId;
4813
+ const owned = S.detailRunId === runId ||
4814
+ // 任务级直连时间线会跨多轮 run 回放;按钮上的 run 可能不是最新一轮,
4815
+ // 只按 latest.id 判断会让旧轮次的「CLI 日志」点击后悄悄失效。
4816
+ (S.detailTaskKey && S.lastRun && S.lastRun.task_id === S.detailTaskKey) ||
4817
+ ((S.inspData || {}).run || {}).id === runId;
4692
4818
  if (!owned) return;
4693
4819
  pre.textContent = r.log || t("(等待输出…)");
4694
4820
  box.classList.remove("hidden");
4695
- currentLog = rel;
4821
+ currentLog = { runId, rel };
4696
4822
  const st = $("rd-log-step");
4697
4823
  if (st) st.textContent = rdLogStepLabel(runId, rel);
4698
4824
  pre.scrollTop = pre.scrollHeight; // 打开即看最新输出
@@ -4702,10 +4828,10 @@ async function toggleLog(runId, rel) {
4702
4828
  stopLogLive();
4703
4829
  if (!live) return;
4704
4830
  S.logLive = setInterval(async () => {
4705
- if (!currentLog || currentLog !== rel) return stopLogLive();
4831
+ if (!currentLog || currentLog.runId !== runId || currentLog.rel !== rel) return stopLogLive();
4706
4832
  try {
4707
4833
  const rr = await api("/api/runs/" + encodeURIComponent(runId) + "/log?step=" + encodeURIComponent(rel) + "&pretty=1");
4708
- if (currentLog === rel) {
4834
+ if (currentLog && currentLog.runId === runId && currentLog.rel === rel) {
4709
4835
  // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰
4710
4836
  const stick = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48;
4711
4837
  pre.textContent = rr.log || t("(等待输出…)");
@@ -4789,8 +4915,16 @@ window.renderHive = function (run) {
4789
4915
  });
4790
4916
  const running = steps.filter((s) => s.status === "running");
4791
4917
  const sub = $("rd-hive-sub");
4792
- if (sub) sub.textContent = running.length
4793
- ? t("在岗") + " " + running.length + " / " + steps.length : t("全部空闲");
4918
+ if (sub) {
4919
+ const done = steps.filter((s) => ["done", "completed", "success"].includes(String(s.status || "").toLowerCase())).length;
4920
+ const issues = steps.filter((s) => ["failed", "timeout", "cancelled"].includes(String(s.status || "").toLowerCase())).length;
4921
+ sub.innerHTML = '<span class="hive-count hive-count-live"><i class="live-dot"></i>' +
4922
+ esc(t("在岗")) + " " + running.length + '</span>' +
4923
+ '<span class="hive-count hive-count-done">✓ ' + done + '</span>' +
4924
+ (issues ? '<span class="hive-count hive-count-issue">! ' + issues + '</span>' : "") +
4925
+ '<span class="hive-count hive-count-total">' + steps.length + ' ' + esc(t("步骤")) + '</span>' +
4926
+ '<span class="hive-hint">' + esc(t("点击步骤卡片查看 CLI 日志")) + '</span>';
4927
+ }
4794
4928
  const activeIdx = lanes.map((l) => byStage[l].some((s) => s.status === "running")).lastIndexOf(true);
4795
4929
  const cell = (s, laneIdx, cellIdx) => {
4796
4930
  // 五归一:取消/超时的格子不再冒充「完成」——与步骤芯片同三色体系
@@ -4805,16 +4939,19 @@ window.renderHive = function (run) {
4805
4939
  const key = run.id + "|" + (s.log || "");
4806
4940
  if (st !== "running") delete hiveTails[key];
4807
4941
  const tailText = st === "running" ? (hiveTails[key] || concl) : concl;
4808
- const title = [s.role, who, s.started_at ? t("开始于 ") + s.started_at : "",
4942
+ const title = [s.role, who,
4943
+ (s.duration_s != null ? s.duration_s + "s" : ""),
4944
+ s.started_at ? t("开始于 ") + s.started_at : "",
4809
4945
  (s.note ? "◆ " + s.note : "")].filter(Boolean).join(" · ")
4810
4946
  + (concl && st !== "running" ? "\n" + t("结论:") + concl : "");
4811
4947
  // --d:入场瀑布逐格延迟(泳道间 110ms + 同泳道逐格 45ms),样式端消费
4812
- return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '"' +
4813
- ' style="--d:' + (laneIdx * 110 + cellIdx * 45) + 'ms"' +
4814
- ' title="' + esc(title) + '" ' +
4815
- 'onclick="hiveOpenLog(\'' + esc(run.id) + "', '" + esc(s.log || "") + '\')">' +
4948
+ return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '" role="button" tabindex="0"' +
4949
+ ' data-hive-run="' + esc(run.id) + '" data-hive-log="' + esc(s.log || "") + '"' +
4950
+ ' style="--d:' + (laneIdx * 110 + cellIdx * 45) + 'ms"' +
4951
+ ' title="' + esc(title) + '" ' +
4952
+ '>' +
4816
4953
  '<div class="hc-head">' +
4817
- '<svg class="ico hc-bee" aria-hidden="true"><use href="#i-bee"></use></svg>' +
4954
+ '<img class="hc-bee" src="icons/bee.svg" alt="" aria-hidden="true">' +
4818
4955
  '<span class="hc-role">' + esc(s.role || "") + "</span>" +
4819
4956
  '<span class="hc-who">' + esc(who) + "</span></div>" +
4820
4957
  '<div class="hc-tail" data-log="' + esc(s.log || "") + '">' +
@@ -4822,9 +4959,10 @@ window.renderHive = function (run) {
4822
4959
  '<div class="hc-meta"><span class="hc-elapsed" data-started="' + esc(s.started_at || "") + '">' +
4823
4960
  (s.duration_s != null ? s.duration_s + "s" : hiveElapsed(s.started_at)) + "</span>" +
4824
4961
  (st === "running" ? '<span class="hc-live"><i class="live-dot"></i>' + t("工作中") + "</span>" : "") +
4825
- (st === "timeout" ? '<span class="hc-dead">⏱ ' + t("超时") + "</span>" : "") +
4826
- (st === "cancelled" ? '<span class="hc-dead">' + t("已取消") + "</span>" : "") +
4827
- "</div></div>";
4962
+ (st === "timeout" ? '<span class="hc-dead">⏱ ' + t("超时") + "</span>" : "") +
4963
+ (st === "cancelled" ? '<span class="hc-dead">' + t("已取消") + "</span>" : "") +
4964
+ (s.log ? '<span class="hc-log-link">' + esc(t("CLI 日志")) + "</span>" : "") +
4965
+ "</div></div>";
4828
4966
  };
4829
4967
  const dots = (list, laneIdx) => list.slice(-12).map((s, di) => {
4830
4968
  const dc = s.status === "running" ? "run" : s.status === "failed" ? "fail"
@@ -4843,16 +4981,24 @@ window.renderHive = function (run) {
4843
4981
  : (activeIdx === -1 || i < activeIdx ? "lane-done" : "lane-idle");
4844
4982
  // 泳道头视觉 v2:阶段序号徽章(01/02…流水线站序)+ 已完成进度(settled/total)
4845
4983
  const settled = list.filter((s) => !["running", "queued"].includes(s.status)).length;
4984
+ const meter = list.length ? Math.round((settled / list.length) * 100) : 0;
4846
4985
  return '<div class="hive-lane ' + cls + '">' +
4847
4986
  '<div class="lane-head">' +
4848
4987
  '<span class="lane-idx">' + String(i + 1).padStart(2, "0") + "</span>" +
4849
4988
  '<span class="lane-name">' + esc(stage) + '</span>' +
4850
4989
  '<span class="lane-n">×' + list.length + "</span>" +
4851
4990
  (settled ? '<span class="lane-prog">' + settled + "/" + list.length + "</span>" : "") +
4991
+ '<span class="lane-meter" aria-label="' + meter + '%"><i style="width:' + meter + '%"></i></span>' +
4852
4992
  (hasRun ? '<span class="lane-live"><i class="live-dot"></i>' + t("进行中") + "</span>" : "") + "</div>" +
4853
4993
  '<div class="lane-track">' + dots(list, i) + "</div>" +
4854
4994
  '<div class="lane-cells">' + list.slice(-8).map((s, ci) => cell(s, i, ci)).join("") + "</div></div>";
4855
- }).join('<div class="lane-flow" aria-hidden="true"></div>');
4995
+ }).join('<div class="lane-flow" aria-hidden="true">'
4996
+ + '<svg class="flow-line flow-line-down" viewBox="0 0 36 44" aria-hidden="true">'
4997
+ + '<path class="flow-path" d="M14 2 C14 16 22 28 22 42"/><path class="flow-tip" d="M16 36 L22 43 L28 36"/></svg>'
4998
+ + '<svg class="flow-line flow-line-up" viewBox="0 0 36 44" aria-hidden="true">'
4999
+ + '<path class="flow-path" d="M22 2 C22 16 14 28 14 42"/><path class="flow-tip" d="M8 36 L14 43 L20 36"/></svg>'
5000
+ + '<span class="flow-bee"><img src="icons/bee.svg" alt="" aria-hidden="true"></span>'
5001
+ + '</div>');
4856
5002
  // 入场瀑布闸门:只按"结构"(泳道数/格数/状态构成)签名;运行中尾巴/秒表每 2s
4857
5003
  // 变化不能触重播。签名没变就不动 DOM——格子不闪、动画不重启、悬停不弹手。
4858
5004
  const sig = lanes.map((l) => l + ":" + byStage[l].length + "(" +
@@ -5144,9 +5290,10 @@ async function renderChat(run, active) {
5144
5290
  if (sig === chatSig) return; // 轮询重画去抖:内容没变不重建 DOM(保住输入焦点)
5145
5291
  const runForFetch = run.id; // 拉取期间可能切详情:过期响应不落盘(同 renderRunDetail 闸门)
5146
5292
  chatSig = sig;
5147
- let items = [];
5148
- try { items = ((await api("/api/runs/" + encodeURIComponent(run.id) + "/timeline")).items) || []; }
5149
- catch (e) { items = []; }
5293
+ let data = null;
5294
+ try { data = await api("/api/runs/" + encodeURIComponent(run.id) + "/timeline"); }
5295
+ catch (e) { data = null; }
5296
+ const items = (data && data.items) || [];
5150
5297
  if (!$("run-detail") || $("run-detail").classList.contains("hidden")) return;
5151
5298
  // 过期闸门:run 级(renderRunDetail)与任务级(drawTaskDetail)都经 S.lastRun
5152
5299
  // 指向当前详情正在展示的那条 run——拉取期间切了详情就丢弃响应
@@ -5159,7 +5306,7 @@ async function renderChat(run, active) {
5159
5306
  const attName = (a) => { const p = typeof a === "string" ? a
5160
5307
  : ((a && (a.name || a.path)) || "");
5161
5308
  return String(p).split(/[\\/]/).pop() || ""; };
5162
- flow.innerHTML = items.map((it) => {
5309
+ let html = items.map((it) => {
5163
5310
  if (it.kind === "user") {
5164
5311
  // 用户消息:右侧浅灰圆角块,元信息在块内顶部。
5165
5312
  // 附件胶囊可点开预览:data-att 带 _attachments/ 相对路径(老数据裸文件名
@@ -5195,21 +5342,183 @@ async function renderChat(run, active) {
5195
5342
  // 元信息只留 名字+时间(+失败标记):路由/供应商细节属于「步骤」页签,塞这里就是噪音
5196
5343
  const metaBad = it.status === "failed" || it.status === "cancelled";
5197
5344
  // 智能体回复:头像 + 整幅正文(不套气泡框),元信息小字落在正文下方
5198
- return '<div class="chat-row">' +
5199
- '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
5200
- '<div class="chat-bubble agent">' +
5201
- '<div class="chat-body">' + bodyHtml + "</div>" +
5202
- '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5203
- (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div></div></div>";
5204
- }).join("") || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
5345
+ return '<div class="chat-row">' +
5346
+ '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
5347
+ '<div class="chat-bubble agent">' +
5348
+ '<div class="chat-body">' + bodyHtml + "</div>" +
5349
+ '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5350
+ (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div>" +
5351
+ (it.log && it.run ? '<div class="chat-actions"><button class="chat-log-btn" type="button" data-chat-log-run="' +
5352
+ esc(it.run) + '" data-chat-log-rel="' + esc(it.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button></div>" : "") +
5353
+ "</div></div>";
5354
+ }).join("");
5355
+ // 时间线收尾的「执行结果」卡:run 终态后的确定性摘要(成没成/耗时/执行者/
5356
+ // 产出文件)——不依赖模型最后一句话自觉交代(用户反馈:输"1"跑完 55 秒
5357
+ // 只见一句寒暄,执行结果无处可看)。
5358
+ const res = data && data.result;
5359
+ if (res) html += chatResultHTML(run, res);
5205
5360
  const hint = $("rd-chat-hint");
5206
5361
  if (hint) hint.textContent = active
5207
5362
  ? t("运行中:新消息会排队,本轮回答完后依次送达")
5208
5363
  : t("已结束:发送后将自动开新一轮接着做");
5364
+ flow.innerHTML = html || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
5209
5365
  flow.scrollTop = flow.scrollHeight;
5210
5366
  }
5211
5367
 
5212
- function drawChatAtts() {
5368
+ /* 执行结果卡(时间线收尾):run 终态后的确定性摘要——成没成、跑多久、谁执行
5369
+ * 的、产出了哪些文件,全部产品明示,不依赖模型自觉交代。失败给「查看执行步骤」
5370
+ * 入口;文件 chip 复用成品预览通道(artPopup)。 */
5371
+ function chatResultHTML(run, res) {
5372
+ const ok = res.status === "done";
5373
+ const bad = res.status === "failed";
5374
+ const icon = ok ? "#i-check" : "#i-x";
5375
+ const label = ok ? t("任务完成") : (bad ? t("任务失败") : t("已取消"));
5376
+ const meta = [];
5377
+ if (res.executor) meta.push(esc(res.executor));
5378
+ if (res.turns) meta.push(res.turns + " " + t("轮对话"));
5379
+ if (res.duration_s != null) meta.push(chatDurTxt(res.duration_s));
5380
+ const files = res.files || [];
5381
+ const chips = files.map((f) =>
5382
+ '<a class="file-chip chat-file-open" href="/api/runs/' + encodeURIComponent(run.id) + "/file?name=" +
5383
+ encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
5384
+ esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(run.id) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
5385
+ '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
5386
+ '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>").join("");
5387
+ return '<div class="chat-row">' +
5388
+ '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="' + icon + '"></use></svg></span>' +
5389
+ '<div class="chat-result' + (ok ? "" : bad ? " bad" : " off") + '">' +
5390
+ '<div class="cr-head"><svg class="ico" aria-hidden="true"><use href="' + icon + '"></use></svg>' +
5391
+ esc(label) + "</div>" +
5392
+ (meta.length ? '<div class="cr-meta">' + meta.join(" · ") + "</div>" : "") +
5393
+ (bad && res.error ? '<div class="cr-err">' + esc(res.error) + "</div>" : "") +
5394
+ '<div class="cr-files">' + (chips
5395
+ ? '<div class="cr-files-title">' + esc(t("产出文件")) + "(" + files.length + ")</div>" +
5396
+ '<div class="file-chips">' + chips + "</div>"
5397
+ : '<div class="cr-files-title">' + esc(t("无文件产出")) + "</div>") + "</div>" +
5398
+ (bad ? '<button class="ghost cr-steps" onclick="rdChatNavGo(\'steps\')">' +
5399
+ esc(t("查看执行步骤")) + "</button>" : "") +
5400
+ "</div></div>";
5401
+ }
5402
+
5403
+ function chatDurTxt(s) {
5404
+ s = Number(s) || 0;
5405
+ if (s < 60) return s + " " + t("秒");
5406
+ const m = Math.floor(s / 60), r = s % 60;
5407
+ return m + " " + t("分") + (r ? " " + r + " " + t("秒") : "");
5408
+ }
5409
+
5410
+ /* 代码/编排任务的确定性结果摘要:报告是长文,步骤是过程,用户还需要一眼
5411
+ * 知道这次到底成没成、做了多少步、用了多久,以及失败原因。这个卡片不依赖
5412
+ * 智能体是否在最后一句话里主动总结;直连任务也能在「成果」页签看到同一口径。 */
5413
+ function runDurationSeconds(run) {
5414
+ if (!run) return null;
5415
+ if (Number.isFinite(Number(run.duration_s))) return Number(run.duration_s);
5416
+ const steps = (run.steps || []).filter((s) => Number.isFinite(Number(s.duration_s)));
5417
+ if (steps.length) return steps.reduce((a, s) => a + Number(s.duration_s || 0), 0);
5418
+ const a = Date.parse(String(run.started_at || "").replace(" ", "T"));
5419
+ const b = Date.parse(String(run.ended_at || "").replace(" ", "T"));
5420
+ return Number.isFinite(a) && Number.isFinite(b) && b >= a ? (b - a) / 1000 : null;
5421
+ }
5422
+
5423
+ function runOutcomeSummary(run) {
5424
+ if (!run) return "";
5425
+ const last = (run.steps || []).slice().reverse().find((s) => s.output || s.summary);
5426
+ const raw = run.summary || (last && (last.output || last.summary)) || run.error || "";
5427
+ return String(raw).replace(/\s+/g, " ").trim().slice(0, 520);
5428
+ }
5429
+
5430
+ function renderRunOutcome(run, runs) {
5431
+ const box = $("rd-outcome");
5432
+ if (!box) return;
5433
+ if (!run) { box.classList.add("hidden"); box.innerHTML = ""; return; }
5434
+ const all = runs && runs.length ? runs : [run];
5435
+ const steps = (run.steps || []);
5436
+ const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5437
+ const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5438
+ !["running", "queued"].includes(String(s.status || ""))).length, 0);
5439
+ const status = String(run.status || "");
5440
+ const active = status === "running" || status === "queued";
5441
+ const bad = status === "failed" || status === "cancelled" || status === "timeout";
5442
+ const label = active ? (status === "queued" ? t("排队中") : t("运行中"))
5443
+ : (status === "done" ? t("完成") : status === "failed" ? t("失败") : status === "cancelled" ? t("已取消") : status || t("未知状态"));
5444
+ const icon = status === "done" ? "#i-check" : bad ? "#i-x" : "#i-gauge";
5445
+ const duration = runDurationSeconds(run);
5446
+ const latest = steps.slice().reverse().find((s) => s.agent_label || s.agent || s.role);
5447
+ const route = run.route && typeof run.route === "object" ? run.route.implementer : "";
5448
+ // route.implementer 是路由评分说明,不是用户真正关心的 CLI 名称;优先
5449
+ // 展示步骤上的 agent_label,只有旧数据没有步骤执行者时才回退到路由说明。
5450
+ const executor = run.executor || run.implementer || (latest && (latest.agent_label || latest.agent)) || route || "";
5451
+ const summary = runOutcomeSummary(run);
5452
+ const meta = [];
5453
+ if (executor) meta.push('<span><b>' + esc(t("执行者")) + '</b> ' + esc(executor) + "</span>");
5454
+ if (total) meta.push('<span><b>' + esc(t("步骤")) + '</b> ' + settled + "/" + total + "</span>");
5455
+ if (duration != null) meta.push('<span><b>' + esc(t("耗时")) + '</b> ' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + "</span>");
5456
+ return (box.classList.remove("hidden"), box.innerHTML =
5457
+ '<div class="outcome-top">' +
5458
+ '<div class="outcome-title ' + (bad ? "bad" : active ? "live" : "ok") + '">' +
5459
+ '<svg class="ico" aria-hidden="true"><use href="' + icon + '"></use></svg>' + esc(t("执行结果")) +
5460
+ '<span class="outcome-status">' + esc(label) + "</span></div>" +
5461
+ (meta.length ? '<div class="outcome-meta">' + meta.join("") + "</div>" : "") +
5462
+ "</div>" +
5463
+ (summary ? '<div class="outcome-summary">' + esc(summary) + "</div>" : "") +
5464
+ (run.error ? '<div class="outcome-error">' + esc(run.error) + "</div>" : "") +
5465
+ (total ? '<div class="outcome-actions"><button class="ghost" type="button" onclick="rdChatNavGo(\'steps\')">' + esc(t("查看执行步骤")) + "</button></div>" : "")
5466
+ );
5467
+ }
5468
+
5469
+ /* 任务详情首屏概览:把“这次任务做了什么、做到哪、最近发生了什么”放在
5470
+ * 蜂巢上方。直连任务仍以对话为主,由 chat-mode 隐藏概览,避免它抢走输入空间。 */
5471
+ function renderDetailOverview(run, runs, task) {
5472
+ const box = $("rd-overview");
5473
+ if (!box || !run) return;
5474
+ if (chatEngineIsDirect(run)) {
5475
+ box.classList.add("hidden");
5476
+ box.innerHTML = "";
5477
+ return;
5478
+ }
5479
+ const all = runs && runs.length ? runs : [run];
5480
+ const steps = (run.steps || []);
5481
+ const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5482
+ const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5483
+ !["running", "queued"].includes(String(s.status || ""))).length, 0);
5484
+ const pct = total ? Math.max(0, Math.min(100, Math.round((settled / total) * 100))) : 0;
5485
+ const status = String(run.status || "");
5486
+ const active = status === "running" || status === "queued";
5487
+ const bad = status === "failed" || status === "cancelled" || status === "timeout";
5488
+ const statusText = active ? (status === "queued" ? t("排队中") : t("运行中"))
5489
+ : status === "done" ? t("完成") : status === "failed" ? t("失败")
5490
+ : status === "cancelled" ? t("已取消") : status || t("未知状态");
5491
+ const last = steps.slice().reverse().find((s) => s.summary || s.output || s.note) || steps[steps.length - 1];
5492
+ const lastText = last ? String(last.summary || last.output || last.note || "").replace(/\s+/g, " ").trim().slice(0, 240) : "";
5493
+ const executor = run.executor || run.implementer || (last && (last.agent_label || last.agent)) || "";
5494
+ const duration = runDurationSeconds(run);
5495
+ const goal = (task && task.goal) || run.goal || run.title || "";
5496
+ const actions = [];
5497
+ if (total) actions.push('<button type="button" class="ghost" data-overview-tab="steps">' + esc(t("查看步骤")) + "</button>");
5498
+ if (last && last.log) actions.push('<button type="button" class="ghost" data-overview-run="' + esc(run.id) + '" data-overview-log="' + esc(last.log) + '">' + esc(t("打开 CLI 日志")) + "</button>");
5499
+ if (status === "done" || run.report) actions.push('<button type="button" class="ghost" data-overview-tab="result">' + esc(t("查看成果")) + "</button>");
5500
+ box.innerHTML =
5501
+ '<div class="rd-overview-main">' +
5502
+ '<div class="rd-overview-kicker">' + esc(t("任务概览")) + '</div>' +
5503
+ '<div class="rd-overview-title" title="' + esc(run.title || "") + '">' + esc(run.title || "") + '</div>' +
5504
+ (goal ? '<div class="rd-overview-goal" title="' + esc(goal) + '">' + esc(goal) + '</div>' : "") +
5505
+ '</div>' +
5506
+ '<div class="rd-overview-state">' +
5507
+ '<span class="state-label">' + esc(t("当前状态")) + '</span>' +
5508
+ '<strong class="state-value ' + (bad ? "bad" : status === "done" ? "ok" : "") + '">' + esc(statusText) + '</strong>' +
5509
+ '</div>' +
5510
+ '<div class="rd-overview-progress" aria-label="' + esc(pct + "%") + '"><i style="width:' + pct + '%"></i></div>' +
5511
+ '<div class="rd-overview-facts">' +
5512
+ '<span><b>' + settled + "/" + total + '</b> ' + esc(t("步骤已完成")) + '</span>' +
5513
+ (executor ? '<span>' + esc(t("执行者")) + ' <b>' + esc(executor) + '</b></span>' : "") +
5514
+ (duration != null ? '<span>' + esc(t("耗时")) + ' <b>' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + '</b></span>' : "") +
5515
+ '</div>' +
5516
+ (lastText ? '<div class="rd-overview-last"><b>' + esc(t("最近一步")) + '</b><span>' + esc(lastText) + '</span></div>' : "") +
5517
+ (actions.length ? '<div class="rd-overview-actions">' + actions.join("") + '</div>' : "");
5518
+ box.classList.remove("hidden");
5519
+ }
5520
+
5521
+ function drawChatAtts() {
5213
5522
  const box = $("rd-chat-att-list");
5214
5523
  if (!box) return;
5215
5524
  // 点名字开预览(待提交区原文走 /api/attachments/<id>),× 才是移除
@@ -5324,8 +5633,16 @@ function bindChat() {
5324
5633
  // 附件胶囊点击预览:委托绑在静态容器上——时间线轮询整块重建 innerHTML、
5325
5634
  // 输入条胶囊增删重画,都不丢监听。时间线胶囊走已落盘文件,输入条胶囊
5326
5635
  // (× 移除按钮除外)走待提交区
5327
- $("rd-chat-flow").addEventListener("click", (e) => {
5328
- const chip = e.target.closest(".att-open");
5636
+ $("rd-chat-flow").addEventListener("click", (e) => {
5637
+ const logBtn = e.target.closest("[data-chat-log-run][data-chat-log-rel]");
5638
+ if (logBtn) { e.stopPropagation(); toggleLog(logBtn.dataset.chatLogRun, logBtn.dataset.chatLogRel); return; }
5639
+ const file = e.target.closest(".chat-file-open[data-file-run]");
5640
+ if (file) {
5641
+ e.preventDefault();
5642
+ artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
5643
+ return;
5644
+ }
5645
+ const chip = e.target.closest(".att-open");
5329
5646
  if (chip) window.chatAttPopup(chip.dataset.att);
5330
5647
  });
5331
5648
  const attBox = $("rd-chat-att-list");
@@ -5761,20 +6078,20 @@ function bindSelById(id) {
5761
6078
  function bindRepaint() { S.bindSig = null; renderBindings(); }
5762
6079
 
5763
6080
  /* 一键推荐绑定:给每个链为空的 CLI 按协议适配规则预填一条主模型。
5764
- * 推荐顺序:显式协议原生匹配 > auto 且 wire_caps 命中;默认模型停用的厂商
5765
- * 降权(当前键位是坏的,除非只剩它,否则不当首选——失效链重推荐实测);
5766
- * 同分按 priority / 数组序稳定。只预填草稿态(dirty),逐条看清后各自保存
6081
+ * 推荐顺序:显式协议原生匹配 > auto 且 wire_caps 命中;同分按 priority /
6082
+ * 数组序稳定。默认模型停用的厂商直接不推荐——它当前键位就是坏的,绑上去
6083
+ * 解析照样失败;没有合适的就什么都不绑(2026-09-17 用户拍板,不再保留
6084
+ * 「只剩它也上」的兜底)。只预填草稿态(dirty),逐条看清后各自保存
5767
6085
  * 或「全部保存」,健康链绝不被覆盖。 */
5768
6086
  function recommendFor(c) {
5769
- const allow = bindAllowedProtocols(c.orch_kind);
5770
- const provs = (S.providers || []).filter((p) => chainProvUsable(p, allow));
6087
+ // 目录页「默认模型」下拉用 orch_kind || id 判协议(仅管理条目没有 orch_kind),
6088
+ // 推荐口径与下拉过滤保持一致;绑定页条目恒有 orch_kind,不受影响
6089
+ const allow = bindAllowedProtocols(c.orch_kind || c.id);
6090
+ const badDefault = (p) => (p.models || []).some((m) => m.name === p.model &&
6091
+ (m.hidden || m.enabled === false));
6092
+ const provs = (S.providers || []).filter((p) => !badDefault(p) && chainProvUsable(p, allow));
5771
6093
  if (!provs.length) return null;
5772
- const score = (p) => {
5773
- const native = allow.indexOf(p.protocol) >= 0;
5774
- const badDefault = (p.models || []).some((m) => m.name === p.model &&
5775
- (m.hidden || m.enabled === false)) ? 500 : 0;
5776
- return (native ? 0 : 1) * 1000 + badDefault + (p.priority || 0);
5777
- };
6094
+ const score = (p) => (allow.indexOf(p.protocol) >= 0 ? 0 : 1) * 1000 + (p.priority || 0);
5778
6095
  provs.sort((a, b) => score(a) - score(b));
5779
6096
  const p = provs[0];
5780
6097
  const models = (p.models || []).filter((m) => !m.hidden && m.enabled !== false);
@@ -5812,48 +6129,38 @@ function chainDeadReasons(c, chain) {
5812
6129
  return dead;
5813
6130
  }
5814
6131
 
6132
+ /* 单条绑定链的推荐修复动作:需要改返回新链,不动返回 null。
6133
+ * 空链 → 预填推荐;整条死透(或只有链首且已死)→ 重推荐;链首死但链内还有
6134
+ * 活的备选 → 新链首插最前(原降级序保留,推荐项已在链内则升首去重)。 */
6135
+ function bindRepairAction(c, st) {
6136
+ const rec = recommendFor(c);
6137
+ if (!st.chain.length) return rec ? [rec] : null;
6138
+ const dead = chainDeadReasons(c, st.chain);
6139
+ const allDead = dead.every((d) => d);
6140
+ const headDead = dead[0] !== ""; // 空 p = CLI 默认凭据,是有意配置不算死
6141
+ if (allDead) return rec ? [rec] : null;
6142
+ if (headDead && rec && chainKey(st.chain) !== chainKey([rec]))
6143
+ return [rec].concat(st.chain.filter((x) => !(x.p === rec.p && x.m === rec.m)));
6144
+ return null;
6145
+ }
6146
+
5815
6147
  function autoBindAll() {
5816
6148
  const targets = (S.catalog || []).filter((c) => c.installed && c.orch_kind);
5817
6149
  let filled = 0, skipped = 0, noProv = 0, refilled = 0;
5818
6150
  for (const c of targets) {
5819
6151
  const st = bindSelById(c.id);
5820
- if (!st.chain.length) {
5821
- const rec = recommendFor(c);
5822
- if (!rec) { noProv++; continue; }
5823
- st.chain = [rec];
5824
- st.dirty = true;
5825
- filled++;
6152
+ const act = bindRepairAction(c, st);
6153
+ if (!act) {
6154
+ // 没动它:分清「健康链无需推荐」和「没有可推荐的」两种落空
6155
+ if (!st.chain.length) { noProv++; continue; }
6156
+ const dead = chainDeadReasons(c, st.chain);
6157
+ if (dead.every((d) => d)) noProv++; else skipped++;
5826
6158
  continue;
5827
6159
  }
5828
- // 链非空:只处理死链——整条链全部死透(或只有链首且已死)才重推荐,
5829
- // 部分存活的长链保留(还降级得动,不擅自覆盖用户手工配的结构)
5830
- const dead = chainDeadReasons(c, st.chain);
5831
- const aliveN = dead.filter((d) => !d).length;
5832
- const allDead = aliveN === 0;
5833
- const headDead = dead[0] !== ""; // 空 p = CLI 默认凭据,是有意配置不算死
5834
- const rec = recommendFor(c);
5835
- if (allDead && rec) {
5836
- st.chain = [rec];
5837
- st.dirty = true;
5838
- refilled++;
5839
- } else if (allDead) {
5840
- noProv++;
5841
- } else if (headDead && rec && chainKey(st.chain) !== chainKey([rec])) {
5842
- // 链首死但链内还有活的备选:只把推荐的新链首插到最前面(原降级序保留)
5843
- const hasRec = st.chain.some((x) => x.p === rec.p && x.m === rec.m);
5844
- if (!hasRec) {
5845
- st.chain = [rec].concat(st.chain);
5846
- st.dirty = true;
5847
- refilled++;
5848
- } else {
5849
- // 推荐项已在链内:把它升到链首
5850
- st.chain = [rec].concat(st.chain.filter((x) => x !== rec));
5851
- st.dirty = true;
5852
- refilled++;
5853
- }
5854
- } else {
5855
- skipped++;
5856
- }
6160
+ const wasEmpty = !st.chain.length;
6161
+ st.chain = act;
6162
+ st.dirty = true;
6163
+ if (wasEmpty) filled++; else refilled++;
5857
6164
  }
5858
6165
  bindRepaint();
5859
6166
  if (filled && refilled) {
@@ -5871,6 +6178,56 @@ function autoBindAll() {
5871
6178
  }
5872
6179
  }
5873
6180
 
6181
+ /* 厂商/模型停用·启用·删除后自动补一次推荐绑定(2026-09-17 用户拍板):
6182
+ * 绑定链——空链预填、死链重推荐、死链首插新首,与「一键推荐绑定」同规则,
6183
+ * 但直接落盘(自动场景没有人工确认环节);目录页——空默认模型直填。
6184
+ * 没有合适的推荐就保持原样,什么都不绑。绑定页上用户手改中的草稿(dirty)
6185
+ * 不碰;一处都没改成静默返回,不打扰停用/启用的操作反馈。 */
6186
+ let _autoRebindRunning = false, _autoRebindAgain = false;
6187
+ async function autoRebindSoon() {
6188
+ if (_autoRebindRunning) { _autoRebindAgain = true; return; }
6189
+ _autoRebindRunning = true;
6190
+ try {
6191
+ await poll(); // 拿停用/启用落盘后的最新 providers / bindings / catalog
6192
+ let fixed = 0;
6193
+ for (const c of (S.catalog || []).filter((x) => x.installed && x.orch_kind)) {
6194
+ const st = bindSelById(c.id);
6195
+ if (st.dirty) continue; // 用户手改中,不覆盖草稿
6196
+ const act = bindRepairAction(c, st);
6197
+ if (!act) continue;
6198
+ const b = (S.bindings || {})[c.id] || {};
6199
+ try {
6200
+ await api("/api/models/binding", { method: "POST", body: JSON.stringify({
6201
+ agent_id: c.id, provider_id: act[0].p,
6202
+ chain: act.map((x) => ({ provider_id: x.p, model: x.m })),
6203
+ difficulty_routing: !!b.difficulty_routing }) });
6204
+ st.chain = act; st.dirty = false; st.key = chainKey(act);
6205
+ fixed++;
6206
+ } catch (e) { /* 单条失败不打断,等下次变更再补 */ }
6207
+ }
6208
+ for (const c of (S.catalog || []).filter((x) =>
6209
+ x.installed && x.config_writable && !fmtModel(x.model))) {
6210
+ const rec = recommendFor(c);
6211
+ if (!rec) continue;
6212
+ try {
6213
+ const r = await api("/api/catalog/" + encodeURIComponent(c.id) + "/model",
6214
+ { method: "POST", body: JSON.stringify({ model: rec.m }) });
6215
+ c.model = r.model || rec.m;
6216
+ fixed++;
6217
+ } catch (e) { /* 同上 */ }
6218
+ }
6219
+ if (fixed) {
6220
+ S.catSig = null; S.bindSig = null;
6221
+ render();
6222
+ toast(t("厂商/模型变动,已自动重绑 %1 处。").replace("%1", fixed));
6223
+ }
6224
+ } catch (e) { /* 自动补绑失败静默:不打断用户的停用/启用操作 */ }
6225
+ finally {
6226
+ _autoRebindRunning = false;
6227
+ if (_autoRebindAgain) { _autoRebindAgain = false; autoRebindSoon(); }
6228
+ }
6229
+ }
6230
+
5874
6231
  async function saveAllBindings() {
5875
6232
  const targets = (S.catalog || []).filter((c) => c.installed && c.orch_kind);
5876
6233
  const dirty = targets.filter((c) => (S.bindSel[c.id] || {}).dirty);
@@ -5916,7 +6273,7 @@ function bindModelBox(c, provId) {
5916
6273
  "</span>";
5917
6274
  }).join("")
5918
6275
  : '<span class="hint">' + t("未设置") + (provId
5919
- ? t("(按供应商/难度自动解析——供应商协议不匹配或被停用时解析为空,回落 CLI 本机默认)")
6276
+ ? t("(按供应商/难度自动解析——供应商协议不匹配或被停用时解析为空,相关步骤将判失败)")
5920
6277
  : t("(用 CLI 默认模型——不会注入任何供应商凭据)")) + "</span>";
5921
6278
  return '<div class="field"><label>' + t("运行时模型链(跨厂商,最多 ") + MAX_ORCH_MODELS + t(" 条)") + "</label>" +
5922
6279
  '<div class="orch-row">' + chips +
@@ -6106,6 +6463,45 @@ async function saveModel(id) {
6106
6463
  poll();
6107
6464
  }
6108
6465
 
6466
+ /* 一键绑定推荐模型(智能体目录页):给「默认模型」还空着的已安装智能体按
6467
+ * 协议适配规则(复用绑定页 recommendFor 评分)挑一家厂商的启用模型并直接
6468
+ * 写入该 CLI 自身配置(写入侧先自动 .bak 备份)。已配置的不动——目录页的
6469
+ * 默认模型会写进 CLI 全局配置,手工设置过的值(含 CLI 订阅自带的默认)不
6470
+ * 该被一键覆盖;要换模型逐卡下拉改就行。 */
6471
+ async function catAutoBindAll() {
6472
+ const targets = (S.catalog || []).filter((c) => c.installed && c.config_writable);
6473
+ if (!targets.length) { toast(t("没有支持写入默认模型的已安装智能体。"), true); return; }
6474
+ const empty = targets.filter((c) => !fmtModel(c.model));
6475
+ if (!empty.length) { toast(t("所有已安装智能体都已配置默认模型,无需绑定。")); return; }
6476
+ let done = 0, failed = 0, noProv = 0, firstErr = "";
6477
+ for (const c of empty) {
6478
+ const rec = recommendFor(c);
6479
+ if (!rec) { noProv++; continue; }
6480
+ try {
6481
+ const r = await api("/api/catalog/" + encodeURIComponent(c.id) + "/model",
6482
+ { method: "POST", body: JSON.stringify({ model: rec.m }) });
6483
+ c.model = r.model || rec.m;
6484
+ done++;
6485
+ } catch (e) {
6486
+ failed++;
6487
+ if (!firstErr) firstErr = c.name + ":" + e.message;
6488
+ }
6489
+ }
6490
+ S.catSig = null;
6491
+ renderCatalog();
6492
+ if (done && failed) {
6493
+ toast(t("已为 %1 个智能体写入推荐模型,%2 个失败。").replace("%1", done).replace("%2", failed) +
6494
+ (firstErr ? " " + firstErr : ""), true);
6495
+ } else if (done) {
6496
+ toast(t("已为 %1 个智能体写入推荐模型(原配置已自动备份 .bak)。").replace("%1", done));
6497
+ } else if (failed) {
6498
+ toast(t("推荐模型写入失败:") + firstErr, true);
6499
+ } else {
6500
+ toast(t("没有可推荐的:先到「模型接入」页导入与 CLI 协议匹配的供应商。"), true);
6501
+ }
6502
+ poll();
6503
+ }
6504
+
6109
6505
  /* 一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
6110
6506
  用的模型就是目录页「默认模型」已写入该 CLI 配置文件的那份;密钥由服务端按编排同款规则注入 */
6111
6507
  async function openAgent(id) {
@@ -7255,7 +7651,8 @@ async function suApply() {
7255
7651
  clearInterval(SU_TIMER);
7256
7652
  SU_TIMER = setInterval(async () => {
7257
7653
  try {
7258
- const run = await api("/api/runs/" + encodeURIComponent(runId));
7654
+ const d = await api("/api/runs/" + encodeURIComponent(runId));
7655
+ const run = d && d.run; // /api/runs/<id> 返回 {run:{…}} 信封,别读外壳
7259
7656
  if (run && run.status && run.status !== "running" && run.status !== "queued") {
7260
7657
  clearInterval(SU_TIMER); SU_TIMER = null;
7261
7658
  $("su-apply").disabled = false;
@@ -7587,18 +7984,31 @@ function usageMultilineSvg(byDay, byDayModel) {
7587
7984
  "<title>" + esc(days[i] + "\n" + s.name + " " + fmtTok(s.vals[i]) + t(" tokens")) + "</title></circle>";
7588
7985
  });
7589
7986
  });
7590
- const grid = [0.25, 0.5, 0.75].map((f) =>
7591
- '<line class="uc-grid" x1="' + padL + '" y1="' + (padT + ih * f).toFixed(1) +
7592
- '" x2="' + (W - padR) + '" y2="' + (padT + ih * f).toFixed(1) + '"/>').join("") +
7593
- '<line class="uc-base" x1="' + padL + '" y1="' + (padT + ih) + '" x2="' + (W - padR) + '" y2="' + (padT + ih) + '"/>';
7987
+ const grid = [0.25, 0.5, 0.75].map((f) =>
7988
+ '<line class="uc-grid" x1="' + padL + '" y1="' + (padT + ih * f).toFixed(1) +
7989
+ '" x2="' + (W - padR) + '" y2="' + (padT + ih * f).toFixed(1) + '"/>').join("") +
7990
+ '<line class="uc-base" x1="' + padL + '" y1="' + (padT + ih) + '" x2="' + (W - padR) + '" y2="' + (padT + ih) + '"/>';
7991
+ // 底层活动量柱保留每日总量轮廓;折线仍表达各模型趋势,双层信息比单一曲线
7992
+ // 更容易看出“哪天整体有调用、哪天只是某个模型占比变化”。
7993
+ const dailyTotals = days.map((_, i) => series.reduce((sum, s) => sum + (s.vals[i] || 0), 0));
7994
+ const dayMax = Math.max(1, ...dailyTotals);
7995
+ const barW = Math.max(3, (iw / n) * 0.52);
7996
+ const bars = dailyTotals.map((v, i) => {
7997
+ const h = ih * v / dayMax;
7998
+ const x = xOf(i) - barW / 2;
7999
+ const y = padT + ih - h;
8000
+ return '<rect class="uc-bar" x="' + x.toFixed(1) + '" y="' + y.toFixed(1) +
8001
+ '" width="' + barW.toFixed(1) + '" height="' + Math.max(0.5, h).toFixed(1) +
8002
+ '" rx="2"><title>' + esc(days[i] + " · " + fmtTok(v) + t(" tokens")) + "</title></rect>";
8003
+ }).join("");
7594
8004
  const legend = '<div class="um-legend">' +
7595
8005
  series.map((s, si) =>
7596
8006
  '<span><i style="background:' + usageColor(si) + '"></i>' +
7597
8007
  esc(s.name.length > 22 ? s.name.slice(0, 21) + "…" : s.name) + "</span>").join("") +
7598
8008
  '<span class="um-peak">' + esc(t("峰值 ") + fmtTok(max)) + "</span></div>";
7599
- return legend +
7600
- '<svg class="usage-svg" viewBox="0 0 ' + W + " " + H + '" role="img" preserveAspectRatio="xMidYMid meet">' +
7601
- grid + body + labels + "</svg>";
8009
+ return legend +
8010
+ '<svg class="usage-svg" viewBox="0 0 ' + W + " " + H + '" role="img" preserveAspectRatio="xMidYMid meet">' +
8011
+ grid + bars + body + labels + "</svg>";
7602
8012
  }
7603
8013
 
7604
8014
  /* Token 活动热力图:GitHub 贡献图风格。窗口自适应——覆盖全部活跃历史(封顶
@@ -7829,9 +8239,10 @@ function renderUsage() {
7829
8239
  // 不提示的话「输入 0 · 输出 0」会被误读成统计坏了
7830
8240
  const noBreakdown = tot.tokens > 0 && !tot.input && !tot.output && !tot.cached;
7831
8241
  $("usage-kpis").innerHTML = [
7832
- kpiCard(t("累计 Token 数"), fmtTok(tot.tokens),
7833
- t("调用 ") + fmtTok(tot.calls) + t(" 次 · 成功率 ") +
7834
- (tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " + fmtUsd(tot.cost_usd),
8242
+ kpiCard(t("累计 Token 数"), fmtTok(tot.tokens),
8243
+ t("调用 ") + fmtTok(tot.calls) + t(" 次 · 成功率 ") +
8244
+ (tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " +
8245
+ t("失败 ") + fmtTok(tot.failed || 0) + t(" · ") + fmtUsd(tot.cost_usd),
7835
8246
  true, "i-sigma"),
7836
8247
  kpiCard(t("峰值 Token 数"), fmtTok(tot.peak_tokens),
7837
8248
  t("单日最高 · 日均 ") + fmtTok(activeDays ? tot.tokens / activeDays : 0), false, "i-gauge"),
@@ -8105,9 +8516,47 @@ document.addEventListener("DOMContentLoaded", () => {
8105
8516
  b.addEventListener("click", () => setUsageHeatMode(b.dataset.heat)));
8106
8517
  $("btn-back").addEventListener("click", closeRun);
8107
8518
  $("btn-cancel").addEventListener("click", cancelRun);
8108
- bindDirector();
8109
- bindChat();
8110
- // 详情标签页:手点即切并钉住——状态变化触发的自动选卡不再抢用户的手选
8519
+ bindDirector();
8520
+ bindChat();
8521
+ const bindArtifactClicks = (root) => {
8522
+ if (!root) return;
8523
+ root.addEventListener("click", (e) => {
8524
+ const file = e.target.closest("[data-file-run][data-file-name]");
8525
+ if (!file) return;
8526
+ e.preventDefault();
8527
+ artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
8528
+ });
8529
+ };
8530
+ bindArtifactClicks($("rd-arts"));
8531
+ bindArtifactClicks($("insp-artifacts"));
8532
+ // 动态详情区统一用 data 属性委托,日志路径/文件名不再拼进 inline JS。
8533
+ const detailOverview = $("rd-overview");
8534
+ if (detailOverview) detailOverview.addEventListener("click", (e) => {
8535
+ const log = e.target.closest("[data-overview-log]");
8536
+ if (log) { toggleLog(log.dataset.overviewRun, log.dataset.overviewLog); return; }
8537
+ const tab = e.target.closest("[data-overview-tab]");
8538
+ if (tab) { S.rdTab = tab.dataset.overviewTab; S.rdTabPin = true; applyRdTabs(); }
8539
+ });
8540
+ const stepBox = $("rd-steps");
8541
+ if (stepBox) stepBox.addEventListener("click", (e) => {
8542
+ const btn = e.target.closest("[data-log-run][data-log-rel]");
8543
+ const row = e.target.closest(".step[data-log]");
8544
+ if (btn) { e.stopPropagation(); toggleLog(btn.dataset.logRun, btn.dataset.logRel); return; }
8545
+ if (row && row.dataset.log) toggleLog(row.dataset.runId || S.detailRunId, row.dataset.log);
8546
+ });
8547
+ const hiveBox = $("rd-hive-cells");
8548
+ if (hiveBox) {
8549
+ hiveBox.addEventListener("click", (e) => {
8550
+ const cell = e.target.closest("[data-hive-run][data-hive-log]");
8551
+ if (cell) hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog);
8552
+ });
8553
+ hiveBox.addEventListener("keydown", (e) => {
8554
+ if (e.key !== "Enter" && e.key !== " ") return;
8555
+ const cell = e.target.closest("[data-hive-run][data-hive-log]");
8556
+ if (cell) { e.preventDefault(); hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog); }
8557
+ });
8558
+ }
8559
+ // 详情标签页:手点即切并钉住——状态变化触发的自动选卡不再抢用户的手选
8111
8560
  $("rd-tabs").addEventListener("click", (e) => {
8112
8561
  const b = e.target.closest(".rd-tab");
8113
8562
  if (!b || b.classList.contains("hidden")) return;
@@ -8115,6 +8564,21 @@ document.addEventListener("DOMContentLoaded", () => {
8115
8564
  S.rdTabPin = true;
8116
8565
  applyRdTabs();
8117
8566
  });
8567
+ $("rd-tabs").addEventListener("keydown", (e) => {
8568
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
8569
+ const tabs = Array.from($("rd-tabs").querySelectorAll(".rd-tab:not(.hidden)"));
8570
+ if (!tabs.length) return;
8571
+ const current = Math.max(0, tabs.indexOf(document.activeElement));
8572
+ let next = current;
8573
+ if (e.key === "ArrowRight") next = (current + 1) % tabs.length;
8574
+ if (e.key === "ArrowLeft") next = (current - 1 + tabs.length) % tabs.length;
8575
+ if (e.key === "Home") next = 0;
8576
+ if (e.key === "End") next = tabs.length - 1;
8577
+ e.preventDefault();
8578
+ tabs[next].focus();
8579
+ tabs[next].click();
8580
+ tabs[next].scrollIntoView({ block: "nearest", inline: "nearest" });
8581
+ });
8118
8582
  // 详情子页显隐 → 容器宽度 class(:has 在部分浏览器不生效,JS 同步为准)
8119
8583
  (function () {
8120
8584
  const sub = document.getElementById("sub-runs");
@@ -8258,6 +8722,7 @@ document.addEventListener("DOMContentLoaded", () => {
8258
8722
  $("btn-reload-catalog").addEventListener("click", async () => {
8259
8723
  await api("/api/catalog/reload", { method: "POST" }); poll(); refreshSessionAgents();
8260
8724
  });
8725
+ $("btn-catalog-autobind").addEventListener("click", catAutoBindAll);
8261
8726
  $("btn-import").addEventListener("click", openImportDialog);
8262
8727
  $("btn-add-provider").addEventListener("click", openAddProviderDialog);
8263
8728
  $("prov-search").addEventListener("input", () => {