codebee 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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() {
@@ -27,7 +27,10 @@ function flowIconHtml(f) {
27
27
 
28
28
  function flowDesc(f) {
29
29
  if (f.engine === "direct") return t("单智能体直达(快)");
30
- return f.engine === "code" ? t("实现 → 验证 → 评审") : t("起草 → 多维评审 → 门禁");
30
+ if (f.engine === "code") return t("实现 → 验证 → 评审");
31
+ // 连载与单稿件同引擎,描述必须区分:连载强调逐章与断点续跑
32
+ if (f.serial) return t("大纲 → 逐章起草评审 → 合并(可续跑)");
33
+ return t("起草 → 多维评审 → 门禁");
31
34
  }
32
35
 
33
36
  function renderTypeOptions() {
@@ -57,9 +60,17 @@ function renderTypeMenu() {
57
60
  (f.builtin ? "" : '<em class="ti-tag">' + t("自定义") + "</em>") + "</span>" +
58
61
  '<span class="ti-desc">' + esc(flowDesc(f)) + "</span></span>" +
59
62
  '<svg class="ico ti-check" aria-hidden="true"><use href="#i-check"></use></svg>' +
60
- "</button>").join("");
63
+ "</button>").join("")
64
+ /* 底部固定入口:管理流程替代原工具条上的「管理」按钮(简化 composer 工具行) */
65
+ + '<div class="type-menu-foot"><button type="button" class="type-item" onclick="manageFlowsFromMenu()">' +
66
+ '<span class="ti-ico" aria-hidden="true"><svg class="ico"><use href="#i-gear"></use></svg></span>' +
67
+ '<span class="ti-body"><span class="ti-name">' + esc(t("管理任务类型…")) + "</span></span></button></div>";
61
68
  syncTypeBtn();
62
69
  }
70
+ window.manageFlowsFromMenu = function () {
71
+ toggleTypeMenu(false);
72
+ openFlowsManager();
73
+ };
63
74
 
64
75
  function syncTypeBtn() {
65
76
  const sel = $("f-type");
@@ -101,21 +112,33 @@ function onTypeChange() {
101
112
  // 直连:验证命令与评审参数都不适用,两块一起收起(目标+附件即全部输入)
102
113
  if (codeOnly) codeOnly.classList.toggle("hidden", isReview || isDirect);
103
114
  if (reviewOnly) reviewOnly.classList.toggle("hidden", !isReview);
115
+ const bibleCreate = $("bible-create-field");
116
+ const isSerialReview = isReview && !!(flow && flow.serial);
117
+ if (bibleCreate) bibleCreate.classList.toggle("hidden", !isSerialReview);
118
+ const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible"].forEach((id) => {
119
+ const el = $(id);
120
+ if (el) el.value = "";
121
+ });
104
122
  const goal = $("f-goal");
105
123
  if (goal && flow && flow.goal_hint) goal.placeholder = t(flow.goal_hint);
106
124
  if (isReview && flow) {
107
125
  if (flow.manuscript) $("f-manuscript").value = flow.manuscript;
108
126
  if (flow.rounds) $("f-rounds").value = flow.rounds;
109
127
  if (flow.threshold) $("f-threshold").value = flow.threshold;
128
+ if (flow.best_of) $("f-bestof").value = flow.best_of;
110
129
  const saved = ($("f-rubric").value || "").trim();
111
130
  if (!saved && flow.rubric) $("f-rubric").value = flow.rubric.join(", ");
112
131
  // 连载参数预填(用户可改/可清空 = 单稿件模式)
113
132
  if (flow.serial) {
114
133
  if (!$("f-chapters").value) $("f-chapters").value = flow.serial.chapters || "";
115
134
  if (!$("f-words-per-ch").value) $("f-words-per-ch").value = flow.serial.words_per_chapter || "";
116
- }
135
+ } else clearSerialFields();
136
+ } else {
137
+ // 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
138
+ clearSerialFields();
117
139
  }
118
140
  renderImplSelects();
141
+ refreshEstimate();
119
142
  }
120
143
 
121
144
  /* 快捷类型 chips(Composer 框下方):内置流程一键选定,选中态跟 #f-type 走 */
@@ -149,6 +172,28 @@ function cmpGreeting() {
149
172
  : "晚上好呀,有什么想让我帮忙的吗");
150
173
  }
151
174
 
175
+ /* 开跑前成本预估(借鉴 omnigent 的 pre-run estimate):读台账同类任务给量级参考。
176
+ * 无样本/请求失败都保持隐藏;快速切类型用序号丢弃过期响应。 */
177
+ let _estSeq = 0;
178
+ async function refreshEstimate() {
179
+ const el = $("cmp-estimate");
180
+ const flowId = ($("f-type") || {}).value || "";
181
+ if (!el) return;
182
+ const seq = ++_estSeq;
183
+ try {
184
+ const d = await api("/api/usage/estimate?type=" + encodeURIComponent(flowId) + "&days=90");
185
+ if (seq !== _estSeq) return;
186
+ if (!d || !(d.samples > 0)) { el.classList.add("hidden"); el.textContent = ""; return; }
187
+ const tk = "≈" + fmtTok(d.median_tokens) + " tokens";
188
+ const tail = "(" + t("近{0}天 · {1}次同类", d.days || 90, d.samples) + ")";
189
+ const cost = (d.median_cost_usd || 0) > 0 ? " · ≈$" + (d.median_cost_usd).toFixed(2) : "";
190
+ el.textContent = t("预估:≈{0}{1}", tk, cost) + tail;
191
+ el.classList.remove("hidden");
192
+ } catch (e) {
193
+ if (seq === _estSeq) { el.classList.add("hidden"); el.textContent = ""; }
194
+ }
195
+ }
196
+
152
197
  /* ---------------------------------------------------------- 本机身份与鉴权 */
153
198
  function clientId() {
154
199
  let id = localStorage.getItem("orch.client");
@@ -294,126 +339,6 @@ function uiPrompt(title, value) {
294
339
  }).then((ok) => ((ok && inp) ? inp.value.trim() : null));
295
340
  }
296
341
 
297
- /* ------------------------------------------------- 采访式向导(新建任务) */
298
- /* 不想面对整张表单的新用户:三问一确认——目标 → 类型 → 目录 → 预览填入。
299
- * 代码类流程多追问一步「验证命令」(怎么算跑通);页序列按所选类型动态生成。
300
- * 向导只把答案预填进既有表单,提交/附件/评审设置全部留在表单里:
301
- * 不新增提交路径,质量闸门与既有测试照旧。目录浏览用表单里的「选择…」。 */
302
- let wiz = null; // { page, pages, goal, type, workdir, verify }
303
-
304
- function wizardPages(type) {
305
- const flow = flowById(type);
306
- return ["goal", "type"].concat(
307
- flow && flow.engine === "code" ? ["verify"] : [], ["workdir", "summary"]);
308
- }
309
-
310
- window.openTaskWizard = async function () {
311
- // 冷启动时 boot 的 loadFlows 可能晚于用户点击:空了就现拉一次(autoForm 同款防御)
312
- if (!S.flows || !S.flows.length) { try { await loadFlows(); } catch (e) { /* 渲染时兜底 */ } }
313
- const type = (($("f-type") || {}).value) || ((S.flows || [])[0] || {}).id || "";
314
- wiz = { page: 0, pages: wizardPages(type), goal: "", type,
315
- workdir: (($("f-workdir") || {}).value || ""), verify: "" };
316
- renderWizard();
317
- };
318
-
319
- window.wizardBack = function () { if (wiz && wiz.page > 0) { wiz.page--; renderWizard(); } };
320
- window.wizardPickType = function (id) {
321
- if (!wiz) return;
322
- wiz.type = id;
323
- wiz.pages = wizardPages(id); // 按新类型重排页序列(代码流程插入验证步)
324
- wiz.page = 2; // 类型页的下一页
325
- renderWizard();
326
- };
327
-
328
- window.wizardNext = function () {
329
- if (!wiz) return;
330
- const id = wiz.pages[wiz.page];
331
- if (id === "goal") {
332
- const v = (($("wz-goal") || {}).value || "").trim();
333
- if (!v) { toast(t("目标还不能为空"), true); return; }
334
- wiz.goal = v;
335
- wiz.page++;
336
- } else if (id === "verify") {
337
- wiz.verify = (($("wz-verify") || {}).value || "").trim();
338
- wiz.page++;
339
- } else if (id === "workdir") {
340
- wiz.workdir = (($("wz-workdir") || {}).value || "").trim();
341
- wiz.page++;
342
- }
343
- renderWizard();
344
- };
345
-
346
- function wizardFoot(back) {
347
- return (back ? '<button class="ghost" onclick="wizardBack()">' + esc(t("上一步")) + "</button>" : "") +
348
- '<button class="primary" onclick="wizardNext()">' + esc(t("下一步")) + "</button>";
349
- }
350
-
351
- function renderWizard() {
352
- if (!wiz) return;
353
- const f = flowById(wiz.type);
354
- const id = wiz.pages[wiz.page];
355
- let body = "", foot = "";
356
- if (id === "goal") {
357
- body = '<textarea id="wz-goal" class="wz-goal" rows="4" placeholder="' +
358
- esc(t("要完成什么,一句话即可")) +
359
- '" onkeydown="if(event.key===\'Enter\'&&!event.shiftKey){event.preventDefault();wizardNext()}">' +
360
- esc(wiz.goal) + "</textarea>";
361
- foot = wizardFoot(false);
362
- } else if (id === "type") {
363
- body = '<div class="wz-flows">' + (S.flows || []).map((fl) =>
364
- '<button type="button" class="wz-flow' + (fl.id === wiz.type ? " on" : "") +
365
- '" onclick="wizardPickType(\'' + esc(fl.id) + '\')">' +
366
- flowIconHtml(fl) +
367
- '<span class="wz-fname">' + esc(fl.name || fl.id) + "</span>" +
368
- '<span class="wz-fdesc">' + esc(flowDesc(fl)) + "</span></button>").join("") + "</div>" +
369
- '<p class="hint">' + esc(t("点一个类型即选定并继续;进表单后仍可改")) + "</p>";
370
- foot = wizardFoot(true);
371
- } else if (id === "verify") {
372
- body = '<input id="wz-verify" class="wz-input" autocomplete="off" value="' + esc(wiz.verify) +
373
- '" placeholder="' + esc(t("例:npm test、pytest -q;留空=只靠 AI 评审")) +
374
- '" onkeydown="if(event.key===\'Enter\'){event.preventDefault();wizardNext()}">' +
375
- '<p class="hint">' + esc(t("一条能在工作目录里跑的命令,退出码 0 即视为通过;之后可在表单改")) + "</p>";
376
- foot = wizardFoot(true);
377
- } else if (id === "workdir") {
378
- body = '<input id="wz-workdir" class="wz-input" autocomplete="off" value="' + esc(wiz.workdir) +
379
- '" placeholder="' + esc(t("留空用默认保存路径;也可稍后在表单里点「选择…」")) +
380
- '" onkeydown="if(event.key===\'Enter\'){event.preventDefault();wizardNext()}">' +
381
- '<p class="hint">' + esc(t("建议给每个任务一个独立目录,产出互不覆盖")) + "</p>";
382
- foot = wizardFoot(true);
383
- } else {
384
- body = '<div class="wz-sum">' +
385
- '<div><span class="wz-k">' + esc(t("类型")) + "</span>" + esc((f && f.name) || wiz.type) + "</div>" +
386
- '<div><span class="wz-k">' + esc(t("目标")) + "</span>" + esc(wiz.goal.slice(0, 120)) + "</div>" +
387
- (f && f.engine === "code"
388
- ? '<div><span class="wz-k">' + esc(t("验证命令")) + "</span>" +
389
- esc(wiz.verify || t("留空靠 AI 评审")) + "</div>"
390
- : "") +
391
- '<div><span class="wz-k">' + esc(t("工作目录")) + "</span>" +
392
- esc(wiz.workdir || t("默认保存路径")) + "</div></div>" +
393
- '<p class="hint">' + esc(t("填入表单后可继续:加附件、选实现者、改评审设置")) + "</p>";
394
- foot = '<button class="ghost" onclick="wizardBack()">' + esc(t("上一步")) + "</button>" +
395
- '<button class="primary" onclick="wizardApply()">' + esc(t("填入表单")) + "</button>";
396
- }
397
- openModal(t("新建任务向导") + " · " + (wiz.page + 1) + "/" + wiz.pages.length, body, foot);
398
- const first = $("wz-goal") || $("wz-verify") || $("wz-workdir");
399
- if (first) first.focus();
400
- }
401
-
402
- window.wizardApply = function () {
403
- if (!wiz) return;
404
- closeModal();
405
- const flow = flowById(wiz.type);
406
- if (flow && $("f-type").value !== wiz.type) { $("f-type").value = wiz.type; onTypeChange(); }
407
- $("f-goal").value = wiz.goal;
408
- if (flow && flow.engine === "code") $("f-verify").value = wiz.verify || "";
409
- if (wiz.workdir) $("f-workdir").value = wiz.workdir; // 只预填本次表单,不再持久化为「下次默认」
410
- S.atts = []; renderAttachChips(); // 新目标不带旧附件
411
- queueGitProbe(); // 工作目录可能变了,重新探测代码版本
412
- $("f-goal").focus();
413
- toast(t("已按向导预填,检查或补充后点「创建并运行」"));
414
- wiz = null;
415
- };
416
-
417
342
  /* 访问令牌门(仅远程设备会碰到) */
418
343
  function showTokenGate(err) {
419
344
  const g = $("token-gate");
@@ -1040,13 +965,16 @@ function renderProvDetail() {
1040
965
  html += '<details class="pd-config"><summary>' + t("编辑供应商配置(地址 / 协议 / 难度模型)") + '</summary>' +
1041
966
  providerCard(p) + "</details>";
1042
967
  if (p.models == null) {
1043
- html += '<div class="empty">' + t("尚未获取模型列表——点上方「获取模型列表」。") + '</div>';
968
+ html += '<div class="empty">' + t("尚未获取模型列表——点上方「获取模型列表」。") + '</div>' +
969
+ '<div class="hint">' + t("厂商列表接口调不通?直接手工添加模型名也能用。") + '</div>' +
970
+ pmAddHtml(p);
1044
971
  } else {
1045
972
  const kw = (S.modelFilter || {})[p.id] || "";
1046
973
  html += '<div class="pm-tools">' +
1047
974
  '<input id="pm-search-' + esc(p.id) + '" type="search" placeholder="' + t("按名称过滤模型…") + '" autocomplete="off"' +
1048
975
  ' value="' + esc(kw) + '" oninput="filterModels(\'' + esc(p.id) + '\', this.value)">' +
1049
- '<span class="pm-count" id="pm-count-' + esc(p.id) + '"></span></div>';
976
+ '<span class="pm-count" id="pm-count-' + esc(p.id) + '"></span>' +
977
+ pmAddHtml(p) + '</div>';
1050
978
  html += '<div id="pm-groups" class="pm-groups' + (selN ? " has-sel" : "") + '">' +
1051
979
  provModelGroupsHtml(p) + "</div>";
1052
980
  if (hidden.length) {
@@ -1481,6 +1409,36 @@ async function refreshAllModels() {
1481
1409
  setTimeout(autoRebindSoon, 8000); // 模型列表落盘后推荐源才完整,补绑一次
1482
1410
  }
1483
1411
 
1412
+ /* 手工添加模型:有的厂商列表接口调不通(或只返回部分),直接填模型名也能进列表。
1413
+ * 后端打 manual 标记,之后刷新即使厂商列表里没有它也不丢。 */
1414
+ function pmAddHtml(p) {
1415
+ return '<div class="pm-add">' +
1416
+ '<input id="pm-add-' + esc(p.id) + '" type="text" autocomplete="off" spellcheck="false"' +
1417
+ ' placeholder="' + t("手工添加模型名(列表接口调不通时用),回车添加") + '"' +
1418
+ ' onkeydown="pmAddKey(event, \'' + esc(p.id) + '\')">' +
1419
+ '<button class="ghost small" title="' + esc(t("厂商列表接口调不通时,直接把模型名加进列表")) + '"' +
1420
+ ' onclick="addModelManual(\'' + esc(p.id) + '\')">' +
1421
+ '<svg class="ico" aria-hidden="true"><use href="#i-plus"/></svg>' + t("添加") + '</button></div>';
1422
+ }
1423
+
1424
+ function pmAddKey(ev, pid) {
1425
+ if (ev.key === "Enter") { ev.preventDefault(); addModelManual(pid); }
1426
+ }
1427
+
1428
+ async function addModelManual(pid) {
1429
+ const inp = $("pm-add-" + pid);
1430
+ const name = ((inp && inp.value) || "").trim();
1431
+ if (!name) { toast(t("先填模型名"), true); return; }
1432
+ try {
1433
+ const r = await api("/api/models/add", { method: "POST", body: JSON.stringify({ id: pid, name }) });
1434
+ if (!r.ok) { toast(t("添加失败:") + (r.message || ""), true); return; }
1435
+ toast(t("已添加模型 ") + name);
1436
+ S.modelsSig = null; // 新模型落盘后签名必变,这里主动置空确保立刻重绘
1437
+ poll();
1438
+ setTimeout(autoRebindSoon, 4000); // 模型可用后推荐源更完整,补绑一次
1439
+ } catch (e) { toast(t("添加失败:") + e.message, true); }
1440
+ }
1441
+
1484
1442
  async function refreshProviderModels(id) {
1485
1443
  toast(t("正在获取模型列表…"));
1486
1444
  try {
@@ -1771,6 +1729,20 @@ function renderImplSelects() {
1771
1729
  }
1772
1730
 
1773
1731
  async function createTask() {
1732
+ if (S.creatingTask) return;
1733
+ S.creatingTask = true;
1734
+ const submit = $("btn-create");
1735
+ if (submit) {
1736
+ submit.disabled = true;
1737
+ submit.setAttribute("aria-busy", "true");
1738
+ }
1739
+ const resetSubmit = () => {
1740
+ S.creatingTask = false;
1741
+ if (submit) {
1742
+ submit.disabled = false;
1743
+ submit.removeAttribute("aria-busy");
1744
+ }
1745
+ };
1774
1746
  const msg = $("create-msg");
1775
1747
  msg.className = "msg"; msg.textContent = t("提交中…");
1776
1748
  const payload = {
@@ -1781,6 +1753,13 @@ async function createTask() {
1781
1753
  context: $("f-context").value.trim(),
1782
1754
  workdir: $("f-workdir").value.trim(),
1783
1755
  };
1756
+ if (!payload.goal) {
1757
+ msg.className = "msg err";
1758
+ msg.textContent = t("请先填写目标");
1759
+ $("f-goal").focus();
1760
+ resetSubmit();
1761
+ return;
1762
+ }
1784
1763
  if (!payload.workdir) delete payload.workdir; // 留空 → 服务端用「默认保存路径」(编排设置可改)
1785
1764
  else unhideSideDir(payload.workdir); // 在已移除的目录新建任务 → 自动恢复显示
1786
1765
  if (payload.mode === "manual") payload.implementer = $("f-impl").value;
@@ -1799,7 +1778,8 @@ async function createTask() {
1799
1778
  payload.resume = { agent: resumeAgent, session: sid,
1800
1779
  preview: opt ? opt.textContent : "", project: (hit && hit.project) || "" };
1801
1780
  }
1802
- const _eng = (flowById(payload.type) || {}).engine;
1781
+ const flow = flowById(payload.type) || {};
1782
+ const _eng = flow.engine;
1803
1783
  if (_eng === "code") {
1804
1784
  payload.verify_command = $("f-verify").value.trim();
1805
1785
  } else if (_eng === "direct") {
@@ -1808,14 +1788,22 @@ async function createTask() {
1808
1788
  payload.manuscript = $("f-manuscript").value.trim() || "manuscript.md";
1809
1789
  payload.rounds = parseInt($("f-rounds").value, 10) || 2;
1810
1790
  payload.threshold = parseFloat($("f-threshold").value) || 7.0;
1791
+ payload.best_of = Math.max(1, Math.min(3, parseInt($("f-bestof").value, 10) || 1));
1811
1792
  const rubric = $("f-rubric").value.trim();
1812
1793
  if (rubric) payload.rubric = rubric.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
1813
1794
  const ch = parseInt($("f-chapters").value, 10);
1814
- if (ch >= 2) payload.serial = {
1815
- chapters: ch,
1816
- words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
1817
- variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
1818
- };
1795
+ if (ch >= 2) {
1796
+ payload.serial = {
1797
+ chapters: ch,
1798
+ words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
1799
+ variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
1800
+ };
1801
+ } else if (flow.serial) {
1802
+ // 显式 null 覆盖 serial_novel 的流程默认,空章节就是单稿件。
1803
+ payload.serial = null;
1804
+ }
1805
+ const initialBible = ($("f-bible") || {}).value ? $("f-bible").value.trim() : "";
1806
+ if (initialBible && flow.serial && payload.serial) payload.story_bible = initialBible;
1819
1807
  const critics = Array.from($("f-critics").querySelectorAll("input:checked")).map((i) => i.value);
1820
1808
  if (critics.length) payload.critics = critics;
1821
1809
  }
@@ -1827,9 +1815,12 @@ async function createTask() {
1827
1815
  try { await refreshState(); } catch (e) { /* 刷失败等轮询兜底 */ }
1828
1816
  jumpToRun(r.run_id);
1829
1817
  $("f-goal").value = "";
1818
+ if ($("f-bible")) $("f-bible").value = "";
1830
1819
  S.atts = []; renderAttachChips(); // 附件已移交任务待提交区,清空本地列表
1831
1820
  } catch (e) {
1832
1821
  msg.className = "msg err"; msg.textContent = e.message;
1822
+ } finally {
1823
+ resetSubmit();
1833
1824
  }
1834
1825
  }
1835
1826
 
@@ -1845,14 +1836,25 @@ const ATT_CAP_OFFICE = 24 * 1024 * 1024; // Office 文档放宽到 24MB
1845
1836
  function renderAttachChips() {
1846
1837
  const box = $("att-chips");
1847
1838
  if (!box) return;
1848
- box.innerHTML = (S.atts || []).map((a) =>
1849
- '<span class="att-chip" title="' + esc(a.name) + '">' +
1850
- '<svg class="ico"><use href="#i-paperclip"/></svg>' +
1851
- "<span>" + esc(a.name) + "</span>" +
1852
- " <i>" + fmtSize(a.size) + "</i>" +
1853
- '<button class="ax" title="' + esc(t("移除")) + '" onclick="removeAtt(\'' + a.id + '\')">' +
1854
- '<svg class="ico"><use href="#i-x"/></svg></button></span>'
1855
- ).join("");
1839
+ box.innerHTML = (S.atts || []).map((a) => {
1840
+ const name = String(a.name || "attachment");
1841
+ const id = String(a.id || "");
1842
+ const image = /^image\//i.test(String(a.mime || "")) ||
1843
+ /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name);
1844
+ const remove = '<button type="button" class="ax" data-att-remove="' + esc(id) +
1845
+ '" title="' + esc(t("移除")) + '" aria-label="' + esc(t("移除")) + '">' +
1846
+ '<svg class="ico" aria-hidden="true"><use href="#i-x"></use></svg></button>';
1847
+ if (image && id) {
1848
+ const src = "/api/attachments/" + encodeURIComponent(id) + qsAuth();
1849
+ return '<span class="att-chip att-image" title="' + esc(name) + '">' +
1850
+ '<img src="' + esc(src) + '" alt="' + esc(name) + '" loading="lazy" draggable="false">' +
1851
+ '<span class="att-image-name">' + esc(name) + "</span>" + remove + "</span>";
1852
+ }
1853
+ return '<span class="att-chip" title="' + esc(name) + '">' +
1854
+ '<svg class="ico" aria-hidden="true"><use href="#i-paperclip"></use></svg>' +
1855
+ "<span>" + esc(name) + "</span>" +
1856
+ " <i>" + fmtSize(a.size) + "</i>" + remove + "</span>";
1857
+ }).join("");
1856
1858
  }
1857
1859
 
1858
1860
  function fmtSize(n) {
@@ -1944,7 +1946,9 @@ async function probeGit() {
1944
1946
  const prev = sel.value;
1945
1947
  const opt = (v, label) => '<option value="' + esc(v) + '">' + esc(label) + "</option>";
1946
1948
  const groups = [];
1947
- groups.push(opt("", t("不切换(用当前分支 %1)").replace("%1", info.branch || "HEAD")));
1949
+ // 首选项=跟随当前分支:胶囊上直接显示分支名(参考工作区选择器的「main ▾」),
1950
+ // 悬停胶囊看完整说明(renderGitHint 写 row.title);换分支在「分支」组里选
1951
+ groups.push(opt("", info.branch || "HEAD"));
1948
1952
  groups.push('<optgroup label="' + esc(t("分支")) + '">'
1949
1953
  + (info.branches || []).map((b) => opt("branch:" + b, b)).join("") + "</optgroup>");
1950
1954
  if ((info.tags || []).length) {
@@ -1972,10 +1976,69 @@ function renderGitHint() {
1972
1976
  const act = kind
1973
1977
  ? t(";运行时将从「%1」检出任务分支 codebee/&lt;任务ID&gt;").replace("%1", rev)
1974
1978
  : "";
1975
- $("git-hint").innerHTML = esc(base + dirty + act);
1979
+ const text = base + dirty + act;
1980
+ $("git-hint").innerHTML = esc(text);
1981
+ // 极简:提示行不再常驻,分支胶囊的悬停 title 承载同一份详情(剥掉 innerHTML 用的实体)
1982
+ const row = $("row-git");
1983
+ if (row) row.title = text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
1976
1984
  }
1977
1985
  window.probeGit = probeGit;
1978
1986
 
1987
+ /* ---- 工作目录「最近文件夹」下拉:默认选中一个目录,▾ 展开最近 5 个 + 浏览/用默认 ---- */
1988
+ function wdRecents() {
1989
+ const st = S.state || {};
1990
+ const seen = new Set(); const out = [];
1991
+ for (const tk of (st.tasks || []).concat(st.archived_tasks || [])) {
1992
+ const wd = (tk.workdir || "").trim();
1993
+ if (!wd || seen.has(wd)) continue;
1994
+ seen.add(wd); out.push(wd);
1995
+ if (out.length >= 5) break;
1996
+ }
1997
+ return out;
1998
+ }
1999
+ window.toggleWdMenu = function (ev) {
2000
+ if (ev) ev.stopPropagation();
2001
+ const menu = $("wd-menu");
2002
+ if (!menu) return;
2003
+ if (!menu.classList.contains("hidden")) { menu.classList.add("hidden"); return; }
2004
+ const cur = (($("f-workdir") || {}).value || "").trim();
2005
+ const eff = ((S.settings || {}).default_workdir_effective || "").trim();
2006
+ const base = (p) => p.split(/[\\/]/).filter(Boolean).pop() || p;
2007
+ const row = (p, label, on) =>
2008
+ '<button type="button" class="wd-item' + (on ? " on" : "") + '" data-wd="' + esc(p) +
2009
+ '" title="' + esc(p) + '">' +
2010
+ '<svg class="ico" aria-hidden="true"><use href="#i-folder"></use></svg>' +
2011
+ "<span>" + esc(label) + "</span>" +
2012
+ (on ? '<svg class="ico wd-check" aria-hidden="true"><use href="#i-check"></use></svg>' : "") +
2013
+ "</button>";
2014
+ let html = wdRecents().map((p) => row(p, base(p), p === cur)).join("");
2015
+ html += '<div class="wd-sep"></div>' +
2016
+ '<button type="button" class="wd-item" data-wd-act="browse">' +
2017
+ '<svg class="ico" aria-hidden="true"><use href="#i-folder"></use></svg>' +
2018
+ "<span>" + esc(t("浏览本机目录…")) + "</span></button>" +
2019
+ '<button type="button" class="wd-item" data-wd-act="default"' + (eff ? "" : " disabled") + ">" +
2020
+ '<svg class="ico" aria-hidden="true"><use href="#i-refresh"></use></svg>' +
2021
+ "<span>" + esc(t("用默认路径")) + "</span></button>";
2022
+ menu.innerHTML = html;
2023
+ menu.classList.remove("hidden");
2024
+ };
2025
+ window.wdMenuPick = function (item) {
2026
+ const inp = $("f-workdir"), menu = $("wd-menu");
2027
+ if (!item || !inp || item.disabled) return;
2028
+ if (item.dataset.wdAct === "browse") {
2029
+ if (menu) menu.classList.add("hidden");
2030
+ window.pickFolder("f-workdir");
2031
+ return;
2032
+ }
2033
+ inp.value = item.dataset.wdAct === "default"
2034
+ ? ((S.settings || {}).default_workdir_effective || "")
2035
+ : (item.dataset.wd || "");
2036
+ inp.dispatchEvent(new Event("input", { bubbles: true })); // queueGitProbe 跟上换目录
2037
+ inp.dispatchEvent(new Event("change", { bubbles: true }));
2038
+ if (menu) menu.classList.add("hidden");
2039
+ inp.focus();
2040
+ };
2041
+
1979
2042
  /* ---------------------------------------------------------- 会话延续 */
1980
2043
  /* 后端支持会话扫描的工具集合(/api/sessions 的键);决定继续会话下拉出现哪些工具 */
1981
2044
  async function refreshSessionAgents() {
@@ -2106,6 +2169,45 @@ function closeCtxMenu() {
2106
2169
  if (menu && !menu.classList.contains("hidden")) menu.classList.add("hidden");
2107
2170
  }
2108
2171
 
2172
+ /* 任务行的「⋯」与右键共用同一组动作,避免两个入口越改越不一样。
2173
+ * det 是 .stask 元素;菜单位置由调用方提供,缺省时贴在行右下角。 */
2174
+ function taskContextItems(det) {
2175
+ const taskId = det && det.dataset ? (det.dataset.task || "") : "";
2176
+ const runId = det && det.dataset ? (det.dataset.run || "") : "";
2177
+ const items = [];
2178
+ if (runId) items.push({ label: t("打开详情"), fn: () => sideOpenRun(runId) });
2179
+ if (taskId) {
2180
+ items.push("-");
2181
+ items.push({ label: t("打开工作目录"), fn: () => revealPath("tasks", taskId, true) });
2182
+ items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2183
+ if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2184
+ const st = det.dataset.status || "";
2185
+ if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2186
+ items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2187
+ fn: () => newFromTask(taskId) });
2188
+ const sTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
2189
+ if (sTask && sTask.serial && st !== "running" && st !== "queued")
2190
+ items.push({ label: t("继续连载(新任务)"), fn: () => continueSerial(taskId) });
2191
+ items.push({ label: t("重命名任务"), fn: () => renameTask(taskId) });
2192
+ const archived = archivedTaskIds().has(taskId);
2193
+ items.push({ label: archived ? t("取消归档") : t("归档"), fn: () => archiveTask(taskId, !archived) });
2194
+ items.push({ label: t("删除任务"), danger: true, fn: () => deleteTask(taskId) });
2195
+ } else if (runId) {
2196
+ items.push("-");
2197
+ items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2198
+ items.push({ label: t("删除记录"), danger: true, fn: () => deleteRun(runId) });
2199
+ }
2200
+ return items;
2201
+ }
2202
+
2203
+ window.openTaskActions = function (det, ev) {
2204
+ if (!det) return;
2205
+ const r = det.getBoundingClientRect();
2206
+ const x = ev && Number.isFinite(ev.clientX) ? ev.clientX : r.right - 8;
2207
+ const y = ev && Number.isFinite(ev.clientY) ? ev.clientY : r.bottom;
2208
+ openCtxMenu(x, y, taskContextItems(det));
2209
+ };
2210
+
2109
2211
  function bindCtxMenus() {
2110
2212
  // 侧栏任务树:右键任务 → 详情/目录/重试/重命名/归档/删除;管理类运行 → 详情/目录/删除记录
2111
2213
  $("side-tasks").addEventListener("contextmenu", (e) => {
@@ -2113,31 +2215,7 @@ function bindCtxMenus() {
2113
2215
  const det = e.target.closest(".stask");
2114
2216
  if (det) {
2115
2217
  e.preventDefault();
2116
- const taskId = det.dataset.task || "", runId = det.dataset.run || "";
2117
- const items = [];
2118
- if (runId) items.push({ label: t("打开详情"), fn: () => sideOpenRun(runId) });
2119
- if (taskId) {
2120
- items.push("-");
2121
- items.push({ label: t("打开工作目录"), fn: () => revealPath("tasks", taskId, true) });
2122
- items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2123
- if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2124
- const st = det.dataset.status || "";
2125
- if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2126
- // 与详情页同一套说法:失败/取消叫「编辑重试」,其余叫「基于此任务新建」
2127
- items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2128
- fn: () => newFromTask(taskId) });
2129
- const sTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
2130
- if (sTask && sTask.serial && st !== "running" && st !== "queued")
2131
- items.push({ label: t("继续连载(新任务)"), fn: () => continueSerial(taskId) });
2132
- items.push({ label: t("重命名任务"), fn: () => renameTask(taskId) });
2133
- items.push({ label: archivedTaskIds().has(taskId) ? t("取消归档") : t("归档"), fn: () => archiveTask(taskId, !archivedTaskIds().has(taskId)) });
2134
- items.push({ label: t("删除任务"), danger: true, fn: () => deleteTask(taskId) });
2135
- } else if (runId) {
2136
- items.push("-");
2137
- items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2138
- items.push({ label: t("删除记录"), danger: true, fn: () => deleteRun(runId) });
2139
- }
2140
- openCtxMenu(e.clientX, e.clientY, items);
2218
+ openCtxMenu(e.clientX, e.clientY, taskContextItems(det));
2141
2219
  return;
2142
2220
  }
2143
2221
  // 文件夹行(ZCode 桌面端式样):新建任务到该目录 / 打开工作目录 / 复制路径 / 移除。
@@ -2174,10 +2252,11 @@ function bindCtxMenus() {
2174
2252
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeCtxMenu(); });
2175
2253
  }
2176
2254
 
2177
- /* ------------------------------------------------- 文件夹选择弹框(工作目录「选择…」/点输入框)
2178
- * 服务端目录浏览(/api/browse 仅本机;浏览器安全模型拿不到本机绝对路径,
2179
- * 原生目录选择器只回相对路径,没法用)。进入=点行,选定=行内「选这个」或
2180
- * 底部「使用当前目录」。远端设备 browse 被 403 拒:点输入框不再弹框,继续手输。 */
2255
+ /* ------------------------------------------------- 文件夹选择(工作目录「选择…」/点输入框)
2256
+ * 首选系统原生对话框:服务端 tkinter 子进程弹真窗口(/api/pick_folder,仅本机),
2257
+ * 前端只收回填路径。机器没有 tkinter(fallback=true)时回落网页目录弹框:
2258
+ * /api/browse 浏览(服务端只给目录名、前端拼绝对路径),选定=行内「选这个」或
2259
+ * 底部「使用当前目录」。远端设备两条通道都被 403 拒:点输入框不再弹框,继续手输。 */
2181
2260
  const pickerSt = { target: "", cwd: "", parent: "", local: null }; // local: null=未知 true=本机 false=远端
2182
2261
 
2183
2262
  function pickerRender(r) {
@@ -2225,19 +2304,35 @@ async function pickerBrowse(p) {
2225
2304
  return true;
2226
2305
  }
2227
2306
 
2307
+ /* 网页目录弹框(原生对话框不可用时的回落通道) */
2308
+ async function webPickFolder(targetId, cur) {
2309
+ openModal(t("选择文件夹"), '<div id="pk-body" class="hint">' + esc(t("正在读取目录…")) + "</div>",
2310
+ '<button class="ghost" onclick="closeModal();pkRefocus()">' + esc(t("取消")) + "</button>" +
2311
+ '<button class="primary" onclick="pickUseP(pickerCwd())">' + esc(t("使用当前目录")) + "</button>");
2312
+ await pickerBrowse(cur || "__drives__");
2313
+ }
2314
+
2228
2315
  window.pickFolder = async function (targetId, fromClick) {
2229
2316
  if (fromClick && pickerSt.local === false) return; // 远端已确认被拒:点击不打扰
2230
2317
  pickerSt.target = targetId;
2231
2318
  const cur = (($(targetId) || {}).value || "").trim();
2232
- openModal(t("选择文件夹"), '<div id="pk-body" class="hint">' + esc(t("正在读取目录…")) + "</div>",
2233
- '<button class="ghost" onclick="closeModal();pkRefocus()">' + esc(t("取消")) + "</button>" +
2234
- '<button class="primary" onclick="pickUseP(pickerCwd())">' + esc(t("使用当前目录")) + "</button>");
2235
- const ok = await pickerBrowse(cur || "__drives__");
2236
- // 点输入框进来的:远端被拒就收框并提示,别让每次点击都吃一个错误弹窗
2237
- if (!ok && fromClick && pickerSt.local === false) {
2238
- closeModal();
2239
- toast(t("目录选择仅限本机使用,请手动输入路径"), true);
2319
+ let r = null;
2320
+ try {
2321
+ r = await api("/api/pick_folder", { method: "POST",
2322
+ body: JSON.stringify({ initial: cur, title: t("选择文件夹") }) });
2323
+ } catch (e) {
2324
+ if (/仅限本机/.test(e.message || "")) {
2325
+ pickerSt.local = false;
2326
+ if (fromClick) toast(t("目录选择仅限本机使用,请手动输入路径"), true);
2327
+ return;
2328
+ }
2329
+ toast(t("操作失败:") + (e.message || e), true);
2330
+ return;
2240
2331
  }
2332
+ if (r.busy) { toast(t("已有一个选择窗口正在等待"), true); return; }
2333
+ if (r.fallback) { await webPickFolder(targetId, cur); return; }
2334
+ if (r.path) pickUseP(r.path);
2335
+ else window.pkRefocus(); // 用户取消:焦点还给输入框,方便手输
2241
2336
  };
2242
2337
  window.pickEnterP = (p) => pickerBrowse(p);
2243
2338
  window.pickParentP = () => pickerBrowse(pickerSt.parent || "__drives__");
@@ -2351,6 +2446,7 @@ function newFromTask(id) {
2351
2446
  $("f-manuscript").value = tk.manuscript || "manuscript.md";
2352
2447
  $("f-rounds").value = tk.rounds || 2;
2353
2448
  $("f-threshold").value = (tk.threshold != null ? tk.threshold : 7.0);
2449
+ $("f-bestof").value = tk.best_of || 1;
2354
2450
  $("f-rubric").value = (tk.rubric || []).join(", ");
2355
2451
  // 连载参数只带批次设置:不带 start_chapter/continues(那是「继续连载」的衔接语义)
2356
2452
  $("f-chapters").value = tk.serial ? tk.serial.chapters : "";
@@ -2812,7 +2908,9 @@ function renderSideTasks() {
2812
2908
  '<span class="t">' + esc(g.title) + "</span>" +
2813
2909
  (g.archived ? '<span class="sbadge archb">' + t("已归档") + "</span>" : "") +
2814
2910
  (g.verdict ? '<span class="sbadge">' + t("待裁决") + "</span>" : "") +
2815
- '<span class="tm">' + esc(relTime(g.time)) + "</span></div>";
2911
+ '<span class="tm">' + esc(relTime(g.time)) + "</span>" +
2912
+ '<button class="stask-more" type="button" aria-haspopup="menu" aria-label="' + esc(t("任务操作")) + '" title="' + esc(t("任务操作")) +
2913
+ '" onclick="event.stopPropagation();openTaskActions(this.closest(\'.stask\'), event)">⋯</button></div>';
2816
2914
  };
2817
2915
  // 一级文件夹行:折叠箭头 + 目录图标 + 末段名(hover 显全路径)+ 任务计数徽标
2818
2916
  box.innerHTML = dirs.map((d) => {
@@ -2867,7 +2965,8 @@ function jumpToRun(id) {
2867
2965
  * 聚合该任务全部 run 的步骤;不允许任何行点了没反应。
2868
2966
  * 无主运行行(任务已删/管理运行,data-task 为空)→ 直开它自己的运行详情;
2869
2967
  * 已归档任务 → toast 指路(归档行先取消归档);
2870
- * 没跑过/排队中 → toast 说明(没有可展示的运行内容)。 */
2968
+ * 从未跑过 → toast 说明;跑过但最新 run 排队中(自动续跑退避窗口)→ 照常开
2969
+ * 任务详情,能回看历史轮次的步骤与日志。 */
2871
2970
  function sideRowActivate(det) {
2872
2971
  const taskId = det.dataset.task || "", runId = det.dataset.run || "";
2873
2972
  if (!taskId) {
@@ -2880,7 +2979,10 @@ function sideRowActivate(det) {
2880
2979
  toast(arch ? t("已归档任务:先在右键菜单里取消归档,再看任务详情") : t("任务不存在或已删除"), true);
2881
2980
  return;
2882
2981
  }
2883
- if (!inspEligible(taskId)) { toast(t("该任务还没跑过(或排队中),还没有任务详情"), true); return; }
2982
+ if (!inspEligible(taskId)) {
2983
+ const lr = ((S.state || {}).task_latest || {})[taskId];
2984
+ if (!lr) { toast(t("该任务还没跑过,还没有任务详情"), true); return; }
2985
+ }
2884
2986
  sideOpenTask(taskId);
2885
2987
  }
2886
2988
 
@@ -2956,28 +3058,42 @@ function drawTaskDetail(key, runs) {
2956
3058
  // 取消收尾把僵尸步骤落成「已取消」时要立即重画,不等条数变化;
2957
3059
  // 作品信息状态入签名:后台一键生成 running→done 要立刻反映到成果区面板
2958
3060
  const bmTask = ((S.state || {}).tasks || []).find((x) => x.id === key);
3061
+ // 自动续跑退避相位入签名:预定入队时刻过了之后 chip 要从「将于 HH:MM」翻回「排队中」
3062
+ const lr0 = runs[0] || {};
3063
+ const resumePending = (lr0.resume_enqueue_at &&
3064
+ Date.now() < Date.parse(String(lr0.resume_enqueue_at).replace(" ", "T"))) ? 1 : 0;
2959
3065
  const sig = JSON.stringify(runs.map((r) => [r.id, r.status, (r.steps || []).length,
2960
3066
  (r.steps || []).map((s) => s.status).join(""),
2961
3067
  (r.messages || []).length, (r.messages || []).filter((m) => !m.consumed).length])
2962
- .concat([JSON.stringify((bmTask || {}).book_meta || null)]));
3068
+ .concat([JSON.stringify((bmTask || {}).book_meta || null), resumePending]));
2963
3069
  if (sig === S.taskSig) return;
2964
3070
  S.taskSig = sig;
2965
3071
  const latest = runs[0]; // runs 新→旧
2966
- const chrono = runs.slice().reverse(); // 展示按时间正序
3072
+ const ordered = runs.slice(); // 详情按最近运行优先,方便排查
2967
3073
  const totalSteps = runs.reduce((a, r) => a + (r.steps || []).length, 0);
2968
3074
  const active = runs.some((r) => r.status === "running" || r.status === "queued");
2969
- const st = active ? "running" : latest.status;
3075
+ // 活跃态细分真实状态:排队里还分「等并发」和「自动续跑退避(预定 HH:MM 入队)」,
3076
+ // 后者在 chip 上写明下一轮何时起跑,别让 5 分钟退避窗口看起来像卡死(Z.ai 误伤案)
3077
+ const activeRun0 = runs.find((r) => r.status === "running" || r.status === "queued");
3078
+ const st = activeRun0 ? activeRun0.status : latest.status;
3079
+ const resumeIn = (st === "queued" && resumePending)
3080
+ ? String(latest.resume_enqueue_at).slice(11, 16) : "";
2970
3081
  $("rd-title").textContent = latest.title || key;
2971
3082
  const chip = $("rd-status");
2972
3083
  chip.className = "chip " + st;
2973
- chip.textContent = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st;
3084
+ chip.textContent = resumeIn
3085
+ ? t("将于 ") + resumeIn + t(" 自动续跑(第 ") + (Number(latest.auto_resumes) || 0) + t(" 次)")
3086
+ : ({ queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st);
2974
3087
  const bpt = $("btn-pause");
2975
3088
  if (bpt) bpt.classList.add("hidden");
2976
3089
  $("btn-delete").classList.add("hidden");
2977
3090
  $("btn-retry").classList.toggle("hidden", !(latest.task_id && (latest.status === "failed" || latest.status === "cancelled")));
3091
+ $("btn-talk").classList.toggle("hidden", !(latest.task_id && !chatEngineIsDirect(latest)));
2978
3092
  setEditRetry(latest.task_id, latest.status,
2979
3093
  !!((S.state || {}).tasks || []).some((x) => x.id === latest.task_id));
2980
3094
  S.lastRun = latest;
3095
+ renderRunOutcome(latest, runs);
3096
+ renderDetailOverview(latest, runs, bmTask);
2981
3097
  // 指挥区指向最新的活跃运行(无则隐藏整个指挥区,避免终态任务误导用户以为指令还能生效)
2982
3098
  const activeRun = runs.find((r) => r.status === "queued" || r.status === "running");
2983
3099
  // 活跃运行(含排队中)显示取消按钮;取消目标钉在活跃 run——任务级详情没有
@@ -3002,20 +3118,23 @@ function drawTaskDetail(key, runs) {
3002
3118
  $("rd-plan").classList.add("hidden");
3003
3119
  const statusTxt = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") };
3004
3120
  let html = "";
3005
- chrono.forEach((r, i) => {
3006
- html += '<div class="run-sep"><span class="rs-i">' + t("第 ") + (i + 1) + "/" + chrono.length + t(" 次运行") + '</span>' +
3121
+ ordered.forEach((r, i) => {
3122
+ const runNo = runs.length - i;
3123
+ html += '<div class="run-sep"><span class="rs-i">' + t("第 ") + runNo + "/" + runs.length + t(" 次运行") + '</span>' +
3007
3124
  '<span class="rs-t">' + esc(String(r.created_at || "").slice(5, 16)) + "</span>" +
3008
3125
  '<span class="chip ' + esc(r.status || "") + '">' + (statusTxt[r.status] || r.status || "") + "</span>" +
3009
3126
  (r.error ? '<span class="rs-err" title="' + esc(r.error.slice(0, 200)) + '">' + esc(r.error.slice(0, 60)) + "</span>" : "") +
3010
3127
  "</div>";
3011
- html += (r.steps || []).map((s) =>
3012
- '<div class="step" onclick="toggleLog(\'' + esc(r.id) + "', '" + esc(s.log) + '\')" title="' + esc(s.note || "") + '">' +
3128
+ html += (r.steps || []).slice().reverse().map((s) =>
3129
+ '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(r.id) + '" data-log="' + esc(s.log || "") + '" title="' + esc(s.note || "") + '">' +
3013
3130
  '<span class="n">' + String(s.n).padStart(2, "0") + "</span>" +
3014
3131
  '<span class="role">' + esc(s.role) + "</span>" +
3015
3132
  '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3016
3133
  '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
3017
3134
  '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3018
- statusChip(s.status) + "</div>"
3135
+ statusChip(s.status) +
3136
+ (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>" : "") +
3137
+ "</div>"
3019
3138
  ).join("");
3020
3139
  });
3021
3140
  $("rd-steps").innerHTML = html || '<div class="empty">' + t("尚无步骤") + '</div>';
@@ -3158,7 +3277,7 @@ function applyStepFocus() {
3158
3277
  el.classList.add("flash");
3159
3278
  try { el.scrollIntoView({ block: "center", behavior: "smooth" }); }
3160
3279
  catch (e) { el.scrollIntoView(); }
3161
- if (el.dataset.log) toggleLog(S.detailRunId, el.dataset.log);
3280
+ if (el.dataset.log) toggleLog(el.dataset.runId || S.detailRunId, el.dataset.log);
3162
3281
  setTimeout(() => el.classList.remove("flash"), 1800);
3163
3282
  }
3164
3283
 
@@ -3192,6 +3311,18 @@ function applyRdTabs() {
3192
3311
  });
3193
3312
  document.querySelectorAll("#run-detail .rd-pane").forEach((p) =>
3194
3313
  p.classList.toggle("hidden", p.dataset.pane !== S.rdTab));
3314
+ document.querySelectorAll("#rd-tabs .rd-tab").forEach((b) => {
3315
+ const active = b.dataset.tab === S.rdTab && !b.classList.contains("hidden");
3316
+ b.setAttribute("aria-selected", active ? "true" : "false");
3317
+ b.tabIndex = active ? 0 : -1;
3318
+ });
3319
+ document.querySelectorAll("#run-detail .rd-pane").forEach((p) =>
3320
+ p.setAttribute("aria-hidden", p.dataset.pane === S.rdTab ? "false" : "true"));
3321
+ const focusedTab = document.activeElement;
3322
+ if (focusedTab && focusedTab.matches && focusedTab.matches("#rd-tabs .rd-tab.hidden")) {
3323
+ const nextTab = document.querySelector('#rd-tabs .rd-tab[data-tab="' + S.rdTab + '"]:not(.hidden)');
3324
+ if (nextTab) nextTab.focus({ preventScroll: true });
3325
+ }
3195
3326
  // 对话页签不放日志抽屉:会盖住贴底输入条(手动切来/自动选卡都覆盖)
3196
3327
  if (S.rdTab === "chat" && !$("rd-log").classList.contains("hidden")) window.rdLogClose();
3197
3328
  renderChatNav();
@@ -3269,11 +3400,14 @@ function rdTabsSync(ctx) {
3269
3400
  if (S.rdTabSig !== sig) {
3270
3401
  S.rdTabSig = sig;
3271
3402
  const avail = rdTabAvail();
3272
- // 直连任务:对话就是全部内容,默认落「对话」;其余流程保持既有落位。
3273
- // 没跑到终态时成果分区是空的,落进去只见「运行结束后生成」白板——
3274
- // 运行中先落蜂巢/步骤,收尾后自动选卡会再切到成果
3403
+ const direct = chatAvail && chatEngineIsDirect(S.lastRun);
3404
+ // 直连任务的主问题是「继续聊什么、上一轮回答是什么」——默认把对话
3405
+ // 放在第一视线;编排/代码任务才默认落蜂巢,先看阶段与在岗步骤。
3406
+ // 没跑到终态时成果分区是空的,避免打开详情先看到白板。
3275
3407
  const finishing = !ctx.running && ["done", "failed", "cancelled", "timeout"].includes(ctx.status);
3276
- S.rdTab = (avail.chat ? "chat" : null)
3408
+ S.rdTab = (direct && avail.chat ? "chat" : null)
3409
+ || (avail.hive ? "hive" : null)
3410
+ || (avail.chat ? "chat" : null)
3277
3411
  || (ctx.running ? (avail.hive ? "hive" : "steps")
3278
3412
  : (ctx.gitState === "isolated" && avail.git ? "git"
3279
3413
  : (ctx.hasResult && finishing ? "result" : "steps")));
@@ -3283,12 +3417,26 @@ function rdTabsSync(ctx) {
3283
3417
  rdTabBadges();
3284
3418
  }
3285
3419
 
3420
+ /* 详情头部的按需信息面板:统计和任务概览不再常驻挤占蜂巢/对话空间。 */
3421
+ function setRdMetaOpen(open) {
3422
+ const detail = $("run-detail");
3423
+ const pop = $("rd-meta-popover");
3424
+ const toggle = $("rd-more-toggle");
3425
+ if (!pop || !toggle) return;
3426
+ const on = !!open;
3427
+ pop.classList.toggle("hidden", !on);
3428
+ if (detail) detail.classList.toggle("rd-info-open", on);
3429
+ toggle.setAttribute("aria-expanded", on ? "true" : "false");
3430
+ }
3431
+
3286
3432
  /* 换一个详情目标时清空选卡状态:下一次渲染按新目标自动落位 */
3287
3433
  function rdTabReset(pin) {
3288
3434
  S.rdTab = pin || null;
3289
3435
  S.rdTabSig = "";
3290
3436
  S.rdTabPin = !!pin;
3291
3437
  S._rdCtx = {};
3438
+ syncChatLogSpace(false);
3439
+ setRdMetaOpen(false);
3292
3440
  }
3293
3441
 
3294
3442
  async function renderRunDetail() {
@@ -3309,11 +3457,14 @@ async function renderRunDetail() {
3309
3457
  S.cancelTargetRunId = active ? run.id : null;
3310
3458
  $("btn-delete").classList.toggle("hidden", active);
3311
3459
  $("btn-retry").classList.toggle("hidden", !(run.task_id && (run.status === "failed" || run.status === "cancelled")));
3460
+ $("btn-talk").classList.toggle("hidden", !(run.task_id && !chatEngineIsDirect(run)));
3312
3461
  const rcTask = ((S.state || {}).tasks || []).find((x) => x.id === run.task_id);
3313
3462
  $("btn-continue").classList.toggle("hidden",
3314
3463
  !(rcTask && rcTask.serial && run.status !== "running" && run.status !== "queued"));
3315
3464
  setEditRetry(run.task_id, run.status, !!rcTask);
3316
3465
  S.lastRun = run;
3466
+ renderRunOutcome(run);
3467
+ renderDetailOverview(run, [run], rcTask);
3317
3468
  const bp = $("btn-pause");
3318
3469
  if (bp) { bp.classList.toggle("hidden", !active);
3319
3470
  bp.textContent = run.paused ? t("继续执行") : t("暂停"); }
@@ -3329,15 +3480,19 @@ async function renderRunDetail() {
3329
3480
  '<span class="stat tasksum hidden" id="rd-meta-task"></span>';
3330
3481
  if (S.detailSide) fillMetaTask(S.detailSide.stats || {}); // 缓存命中:轮询重画不闪丢累计组
3331
3482
  renderPlan(run);
3332
- $("rd-steps").innerHTML = (run.steps || []).map((s) =>
3333
- '<div class="step" data-n="' + Number(s.n) + '" data-log="' + esc(s.log || "") +
3334
- '" onclick="toggleLog(\'' + esc(run.id) + "', '" + esc(s.log) + '\')" title="' + esc(s.note || "") + '">' +
3483
+ // 详情页优先展示最近一步,排查运行中的任务时无需滚到底部;
3484
+ // 蜂巢泳道仍按原始流程顺序呈现,避免破坏阶段语义。
3485
+ $("rd-steps").innerHTML = (run.steps || []).slice().reverse().map((s) =>
3486
+ '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(run.id) + '" data-log="' + esc(s.log || "") +
3487
+ '" title="' + esc(s.note || "") + '">' +
3335
3488
  '<span class="n">' + String(s.n).padStart(2, "0") + "</span>" +
3336
3489
  '<span class="role">' + esc(s.role) + "</span>" +
3337
3490
  '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3338
3491
  '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
3339
3492
  '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3340
- statusChip(s.status) + "</div>"
3493
+ statusChip(s.status) +
3494
+ (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>" : "") +
3495
+ "</div>"
3341
3496
  ).join("") || '<div class="empty">' + t("尚无步骤") + '</div>';
3342
3497
  applyStepFocus();
3343
3498
  // 报告
@@ -3816,6 +3971,8 @@ function renderGitWbMain(d, task) {
3816
3971
  '<button class="ghost"' + (noRemote || ro ? " disabled" : "") + ' onclick="gitWbFetch()">' + t("抓取") + "</button>" +
3817
3972
  '<button class="ghost"' + (noRemote || ro ? " disabled" : "") + ' onclick="gitWbPull()">' + t("拉取") + "</button>" +
3818
3973
  '<button class="ghost"' + (noRemote || ro ? " disabled" : "") + ' onclick="gitWbPush()">' + t("推送") + "</button>" +
3974
+ '<button class="ghost"' + (noRemote || ro || d.detached ? " disabled" : "") + ' onclick="gitWbPrCreate()" title="' +
3975
+ esc(t("推送当前分支并用 gh 创建 PR(目标分支自动取 main/master)")) + '">' + t("建 PR") + "</button>" +
3819
3976
  '<button class="ghost"' + (!hasChg || ro ? " disabled" : "") + ' onclick="gitWbStageAll()">' + t("全部暂存") + "</button>" +
3820
3977
  '<button class="ghost"' + (!hasChg || ro ? " disabled" : "") + ' onclick="gitWbStash()" title="' +
3821
3978
  esc(t("把未提交改动收进 stash(不含 _attachments)")) + '">' + t("收起改动") + "</button>" +
@@ -3877,6 +4034,7 @@ function renderGitWbMain(d, task) {
3877
4034
  window.gitWbDiff = async function (path) {
3878
4035
  const key = detailSideTaskKey();
3879
4036
  if (!key || !path) return;
4037
+ _fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; _fpCopyText = null; // 复用单例弹窗,别带上一个文件的编辑态
3880
4038
  _fpOpen(String(path).split("/").pop(), "",
3881
4039
  '<button class="ghost" onclick="copyFPText()">' + esc(t("复制")) + "</button>");
3882
4040
  let d;
@@ -3884,6 +4042,7 @@ window.gitWbDiff = async function (path) {
3884
4042
  catch (e) { _fpSetBody('<div class="fp-hint">' + esc(t("diff 读取失败:") + e.message) + "</div>"); return; }
3885
4043
  if (!filePopIsOpen()) return; // 读取期间被 Esc 关掉:别把内容又糊上去
3886
4044
  const lines = String(d.diff || "").split("\n");
4045
+ _fpCopyText = (d.diff && lines.length) ? lines.join("\n") : ""; // 复制给 diff 原文,行号槽不进剪贴板
3887
4046
  _fpSetBody((d.diff && lines.length)
3888
4047
  ? '<div class="fp-diff">' + lines.map((ln, i) => _fpDiffLine(ln, i + 1)).join("") + "</div>"
3889
4048
  : '<div class="fp-hint">' + esc(t("该文件当前没有未提交的变更。")) + "</div>");
@@ -3908,6 +4067,13 @@ window.gitWbStageAll = function () { return _gitWbPost("stage_all", null, t("已
3908
4067
  window.gitWbFetch = function () { return _gitWbPost("fetch", null, t("已抓取远程更新")); };
3909
4068
  window.gitWbPull = function () { return _gitWbPost("pull", null, t("已拉取远程更新")); };
3910
4069
  window.gitWbPush = function () { return _gitWbPost("push", null, t("已推送到远程")); };
4070
+ window.gitWbPrCreate = async function () {
4071
+ if (!await uiConfirm(t("把当前分支推送到远程并用 gh 创建 PR?(目标分支自动取 main/master)"), { ok: t("建 PR") })) return null;
4072
+ const res = await _gitWbPost("pr_create", null,
4073
+ (r) => (r && r.url ? t("PR 已创建:") + r.url : t("PR 已创建")));
4074
+ if (res && res.url) window.open(res.url, "_blank", "noopener");
4075
+ return res;
4076
+ };
3911
4077
  window.gitWbCheckout = function (br) {
3912
4078
  if (!br) return;
3913
4079
  return _gitWbPost("checkout", { branch: br }, t("已切换到 ") + br);
@@ -4213,8 +4379,8 @@ function drawInspector() {
4213
4379
  ? arts.map((f) =>
4214
4380
  '<a class="file-chip" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4215
4381
  encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
4216
- esc(f.name + " · " + fmtSize(f.size)) + '" onclick="artPopup(\'' + esc(runId) + "', '" +
4217
- esc(f.name) + "', " + (Number(f.size) || 0) + ');return false">' +
4382
+ esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(runId) +
4383
+ '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4218
4384
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
4219
4385
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>").join("")
4220
4386
  : '<span class="insp-hint">' + esc(t("本次运行没有在工作目录里产出新文件。")) + "</span>";
@@ -4259,8 +4425,10 @@ window.inspDiffFile = async function (path) {
4259
4425
  let _fpUrls = []; // 图片预览的 objectURL,关窗统一回收
4260
4426
  /* 编辑态:_fpRawText=开窗时取到的原文(json 展示会美化,编辑始终用原文);
4261
4427
  * _fpSaveCtx={dir,name,mtime} 非空=该弹窗可编辑(目录文件弹窗专属,成品只读);
4262
- * _fpDirty=编辑框有改动未保存——关窗/退出编辑先确认,防误丢。 */
4263
- let _fpRawText = "", _fpSaveCtx = null, _fpDirty = false;
4428
+ * _fpDirty=编辑框有改动未保存——关窗/退出编辑先确认,防误丢;
4429
+ * _fpCopyText=复制按钮要吐的原文(正文是行号表格,innerText 会连行号列一起带出);
4430
+ * null=非文本内容,回退 innerText。 */
4431
+ let _fpRawText = "", _fpSaveCtx = null, _fpDirty = false, _fpCopyText = null;
4264
4432
 
4265
4433
  function filePopIsOpen() {
4266
4434
  const el = document.getElementById("file-pop");
@@ -4274,7 +4442,7 @@ window.filePopClose = async function () {
4274
4442
  const ok = await uiConfirm(t("有未保存的修改,确定关闭?"));
4275
4443
  if (!ok) return;
4276
4444
  }
4277
- _fpDirty = false; _fpSaveCtx = null; _fpRawText = "";
4445
+ _fpDirty = false; _fpSaveCtx = null; _fpRawText = ""; _fpCopyText = null;
4278
4446
  el.classList.add("hidden");
4279
4447
  el.querySelector(".fp-body").innerHTML = "";
4280
4448
  el.querySelector(".fp-acts").innerHTML = "";
@@ -4321,10 +4489,15 @@ function _fpSetBody(html) {
4321
4489
  el.querySelector(".fp-body").innerHTML = html;
4322
4490
  }
4323
4491
 
4324
- window.copyFPText = function () {
4492
+ /* 复制源:文本类正文优先给预留原文(不带行号/着色槽),其余回退 innerText */
4493
+ window.fpCopySource = function () {
4325
4494
  const body = document.querySelector("#file-pop .fp-body");
4326
- if (!body) return;
4327
- try { navigator.clipboard.writeText(body.innerText || ""); toast(t("已复制")); } catch (e) { /* 剪贴板不可用则忽略 */ }
4495
+ if (!body) return "";
4496
+ return _fpCopyText !== null ? _fpCopyText : (body.innerText || "");
4497
+ };
4498
+
4499
+ window.copyFPText = function () {
4500
+ try { navigator.clipboard.writeText(window.fpCopySource() || ""); toast(t("已复制")); } catch (e) { /* 剪贴板不可用则忽略 */ }
4328
4501
  };
4329
4502
 
4330
4503
  /* diff 行着色:+/− 上色、@@ 小节、头部落灰;"--- "/"+++ " 先判,避免内容行误染。
@@ -4346,7 +4519,7 @@ function _fpDiffLine(ln, no) {
4346
4519
  window.fileDiffPopup = async function (path, runId) {
4347
4520
  runId = runId || ((S.inspData || {}).run || {}).id || S.detailRunId || (S.lastRun || {}).id || "";
4348
4521
  if (!runId) return;
4349
- _fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; // diff 弹窗只读,别带上一个目录文件的编辑态
4522
+ _fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; _fpCopyText = null; // diff 弹窗只读,别带上一个目录文件的编辑态
4350
4523
  let sub = "";
4351
4524
  for (const fs of [((S.inspData || {}).changes || {}).files, ((S.lastRun || {}).changes || {}).files]) {
4352
4525
  const f = (fs || []).find((x) => x.path === path);
@@ -4358,6 +4531,7 @@ window.fileDiffPopup = async function (path, runId) {
4358
4531
  catch (e) { _fpSetBody('<div class="fp-hint">' + esc(t("diff 读取失败:") + e.message) + "</div>"); return; }
4359
4532
  if (!filePopIsOpen()) return; // 读取期间被 Esc 关掉:别把内容又糊上去
4360
4533
  const lines = _sliceDiff(((d.run || {}).changes || {}).diff || "", path);
4534
+ _fpCopyText = lines.length ? lines.join("\n") : ""; // 复制给切片原文,行号槽(.fp-no)不进剪贴板
4361
4535
  _fpSetBody(lines.length
4362
4536
  ? '<div class="fp-diff">' + lines.map((ln, idx) => _fpDiffLine(ln, idx + 1)).join("") + "</div>"
4363
4537
  : '<div class="fp-hint">' + esc(t("暂无该文件的已保存 diff(运行结束后生成变更快照,或该运行没有保存变更内容)。")) + "</div>");
@@ -4377,7 +4551,7 @@ async function _fpPreviewUrl(url, name, size, opts) {
4377
4551
  opts = opts || {};
4378
4552
  const ext = _fpExt(name);
4379
4553
  _fpSaveCtx = opts.save || null;
4380
- _fpRawText = ""; _fpDirty = false;
4554
+ _fpRawText = ""; _fpDirty = false; _fpCopyText = null;
4381
4555
  _fpOpen(String(name).split("/").pop(), size ? "<i>" + esc(fmtSize(size)) + "</i>" : "",
4382
4556
  '<a class="ghost" href="' + url + '" download="' + esc(String(name).split("/").pop()) + '">' + esc(t("下载")) + "</a>");
4383
4557
  const el = _fpEnsure();
@@ -4398,9 +4572,10 @@ async function _fpPreviewUrl(url, name, size, opts) {
4398
4572
  if (!filePopIsOpen()) return;
4399
4573
  _fpRawText = text;
4400
4574
  if (_fpSaveCtx) _fpSaveCtx.mtime = r.headers.get("X-Tutti-Mtime") || "";
4401
- const codeHtml = ext === "json"
4402
- ? (() => { let pretty = text; try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 坏 json 原样展示 */ } return codeBlockHTML(pretty); })()
4403
- : codeBlockHTML(text);
4575
+ let shown = text;
4576
+ if (ext === "json") { try { shown = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 坏 json 原样展示 */ } }
4577
+ _fpCopyText = shown; // 复制=看到的原文;行号列不进剪贴板(编辑/保存仍用 _fpRawText 原文)
4578
+ const codeHtml = codeBlockHTML(shown);
4404
4579
  const body = '<div class="fp-code">' + '<div class="fp-vp">' + codeHtml + "</div>" +
4405
4580
  (_fpSaveCtx ? '<textarea class="fp-edit hidden" spellcheck="false"></textarea>' : "") + "</div>";
4406
4581
  _fpSetBody(body);
@@ -4466,6 +4641,7 @@ window.fpSaveEdit = async function () {
4466
4641
  dir: _fpSaveCtx.dir, name: _fpSaveCtx.name, content: ta.value, mtime: _fpSaveCtx.mtime,
4467
4642
  }) });
4468
4643
  _fpRawText = ta.value;
4644
+ _fpCopyText = ta.value;
4469
4645
  _fpSaveCtx.mtime = String(d.mtime || "");
4470
4646
  _fpDirty = false;
4471
4647
  // 回到预览层并重画为保存后的内容(同一个框,只换里子)
@@ -4547,6 +4723,7 @@ function bindInspector() {
4547
4723
  });
4548
4724
  $("side-tasks").addEventListener("keydown", (e) => {
4549
4725
  if (e.key !== "Enter" && e.key !== " ") return;
4726
+ if (e.target.closest("button, a, input, select, textarea, summary")) return;
4550
4727
  const det = e.target.closest(".stask");
4551
4728
  if (det) { e.preventDefault(); sideRowActivate(det); }
4552
4729
  });
@@ -4567,14 +4744,13 @@ function artifactsChips(runId, files) {
4567
4744
  return files.map((f) => {
4568
4745
  const isTxt = FP_TXT.has(_fpExt(f.name)); // 文本/代码都有弹窗预览(按代码格式展示)
4569
4746
  return '<span class="fc-row">' +
4570
- '<a class="file-chip" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4747
+ '<a class="file-chip artifact-file-open" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4571
4748
  encodeURIComponent(f.name) + '" target="_blank" rel="noopener" ' +
4572
- 'title="' + esc(f.name + " · " + fmtSize(f.size)) + '" onclick="artPopup(\'' + esc(runId) + "', '" +
4573
- esc(f.name) + "', " + (Number(f.size) || 0) + ');return false">' +
4749
+ '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) + '">' +
4574
4750
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
4575
4751
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>" +
4576
- (isTxt ? '<a class="file-chip prev" title="' + esc(t("查看内容")) +
4577
- '" onclick="artPopup(\'' + esc(runId) + '\', \'' + esc(f.name) + '\', ' + (Number(f.size) || 0) + ')">' +
4752
+ (isTxt ? '<a class="file-chip prev artifact-file-preview" title="' + esc(t("查看内容")) +
4753
+ '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4578
4754
  '<svg class="ico" aria-hidden="true"><use href="#i-book"/></svg>' + t("预览") + "</a>" : "") +
4579
4755
  "</span>";
4580
4756
  }).join("");
@@ -4625,11 +4801,13 @@ window.copyText = function (t) {
4625
4801
  } catch (e) { /* 剪贴板不可用则忽略 */ }
4626
4802
  };
4627
4803
 
4628
- let currentLog = null;
4804
+ let currentLog = null; // { runId, rel }:同一路径在不同轮次也必须能切换
4629
4805
 
4630
4806
  /* 当前打开的日志是否仍属于这批步骤(重画后日志路径消失说明步骤被重建,才收起) */
4631
- function stepsMatch(runs, rel) {
4632
- return (runs || []).some((r) => (r.steps || []).some((s) => s.log === rel));
4807
+ function stepsMatch(runs, current) {
4808
+ if (!current) return false;
4809
+ return (runs || []).some((r) => String(r.id || "") === String(current.runId || "") &&
4810
+ (r.steps || []).some((s) => s.log === current.rel));
4633
4811
  }
4634
4812
 
4635
4813
  function renderPlan(run) {
@@ -4681,8 +4859,17 @@ function rdLogStepLabel(runId, rel) {
4681
4859
  return rel || "";
4682
4860
  }
4683
4861
 
4862
+ /* 直连任务的输入框只在日志抽屉真正打开时才让出底部空间。
4863
+ * 日志和聊天 pane 位于同一个主区,不能靠普通兄弟选择器反向联动,
4864
+ * 所以用详情根节点的状态类做显式同步。 */
4865
+ function syncChatLogSpace(open) {
4866
+ const detail = $("run-detail");
4867
+ if (detail) detail.classList.toggle("chat-log-open", !!open);
4868
+ }
4869
+
4684
4870
  window.rdLogClose = function () {
4685
4871
  const box = $("rd-log");
4872
+ syncChatLogSpace(false);
4686
4873
  if (!box) return;
4687
4874
  box.classList.add("hidden");
4688
4875
  currentLog = null;
@@ -4693,7 +4880,7 @@ window.rdLogClose = function () {
4693
4880
 
4694
4881
  async function toggleLog(runId, rel) {
4695
4882
  const box = $("rd-log"), pre = $("rd-log-text");
4696
- if (currentLog === rel && !box.classList.contains("hidden")) {
4883
+ if (currentLog && currentLog.runId === runId && currentLog.rel === rel && !box.classList.contains("hidden")) {
4697
4884
  window.rdLogClose(); return;
4698
4885
  }
4699
4886
  try {
@@ -4701,12 +4888,15 @@ async function toggleLog(runId, rel) {
4701
4888
  // 拉取期间可能切详情:过期日志不落盘(A 的步骤输出写进 B 详情的抽屉)。
4702
4889
  // 检查器蜂巢/步骤点开日志同款:run 属检查器当前 run 也算在场
4703
4890
  const owned = S.detailRunId === runId ||
4704
- (S.detailTaskKey && S.lastRun && S.lastRun.id === runId) ||
4891
+ // 任务级直连时间线会跨多轮 run 回放;按钮上的 run 可能不是最新一轮,
4892
+ // 只按 latest.id 判断会让旧轮次的「CLI 日志」点击后悄悄失效。
4893
+ (S.detailTaskKey && S.lastRun && S.lastRun.task_id === S.detailTaskKey) ||
4705
4894
  ((S.inspData || {}).run || {}).id === runId;
4706
4895
  if (!owned) return;
4707
4896
  pre.textContent = r.log || t("(等待输出…)");
4708
4897
  box.classList.remove("hidden");
4709
- currentLog = rel;
4898
+ syncChatLogSpace(true);
4899
+ currentLog = { runId, rel };
4710
4900
  const st = $("rd-log-step");
4711
4901
  if (st) st.textContent = rdLogStepLabel(runId, rel);
4712
4902
  pre.scrollTop = pre.scrollHeight; // 打开即看最新输出
@@ -4716,10 +4906,10 @@ async function toggleLog(runId, rel) {
4716
4906
  stopLogLive();
4717
4907
  if (!live) return;
4718
4908
  S.logLive = setInterval(async () => {
4719
- if (!currentLog || currentLog !== rel) return stopLogLive();
4909
+ if (!currentLog || currentLog.runId !== runId || currentLog.rel !== rel) return stopLogLive();
4720
4910
  try {
4721
4911
  const rr = await api("/api/runs/" + encodeURIComponent(runId) + "/log?step=" + encodeURIComponent(rel) + "&pretty=1");
4722
- if (currentLog === rel) {
4912
+ if (currentLog && currentLog.runId === runId && currentLog.rel === rel) {
4723
4913
  // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰
4724
4914
  const stick = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48;
4725
4915
  pre.textContent = rr.log || t("(等待输出…)");
@@ -4733,6 +4923,7 @@ async function toggleLog(runId, rel) {
4733
4923
  } catch (e) { /* 网络抖动保留上一帧 */ }
4734
4924
  }, 2500);
4735
4925
  } catch (e) { pre.textContent = t("日志读取失败: ") + e.message; box.classList.remove("hidden");
4926
+ syncChatLogSpace(true);
4736
4927
  const st = $("rd-log-step"); if (st) st.textContent = rdLogStepLabel(runId, rel);
4737
4928
  logLiveBadge(false); }
4738
4929
  }
@@ -4803,8 +4994,16 @@ window.renderHive = function (run) {
4803
4994
  });
4804
4995
  const running = steps.filter((s) => s.status === "running");
4805
4996
  const sub = $("rd-hive-sub");
4806
- if (sub) sub.textContent = running.length
4807
- ? t("在岗") + " " + running.length + " / " + steps.length : t("全部空闲");
4997
+ if (sub) {
4998
+ const done = steps.filter((s) => ["done", "completed", "success"].includes(String(s.status || "").toLowerCase())).length;
4999
+ const issues = steps.filter((s) => ["failed", "timeout", "cancelled"].includes(String(s.status || "").toLowerCase())).length;
5000
+ sub.innerHTML = '<span class="hive-count hive-count-live"><i class="live-dot"></i>' +
5001
+ esc(t("在岗")) + " " + running.length + '</span>' +
5002
+ '<span class="hive-count hive-count-done">✓ ' + done + '</span>' +
5003
+ (issues ? '<span class="hive-count hive-count-issue">! ' + issues + '</span>' : "") +
5004
+ '<span class="hive-count hive-count-total">' + steps.length + ' ' + esc(t("步骤")) + '</span>' +
5005
+ '<span class="hive-hint">' + esc(t("点击步骤卡片查看 CLI 日志")) + '</span>';
5006
+ }
4808
5007
  const activeIdx = lanes.map((l) => byStage[l].some((s) => s.status === "running")).lastIndexOf(true);
4809
5008
  const cell = (s, laneIdx, cellIdx) => {
4810
5009
  // 五归一:取消/超时的格子不再冒充「完成」——与步骤芯片同三色体系
@@ -4825,10 +5024,11 @@ window.renderHive = function (run) {
4825
5024
  (s.note ? "◆ " + s.note : "")].filter(Boolean).join(" · ")
4826
5025
  + (concl && st !== "running" ? "\n" + t("结论:") + concl : "");
4827
5026
  // --d:入场瀑布逐格延迟(泳道间 110ms + 同泳道逐格 45ms),样式端消费
4828
- return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '"' +
5027
+ return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '" role="button" tabindex="0"' +
5028
+ ' data-hive-run="' + esc(run.id) + '" data-hive-log="' + esc(s.log || "") + '"' +
4829
5029
  ' style="--d:' + (laneIdx * 110 + cellIdx * 45) + 'ms"' +
4830
5030
  ' title="' + esc(title) + '" ' +
4831
- 'onclick="hiveOpenLog(\'' + esc(run.id) + "', '" + esc(s.log || "") + '\')">' +
5031
+ '>' +
4832
5032
  '<div class="hc-head">' +
4833
5033
  '<img class="hc-bee" src="icons/bee.svg" alt="" aria-hidden="true">' +
4834
5034
  '<span class="hc-role">' + esc(s.role || "") + "</span>" +
@@ -4840,6 +5040,7 @@ window.renderHive = function (run) {
4840
5040
  (st === "running" ? '<span class="hc-live"><i class="live-dot"></i>' + t("工作中") + "</span>" : "") +
4841
5041
  (st === "timeout" ? '<span class="hc-dead">⏱ ' + t("超时") + "</span>" : "") +
4842
5042
  (st === "cancelled" ? '<span class="hc-dead">' + t("已取消") + "</span>" : "") +
5043
+ (s.log ? '<span class="hc-log-link">' + esc(t("CLI 日志")) + "</span>" : "") +
4843
5044
  "</div></div>";
4844
5045
  };
4845
5046
  const dots = (list, laneIdx) => list.slice(-12).map((s, di) => {
@@ -4859,12 +5060,14 @@ window.renderHive = function (run) {
4859
5060
  : (activeIdx === -1 || i < activeIdx ? "lane-done" : "lane-idle");
4860
5061
  // 泳道头视觉 v2:阶段序号徽章(01/02…流水线站序)+ 已完成进度(settled/total)
4861
5062
  const settled = list.filter((s) => !["running", "queued"].includes(s.status)).length;
5063
+ const meter = list.length ? Math.round((settled / list.length) * 100) : 0;
4862
5064
  return '<div class="hive-lane ' + cls + '">' +
4863
5065
  '<div class="lane-head">' +
4864
5066
  '<span class="lane-idx">' + String(i + 1).padStart(2, "0") + "</span>" +
4865
5067
  '<span class="lane-name">' + esc(stage) + '</span>' +
4866
5068
  '<span class="lane-n">×' + list.length + "</span>" +
4867
5069
  (settled ? '<span class="lane-prog">' + settled + "/" + list.length + "</span>" : "") +
5070
+ '<span class="lane-meter" aria-label="' + meter + '%"><i style="width:' + meter + '%"></i></span>' +
4868
5071
  (hasRun ? '<span class="lane-live"><i class="live-dot"></i>' + t("进行中") + "</span>" : "") + "</div>" +
4869
5072
  '<div class="lane-track">' + dots(list, i) + "</div>" +
4870
5073
  '<div class="lane-cells">' + list.slice(-8).map((s, ci) => cell(s, i, ci)).join("") + "</div></div>";
@@ -5082,6 +5285,74 @@ window.dirRetract = async function (msgId) {
5082
5285
  } catch (e) { /* 下一轮轮询兜底 */ }
5083
5286
  };
5084
5287
 
5288
+ /* 人工干预(非直连任务):运行中=递话入箱,下一个步骤开始前送达主智能体
5289
+ * (它判断后引导执行方向);已结束=发送后自动断点续跑,主智能体带着
5290
+ * 反馈重新规划。后端链路:信箱 → retry 继承未消费消息 → 规划 peek/执行 drain。 */
5291
+ window.rdTalkToggle = function () {
5292
+ const box = $("rd-talk");
5293
+ if (!box) return;
5294
+ const show = box.classList.contains("hidden");
5295
+ box.classList.toggle("hidden", !show);
5296
+ const hint = $("rd-talk-hint");
5297
+ if (hint) {
5298
+ const run = S.lastRun;
5299
+ const active = !!run && (run.status === "running" || run.status === "queued");
5300
+ hint.textContent = active ? t("运行中:下一个步骤开始前送达主智能体")
5301
+ : t("已结束:发送后自动断点续跑,主智能体带着反馈重新规划");
5302
+ }
5303
+ if (show) { const ta = $("rd-talk-input"); if (ta) setTimeout(() => ta.focus(), 0); }
5304
+ };
5305
+
5306
+ window.rdTalkSend = async function () {
5307
+ const run = S.lastRun;
5308
+ const ta = $("rd-talk-input");
5309
+ const text = ((ta && ta.value) || "").trim();
5310
+ if (!run || !run.id) return;
5311
+ if (!text && !talkAtts.length) { if (ta) ta.focus(); return; }
5312
+ const btn = $("rd-talk-send");
5313
+ btn.disabled = true;
5314
+ try {
5315
+ await api("/api/runs/" + encodeURIComponent(run.id) + "/messages",
5316
+ { method: "POST", body: JSON.stringify({ text, attachments: talkAtts.map((a) => a.id) }) });
5317
+ if (ta) ta.value = "";
5318
+ talkAtts = []; drawTalkAtts();
5319
+ const box = $("rd-talk");
5320
+ if (box) box.classList.add("hidden");
5321
+ const active = run.status === "running" || run.status === "queued";
5322
+ if (!active && run.task_id) {
5323
+ await retryTask(run.task_id);
5324
+ toast(t("已转交:带着你的反馈断点续跑,主智能体会重新规划"));
5325
+ } else {
5326
+ toast(t("已递话:主智能体在下一个步骤开始前会看到"));
5327
+ }
5328
+ } catch (e) { toast(t("发送失败:") + e.message, true); }
5329
+ finally { btn.disabled = false; }
5330
+ };
5331
+
5332
+ /* 递话附件:与运行中指挥同一落盘通道(/api/attachments 待提交区 → 随消息入箱) */
5333
+ let talkAtts = [];
5334
+ async function talkUploadFiles(files) {
5335
+ for (const f of files) {
5336
+ if (talkAtts.length >= DIR_ATT_MAX) { toast(t("附件最多 ") + DIR_ATT_MAX + t(" 个"), true); break; }
5337
+ if (f.size > DIR_ATT_BYTES) { toast(f.name + t(":超过 8MB,已跳过"), true); continue; }
5338
+ try {
5339
+ const b64 = await fileToB64(f);
5340
+ const d = await api("/api/attachments", { method: "POST",
5341
+ body: JSON.stringify({ name: f.name, data: b64 }) });
5342
+ if (d.attachment) talkAtts.push(d.attachment);
5343
+ } catch (e) { toast(t("附件上传失败:") + e.message, true); }
5344
+ }
5345
+ drawTalkAtts();
5346
+ }
5347
+ function drawTalkAtts() {
5348
+ const box = $("rd-talk-atts");
5349
+ if (!box) return;
5350
+ box.innerHTML = talkAtts.map((a, i) =>
5351
+ '<span class="att-chip2">' + esc(a.name) + "<b onclick=\"talkRemoveAtt(" + i + ')" title="' +
5352
+ t("移除") + "\">×</b></span>").join("");
5353
+ }
5354
+ window.talkRemoveAtt = function (i) { talkAtts.splice(i, 1); drawTalkAtts(); };
5355
+
5085
5356
  function bindDirector() {
5086
5357
  $("rd-attach-btn").addEventListener("click", () => $("rd-attach-file").click());
5087
5358
  $("btn-pause").addEventListener("click", window.dirTogglePause);
@@ -5118,8 +5389,11 @@ let chatAtts = [];
5118
5389
  let chatSig = "";
5119
5390
 
5120
5391
  function chatEngineIsDirect(run) {
5121
- const t = ((S.state || {}).tasks || []).find((x) => x.id === (run && run.task_id));
5122
- return !!(t && t.engine === "direct");
5392
+ const st = S.state || {};
5393
+ const all = (st.tasks || []).concat(st.archived_tasks || []);
5394
+ const task = all.find((x) => x.id === (run && run.task_id));
5395
+ return !!((task && task.engine === "direct") ||
5396
+ (!task && run && (run.engine === "direct" || run.type === "direct")));
5123
5397
  }
5124
5398
 
5125
5399
  /* 对话正文:``` 围栏渲染成真代码块(复用 codeBlockHTML:行号+高亮+代码主题),
@@ -5160,15 +5434,18 @@ async function renderChat(run, active) {
5160
5434
  // 只对直连任务启用(其它流程有自己的蜂巢/步骤视图,不抢默认选卡)
5161
5435
  if (!run || !chatEngineIsDirect(run)) { box.classList.add("hidden"); return; }
5162
5436
  box.classList.remove("hidden");
5437
+ const continueNote = $("rd-chat-continue-note");
5438
+ if (continueNote) continueNote.classList.toggle("hidden", !!active);
5163
5439
  if (chatRunId !== run.id) { chatRunId = run.id; chatAtts = []; drawChatAtts(); chatSig = ""; }
5164
5440
  const sig = run.id + "|" + run.status + "|" + (run.steps || []).length +
5165
5441
  "|" + ((run.messages || []).length);
5166
5442
  if (sig === chatSig) return; // 轮询重画去抖:内容没变不重建 DOM(保住输入焦点)
5167
5443
  const runForFetch = run.id; // 拉取期间可能切详情:过期响应不落盘(同 renderRunDetail 闸门)
5168
5444
  chatSig = sig;
5169
- let items = [];
5170
- try { items = ((await api("/api/runs/" + encodeURIComponent(run.id) + "/timeline")).items) || []; }
5171
- catch (e) { items = []; }
5445
+ let data = null;
5446
+ try { data = await api("/api/runs/" + encodeURIComponent(run.id) + "/timeline"); }
5447
+ catch (e) { data = null; }
5448
+ const items = (data && data.items) || [];
5172
5449
  if (!$("run-detail") || $("run-detail").classList.contains("hidden")) return;
5173
5450
  // 过期闸门:run 级(renderRunDetail)与任务级(drawTaskDetail)都经 S.lastRun
5174
5451
  // 指向当前详情正在展示的那条 run——拉取期间切了详情就丢弃响应
@@ -5181,7 +5458,7 @@ async function renderChat(run, active) {
5181
5458
  const attName = (a) => { const p = typeof a === "string" ? a
5182
5459
  : ((a && (a.name || a.path)) || "");
5183
5460
  return String(p).split(/[\\/]/).pop() || ""; };
5184
- flow.innerHTML = items.map((it) => {
5461
+ let html = items.map((it) => {
5185
5462
  if (it.kind === "user") {
5186
5463
  // 用户消息:右侧浅灰圆角块,元信息在块内顶部。
5187
5464
  // 附件胶囊可点开预览:data-att 带 _attachments/ 相对路径(老数据裸文件名
@@ -5207,8 +5484,15 @@ async function renderChat(run, active) {
5207
5484
  : "") +
5208
5485
  "</div></div>";
5209
5486
  }
5210
- // 协议尾行是给编排器判收的机器标记,不该出现在聊天正文里(真源在提示词约定)
5211
- const body = String(it.text || "").replace(/\n*DIRECT_DONE:.*\s*$/s, "").trim();
5487
+ // 协议尾行是给编排器判收的机器标记,不该出现在聊天正文里(真源在提示词约定);
5488
+ // <followups> 块后端已剥并转成结构化字段,这里再兜一次底防老数据/漏剥;
5489
+ // 开场「本轮做了什么:…」+「回复:」是模型复读协议措辞的元描述(提示词已禁,
5490
+ // 这里兜历史数据),只剥消息开头的第一行
5491
+ const body = String(it.text || "")
5492
+ .replace(/^\s*本轮做了什么\s*[::][^\n]*\n+(回复\s*[::]\s*\n?)?/, "")
5493
+ .replace(/\n*DIRECT_DONE:.*\s*$/s, "")
5494
+ .replace(/\n*<followups>[\s\S]*?<\/followups>\s*$/s, "")
5495
+ .trim();
5212
5496
  // 运行中还没有正文:三点打字动画(终态无正文才落「无文本输出」占位)
5213
5497
  const bodyHtml = body ? chatBodyHTML(body)
5214
5498
  : (it.status === "running"
@@ -5222,15 +5506,179 @@ async function renderChat(run, active) {
5222
5506
  '<div class="chat-bubble agent">' +
5223
5507
  '<div class="chat-body">' + bodyHtml + "</div>" +
5224
5508
  '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5225
- (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div></div></div>";
5226
- }).join("") || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
5509
+ (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div>" +
5510
+ (it.log && it.run ? '<div class="chat-actions"><button class="chat-log-btn" type="button" data-chat-log-run="' +
5511
+ esc(it.run) + '" data-chat-log-rel="' + esc(it.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button></div>" : "") +
5512
+ (Array.isArray(it.followups) && it.followups.length
5513
+ ? '<div class="chat-fups">' + it.followups.map((f) =>
5514
+ '<button type="button" class="chat-fup-btn" data-fup="' + esc(f.prompt || "") +
5515
+ '" title="' + esc(f.prompt || "") + '">' + esc(f.label || "") + "</button>").join("") + "</div>"
5516
+ : "") +
5517
+ "</div></div>";
5518
+ }).join("");
5519
+ // 时间线收尾的「执行结果」卡:run 终态后的确定性摘要(成没成/耗时/执行者/
5520
+ // 产出文件)——不依赖模型最后一句话自觉交代(用户反馈:输"1"跑完 55 秒
5521
+ // 只见一句寒暄,执行结果无处可看)。
5522
+ const res = data && data.result;
5523
+ if (res) html += chatResultHTML(run, res);
5227
5524
  const hint = $("rd-chat-hint");
5228
5525
  if (hint) hint.textContent = active
5229
5526
  ? t("运行中:新消息会排队,本轮回答完后依次送达")
5230
5527
  : t("已结束:发送后将自动开新一轮接着做");
5528
+ flow.innerHTML = html || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
5231
5529
  flow.scrollTop = flow.scrollHeight;
5232
5530
  }
5233
5531
 
5532
+ /* 执行结果卡(时间线收尾):run 终态后的确定性摘要——成没成、跑多久、谁执行
5533
+ * 的、产出了哪些文件,全部产品明示,不依赖模型自觉交代。失败给「查看执行步骤」
5534
+ * 入口;文件 chip 复用成品预览通道(artPopup)。 */
5535
+ function chatResultHTML(run, res) {
5536
+ const ok = res.status === "done";
5537
+ const bad = res.status === "failed";
5538
+ const icon = ok ? "#i-check" : "#i-x";
5539
+ const label = ok ? t("任务完成") : (bad ? t("任务失败") : t("已取消"));
5540
+ const meta = [];
5541
+ if (res.executor) meta.push(esc(res.executor));
5542
+ if (res.turns) meta.push(res.turns + " " + t("轮对话"));
5543
+ if (res.duration_s != null) meta.push(chatDurTxt(res.duration_s));
5544
+ const files = res.files || [];
5545
+ const chips = files.map((f) =>
5546
+ '<a class="file-chip chat-file-open" href="/api/runs/' + encodeURIComponent(run.id) + "/file?name=" +
5547
+ encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
5548
+ 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) + '">' +
5549
+ '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
5550
+ '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>").join("");
5551
+ return '<div class="chat-row">' +
5552
+ '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="' + icon + '"></use></svg></span>' +
5553
+ '<div class="chat-result' + (ok ? "" : bad ? " bad" : " off") + '">' +
5554
+ '<div class="cr-head"><svg class="ico" aria-hidden="true"><use href="' + icon + '"></use></svg>' +
5555
+ esc(label) + "</div>" +
5556
+ (meta.length ? '<div class="cr-meta">' + meta.join(" · ") + "</div>" : "") +
5557
+ (bad && res.error ? '<div class="cr-err">' + esc(res.error) + "</div>" : "") +
5558
+ '<div class="cr-files">' + (chips
5559
+ ? '<div class="cr-files-title">' + esc(t("产出文件")) + "(" + files.length + ")</div>" +
5560
+ '<div class="file-chips">' + chips + "</div>"
5561
+ : '<div class="cr-files-title">' + esc(t("无文件产出")) + "</div>") + "</div>" +
5562
+ (bad ? '<button class="ghost cr-steps" onclick="rdChatNavGo(\'steps\')">' +
5563
+ esc(t("查看执行步骤")) + "</button>" : "") +
5564
+ "</div></div>";
5565
+ }
5566
+
5567
+ function chatDurTxt(s) {
5568
+ s = Number(s) || 0;
5569
+ if (s < 60) return s + " " + t("秒");
5570
+ const m = Math.floor(s / 60), r = s % 60;
5571
+ return m + " " + t("分") + (r ? " " + r + " " + t("秒") : "");
5572
+ }
5573
+
5574
+ /* 代码/编排任务的确定性结果摘要:报告是长文,步骤是过程,用户还需要一眼
5575
+ * 知道这次到底成没成、做了多少步、用了多久,以及失败原因。这个卡片不依赖
5576
+ * 智能体是否在最后一句话里主动总结;直连任务也能在「成果」页签看到同一口径。 */
5577
+ function runDurationSeconds(run) {
5578
+ if (!run) return null;
5579
+ if (Number.isFinite(Number(run.duration_s))) return Number(run.duration_s);
5580
+ const steps = (run.steps || []).filter((s) => Number.isFinite(Number(s.duration_s)));
5581
+ if (steps.length) return steps.reduce((a, s) => a + Number(s.duration_s || 0), 0);
5582
+ const a = Date.parse(String(run.started_at || "").replace(" ", "T"));
5583
+ const b = Date.parse(String(run.ended_at || "").replace(" ", "T"));
5584
+ return Number.isFinite(a) && Number.isFinite(b) && b >= a ? (b - a) / 1000 : null;
5585
+ }
5586
+
5587
+ function runOutcomeSummary(run) {
5588
+ if (!run) return "";
5589
+ const last = (run.steps || []).slice().reverse().find((s) => s.output || s.summary);
5590
+ const raw = run.summary || (last && (last.output || last.summary)) || run.error || "";
5591
+ return String(raw).replace(/\s+/g, " ").trim().slice(0, 520);
5592
+ }
5593
+
5594
+ function renderRunOutcome(run, runs) {
5595
+ const box = $("rd-outcome");
5596
+ if (!box) return;
5597
+ if (!run) { box.classList.add("hidden"); box.innerHTML = ""; return; }
5598
+ const all = runs && runs.length ? runs : [run];
5599
+ const steps = (run.steps || []);
5600
+ const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5601
+ const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5602
+ !["running", "queued"].includes(String(s.status || ""))).length, 0);
5603
+ const status = String(run.status || "");
5604
+ const active = status === "running" || status === "queued";
5605
+ const bad = status === "failed" || status === "cancelled" || status === "timeout";
5606
+ const label = active ? (status === "queued" ? t("排队中") : t("运行中"))
5607
+ : (status === "done" ? t("完成") : status === "failed" ? t("失败") : status === "cancelled" ? t("已取消") : status || t("未知状态"));
5608
+ const icon = status === "done" ? "#i-check" : bad ? "#i-x" : "#i-gauge";
5609
+ const duration = runDurationSeconds(run);
5610
+ const latest = steps.slice().reverse().find((s) => s.agent_label || s.agent || s.role);
5611
+ const route = run.route && typeof run.route === "object" ? run.route.implementer : "";
5612
+ // route.implementer 是路由评分说明,不是用户真正关心的 CLI 名称;优先
5613
+ // 展示步骤上的 agent_label,只有旧数据没有步骤执行者时才回退到路由说明。
5614
+ const executor = run.executor || run.implementer || (latest && (latest.agent_label || latest.agent)) || route || "";
5615
+ const summary = runOutcomeSummary(run);
5616
+ const meta = [];
5617
+ if (executor) meta.push('<span><b>' + esc(t("执行者")) + '</b> ' + esc(executor) + "</span>");
5618
+ if (total) meta.push('<span><b>' + esc(t("步骤")) + '</b> ' + settled + "/" + total + "</span>");
5619
+ if (duration != null) meta.push('<span><b>' + esc(t("耗时")) + '</b> ' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + "</span>");
5620
+ return (box.classList.remove("hidden"), box.innerHTML =
5621
+ '<div class="outcome-top">' +
5622
+ '<div class="outcome-title ' + (bad ? "bad" : active ? "live" : "ok") + '">' +
5623
+ '<svg class="ico" aria-hidden="true"><use href="' + icon + '"></use></svg>' + esc(t("执行结果")) +
5624
+ '<span class="outcome-status">' + esc(label) + "</span></div>" +
5625
+ (meta.length ? '<div class="outcome-meta">' + meta.join("") + "</div>" : "") +
5626
+ "</div>" +
5627
+ (summary ? '<div class="outcome-summary">' + esc(summary) + "</div>" : "") +
5628
+ (run.error ? '<div class="outcome-error">' + esc(run.error) + "</div>" : "") +
5629
+ (total ? '<div class="outcome-actions"><button class="ghost" type="button" onclick="rdChatNavGo(\'steps\')">' + esc(t("查看执行步骤")) + "</button></div>" : "")
5630
+ );
5631
+ }
5632
+
5633
+ /* 任务详情首屏概览:把“这次任务做了什么、做到哪、最近发生了什么”放在
5634
+ * 蜂巢上方。直连任务仍以对话为主,由 chat-mode 隐藏概览,避免它抢走输入空间。 */
5635
+ function renderDetailOverview(run, runs, task) {
5636
+ const box = $("rd-overview");
5637
+ if (!box || !run) return;
5638
+ const direct = chatEngineIsDirect(run);
5639
+ box.classList.toggle("direct-overview", direct);
5640
+ const all = runs && runs.length ? runs : [run];
5641
+ const steps = (run.steps || []);
5642
+ const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5643
+ const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5644
+ !["running", "queued"].includes(String(s.status || ""))).length, 0);
5645
+ const pct = total ? Math.max(0, Math.min(100, Math.round((settled / total) * 100))) : 0;
5646
+ const status = String(run.status || "");
5647
+ const active = status === "running" || status === "queued";
5648
+ const bad = status === "failed" || status === "cancelled" || status === "timeout";
5649
+ const statusText = active ? (status === "queued" ? t("排队中") : t("运行中"))
5650
+ : status === "done" ? t("完成") : status === "failed" ? t("失败")
5651
+ : status === "cancelled" ? t("已取消") : status || t("未知状态");
5652
+ const last = steps.slice().reverse().find((s) => s.summary || s.output || s.note) || steps[steps.length - 1];
5653
+ const lastText = last ? String(last.summary || last.output || last.note || "").replace(/\s+/g, " ").trim().slice(0, 240) : "";
5654
+ const executor = run.executor || run.implementer || (last && (last.agent_label || last.agent)) || "";
5655
+ const duration = runDurationSeconds(run);
5656
+ const goal = (task && task.goal) || run.goal || run.title || "";
5657
+ const actions = [];
5658
+ if (total) actions.push('<button type="button" class="ghost" data-overview-tab="steps">' + esc(t("查看步骤")) + "</button>");
5659
+ 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>");
5660
+ if (status === "done" || run.report) actions.push('<button type="button" class="ghost" data-overview-tab="result">' + esc(t("查看成果")) + "</button>");
5661
+ box.innerHTML =
5662
+ '<div class="rd-overview-main">' +
5663
+ '<div class="rd-overview-kicker">' + esc(t("任务概览")) + '</div>' +
5664
+ '<div class="rd-overview-title" title="' + esc(run.title || "") + '">' + esc(run.title || "") + '</div>' +
5665
+ (goal ? '<div class="rd-overview-goal" title="' + esc(goal) + '">' + esc(goal) + '</div>' : "") +
5666
+ '</div>' +
5667
+ '<div class="rd-overview-state">' +
5668
+ '<span class="state-label">' + esc(t("当前状态")) + '</span>' +
5669
+ '<strong class="state-value ' + (bad ? "bad" : status === "done" ? "ok" : "") + '">' + esc(statusText) + '</strong>' +
5670
+ '</div>' +
5671
+ '<div class="rd-overview-progress" aria-label="' + esc(pct + "%") + '"><i style="width:' + pct + '%"></i></div>' +
5672
+ '<div class="rd-overview-facts">' +
5673
+ '<span><b>' + settled + "/" + total + '</b> ' + esc(t("步骤已完成")) + '</span>' +
5674
+ (executor ? '<span>' + esc(t("执行者")) + ' <b>' + esc(executor) + '</b></span>' : "") +
5675
+ (duration != null ? '<span>' + esc(t("耗时")) + ' <b>' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + '</b></span>' : "") +
5676
+ '</div>' +
5677
+ (lastText ? '<div class="rd-overview-last"><b>' + esc(t("最近一步")) + '</b><span>' + esc(lastText) + '</span></div>' : "") +
5678
+ (actions.length ? '<div class="rd-overview-actions">' + actions.join("") + '</div>' : "");
5679
+ box.classList.remove("hidden");
5680
+ }
5681
+
5234
5682
  function drawChatAtts() {
5235
5683
  const box = $("rd-chat-att-list");
5236
5684
  if (!box) return;
@@ -5318,6 +5766,13 @@ function bindChat() {
5318
5766
  const btn = $("rd-chat-attach"), file = $("rd-chat-file"), send = $("rd-chat-send"),
5319
5767
  ta = $("rd-chat-input");
5320
5768
  if (!btn || !file || !send || !ta) return;
5769
+ const focusBtn = $("rd-chat-focus");
5770
+ if (focusBtn) focusBtn.addEventListener("click", () => {
5771
+ S.rdTab = "chat";
5772
+ S.rdTabPin = true;
5773
+ applyRdTabs();
5774
+ setTimeout(() => ta.focus(), 0);
5775
+ });
5321
5776
  btn.addEventListener("click", () => file.click());
5322
5777
  file.addEventListener("change", (e) => {
5323
5778
  chatUploadFiles(Array.from(e.target.files || []));
@@ -5347,6 +5802,25 @@ function bindChat() {
5347
5802
  // 输入条胶囊增删重画,都不丢监听。时间线胶囊走已落盘文件,输入条胶囊
5348
5803
  // (× 移除按钮除外)走待提交区
5349
5804
  $("rd-chat-flow").addEventListener("click", (e) => {
5805
+ const logBtn = e.target.closest("[data-chat-log-run][data-chat-log-rel]");
5806
+ if (logBtn) { e.stopPropagation(); toggleLog(logBtn.dataset.chatLogRun, logBtn.dataset.chatLogRel); return; }
5807
+ // 建议追问芯片:点击把整句回填输入框(不直接发送——用户可改后再发),焦点落输入框
5808
+ const fup = e.target.closest(".chat-fup-btn[data-fup]");
5809
+ if (fup) {
5810
+ const ta = $("rd-chat-input");
5811
+ if (ta) {
5812
+ ta.value = fup.dataset.fup;
5813
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
5814
+ ta.focus();
5815
+ }
5816
+ return;
5817
+ }
5818
+ const file = e.target.closest(".chat-file-open[data-file-run]");
5819
+ if (file) {
5820
+ e.preventDefault();
5821
+ artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
5822
+ return;
5823
+ }
5350
5824
  const chip = e.target.closest(".att-open");
5351
5825
  if (chip) window.chatAttPopup(chip.dataset.att);
5352
5826
  });
@@ -5690,11 +6164,20 @@ function protoLabel(p) {
5690
6164
  * 通过的 wire_caps(auto 供应商全靠它)。返回命中的协议名,不适配返回空串。
5691
6165
  * 与 modelhub._entry_endpoint 同规则——它同时就是「注入本 CLI 实际走的 wire」,
5692
6166
  * 勾选面板据此标死解析时会被跳过的条目。 */
5693
- function provAdaptedProto(p, allow) {
6167
+ function provAdaptedProto(p, allow, kind) {
5694
6168
  if (!p) return "";
5695
- if (allow.indexOf(p.protocol) >= 0) return p.protocol;
6169
+ // codex 0.154+ 只讲 responses wire:chat-only 端点(显式 wire_api=chat 或
6170
+ // wire_caps 实测为 chat)对 codex 等于没有可用协议——与后端 resolve_binding
6171
+ // 的剔除规则同源(2026-09-18 重写任务案:链显示健康、运行时必剔死的假象)
6172
+ const codexOnly = /codex/.test(String(kind || "").toLowerCase());
6173
+ const chatBlocked = (w) => codexOnly && (w || "responses") === "chat";
6174
+ if (allow.indexOf(p.protocol) >= 0) {
6175
+ if (chatBlocked(p.wire_api)) return "";
6176
+ return p.protocol;
6177
+ }
5696
6178
  const caps = p.wire_caps || {};
5697
- return allow.find((pr) => (caps[pr] || {}).base) || "";
6179
+ return allow.find((pr) => (caps[pr] || {}).base
6180
+ && !chatBlocked((caps[pr] || {}).wire_api)) || "";
5698
6181
  }
5699
6182
 
5700
6183
  /* CLI 允许注入的供应商 wire 协议——与 modelhub.resolve_binding 的 allowed 规则
@@ -5715,7 +6198,7 @@ function modelSelectHtml(id, current, kind) {
5715
6198
  const allow = bindAllowedProtocols(kind);
5716
6199
  const groups = modelGroups().filter((g) => {
5717
6200
  const p = (S.providers || []).find((x) => x.id === g.id);
5718
- return provAdaptedProto(p, allow);
6201
+ return provAdaptedProto(p, allow, kind);
5719
6202
  });
5720
6203
  let opts = '<option value="">' + t("(未设置)") + '</option>';
5721
6204
  if (cur && !groups.some((g) => g.models.includes(cur))) {
@@ -5794,7 +6277,7 @@ function recommendFor(c) {
5794
6277
  const allow = bindAllowedProtocols(c.orch_kind || c.id);
5795
6278
  const badDefault = (p) => (p.models || []).some((m) => m.name === p.model &&
5796
6279
  (m.hidden || m.enabled === false));
5797
- const provs = (S.providers || []).filter((p) => !badDefault(p) && chainProvUsable(p, allow));
6280
+ const provs = (S.providers || []).filter((p) => !badDefault(p) && chainProvUsable(p, allow, c.orch_kind || c.id));
5798
6281
  if (!provs.length) return null;
5799
6282
  const score = (p) => (allow.indexOf(p.protocol) >= 0 ? 0 : 1) * 1000 + (p.priority || 0);
5800
6283
  provs.sort((a, b) => score(a) - score(b));
@@ -5806,9 +6289,9 @@ function recommendFor(c) {
5806
6289
 
5807
6290
  /* 供应商对某 CLI 是否「推荐可用」:启用、协议适配,且至少有一把不在冷却期的
5808
6291
  * KEY(全冷却=欠费失效,推荐了也白推荐——解析层照样失败)。 */
5809
- function chainProvUsable(p, allow) {
6292
+ function chainProvUsable(p, allow, kind) {
5810
6293
  if (!p || p.enabled === false) return false;
5811
- if (!provAdaptedProto(p, allow)) return false;
6294
+ if (!provAdaptedProto(p, allow, kind)) return false;
5812
6295
  const ks = p.keys || [];
5813
6296
  if (!ks.length) return true; // 老数据单 KEY(api_key 镜像):无 keys 结构视为可用
5814
6297
  return ks.some((k) => k.enabled !== false && !k.cooling);
@@ -5823,7 +6306,7 @@ function chainDeadReasons(c, chain) {
5823
6306
  const pv = (S.providers || []).find((p) => p.id === x.p);
5824
6307
  if (!pv) return t("供应商已删除");
5825
6308
  if (pv.enabled === false) return t("供应商已停用");
5826
- if (!provAdaptedProto(pv, allow)) return t("协议不匹配");
6309
+ if (!provAdaptedProto(pv, allow, c.orch_kind)) return t("协议不匹配");
5827
6310
  const ks = pv.keys || [];
5828
6311
  if (ks.length && !ks.some((k) => k.enabled !== false && !k.cooling))
5829
6312
  return t("密钥全部冷却中");
@@ -5955,7 +6438,7 @@ function bindModelBox(c, provId) {
5955
6438
  ? st.chain.map((c2, i) => {
5956
6439
  const pname = c2.p ? provName(c2.p) : t("CLI 默认凭据");
5957
6440
  const pv = c2.p ? (S.providers || []).find((p) => p.id === c2.p) : null;
5958
- const adapted = provAdaptedProto(pv, allow);
6441
+ const adapted = provAdaptedProto(pv, allow, c.orch_kind);
5959
6442
  const dead = c2.p && !adapted;
5960
6443
  // auto 供应商按实测 wire 注入是常态,说「自动」;显式协议靠适配才通的
5961
6444
  // 才叫「已适配」——两者含义不同,别混成一句。
@@ -5995,7 +6478,7 @@ function bindPanel(c) {
5995
6478
  const allow = bindAllowedProtocols(c.orch_kind);
5996
6479
  const all = modelGroups();
5997
6480
  // 原生协议匹配,或适配测试过 allow 里某条 wire 的供应商都可勾选
5998
- const groups = all.filter((g) => provAdaptedProto(g, allow));
6481
+ const groups = all.filter((g) => provAdaptedProto(g, allow, c.orch_kind));
5999
6482
  const hiddenN = all.length - groups.length;
6000
6483
  if (!groups.length) {
6001
6484
  return '<div class="ohint">' + esc(all.length
@@ -6010,12 +6493,12 @@ function bindPanel(c) {
6010
6493
  '<div class="ogroup"><div class="ogname">' + esc(g.name) +
6011
6494
  ' <span class="tag">' + (g.protocol === "auto"
6012
6495
  ? t("自动 · 注入该厂商凭据")
6013
- : (provAdaptedProto(g, allow) === g.protocol
6496
+ : (provAdaptedProto(g, allow, c.orch_kind) === g.protocol
6014
6497
  ? t("注入该厂商凭据") : t("已适配 · 注入该厂商凭据"))) + '</span></div>' +
6015
6498
  g.models.map((m) => {
6016
6499
  // 本供应商注入本 CLI 实际走的 wire(与后端 _entry_endpoint 同规则);
6017
6500
  // 无命中 = 勾了也会被解析层整条跳过,标死防止白配
6018
- const wire = provAdaptedProto(g, allow);
6501
+ const wire = provAdaptedProto(g, allow, c.orch_kind);
6019
6502
  const dead = !wire;
6020
6503
  const has = st.chain.some((x) => x.p === g.id && x.m === m);
6021
6504
  return '<label class="oitem' + (dead ? " dim" : "") + '"><input type="checkbox" value="' + esc(m) + '" data-p="' + esc(g.id) + '"' +
@@ -6085,11 +6568,19 @@ function renderCatalog() {
6085
6568
  });
6086
6569
  }
6087
6570
 
6571
+ /* 该条目有管理操作在跑(queued/running)时,写操作按钮置灰防重复触发;
6572
+ 页面刷新后按钮恢复,后端同条目去重闸仍然兜底 */
6573
+ function mgmtBusy(id) {
6574
+ const st = (S.mgmt || {})[id];
6575
+ return !!st && (st.status === "queued" || st.status === "running");
6576
+ }
6577
+
6088
6578
  function card(c) {
6089
6579
  const orchBox = c.orch_kind
6090
6580
  ? '<label class="toggle"><input type="checkbox" ' + (c.orch_enabled ? "checked" : "") +
6091
6581
  ' onchange="toggleOrch(\'' + esc(c.id) + '\', this.checked)">' + t(" 参与编排") + '</label>'
6092
6582
  : '<span class="tag">' + t("仅管理") + '</span>';
6583
+ const dis = mgmtBusy(c.id) ? " disabled" : "";
6093
6584
  const hasModels = modelGroups().length > 0;
6094
6585
  const modelBox = c.config_writable
6095
6586
  ? (hasModels
@@ -6103,10 +6594,10 @@ function card(c) {
6103
6594
  // 一键打开(web 类开浏览器 / console 类新终端跑 TUI):已安装且配了 launch 才出
6104
6595
  c.installed && c.launch ? '<button class="primary small" onclick="openAgent(\'' + esc(c.id) + '\')">' +
6105
6596
  (c.launch.kind === "web" ? t("打开网页") : t("打开")) + "</button>" : "",
6106
- c.installed && c.orch_kind ? '<button class="ghost small" onclick="mgmt(\'' + esc(c.id) + '\', \'smoke\')">' + t("冒烟测试") + '</button>' : "",
6107
- c.installed && c.has_upgrade ? '<button class="ghost small" onclick="mgmt(\'' + esc(c.id) + '\', \'upgrade\')">' + t("升级") + '</button>' : "",
6597
+ c.installed && c.orch_kind ? '<button class="ghost small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'smoke\')">' + t("冒烟测试") + '</button>' : "",
6598
+ c.installed && c.has_upgrade ? '<button class="ghost small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'upgrade\')">' + t("升级") + '</button>' : "",
6108
6599
  c.installed && c.has_upgrade ? '<button class="ghost small" onclick="checkUpdate(\'' + esc(c.id) + '\')">' + t("检查更新") + '</button>' : "",
6109
- !c.installed && c.has_install ? '<button class="ghost small" onclick="mgmt(\'' + esc(c.id) + '\', \'install\')">' + t("安装") + '</button>' : "",
6600
+ !c.installed && c.has_install ? '<button class="ghost small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'install\')">' + t("安装") + '</button>' : "",
6110
6601
  c.installed ? '<button class="ghost small" onclick="showMgmtLog(\'' + esc(c.id) + '\')">' + t("日志") + '</button>' : "",
6111
6602
  !c.installed && !c.has_install ? '<span class="hint">' + t("安装命令待配置(编辑 data/catalog.json)") + '</span>' : "",
6112
6603
  ].join("");
@@ -6114,7 +6605,7 @@ function card(c) {
6114
6605
  '<div class="head"><span class="name">' + esc(c.name) + "</span>" +
6115
6606
  (c.installed ? statusChip("done") : '<span class="tag">' + t("未安装") + '</span>') + updateChip(c) +
6116
6607
  // 卸载放卡片右上角(与状态徽标同行),不再吊在操作行尾部
6117
- (c.installed && c.uninstall_cmd ? '<button class="danger small" onclick="mgmt(\'' + esc(c.id) + '\', \'uninstall\')">' + t("卸载") + '</button>' : "") +
6608
+ (c.installed && c.uninstall_cmd ? '<button class="danger small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'uninstall\')">' + t("卸载") + '</button>' : "") +
6118
6609
  "</div>" +
6119
6610
  '<div class="note">' + esc(t(c.note || "")) + "</div>" +
6120
6611
  '<div class="facts">' + t("版本 ") + '<b>' + esc(c.version || "-") + "</b>" + t(" 模型 ") + "<b>" + esc(fmtModel(c.model) || "-") + "</b>" +
@@ -6243,6 +6734,7 @@ async function mgmt(id, op) {
6243
6734
  return;
6244
6735
  }
6245
6736
  S.mgmt[id].runId = r.run_id;
6737
+ if (r.deduped) toast(t("该条目已有进行中的任务,已转为跟踪该任务"));
6246
6738
  S.catSig = null; renderCatalog();
6247
6739
  pollMgmt(id, r.run_id); // 就地跟踪,不再跳转页面
6248
6740
  }
@@ -7132,6 +7624,8 @@ async function loadSettings() {
7132
7624
  S.settings = await api("/api/settings");
7133
7625
  const inp = $("set-workers");
7134
7626
  if (inp && S.settings) inp.value = S.settings.max_concurrent_jobs;
7627
+ const tel = $("set-telemetry");
7628
+ if (tel && S.settings) tel.checked = S.settings.telemetry_errors !== false;
7135
7629
  const wd = $("set-workdir"), hint = $("set-workdir-hint");
7136
7630
  if (wd && S.settings) wd.value = S.settings.default_workdir_effective || "";
7137
7631
  if (hint && S.settings) {
@@ -7139,9 +7633,13 @@ async function loadSettings() {
7139
7633
  hint.textContent = custom ? t("当前生效:") + S.settings.default_workdir_effective + t("(自定义)")
7140
7634
  : t("当前生效:") + S.settings.default_workdir_effective + t("(内置默认,尚未自定义)");
7141
7635
  }
7142
- // 新任务表单:目录留空即落到默认路径,给一句话提示
7143
- const fhint = $("f-workdir-hint");
7144
- if (fhint && S.settings) fhint.textContent = t("留空则保存到:") + (S.settings.default_workdir_effective || "");
7636
+ // 新任务表单:默认就「选中」默认路径(参考工作区选择器的默认选中态);
7637
+ // 手动清空输入仍表示跟随默认。f-workdir-hint 保留给「继续会话/克隆任务」的覆盖警告
7638
+ const wdInput = $("f-workdir");
7639
+ if (wdInput && S.settings && !wdInput.value.trim()) {
7640
+ wdInput.value = S.settings.default_workdir_effective || "";
7641
+ queueGitProbe(); // 目录在场即探测代码版本,点亮分支胶囊
7642
+ }
7145
7643
  } catch (e) { /* 忽略 */ }
7146
7644
  }
7147
7645
 
@@ -7287,6 +7785,36 @@ function maybeWhatsnew() {
7287
7785
  }
7288
7786
  }
7289
7787
 
7788
+ /* ===== 首次启动欢迎引导:产品是什么 + 装完三步开工 =====
7789
+ * localStorage orch.welcomed 记账,只自动弹一次;「关于与更新」页可手动重开。
7790
+ * 令牌门在场时让位(远端新设备先输令牌,下次启动引导照常弹)。 */
7791
+ function welcomeOpen() {
7792
+ const w = $("welcome");
7793
+ if (!w) return;
7794
+ if (!$("token-gate").classList.contains("hidden")) return;
7795
+ w.classList.remove("hidden");
7796
+ document.body.classList.add("welcome-open");
7797
+ }
7798
+ function welcomeClose() {
7799
+ const w = $("welcome");
7800
+ if (!w) return;
7801
+ w.classList.add("hidden");
7802
+ document.body.classList.remove("welcome-open");
7803
+ try { localStorage.setItem("orch.welcomed", "1"); } catch (e) { /* 存储不可用则下次再弹 */ }
7804
+ }
7805
+ function welcomeGo(tab) {
7806
+ welcomeClose();
7807
+ switchTab(tab); // 直达「模型接入」/「CLI 绑定」
7808
+ }
7809
+ function maybeWelcome() {
7810
+ let seen = "";
7811
+ try { seen = localStorage.getItem("orch.welcomed"); } catch (e) { /* ignore */ }
7812
+ if (!seen) welcomeOpen();
7813
+ }
7814
+ window.welcomeOpen = welcomeOpen;
7815
+ window.welcomeClose = welcomeClose;
7816
+ window.welcomeGo = welcomeGo;
7817
+
7290
7818
  function renderSu() {
7291
7819
  const info = $("su-info");
7292
7820
  if (!info) return;
@@ -7331,6 +7859,57 @@ function updDismiss() {
7331
7859
  toast(t("已忽略该版本提醒,可随时在「关于与更新」里升级"));
7332
7860
  }
7333
7861
 
7862
+ /* 匿名错误报告开关:改完立即生效(后端每轮上报前都会读一次设置) */
7863
+ async function saveTelemetry(on) {
7864
+ try {
7865
+ const r = await api("/api/settings", { method: "POST", body: JSON.stringify({ telemetry_errors: !!on }) });
7866
+ S.settings = r.settings;
7867
+ toast(on ? t("已开启匿名错误报告,感谢帮助改进 CodeBee")
7868
+ : t("已关闭匿名错误报告,数据不再离开本机"));
7869
+ } catch (e) {
7870
+ const tel = $("set-telemetry");
7871
+ if (tel) tel.checked = !on; // 存失败弹回旧值,不让 UI 与后端各说各话
7872
+ toast(e.message || t("保存失败"), true);
7873
+ }
7874
+ }
7875
+
7876
+ /* 导出诊断包:脱敏错误台账 + 用量统计 + 版本环境信息(zip),贴 Issue 用 */
7877
+ async function exportDiagBundle() {
7878
+ const b = $("diag-export");
7879
+ if (b) b.disabled = true;
7880
+ try {
7881
+ const r = await fetch("/api/diagnostics/bundle", { headers: authHeaders() });
7882
+ if (!r.ok) throw new Error("HTTP " + r.status);
7883
+ const u = URL.createObjectURL(await r.blob());
7884
+ const a = document.createElement("a");
7885
+ a.href = u;
7886
+ a.download = "codebee-diag-" + new Date().toISOString().slice(0, 10) + ".zip";
7887
+ document.body.appendChild(a);
7888
+ a.click();
7889
+ a.remove();
7890
+ setTimeout(() => URL.revokeObjectURL(u), 5000);
7891
+ toast(t("诊断包已开始下载,反馈问题时可附在 Issue 里"));
7892
+ } catch (e) {
7893
+ toast(t("诊断包导出失败:") + e.message, true);
7894
+ } finally {
7895
+ if (b) b.disabled = false;
7896
+ }
7897
+ }
7898
+
7899
+ /* 一键反馈 Issue:拉脱敏摘要 → 预填 GitHub Issue 新建页(用户亲手提交,不自动回传) */
7900
+ async function reportIssue() {
7901
+ try {
7902
+ const s = await api("/api/diagnostics/issue-summary");
7903
+ const url = "https://github.com/Vercel-By-WXP/CodeBee/issues/new" +
7904
+ "?title=" + encodeURIComponent(s.title || "") +
7905
+ "&body=" + encodeURIComponent(s.body || "");
7906
+ window.open(url, "_blank");
7907
+ toast(t("已打开 GitHub 反馈页,内容已预填,可直接提交(也可附上诊断包 zip)"));
7908
+ } catch (e) {
7909
+ toast(t("反馈摘要生成失败:") + e.message, true);
7910
+ }
7911
+ }
7912
+
7334
7913
  async function suCheck() {
7335
7914
  const b = $("su-check");
7336
7915
  if (b) b.disabled = true;
@@ -7356,7 +7935,8 @@ async function suApply() {
7356
7935
  clearInterval(SU_TIMER);
7357
7936
  SU_TIMER = setInterval(async () => {
7358
7937
  try {
7359
- const run = await api("/api/runs/" + encodeURIComponent(runId));
7938
+ const d = await api("/api/runs/" + encodeURIComponent(runId));
7939
+ const run = d && d.run; // /api/runs/<id> 返回 {run:{…}} 信封,别读外壳
7360
7940
  if (run && run.status && run.status !== "running" && run.status !== "queued") {
7361
7941
  clearInterval(SU_TIMER); SU_TIMER = null;
7362
7942
  $("su-apply").disabled = false;
@@ -7692,6 +8272,19 @@ function usageMultilineSvg(byDay, byDayModel) {
7692
8272
  '<line class="uc-grid" x1="' + padL + '" y1="' + (padT + ih * f).toFixed(1) +
7693
8273
  '" x2="' + (W - padR) + '" y2="' + (padT + ih * f).toFixed(1) + '"/>').join("") +
7694
8274
  '<line class="uc-base" x1="' + padL + '" y1="' + (padT + ih) + '" x2="' + (W - padR) + '" y2="' + (padT + ih) + '"/>';
8275
+ // 底层活动量柱保留每日总量轮廓;折线仍表达各模型趋势,双层信息比单一曲线
8276
+ // 更容易看出“哪天整体有调用、哪天只是某个模型占比变化”。
8277
+ const dailyTotals = days.map((_, i) => series.reduce((sum, s) => sum + (s.vals[i] || 0), 0));
8278
+ const dayMax = Math.max(1, ...dailyTotals);
8279
+ const barW = Math.max(3, (iw / n) * 0.52);
8280
+ const bars = dailyTotals.map((v, i) => {
8281
+ const h = ih * v / dayMax;
8282
+ const x = xOf(i) - barW / 2;
8283
+ const y = padT + ih - h;
8284
+ return '<rect class="uc-bar" x="' + x.toFixed(1) + '" y="' + y.toFixed(1) +
8285
+ '" width="' + barW.toFixed(1) + '" height="' + Math.max(0.5, h).toFixed(1) +
8286
+ '" rx="2"><title>' + esc(days[i] + " · " + fmtTok(v) + t(" tokens")) + "</title></rect>";
8287
+ }).join("");
7695
8288
  const legend = '<div class="um-legend">' +
7696
8289
  series.map((s, si) =>
7697
8290
  '<span><i style="background:' + usageColor(si) + '"></i>' +
@@ -7699,7 +8292,7 @@ function usageMultilineSvg(byDay, byDayModel) {
7699
8292
  '<span class="um-peak">' + esc(t("峰值 ") + fmtTok(max)) + "</span></div>";
7700
8293
  return legend +
7701
8294
  '<svg class="usage-svg" viewBox="0 0 ' + W + " " + H + '" role="img" preserveAspectRatio="xMidYMid meet">' +
7702
- grid + body + labels + "</svg>";
8295
+ grid + bars + body + labels + "</svg>";
7703
8296
  }
7704
8297
 
7705
8298
  /* Token 活动热力图:GitHub 贡献图风格。窗口自适应——覆盖全部活跃历史(封顶
@@ -7932,7 +8525,8 @@ function renderUsage() {
7932
8525
  $("usage-kpis").innerHTML = [
7933
8526
  kpiCard(t("累计 Token 数"), fmtTok(tot.tokens),
7934
8527
  t("调用 ") + fmtTok(tot.calls) + t(" 次 · 成功率 ") +
7935
- (tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " + fmtUsd(tot.cost_usd),
8528
+ (tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " +
8529
+ t("失败 ") + fmtTok(tot.failed || 0) + t(" · ") + fmtUsd(tot.cost_usd),
7936
8530
  true, "i-sigma"),
7937
8531
  kpiCard(t("峰值 Token 数"), fmtTok(tot.peak_tokens),
7938
8532
  t("单日最高 · 日均 ") + fmtTok(activeDays ? tot.tokens / activeDays : 0), false, "i-gauge"),
@@ -8035,6 +8629,7 @@ function collapseDrawerIfMobile() {
8035
8629
 
8036
8630
  function switchTab(name) {
8037
8631
  if (name === "__phone") { openPhoneConnect(); return; } // 手机连接是弹框,不切页
8632
+ if (name === "__guide") { welcomeOpen(); return; } // 使用引导是弹层,不切页(设置导航「软件」组)
8038
8633
  // 导航收进「设置」:进设置后左栏整体换成设置导航,内容铺满
8039
8634
  S.tab = name;
8040
8635
  if (SET_TABS.has(name)) localStorage.setItem("orch.setTab", name);
@@ -8208,6 +8803,58 @@ document.addEventListener("DOMContentLoaded", () => {
8208
8803
  $("btn-cancel").addEventListener("click", cancelRun);
8209
8804
  bindDirector();
8210
8805
  bindChat();
8806
+ const bindArtifactClicks = (root) => {
8807
+ if (!root) return;
8808
+ root.addEventListener("click", (e) => {
8809
+ const file = e.target.closest("[data-file-run][data-file-name]");
8810
+ if (!file) return;
8811
+ e.preventDefault();
8812
+ artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
8813
+ });
8814
+ };
8815
+ bindArtifactClicks($("rd-arts"));
8816
+ bindArtifactClicks($("insp-artifacts"));
8817
+ // 动态详情区统一用 data 属性委托,日志路径/文件名不再拼进 inline JS。
8818
+ const detailOverview = $("rd-overview");
8819
+ if (detailOverview) detailOverview.addEventListener("click", (e) => {
8820
+ const log = e.target.closest("[data-overview-log]");
8821
+ if (log) { toggleLog(log.dataset.overviewRun, log.dataset.overviewLog); return; }
8822
+ const tab = e.target.closest("[data-overview-tab]");
8823
+ if (tab) { S.rdTab = tab.dataset.overviewTab; S.rdTabPin = true; applyRdTabs(); }
8824
+ });
8825
+ const rdMore = $("rd-more-toggle"), rdMetaPop = $("rd-meta-popover"), rdMoreClose = $("rd-more-close");
8826
+ if (rdMore) rdMore.addEventListener("click", (e) => {
8827
+ e.stopPropagation();
8828
+ setRdMetaOpen(rdMetaPop && rdMetaPop.classList.contains("hidden"));
8829
+ });
8830
+ if (rdMoreClose) rdMoreClose.addEventListener("click", () => setRdMetaOpen(false));
8831
+ document.addEventListener("click", (e) => {
8832
+ if (!rdMetaPop || rdMetaPop.classList.contains("hidden")) return;
8833
+ if (rdMetaPop.contains(e.target) || (rdMore && rdMore.contains(e.target))) return;
8834
+ setRdMetaOpen(false);
8835
+ });
8836
+ document.addEventListener("keydown", (e) => {
8837
+ if (e.key === "Escape" && rdMetaPop && !rdMetaPop.classList.contains("hidden")) setRdMetaOpen(false);
8838
+ });
8839
+ const stepBox = $("rd-steps");
8840
+ if (stepBox) stepBox.addEventListener("click", (e) => {
8841
+ const btn = e.target.closest("[data-log-run][data-log-rel]");
8842
+ const row = e.target.closest(".step[data-log]");
8843
+ if (btn) { e.stopPropagation(); toggleLog(btn.dataset.logRun, btn.dataset.logRel); return; }
8844
+ if (row && row.dataset.log) toggleLog(row.dataset.runId || S.detailRunId, row.dataset.log);
8845
+ });
8846
+ const hiveBox = $("rd-hive-cells");
8847
+ if (hiveBox) {
8848
+ hiveBox.addEventListener("click", (e) => {
8849
+ const cell = e.target.closest("[data-hive-run][data-hive-log]");
8850
+ if (cell) hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog);
8851
+ });
8852
+ hiveBox.addEventListener("keydown", (e) => {
8853
+ if (e.key !== "Enter" && e.key !== " ") return;
8854
+ const cell = e.target.closest("[data-hive-run][data-hive-log]");
8855
+ if (cell) { e.preventDefault(); hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog); }
8856
+ });
8857
+ }
8211
8858
  // 详情标签页:手点即切并钉住——状态变化触发的自动选卡不再抢用户的手选
8212
8859
  $("rd-tabs").addEventListener("click", (e) => {
8213
8860
  const b = e.target.closest(".rd-tab");
@@ -8216,6 +8863,21 @@ document.addEventListener("DOMContentLoaded", () => {
8216
8863
  S.rdTabPin = true;
8217
8864
  applyRdTabs();
8218
8865
  });
8866
+ $("rd-tabs").addEventListener("keydown", (e) => {
8867
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
8868
+ const tabs = Array.from($("rd-tabs").querySelectorAll(".rd-tab:not(.hidden)"));
8869
+ if (!tabs.length) return;
8870
+ const current = Math.max(0, tabs.indexOf(document.activeElement));
8871
+ let next = current;
8872
+ if (e.key === "ArrowRight") next = (current + 1) % tabs.length;
8873
+ if (e.key === "ArrowLeft") next = (current - 1 + tabs.length) % tabs.length;
8874
+ if (e.key === "Home") next = 0;
8875
+ if (e.key === "End") next = tabs.length - 1;
8876
+ e.preventDefault();
8877
+ tabs[next].focus();
8878
+ tabs[next].click();
8879
+ tabs[next].scrollIntoView({ block: "nearest", inline: "nearest" });
8880
+ });
8219
8881
  // 详情子页显隐 → 容器宽度 class(:has 在部分浏览器不生效,JS 同步为准)
8220
8882
  (function () {
8221
8883
  const sub = document.getElementById("sub-runs");
@@ -8226,6 +8888,60 @@ document.addEventListener("DOMContentLoaded", () => {
8226
8888
  sync();
8227
8889
  })();
8228
8890
  $("btn-retry").addEventListener("click", () => { const r = S.lastRun; if (r && r.task_id) retryTask(r.task_id); });
8891
+ /* 日志控制台高度:顶边手柄拖拽调整(localStorage 记忆),双击复位为默认弹性高度 */
8892
+ (() => {
8893
+ const grip = $("rd-log-grip"), box = $("rd-log");
8894
+ if (!grip || !box) return;
8895
+ const KEY = "orch.rdLogH";
8896
+ const saved = parseInt(localStorage.getItem(KEY), 10);
8897
+ /* 记忆值是「期望高度」:flex 允许收缩(shrink 1),窗口变矮时自动缩回视口内,
8898
+ 永不把抽屉顶出窗口底(用户反馈「还是超长了」的根因就是固定 px 不收缩) */
8899
+ if (saved >= 140) box.style.flex = "0 1 " + saved + "px";
8900
+ grip.addEventListener("dblclick", () => {
8901
+ localStorage.removeItem(KEY);
8902
+ box.style.flex = "";
8903
+ });
8904
+ grip.addEventListener("pointerdown", (e) => {
8905
+ e.preventDefault();
8906
+ try { grip.setPointerCapture(e.pointerId); } catch (err) { /* 老浏览器降级 */ }
8907
+ const startY = e.clientY;
8908
+ const startH = box.getBoundingClientRect().height;
8909
+ const pre = $("rd-log-text");
8910
+ const stick = pre ? pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48 : false;
8911
+ let lastH = Math.round(Math.max(startH, 140));
8912
+ document.body.classList.add("rd-log-resizing");
8913
+ const onMove = (ev) => {
8914
+ lastH = Math.round(Math.min(Math.max(startH + (startY - ev.clientY), 140), window.innerHeight * 0.72));
8915
+ box.style.flex = "0 1 " + lastH + "px";
8916
+ };
8917
+ const onUp = (ev) => {
8918
+ try { grip.releasePointerCapture(ev.pointerId); } catch (err) { /* 已释放 */ }
8919
+ document.body.classList.remove("rd-log-resizing");
8920
+ grip.removeEventListener("pointermove", onMove);
8921
+ grip.removeEventListener("pointerup", onUp);
8922
+ grip.removeEventListener("pointercancel", onUp);
8923
+ localStorage.setItem(KEY, String(lastH)); // 存钳制后的目标值,隐藏态 rect 是 0 不能用
8924
+ if (stick && pre) pre.scrollTop = pre.scrollHeight; // 拖完仍在贴底状态则继续跟最新
8925
+ };
8926
+ grip.addEventListener("pointermove", onMove);
8927
+ grip.addEventListener("pointerup", onUp);
8928
+ grip.addEventListener("pointercancel", onUp);
8929
+ });
8930
+ })();
8931
+ if ($("btn-talk")) $("btn-talk").addEventListener("click", window.rdTalkToggle);
8932
+ if ($("rd-talk-send")) $("rd-talk-send").addEventListener("click", window.rdTalkSend);
8933
+ if ($("rd-talk-input")) $("rd-talk-input").addEventListener("keydown", (e) => {
8934
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); window.rdTalkSend(); }
8935
+ });
8936
+ if ($("rd-talk-attach")) $("rd-talk-attach").addEventListener("click", () => $("rd-talk-file").click());
8937
+ if ($("rd-talk-file")) $("rd-talk-file").addEventListener("change", (e) => {
8938
+ talkUploadFiles(Array.from(e.target.files || []));
8939
+ e.target.value = "";
8940
+ });
8941
+ if ($("rd-talk-input")) $("rd-talk-input").addEventListener("paste", (e) => {
8942
+ const files = Array.from((e.clipboardData || {}).files || []);
8943
+ if (files.length) { e.preventDefault(); talkUploadFiles(files); }
8944
+ });
8229
8945
  $("btn-continue").addEventListener("click", () => {
8230
8946
  const tid = (S.lastRun && S.lastRun.task_id) || S.detailTaskKey || "";
8231
8947
  if (tid) continueSerial(tid);
@@ -8311,8 +9027,8 @@ document.addEventListener("DOMContentLoaded", () => {
8311
9027
  $("f-resume-agent").addEventListener("change", loadSessions);
8312
9028
  $("f-resume-session").addEventListener("change", showResumeHint);
8313
9029
  /* 工作目录不再用「上次用过」的旧记忆预填:那样改默认保存路径后,新建任务仍会被
8314
- * 旧目录盖过(留空才是真正跟随默认)。显式选目录的入口(向导/克隆/右键新建任务到该目录)
8315
- * 各自直接写字段值,不跨刷新持久化。清掉旧版残留键,避免误解为默认选中。 */
9030
+ * 旧目录盖过。首屏默认选中设置里的默认路径(loadSettings 落值),克隆/右键新建
9031
+ * 等显式入口各自直接写字段值,不跨刷新持久化。清掉旧版残留键,避免误解为默认选中。 */
8316
9032
  localStorage.removeItem("orch.workdir");
8317
9033
  // 附件:按钮选文件 + 目标框粘贴截图;工作目录变化时探测 git 仓库(代码版本下拉)
8318
9034
  $("btn-attach").addEventListener("click", () => $("f-attach-file").click());
@@ -8320,6 +9036,15 @@ document.addEventListener("DOMContentLoaded", () => {
8320
9036
  addAttachFiles(Array.from(e.target.files || []));
8321
9037
  e.target.value = ""; // 允许重复选同一个文件
8322
9038
  });
9039
+ const attChipBox = $("att-chips");
9040
+ if (attChipBox) attChipBox.addEventListener("click", (e) => {
9041
+ const remove = e.target.closest("[data-att-remove]");
9042
+ if (remove) {
9043
+ e.preventDefault();
9044
+ e.stopPropagation();
9045
+ removeAtt(remove.dataset.attRemove || "");
9046
+ }
9047
+ });
8323
9048
  $("f-goal").addEventListener("paste", onGoalPaste);
8324
9049
  // Composer 手感:Enter 直接发送,Shift+Enter 换行(输入法组词中不劫持)
8325
9050
  $("f-goal").addEventListener("keydown", (e) => {
@@ -8329,6 +9054,18 @@ document.addEventListener("DOMContentLoaded", () => {
8329
9054
  $("f-workdir").addEventListener("change", queueGitProbe);
8330
9055
  // 点工作目录输入框直接进文件夹选择(远端设备被 403 守卫拦下,静默继续手输)
8331
9056
  $("f-workdir").addEventListener("click", () => window.pickFolder("f-workdir", true));
9057
+ // 最近文件夹菜单:条目点击统一走 wdMenuPick;点外面收起
9058
+ const wdMenu = $("wd-menu");
9059
+ if (wdMenu) wdMenu.addEventListener("click", (e) => {
9060
+ const item = e.target.closest(".wd-item");
9061
+ if (!item) return;
9062
+ e.stopPropagation();
9063
+ window.wdMenuPick(item);
9064
+ });
9065
+ document.addEventListener("click", (e) => {
9066
+ if (wdMenu && !wdMenu.classList.contains("hidden") &&
9067
+ !e.target.closest("#wd-menu, #f-workdir-menu-btn")) wdMenu.classList.add("hidden");
9068
+ });
8332
9069
  $("set-workdir").addEventListener("click", () => window.pickFolder("set-workdir", true));
8333
9070
  $("f-git-rev").addEventListener("change", renderGitHint);
8334
9071
  if ($("f-workdir").value) queueGitProbe(); // 回填的目录:进页面即探测代码版本
@@ -8373,6 +9110,7 @@ document.addEventListener("DOMContentLoaded", () => {
8373
9110
  && e.target.tagName !== "TEXTAREA") { _askClose(true); return; }
8374
9111
  // 文件内容弹窗(240) 夹在 ask(250) 与 modal(200) 之间,Esc 同样逐层关
8375
9112
  if (e.key === "Escape" && filePopIsOpen()) { window.filePopClose(); return; }
9113
+ if (e.key === "Escape" && !$("welcome").classList.contains("hidden")) { welcomeClose(); return; }
8376
9114
  if (e.key === "Escape" && !$("modal").classList.contains("hidden")) closeModal();
8377
9115
  // 日志抽屉:Esc 收起(最底层,放在弹窗之后)
8378
9116
  if (e.key === "Escape" && !$("rd-log").classList.contains("hidden")) window.rdLogClose();
@@ -8386,7 +9124,7 @@ document.addEventListener("DOMContentLoaded", () => {
8386
9124
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
8387
9125
  if (!typing && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === "n" || e.key === "N")
8388
9126
  && $("modal").classList.contains("hidden") && $("ask").classList.contains("hidden")
8389
- && filePopIsOpen() === false && $("cmdk-mask").classList.contains("hidden")
9127
+ && $("welcome").classList.contains("hidden") && filePopIsOpen() === false && $("cmdk-mask").classList.contains("hidden")
8390
9128
  && !document.body.classList.contains("settings-mode")
8391
9129
  && !document.body.classList.contains("files-mode")) {
8392
9130
  $("btn-new-task").click();
@@ -8448,8 +9186,9 @@ document.addEventListener("DOMContentLoaded", () => {
8448
9186
  loadFlows(); // 任务类型下拉(内置 + 自定义流程)
8449
9187
  refreshSessionAgents(); // 继续会话下拉的工具集合(服务端 60s 缓存,开销小)
8450
9188
  loadOrchestrator(); // 侧栏左下角的编排者供应商指示(进入编排设置页时会再拉一次)
8451
- loadSettings(); // 启动即拉设置:新建表单的「留空则保存到:<默认路径>」提示依赖它
9189
+ loadSettings(); // 启动即拉设置:新建表单「默认选中默认路径」依赖它
8452
9190
  suStartupCheck(); // 静默查一次新版本(有新版 toast 提醒,同版本只提一次)
9191
+ maybeWelcome(); // 首次启动弹欢迎引导(orch.welcomed 记账,只弹一次)
8453
9192
  });
8454
9193
 
8455
9194
  /* ===== 命令面板(Ctrl+K / 侧栏搜索行):任务直达 + 快捷命令 ===== */
@@ -8464,6 +9203,7 @@ function cmdkOps() {
8464
9203
  { icon: "i-cpu", label: t("智能体管理"), run: () => switchTab("agents") },
8465
9204
  { icon: "i-chart", label: t("用量统计"), run: () => switchTab("usage") },
8466
9205
  { icon: "i-gear", label: t("设置"), run: () => enterSettings() },
9206
+ { icon: "i-bee", label: t("使用引导"), run: () => welcomeOpen() },
8467
9207
  { icon: "i-phone", label: t("手机连接"), run: () => switchTab("__phone") },
8468
9208
  ];
8469
9209
  }