codebee 0.1.7 → 0.1.9

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, creatingTask: 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() {
@@ -50,17 +53,55 @@ function renderTypeOptions() {
50
53
  function renderTypeMenu() {
51
54
  const menu = $("type-menu");
52
55
  if (!menu || !S.flows) return;
53
- menu.innerHTML = (S.flows || []).map((f) =>
54
- '<button type="button" class="type-item" role="option" data-v="' + esc(f.id) + '" onclick="pickType(\'' + esc(f.id) + '\')">' +
56
+ menu.innerHTML = (S.flows || []).map((f) => {
57
+ const est = S.flowEstimates && S.flowEstimates[f.id];
58
+ return '<button type="button" class="type-item" role="option" data-v="' + esc(f.id) + '" onclick="pickType(\'' + esc(f.id) + '\')">' +
55
59
  '<span class="ti-ico" aria-hidden="true">' + flowIconHtml(f) + "</span>" +
56
60
  '<span class="ti-body"><span class="ti-name">' + esc(t(f.name)) +
57
61
  (f.builtin ? "" : '<em class="ti-tag">' + t("自定义") + "</em>") + "</span>" +
58
- '<span class="ti-desc">' + esc(flowDesc(f)) + "</span></span>" +
62
+ '<span class="ti-desc">' + esc(flowDesc(f)) + "</span>" +
63
+ (est ? '<span class="ti-est">' + esc(t("≈{0} tokens · {1}次", fmtTok(est.median_tokens), est.samples)) + "</span>" : "") +
64
+ "</span>" +
59
65
  '<svg class="ico ti-check" aria-hidden="true"><use href="#i-check"></use></svg>' +
60
- "</button>").join("");
66
+ "</button>";
67
+ }).join("")
68
+ /* 底部固定入口:管理流程替代原工具条上的「管理」按钮(简化 composer 工具行) */
69
+ + '<div class="type-menu-foot"><button type="button" class="type-item" onclick="manageFlowsFromMenu()">' +
70
+ '<span class="ti-ico" aria-hidden="true"><svg class="ico"><use href="#i-gear"></use></svg></span>' +
71
+ '<span class="ti-body"><span class="ti-name">' + esc(t("管理任务类型…")) + "</span></span></button></div>";
61
72
  syncTypeBtn();
73
+ fetchFlowEstimates(); // 异步回填预估行(就地更新,不重建菜单)
62
74
  }
63
75
 
76
+ /* 类型菜单的成本/次数预估(借鉴 omnigent,数据源 /api/usage/estimate):
77
+ * 拉到手后就地更新菜单项——菜单开着也不重建 DOM,不打断滚动/悬停 */
78
+ async function fetchFlowEstimates() {
79
+ if (!S.flows) return;
80
+ const targets = S.flows.slice(0, 10);
81
+ const results = await Promise.all(targets.map(async (f) => {
82
+ try {
83
+ const d = await api("/api/usage/estimate?type=" + encodeURIComponent(f.id) + "&days=90");
84
+ return [f.id, (d && d.samples > 0) ? d : null];
85
+ } catch (e) { return [f.id, null]; }
86
+ }));
87
+ S.flowEstimates = Object.fromEntries(results.filter(([, d]) => d));
88
+ const menu = $("type-menu");
89
+ if (!menu) return;
90
+ menu.querySelectorAll(".type-item[data-v]").forEach((b) => {
91
+ const d = S.flowEstimates[b.dataset.v];
92
+ const body = b.querySelector(".ti-body");
93
+ if (!d || !body || body.querySelector(".ti-est")) return;
94
+ const est = document.createElement("span");
95
+ est.className = "ti-est";
96
+ est.textContent = t("≈{0} tokens · {1}次", fmtTok(d.median_tokens), d.samples);
97
+ body.appendChild(est);
98
+ });
99
+ }
100
+ window.manageFlowsFromMenu = function () {
101
+ toggleTypeMenu(false);
102
+ openFlowsManager();
103
+ };
104
+
64
105
  function syncTypeBtn() {
65
106
  const sel = $("f-type");
66
107
  const flow = flowById(sel.value);
@@ -97,34 +138,35 @@ function onTypeChange() {
97
138
  const engine = flow ? flow.engine : "code";
98
139
  const isReview = engine === "review";
99
140
  const isDirect = engine === "direct";
100
- const codeOnly = $("f-code-only"), reviewOnly = $("f-review-only");
141
+ const codeOnly = $("f-code-only"), reviewOnly = $("f-review-only");
101
142
  // 直连:验证命令与评审参数都不适用,两块一起收起(目标+附件即全部输入)
102
- if (codeOnly) codeOnly.classList.toggle("hidden", isReview || isDirect);
103
- if (reviewOnly) reviewOnly.classList.toggle("hidden", !isReview);
104
- const bibleCreate = $("bible-create-field");
105
- const isSerialReview = isReview && !!(flow && flow.serial);
106
- if (bibleCreate) bibleCreate.classList.toggle("hidden", !isSerialReview);
107
- const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible"].forEach((id) => {
108
- const el = $(id);
109
- if (el) el.value = "";
110
- });
143
+ if (codeOnly) codeOnly.classList.toggle("hidden", isReview || isDirect);
144
+ if (reviewOnly) reviewOnly.classList.toggle("hidden", !isReview);
145
+ const bibleCreate = $("bible-create-field");
146
+ const isSerialReview = isReview && !!(flow && flow.serial);
147
+ if (bibleCreate) bibleCreate.classList.toggle("hidden", !isSerialReview);
148
+ const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible"].forEach((id) => {
149
+ const el = $(id);
150
+ if (el) el.value = "";
151
+ });
111
152
  const goal = $("f-goal");
112
153
  if (goal && flow && flow.goal_hint) goal.placeholder = t(flow.goal_hint);
113
154
  if (isReview && flow) {
114
155
  if (flow.manuscript) $("f-manuscript").value = flow.manuscript;
115
156
  if (flow.rounds) $("f-rounds").value = flow.rounds;
116
157
  if (flow.threshold) $("f-threshold").value = flow.threshold;
158
+ if (flow.best_of) $("f-bestof").value = flow.best_of;
117
159
  const saved = ($("f-rubric").value || "").trim();
118
160
  if (!saved && flow.rubric) $("f-rubric").value = flow.rubric.join(", ");
119
161
  // 连载参数预填(用户可改/可清空 = 单稿件模式)
120
- if (flow.serial) {
121
- if (!$("f-chapters").value) $("f-chapters").value = flow.serial.chapters || "";
122
- if (!$("f-words-per-ch").value) $("f-words-per-ch").value = flow.serial.words_per_chapter || "";
123
- } else clearSerialFields();
124
- } else {
125
- // 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
126
- clearSerialFields();
127
- }
162
+ if (flow.serial) {
163
+ if (!$("f-chapters").value) $("f-chapters").value = flow.serial.chapters || "";
164
+ if (!$("f-words-per-ch").value) $("f-words-per-ch").value = flow.serial.words_per_chapter || "";
165
+ } else clearSerialFields();
166
+ } else {
167
+ // 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
168
+ clearSerialFields();
169
+ }
128
170
  renderImplSelects();
129
171
  refreshEstimate();
130
172
  }
@@ -327,126 +369,6 @@ function uiPrompt(title, value) {
327
369
  }).then((ok) => ((ok && inp) ? inp.value.trim() : null));
328
370
  }
329
371
 
330
- /* ------------------------------------------------- 采访式向导(新建任务) */
331
- /* 不想面对整张表单的新用户:三问一确认——目标 → 类型 → 目录 → 预览填入。
332
- * 代码类流程多追问一步「验证命令」(怎么算跑通);页序列按所选类型动态生成。
333
- * 向导只把答案预填进既有表单,提交/附件/评审设置全部留在表单里:
334
- * 不新增提交路径,质量闸门与既有测试照旧。目录浏览用表单里的「选择…」。 */
335
- let wiz = null; // { page, pages, goal, type, workdir, verify }
336
-
337
- function wizardPages(type) {
338
- const flow = flowById(type);
339
- return ["goal", "type"].concat(
340
- flow && flow.engine === "code" ? ["verify"] : [], ["workdir", "summary"]);
341
- }
342
-
343
- window.openTaskWizard = async function () {
344
- // 冷启动时 boot 的 loadFlows 可能晚于用户点击:空了就现拉一次(autoForm 同款防御)
345
- if (!S.flows || !S.flows.length) { try { await loadFlows(); } catch (e) { /* 渲染时兜底 */ } }
346
- const type = (($("f-type") || {}).value) || ((S.flows || [])[0] || {}).id || "";
347
- wiz = { page: 0, pages: wizardPages(type), goal: "", type,
348
- workdir: (($("f-workdir") || {}).value || ""), verify: "" };
349
- renderWizard();
350
- };
351
-
352
- window.wizardBack = function () { if (wiz && wiz.page > 0) { wiz.page--; renderWizard(); } };
353
- window.wizardPickType = function (id) {
354
- if (!wiz) return;
355
- wiz.type = id;
356
- wiz.pages = wizardPages(id); // 按新类型重排页序列(代码流程插入验证步)
357
- wiz.page = 2; // 类型页的下一页
358
- renderWizard();
359
- };
360
-
361
- window.wizardNext = function () {
362
- if (!wiz) return;
363
- const id = wiz.pages[wiz.page];
364
- if (id === "goal") {
365
- const v = (($("wz-goal") || {}).value || "").trim();
366
- if (!v) { toast(t("目标还不能为空"), true); return; }
367
- wiz.goal = v;
368
- wiz.page++;
369
- } else if (id === "verify") {
370
- wiz.verify = (($("wz-verify") || {}).value || "").trim();
371
- wiz.page++;
372
- } else if (id === "workdir") {
373
- wiz.workdir = (($("wz-workdir") || {}).value || "").trim();
374
- wiz.page++;
375
- }
376
- renderWizard();
377
- };
378
-
379
- function wizardFoot(back) {
380
- return (back ? '<button class="ghost" onclick="wizardBack()">' + esc(t("上一步")) + "</button>" : "") +
381
- '<button class="primary" onclick="wizardNext()">' + esc(t("下一步")) + "</button>";
382
- }
383
-
384
- function renderWizard() {
385
- if (!wiz) return;
386
- const f = flowById(wiz.type);
387
- const id = wiz.pages[wiz.page];
388
- let body = "", foot = "";
389
- if (id === "goal") {
390
- body = '<textarea id="wz-goal" class="wz-goal" rows="4" placeholder="' +
391
- esc(t("要完成什么,一句话即可")) +
392
- '" onkeydown="if(event.key===\'Enter\'&&!event.shiftKey){event.preventDefault();wizardNext()}">' +
393
- esc(wiz.goal) + "</textarea>";
394
- foot = wizardFoot(false);
395
- } else if (id === "type") {
396
- body = '<div class="wz-flows">' + (S.flows || []).map((fl) =>
397
- '<button type="button" class="wz-flow' + (fl.id === wiz.type ? " on" : "") +
398
- '" onclick="wizardPickType(\'' + esc(fl.id) + '\')">' +
399
- flowIconHtml(fl) +
400
- '<span class="wz-fname">' + esc(fl.name || fl.id) + "</span>" +
401
- '<span class="wz-fdesc">' + esc(flowDesc(fl)) + "</span></button>").join("") + "</div>" +
402
- '<p class="hint">' + esc(t("点一个类型即选定并继续;进表单后仍可改")) + "</p>";
403
- foot = wizardFoot(true);
404
- } else if (id === "verify") {
405
- body = '<input id="wz-verify" class="wz-input" autocomplete="off" value="' + esc(wiz.verify) +
406
- '" placeholder="' + esc(t("例:npm test、pytest -q;留空=只靠 AI 评审")) +
407
- '" onkeydown="if(event.key===\'Enter\'){event.preventDefault();wizardNext()}">' +
408
- '<p class="hint">' + esc(t("一条能在工作目录里跑的命令,退出码 0 即视为通过;之后可在表单改")) + "</p>";
409
- foot = wizardFoot(true);
410
- } else if (id === "workdir") {
411
- body = '<input id="wz-workdir" class="wz-input" autocomplete="off" value="' + esc(wiz.workdir) +
412
- '" placeholder="' + esc(t("留空用默认保存路径;也可稍后在表单里点「选择…」")) +
413
- '" onkeydown="if(event.key===\'Enter\'){event.preventDefault();wizardNext()}">' +
414
- '<p class="hint">' + esc(t("建议给每个任务一个独立目录,产出互不覆盖")) + "</p>";
415
- foot = wizardFoot(true);
416
- } else {
417
- body = '<div class="wz-sum">' +
418
- '<div><span class="wz-k">' + esc(t("类型")) + "</span>" + esc((f && f.name) || wiz.type) + "</div>" +
419
- '<div><span class="wz-k">' + esc(t("目标")) + "</span>" + esc(wiz.goal.slice(0, 120)) + "</div>" +
420
- (f && f.engine === "code"
421
- ? '<div><span class="wz-k">' + esc(t("验证命令")) + "</span>" +
422
- esc(wiz.verify || t("留空靠 AI 评审")) + "</div>"
423
- : "") +
424
- '<div><span class="wz-k">' + esc(t("工作目录")) + "</span>" +
425
- esc(wiz.workdir || t("默认保存路径")) + "</div></div>" +
426
- '<p class="hint">' + esc(t("填入表单后可继续:加附件、选实现者、改评审设置")) + "</p>";
427
- foot = '<button class="ghost" onclick="wizardBack()">' + esc(t("上一步")) + "</button>" +
428
- '<button class="primary" onclick="wizardApply()">' + esc(t("填入表单")) + "</button>";
429
- }
430
- openModal(t("新建任务向导") + " · " + (wiz.page + 1) + "/" + wiz.pages.length, body, foot);
431
- const first = $("wz-goal") || $("wz-verify") || $("wz-workdir");
432
- if (first) first.focus();
433
- }
434
-
435
- window.wizardApply = function () {
436
- if (!wiz) return;
437
- closeModal();
438
- const flow = flowById(wiz.type);
439
- if (flow && $("f-type").value !== wiz.type) { $("f-type").value = wiz.type; onTypeChange(); }
440
- $("f-goal").value = wiz.goal;
441
- if (flow && flow.engine === "code") $("f-verify").value = wiz.verify || "";
442
- if (wiz.workdir) $("f-workdir").value = wiz.workdir; // 只预填本次表单,不再持久化为「下次默认」
443
- S.atts = []; renderAttachChips(); // 新目标不带旧附件
444
- queueGitProbe(); // 工作目录可能变了,重新探测代码版本
445
- $("f-goal").focus();
446
- toast(t("已按向导预填,检查或补充后点「创建并运行」"));
447
- wiz = null;
448
- };
449
-
450
372
  /* 访问令牌门(仅远程设备会碰到) */
451
373
  function showTokenGate(err) {
452
374
  const g = $("token-gate");
@@ -1073,13 +995,16 @@ function renderProvDetail() {
1073
995
  html += '<details class="pd-config"><summary>' + t("编辑供应商配置(地址 / 协议 / 难度模型)") + '</summary>' +
1074
996
  providerCard(p) + "</details>";
1075
997
  if (p.models == null) {
1076
- html += '<div class="empty">' + t("尚未获取模型列表——点上方「获取模型列表」。") + '</div>';
998
+ html += '<div class="empty">' + t("尚未获取模型列表——点上方「获取模型列表」。") + '</div>' +
999
+ '<div class="hint">' + t("厂商列表接口调不通?直接手工添加模型名也能用。") + '</div>' +
1000
+ pmAddHtml(p);
1077
1001
  } else {
1078
1002
  const kw = (S.modelFilter || {})[p.id] || "";
1079
1003
  html += '<div class="pm-tools">' +
1080
1004
  '<input id="pm-search-' + esc(p.id) + '" type="search" placeholder="' + t("按名称过滤模型…") + '" autocomplete="off"' +
1081
1005
  ' value="' + esc(kw) + '" oninput="filterModels(\'' + esc(p.id) + '\', this.value)">' +
1082
- '<span class="pm-count" id="pm-count-' + esc(p.id) + '"></span></div>';
1006
+ '<span class="pm-count" id="pm-count-' + esc(p.id) + '"></span>' +
1007
+ pmAddHtml(p) + '</div>';
1083
1008
  html += '<div id="pm-groups" class="pm-groups' + (selN ? " has-sel" : "") + '">' +
1084
1009
  provModelGroupsHtml(p) + "</div>";
1085
1010
  if (hidden.length) {
@@ -1514,6 +1439,36 @@ async function refreshAllModels() {
1514
1439
  setTimeout(autoRebindSoon, 8000); // 模型列表落盘后推荐源才完整,补绑一次
1515
1440
  }
1516
1441
 
1442
+ /* 手工添加模型:有的厂商列表接口调不通(或只返回部分),直接填模型名也能进列表。
1443
+ * 后端打 manual 标记,之后刷新即使厂商列表里没有它也不丢。 */
1444
+ function pmAddHtml(p) {
1445
+ return '<div class="pm-add">' +
1446
+ '<input id="pm-add-' + esc(p.id) + '" type="text" autocomplete="off" spellcheck="false"' +
1447
+ ' placeholder="' + t("手工添加模型名(列表接口调不通时用),回车添加") + '"' +
1448
+ ' onkeydown="pmAddKey(event, \'' + esc(p.id) + '\')">' +
1449
+ '<button class="ghost small" title="' + esc(t("厂商列表接口调不通时,直接把模型名加进列表")) + '"' +
1450
+ ' onclick="addModelManual(\'' + esc(p.id) + '\')">' +
1451
+ '<svg class="ico" aria-hidden="true"><use href="#i-plus"/></svg>' + t("添加") + '</button></div>';
1452
+ }
1453
+
1454
+ function pmAddKey(ev, pid) {
1455
+ if (ev.key === "Enter") { ev.preventDefault(); addModelManual(pid); }
1456
+ }
1457
+
1458
+ async function addModelManual(pid) {
1459
+ const inp = $("pm-add-" + pid);
1460
+ const name = ((inp && inp.value) || "").trim();
1461
+ if (!name) { toast(t("先填模型名"), true); return; }
1462
+ try {
1463
+ const r = await api("/api/models/add", { method: "POST", body: JSON.stringify({ id: pid, name }) });
1464
+ if (!r.ok) { toast(t("添加失败:") + (r.message || ""), true); return; }
1465
+ toast(t("已添加模型 ") + name);
1466
+ S.modelsSig = null; // 新模型落盘后签名必变,这里主动置空确保立刻重绘
1467
+ poll();
1468
+ setTimeout(autoRebindSoon, 4000); // 模型可用后推荐源更完整,补绑一次
1469
+ } catch (e) { toast(t("添加失败:") + e.message, true); }
1470
+ }
1471
+
1517
1472
  async function refreshProviderModels(id) {
1518
1473
  toast(t("正在获取模型列表…"));
1519
1474
  try {
@@ -1803,39 +1758,39 @@ function renderImplSelects() {
1803
1758
  }
1804
1759
  }
1805
1760
 
1806
- async function createTask() {
1807
- if (S.creatingTask) return;
1808
- S.creatingTask = true;
1809
- const submit = $("btn-create");
1810
- if (submit) {
1811
- submit.disabled = true;
1812
- submit.setAttribute("aria-busy", "true");
1813
- }
1814
- const resetSubmit = () => {
1815
- S.creatingTask = false;
1816
- if (submit) {
1817
- submit.disabled = false;
1818
- submit.removeAttribute("aria-busy");
1819
- }
1820
- };
1821
- const msg = $("create-msg");
1761
+ async function createTask() {
1762
+ if (S.creatingTask) return;
1763
+ S.creatingTask = true;
1764
+ const submit = $("btn-create");
1765
+ if (submit) {
1766
+ submit.disabled = true;
1767
+ submit.setAttribute("aria-busy", "true");
1768
+ }
1769
+ const resetSubmit = () => {
1770
+ S.creatingTask = false;
1771
+ if (submit) {
1772
+ submit.disabled = false;
1773
+ submit.removeAttribute("aria-busy");
1774
+ }
1775
+ };
1776
+ const msg = $("create-msg");
1822
1777
  msg.className = "msg"; msg.textContent = t("提交中…");
1823
1778
  const payload = {
1824
1779
  type: $("f-type").value,
1825
1780
  mode: $("f-mode").value,
1826
1781
  title: $("f-title").value.trim(),
1827
1782
  goal: $("f-goal").value.trim(),
1828
- context: $("f-context").value.trim(),
1829
- workdir: $("f-workdir").value.trim(),
1830
- };
1831
- if (!payload.goal) {
1832
- msg.className = "msg err";
1833
- msg.textContent = t("请先填写目标");
1834
- $("f-goal").focus();
1835
- resetSubmit();
1836
- return;
1837
- }
1838
- if (!payload.workdir) delete payload.workdir; // 留空 → 服务端用「默认保存路径」(编排设置可改)
1783
+ context: $("f-context").value.trim(),
1784
+ workdir: $("f-workdir").value.trim(),
1785
+ };
1786
+ if (!payload.goal) {
1787
+ msg.className = "msg err";
1788
+ msg.textContent = t("请先填写目标");
1789
+ $("f-goal").focus();
1790
+ resetSubmit();
1791
+ return;
1792
+ }
1793
+ if (!payload.workdir) delete payload.workdir; // 留空 → 服务端用「默认保存路径」(编排设置可改)
1839
1794
  else unhideSideDir(payload.workdir); // 在已移除的目录新建任务 → 自动恢复显示
1840
1795
  if (payload.mode === "manual") payload.implementer = $("f-impl").value;
1841
1796
  // 附件与代码版本:任务创建时服务端把待提交附件移入 _attachments/ 并注入上下文
@@ -1853,8 +1808,8 @@ async function createTask() {
1853
1808
  payload.resume = { agent: resumeAgent, session: sid,
1854
1809
  preview: opt ? opt.textContent : "", project: (hit && hit.project) || "" };
1855
1810
  }
1856
- const flow = flowById(payload.type) || {};
1857
- const _eng = flow.engine;
1811
+ const flow = flowById(payload.type) || {};
1812
+ const _eng = flow.engine;
1858
1813
  if (_eng === "code") {
1859
1814
  payload.verify_command = $("f-verify").value.trim();
1860
1815
  } else if (_eng === "direct") {
@@ -1863,21 +1818,22 @@ async function createTask() {
1863
1818
  payload.manuscript = $("f-manuscript").value.trim() || "manuscript.md";
1864
1819
  payload.rounds = parseInt($("f-rounds").value, 10) || 2;
1865
1820
  payload.threshold = parseFloat($("f-threshold").value) || 7.0;
1821
+ payload.best_of = Math.max(1, Math.min(3, parseInt($("f-bestof").value, 10) || 1));
1866
1822
  const rubric = $("f-rubric").value.trim();
1867
1823
  if (rubric) payload.rubric = rubric.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
1868
1824
  const ch = parseInt($("f-chapters").value, 10);
1869
- if (ch >= 2) {
1870
- payload.serial = {
1871
- chapters: ch,
1872
- words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
1873
- variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
1874
- };
1875
- } else if (flow.serial) {
1876
- // 显式 null 覆盖 serial_novel 的流程默认,空章节就是单稿件。
1877
- payload.serial = null;
1878
- }
1879
- const initialBible = ($("f-bible") || {}).value ? $("f-bible").value.trim() : "";
1880
- if (initialBible && flow.serial && payload.serial) payload.story_bible = initialBible;
1825
+ if (ch >= 2) {
1826
+ payload.serial = {
1827
+ chapters: ch,
1828
+ words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
1829
+ variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
1830
+ };
1831
+ } else if (flow.serial) {
1832
+ // 显式 null 覆盖 serial_novel 的流程默认,空章节就是单稿件。
1833
+ payload.serial = null;
1834
+ }
1835
+ const initialBible = ($("f-bible") || {}).value ? $("f-bible").value.trim() : "";
1836
+ if (initialBible && flow.serial && payload.serial) payload.story_bible = initialBible;
1881
1837
  const critics = Array.from($("f-critics").querySelectorAll("input:checked")).map((i) => i.value);
1882
1838
  if (critics.length) payload.critics = critics;
1883
1839
  }
@@ -1891,12 +1847,12 @@ async function createTask() {
1891
1847
  $("f-goal").value = "";
1892
1848
  if ($("f-bible")) $("f-bible").value = "";
1893
1849
  S.atts = []; renderAttachChips(); // 附件已移交任务待提交区,清空本地列表
1894
- } catch (e) {
1895
- msg.className = "msg err"; msg.textContent = e.message;
1896
- } finally {
1897
- resetSubmit();
1898
- }
1899
- }
1850
+ } catch (e) {
1851
+ msg.className = "msg err"; msg.textContent = e.message;
1852
+ } finally {
1853
+ resetSubmit();
1854
+ }
1855
+ }
1900
1856
 
1901
1857
  /* ---------------------------------------------------------- 附件(截图/文件) */
1902
1858
  /* 待提交附件:选中/粘贴即上传到服务端 pending 区,创建任务时随 payload 落盘
@@ -1910,14 +1866,25 @@ const ATT_CAP_OFFICE = 24 * 1024 * 1024; // Office 文档放宽到 24MB
1910
1866
  function renderAttachChips() {
1911
1867
  const box = $("att-chips");
1912
1868
  if (!box) return;
1913
- box.innerHTML = (S.atts || []).map((a) =>
1914
- '<span class="att-chip" title="' + esc(a.name) + '">' +
1915
- '<svg class="ico"><use href="#i-paperclip"/></svg>' +
1916
- "<span>" + esc(a.name) + "</span>" +
1917
- " <i>" + fmtSize(a.size) + "</i>" +
1918
- '<button class="ax" title="' + esc(t("移除")) + '" onclick="removeAtt(\'' + a.id + '\')">' +
1919
- '<svg class="ico"><use href="#i-x"/></svg></button></span>'
1920
- ).join("");
1869
+ box.innerHTML = (S.atts || []).map((a) => {
1870
+ const name = String(a.name || "attachment");
1871
+ const id = String(a.id || "");
1872
+ const image = /^image\//i.test(String(a.mime || "")) ||
1873
+ /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name);
1874
+ const remove = '<button type="button" class="ax" data-att-remove="' + esc(id) +
1875
+ '" title="' + esc(t("移除")) + '" aria-label="' + esc(t("移除")) + '">' +
1876
+ '<svg class="ico" aria-hidden="true"><use href="#i-x"></use></svg></button>';
1877
+ if (image && id) {
1878
+ const src = "/api/attachments/" + encodeURIComponent(id) + qsAuth();
1879
+ return '<span class="att-chip att-image" title="' + esc(name) + '">' +
1880
+ '<img src="' + esc(src) + '" alt="' + esc(name) + '" loading="lazy" draggable="false">' +
1881
+ '<span class="att-image-name">' + esc(name) + "</span>" + remove + "</span>";
1882
+ }
1883
+ return '<span class="att-chip" title="' + esc(name) + '">' +
1884
+ '<svg class="ico" aria-hidden="true"><use href="#i-paperclip"></use></svg>' +
1885
+ "<span>" + esc(name) + "</span>" +
1886
+ " <i>" + fmtSize(a.size) + "</i>" + remove + "</span>";
1887
+ }).join("");
1921
1888
  }
1922
1889
 
1923
1890
  function fmtSize(n) {
@@ -2009,7 +1976,9 @@ async function probeGit() {
2009
1976
  const prev = sel.value;
2010
1977
  const opt = (v, label) => '<option value="' + esc(v) + '">' + esc(label) + "</option>";
2011
1978
  const groups = [];
2012
- groups.push(opt("", t("不切换(用当前分支 %1)").replace("%1", info.branch || "HEAD")));
1979
+ // 首选项=跟随当前分支:胶囊上直接显示分支名(参考工作区选择器的「main ▾」),
1980
+ // 悬停胶囊看完整说明(renderGitHint 写 row.title);换分支在「分支」组里选
1981
+ groups.push(opt("", info.branch || "HEAD"));
2013
1982
  groups.push('<optgroup label="' + esc(t("分支")) + '">'
2014
1983
  + (info.branches || []).map((b) => opt("branch:" + b, b)).join("") + "</optgroup>");
2015
1984
  if ((info.tags || []).length) {
@@ -2037,10 +2006,69 @@ function renderGitHint() {
2037
2006
  const act = kind
2038
2007
  ? t(";运行时将从「%1」检出任务分支 codebee/&lt;任务ID&gt;").replace("%1", rev)
2039
2008
  : "";
2040
- $("git-hint").innerHTML = esc(base + dirty + act);
2009
+ const text = base + dirty + act;
2010
+ $("git-hint").innerHTML = esc(text);
2011
+ // 极简:提示行不再常驻,分支胶囊的悬停 title 承载同一份详情(剥掉 innerHTML 用的实体)
2012
+ const row = $("row-git");
2013
+ if (row) row.title = text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
2041
2014
  }
2042
2015
  window.probeGit = probeGit;
2043
2016
 
2017
+ /* ---- 工作目录「最近文件夹」下拉:默认选中一个目录,▾ 展开最近 5 个 + 浏览/用默认 ---- */
2018
+ function wdRecents() {
2019
+ const st = S.state || {};
2020
+ const seen = new Set(); const out = [];
2021
+ for (const tk of (st.tasks || []).concat(st.archived_tasks || [])) {
2022
+ const wd = (tk.workdir || "").trim();
2023
+ if (!wd || seen.has(wd)) continue;
2024
+ seen.add(wd); out.push(wd);
2025
+ if (out.length >= 5) break;
2026
+ }
2027
+ return out;
2028
+ }
2029
+ window.toggleWdMenu = function (ev) {
2030
+ if (ev) ev.stopPropagation();
2031
+ const menu = $("wd-menu");
2032
+ if (!menu) return;
2033
+ if (!menu.classList.contains("hidden")) { menu.classList.add("hidden"); return; }
2034
+ const cur = (($("f-workdir") || {}).value || "").trim();
2035
+ const eff = ((S.settings || {}).default_workdir_effective || "").trim();
2036
+ const base = (p) => p.split(/[\\/]/).filter(Boolean).pop() || p;
2037
+ const row = (p, label, on) =>
2038
+ '<button type="button" class="wd-item' + (on ? " on" : "") + '" data-wd="' + esc(p) +
2039
+ '" title="' + esc(p) + '">' +
2040
+ '<svg class="ico" aria-hidden="true"><use href="#i-folder"></use></svg>' +
2041
+ "<span>" + esc(label) + "</span>" +
2042
+ (on ? '<svg class="ico wd-check" aria-hidden="true"><use href="#i-check"></use></svg>' : "") +
2043
+ "</button>";
2044
+ let html = wdRecents().map((p) => row(p, base(p), p === cur)).join("");
2045
+ html += '<div class="wd-sep"></div>' +
2046
+ '<button type="button" class="wd-item" data-wd-act="browse">' +
2047
+ '<svg class="ico" aria-hidden="true"><use href="#i-folder"></use></svg>' +
2048
+ "<span>" + esc(t("浏览本机目录…")) + "</span></button>" +
2049
+ '<button type="button" class="wd-item" data-wd-act="default"' + (eff ? "" : " disabled") + ">" +
2050
+ '<svg class="ico" aria-hidden="true"><use href="#i-refresh"></use></svg>' +
2051
+ "<span>" + esc(t("用默认路径")) + "</span></button>";
2052
+ menu.innerHTML = html;
2053
+ menu.classList.remove("hidden");
2054
+ };
2055
+ window.wdMenuPick = function (item) {
2056
+ const inp = $("f-workdir"), menu = $("wd-menu");
2057
+ if (!item || !inp || item.disabled) return;
2058
+ if (item.dataset.wdAct === "browse") {
2059
+ if (menu) menu.classList.add("hidden");
2060
+ window.pickFolder("f-workdir");
2061
+ return;
2062
+ }
2063
+ inp.value = item.dataset.wdAct === "default"
2064
+ ? ((S.settings || {}).default_workdir_effective || "")
2065
+ : (item.dataset.wd || "");
2066
+ inp.dispatchEvent(new Event("input", { bubbles: true })); // queueGitProbe 跟上换目录
2067
+ inp.dispatchEvent(new Event("change", { bubbles: true }));
2068
+ if (menu) menu.classList.add("hidden");
2069
+ inp.focus();
2070
+ };
2071
+
2044
2072
  /* ---------------------------------------------------------- 会话延续 */
2045
2073
  /* 后端支持会话扫描的工具集合(/api/sessions 的键);决定继续会话下拉出现哪些工具 */
2046
2074
  async function refreshSessionAgents() {
@@ -2166,59 +2194,59 @@ function openCtxMenu(x, y, items) {
2166
2194
  };
2167
2195
  }
2168
2196
 
2169
- function closeCtxMenu() {
2170
- const menu = $("ctx-menu");
2171
- if (menu && !menu.classList.contains("hidden")) menu.classList.add("hidden");
2172
- }
2173
-
2174
- /* 任务行的「⋯」与右键共用同一组动作,避免两个入口越改越不一样。
2175
- * det 是 .stask 元素;菜单位置由调用方提供,缺省时贴在行右下角。 */
2176
- function taskContextItems(det) {
2177
- const taskId = det && det.dataset ? (det.dataset.task || "") : "";
2178
- const runId = det && det.dataset ? (det.dataset.run || "") : "";
2179
- const items = [];
2180
- if (runId) items.push({ label: t("打开详情"), fn: () => sideOpenRun(runId) });
2181
- if (taskId) {
2182
- items.push("-");
2183
- items.push({ label: t("打开工作目录"), fn: () => revealPath("tasks", taskId, true) });
2184
- items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2185
- if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2186
- const st = det.dataset.status || "";
2187
- if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2188
- items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2189
- fn: () => newFromTask(taskId) });
2190
- const sTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
2191
- if (sTask && sTask.serial && st !== "running" && st !== "queued")
2192
- items.push({ label: t("继续连载(新任务)"), fn: () => continueSerial(taskId) });
2193
- items.push({ label: t("重命名任务"), fn: () => renameTask(taskId) });
2194
- const archived = archivedTaskIds().has(taskId);
2195
- items.push({ label: archived ? t("取消归档") : t("归档"), fn: () => archiveTask(taskId, !archived) });
2196
- items.push({ label: t("删除任务"), danger: true, fn: () => deleteTask(taskId) });
2197
- } else if (runId) {
2198
- items.push("-");
2199
- items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2200
- items.push({ label: t("删除记录"), danger: true, fn: () => deleteRun(runId) });
2201
- }
2202
- return items;
2203
- }
2204
-
2205
- window.openTaskActions = function (det, ev) {
2206
- if (!det) return;
2207
- const r = det.getBoundingClientRect();
2208
- const x = ev && Number.isFinite(ev.clientX) ? ev.clientX : r.right - 8;
2209
- const y = ev && Number.isFinite(ev.clientY) ? ev.clientY : r.bottom;
2210
- openCtxMenu(x, y, taskContextItems(det));
2211
- };
2212
-
2213
- function bindCtxMenus() {
2197
+ function closeCtxMenu() {
2198
+ const menu = $("ctx-menu");
2199
+ if (menu && !menu.classList.contains("hidden")) menu.classList.add("hidden");
2200
+ }
2201
+
2202
+ /* 任务行的「⋯」与右键共用同一组动作,避免两个入口越改越不一样。
2203
+ * det 是 .stask 元素;菜单位置由调用方提供,缺省时贴在行右下角。 */
2204
+ function taskContextItems(det) {
2205
+ const taskId = det && det.dataset ? (det.dataset.task || "") : "";
2206
+ const runId = det && det.dataset ? (det.dataset.run || "") : "";
2207
+ const items = [];
2208
+ if (runId) items.push({ label: t("打开详情"), fn: () => sideOpenRun(runId) });
2209
+ if (taskId) {
2210
+ items.push("-");
2211
+ items.push({ label: t("打开工作目录"), fn: () => revealPath("tasks", taskId, true) });
2212
+ items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2213
+ if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2214
+ const st = det.dataset.status || "";
2215
+ if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2216
+ items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2217
+ fn: () => newFromTask(taskId) });
2218
+ const sTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
2219
+ if (sTask && sTask.serial && st !== "running" && st !== "queued")
2220
+ items.push({ label: t("继续连载(新任务)"), fn: () => continueSerial(taskId) });
2221
+ items.push({ label: t("重命名任务"), fn: () => renameTask(taskId) });
2222
+ const archived = archivedTaskIds().has(taskId);
2223
+ items.push({ label: archived ? t("取消归档") : t("归档"), fn: () => archiveTask(taskId, !archived) });
2224
+ items.push({ label: t("删除任务"), danger: true, fn: () => deleteTask(taskId) });
2225
+ } else if (runId) {
2226
+ items.push("-");
2227
+ items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2228
+ items.push({ label: t("删除记录"), danger: true, fn: () => deleteRun(runId) });
2229
+ }
2230
+ return items;
2231
+ }
2232
+
2233
+ window.openTaskActions = function (det, ev) {
2234
+ if (!det) return;
2235
+ const r = det.getBoundingClientRect();
2236
+ const x = ev && Number.isFinite(ev.clientX) ? ev.clientX : r.right - 8;
2237
+ const y = ev && Number.isFinite(ev.clientY) ? ev.clientY : r.bottom;
2238
+ openCtxMenu(x, y, taskContextItems(det));
2239
+ };
2240
+
2241
+ function bindCtxMenus() {
2214
2242
  // 侧栏任务树:右键任务 → 详情/目录/重试/重命名/归档/删除;管理类运行 → 详情/目录/删除记录
2215
2243
  $("side-tasks").addEventListener("contextmenu", (e) => {
2216
2244
  // .stask 在 .sdir 内部:先判任务行,点到才算文件夹自己的菜单
2217
- const det = e.target.closest(".stask");
2218
- if (det) {
2219
- e.preventDefault();
2220
- openCtxMenu(e.clientX, e.clientY, taskContextItems(det));
2221
- return;
2245
+ const det = e.target.closest(".stask");
2246
+ if (det) {
2247
+ e.preventDefault();
2248
+ openCtxMenu(e.clientX, e.clientY, taskContextItems(det));
2249
+ return;
2222
2250
  }
2223
2251
  // 文件夹行(ZCode 桌面端式样):新建任务到该目录 / 打开工作目录 / 复制路径 / 移除。
2224
2252
  // 「其他」是兜底展示(无主运行的聚合),不是真实目录,不弹菜单。
@@ -2254,10 +2282,11 @@ function bindCtxMenus() {
2254
2282
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeCtxMenu(); });
2255
2283
  }
2256
2284
 
2257
- /* ------------------------------------------------- 文件夹选择弹框(工作目录「选择…」/点输入框)
2258
- * 服务端目录浏览(/api/browse 仅本机;浏览器安全模型拿不到本机绝对路径,
2259
- * 原生目录选择器只回相对路径,没法用)。进入=点行,选定=行内「选这个」或
2260
- * 底部「使用当前目录」。远端设备 browse 被 403 拒:点输入框不再弹框,继续手输。 */
2285
+ /* ------------------------------------------------- 文件夹选择(工作目录「选择…」/点输入框)
2286
+ * 首选系统原生对话框:服务端 tkinter 子进程弹真窗口(/api/pick_folder,仅本机),
2287
+ * 前端只收回填路径。机器没有 tkinter(fallback=true)时回落网页目录弹框:
2288
+ * /api/browse 浏览(服务端只给目录名、前端拼绝对路径),选定=行内「选这个」或
2289
+ * 底部「使用当前目录」。远端设备两条通道都被 403 拒:点输入框不再弹框,继续手输。 */
2261
2290
  const pickerSt = { target: "", cwd: "", parent: "", local: null }; // local: null=未知 true=本机 false=远端
2262
2291
 
2263
2292
  function pickerRender(r) {
@@ -2305,19 +2334,35 @@ async function pickerBrowse(p) {
2305
2334
  return true;
2306
2335
  }
2307
2336
 
2337
+ /* 网页目录弹框(原生对话框不可用时的回落通道) */
2338
+ async function webPickFolder(targetId, cur) {
2339
+ openModal(t("选择文件夹"), '<div id="pk-body" class="hint">' + esc(t("正在读取目录…")) + "</div>",
2340
+ '<button class="ghost" onclick="closeModal();pkRefocus()">' + esc(t("取消")) + "</button>" +
2341
+ '<button class="primary" onclick="pickUseP(pickerCwd())">' + esc(t("使用当前目录")) + "</button>");
2342
+ await pickerBrowse(cur || "__drives__");
2343
+ }
2344
+
2308
2345
  window.pickFolder = async function (targetId, fromClick) {
2309
2346
  if (fromClick && pickerSt.local === false) return; // 远端已确认被拒:点击不打扰
2310
2347
  pickerSt.target = targetId;
2311
2348
  const cur = (($(targetId) || {}).value || "").trim();
2312
- openModal(t("选择文件夹"), '<div id="pk-body" class="hint">' + esc(t("正在读取目录…")) + "</div>",
2313
- '<button class="ghost" onclick="closeModal();pkRefocus()">' + esc(t("取消")) + "</button>" +
2314
- '<button class="primary" onclick="pickUseP(pickerCwd())">' + esc(t("使用当前目录")) + "</button>");
2315
- const ok = await pickerBrowse(cur || "__drives__");
2316
- // 点输入框进来的:远端被拒就收框并提示,别让每次点击都吃一个错误弹窗
2317
- if (!ok && fromClick && pickerSt.local === false) {
2318
- closeModal();
2319
- toast(t("目录选择仅限本机使用,请手动输入路径"), true);
2349
+ let r = null;
2350
+ try {
2351
+ r = await api("/api/pick_folder", { method: "POST",
2352
+ body: JSON.stringify({ initial: cur, title: t("选择文件夹") }) });
2353
+ } catch (e) {
2354
+ if (/仅限本机/.test(e.message || "")) {
2355
+ pickerSt.local = false;
2356
+ if (fromClick) toast(t("目录选择仅限本机使用,请手动输入路径"), true);
2357
+ return;
2358
+ }
2359
+ toast(t("操作失败:") + (e.message || e), true);
2360
+ return;
2320
2361
  }
2362
+ if (r.busy) { toast(t("已有一个选择窗口正在等待"), true); return; }
2363
+ if (r.fallback) { await webPickFolder(targetId, cur); return; }
2364
+ if (r.path) pickUseP(r.path);
2365
+ else window.pkRefocus(); // 用户取消:焦点还给输入框,方便手输
2321
2366
  };
2322
2367
  window.pickEnterP = (p) => pickerBrowse(p);
2323
2368
  window.pickParentP = () => pickerBrowse(pickerSt.parent || "__drives__");
@@ -2431,6 +2476,7 @@ function newFromTask(id) {
2431
2476
  $("f-manuscript").value = tk.manuscript || "manuscript.md";
2432
2477
  $("f-rounds").value = tk.rounds || 2;
2433
2478
  $("f-threshold").value = (tk.threshold != null ? tk.threshold : 7.0);
2479
+ $("f-bestof").value = tk.best_of || 1;
2434
2480
  $("f-rubric").value = (tk.rubric || []).join(", ");
2435
2481
  // 连载参数只带批次设置:不带 start_chapter/continues(那是「继续连载」的衔接语义)
2436
2482
  $("f-chapters").value = tk.serial ? tk.serial.chapters : "";
@@ -2883,19 +2929,19 @@ function renderSideTasks() {
2883
2929
  // 二级任务行:单行即全部——点击主栏直开任务详情。
2884
2930
  // 内嵌步骤层已砍:步骤/运行明细在任务详情里更全,侧栏只留状态字形 + 徽章 + 相对时间。
2885
2931
  // 右键菜单仍吃 data-task / data-run;主栏开着某任务详情时行保持高亮。
2886
- const row = (g) => {
2887
- if (g.active) g.status = "running";
2888
- const sel = (g.key === S.detailTaskKey || g.runIds.indexOf(S.detailRunId) >= 0) ? " active" : "";
2889
- return '<div class="stask' + sel + (g.archived ? " archived" : "") + '" tabindex="0" role="button" data-key="' + esc(g.key) +
2890
- '" data-task="' + esc(g.taskId || "") + '" data-run="' + esc(g.runIds[0] || "") +
2891
- '" data-status="' + esc(g.status || "") + '">' + staskGlyph(g.status) +
2892
- '<span class="t">' + esc(g.title) + "</span>" +
2893
- (g.archived ? '<span class="sbadge archb">' + t("已归档") + "</span>" : "") +
2894
- (g.verdict ? '<span class="sbadge">' + t("待裁决") + "</span>" : "") +
2895
- '<span class="tm">' + esc(relTime(g.time)) + "</span>" +
2896
- '<button class="stask-more" type="button" aria-haspopup="menu" aria-label="' + esc(t("任务操作")) + '" title="' + esc(t("任务操作")) +
2897
- '" onclick="event.stopPropagation();openTaskActions(this.closest(\'.stask\'), event)">⋯</button></div>';
2898
- };
2932
+ const row = (g) => {
2933
+ if (g.active) g.status = "running";
2934
+ const sel = (g.key === S.detailTaskKey || g.runIds.indexOf(S.detailRunId) >= 0) ? " active" : "";
2935
+ return '<div class="stask' + sel + (g.archived ? " archived" : "") + '" tabindex="0" role="button" data-key="' + esc(g.key) +
2936
+ '" data-task="' + esc(g.taskId || "") + '" data-run="' + esc(g.runIds[0] || "") +
2937
+ '" data-status="' + esc(g.status || "") + '">' + staskGlyph(g.status) +
2938
+ '<span class="t">' + esc(g.title) + "</span>" +
2939
+ (g.archived ? '<span class="sbadge archb">' + t("已归档") + "</span>" : "") +
2940
+ (g.verdict ? '<span class="sbadge">' + t("待裁决") + "</span>" : "") +
2941
+ '<span class="tm">' + esc(relTime(g.time)) + "</span>" +
2942
+ '<button class="stask-more" type="button" aria-haspopup="menu" aria-label="' + esc(t("任务操作")) + '" title="' + esc(t("任务操作")) +
2943
+ '" onclick="event.stopPropagation();openTaskActions(this.closest(\'.stask\'), event)">⋯</button></div>';
2944
+ };
2899
2945
  // 一级文件夹行:折叠箭头 + 目录图标 + 末段名(hover 显全路径)+ 任务计数徽标
2900
2946
  box.innerHTML = dirs.map((d) => {
2901
2947
  const isOrphan = d.dir === ORPHAN;
@@ -2949,7 +2995,8 @@ function jumpToRun(id) {
2949
2995
  * 聚合该任务全部 run 的步骤;不允许任何行点了没反应。
2950
2996
  * 无主运行行(任务已删/管理运行,data-task 为空)→ 直开它自己的运行详情;
2951
2997
  * 已归档任务 → toast 指路(归档行先取消归档);
2952
- * 没跑过/排队中 → toast 说明(没有可展示的运行内容)。 */
2998
+ * 从未跑过 → toast 说明;跑过但最新 run 排队中(自动续跑退避窗口)→ 照常开
2999
+ * 任务详情,能回看历史轮次的步骤与日志。 */
2953
3000
  function sideRowActivate(det) {
2954
3001
  const taskId = det.dataset.task || "", runId = det.dataset.run || "";
2955
3002
  if (!taskId) {
@@ -2962,7 +3009,10 @@ function sideRowActivate(det) {
2962
3009
  toast(arch ? t("已归档任务:先在右键菜单里取消归档,再看任务详情") : t("任务不存在或已删除"), true);
2963
3010
  return;
2964
3011
  }
2965
- if (!inspEligible(taskId)) { toast(t("该任务还没跑过(或排队中),还没有任务详情"), true); return; }
3012
+ if (!inspEligible(taskId)) {
3013
+ const lr = ((S.state || {}).task_latest || {})[taskId];
3014
+ if (!lr) { toast(t("该任务还没跑过,还没有任务详情"), true); return; }
3015
+ }
2966
3016
  sideOpenTask(taskId);
2967
3017
  }
2968
3018
 
@@ -3038,30 +3088,42 @@ function drawTaskDetail(key, runs) {
3038
3088
  // 取消收尾把僵尸步骤落成「已取消」时要立即重画,不等条数变化;
3039
3089
  // 作品信息状态入签名:后台一键生成 running→done 要立刻反映到成果区面板
3040
3090
  const bmTask = ((S.state || {}).tasks || []).find((x) => x.id === key);
3091
+ // 自动续跑退避相位入签名:预定入队时刻过了之后 chip 要从「将于 HH:MM」翻回「排队中」
3092
+ const lr0 = runs[0] || {};
3093
+ const resumePending = (lr0.resume_enqueue_at &&
3094
+ Date.now() < Date.parse(String(lr0.resume_enqueue_at).replace(" ", "T"))) ? 1 : 0;
3041
3095
  const sig = JSON.stringify(runs.map((r) => [r.id, r.status, (r.steps || []).length,
3042
3096
  (r.steps || []).map((s) => s.status).join(""),
3043
3097
  (r.messages || []).length, (r.messages || []).filter((m) => !m.consumed).length])
3044
- .concat([JSON.stringify((bmTask || {}).book_meta || null)]));
3098
+ .concat([JSON.stringify((bmTask || {}).book_meta || null), resumePending]));
3045
3099
  if (sig === S.taskSig) return;
3046
3100
  S.taskSig = sig;
3047
3101
  const latest = runs[0]; // runs 新→旧
3048
3102
  const ordered = runs.slice(); // 详情按最近运行优先,方便排查
3049
3103
  const totalSteps = runs.reduce((a, r) => a + (r.steps || []).length, 0);
3050
3104
  const active = runs.some((r) => r.status === "running" || r.status === "queued");
3051
- const st = active ? "running" : latest.status;
3105
+ // 活跃态细分真实状态:排队里还分「等并发」和「自动续跑退避(预定 HH:MM 入队)」,
3106
+ // 后者在 chip 上写明下一轮何时起跑,别让 5 分钟退避窗口看起来像卡死(Z.ai 误伤案)
3107
+ const activeRun0 = runs.find((r) => r.status === "running" || r.status === "queued");
3108
+ const st = activeRun0 ? activeRun0.status : latest.status;
3109
+ const resumeIn = (st === "queued" && resumePending)
3110
+ ? String(latest.resume_enqueue_at).slice(11, 16) : "";
3052
3111
  $("rd-title").textContent = latest.title || key;
3053
3112
  const chip = $("rd-status");
3054
3113
  chip.className = "chip " + st;
3055
- chip.textContent = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st;
3114
+ chip.textContent = resumeIn
3115
+ ? t("将于 ") + resumeIn + t(" 自动续跑(第 ") + (Number(latest.auto_resumes) || 0) + t(" 次)")
3116
+ : ({ queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st);
3056
3117
  const bpt = $("btn-pause");
3057
3118
  if (bpt) bpt.classList.add("hidden");
3058
3119
  $("btn-delete").classList.add("hidden");
3059
3120
  $("btn-retry").classList.toggle("hidden", !(latest.task_id && (latest.status === "failed" || latest.status === "cancelled")));
3121
+ $("btn-talk").classList.toggle("hidden", !(latest.task_id && !chatEngineIsDirect(latest)));
3060
3122
  setEditRetry(latest.task_id, latest.status,
3061
3123
  !!((S.state || {}).tasks || []).some((x) => x.id === latest.task_id));
3062
- S.lastRun = latest;
3063
- renderRunOutcome(latest, runs);
3064
- renderDetailOverview(latest, runs, bmTask);
3124
+ S.lastRun = latest;
3125
+ renderRunOutcome(latest, runs);
3126
+ renderDetailOverview(latest, runs, bmTask);
3065
3127
  // 指挥区指向最新的活跃运行(无则隐藏整个指挥区,避免终态任务误导用户以为指令还能生效)
3066
3128
  const activeRun = runs.find((r) => r.status === "queued" || r.status === "running");
3067
3129
  // 活跃运行(含排队中)显示取消按钮;取消目标钉在活跃 run——任务级详情没有
@@ -3093,16 +3155,16 @@ function drawTaskDetail(key, runs) {
3093
3155
  '<span class="chip ' + esc(r.status || "") + '">' + (statusTxt[r.status] || r.status || "") + "</span>" +
3094
3156
  (r.error ? '<span class="rs-err" title="' + esc(r.error.slice(0, 200)) + '">' + esc(r.error.slice(0, 60)) + "</span>" : "") +
3095
3157
  "</div>";
3096
- html += (r.steps || []).slice().reverse().map((s) =>
3097
- '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(r.id) + '" data-log="' + esc(s.log || "") + '" title="' + esc(s.note || "") + '">' +
3158
+ html += (r.steps || []).slice().reverse().map((s) =>
3159
+ '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(r.id) + '" data-log="' + esc(s.log || "") + '" title="' + esc(s.note || "") + '">' +
3098
3160
  '<span class="n">' + String(s.n).padStart(2, "0") + "</span>" +
3099
3161
  '<span class="role">' + esc(s.role) + "</span>" +
3100
3162
  '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3101
3163
  '<span class="sum">' + esc((s.note ? "◆ " + s.note + " — " : "") + (s.summary || "")) + "</span>" +
3102
3164
  '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3103
- statusChip(s.status) +
3104
- (s.log ? '<button class="step-log-btn" type="button" data-log-run="' + esc(r.id) + '" data-log-rel="' + esc(s.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button>" : "") +
3105
- "</div>"
3165
+ statusChip(s.status) +
3166
+ (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>" : "") +
3167
+ "</div>"
3106
3168
  ).join("");
3107
3169
  });
3108
3170
  $("rd-steps").innerHTML = html || '<div class="empty">' + t("尚无步骤") + '</div>';
@@ -3245,7 +3307,7 @@ function applyStepFocus() {
3245
3307
  el.classList.add("flash");
3246
3308
  try { el.scrollIntoView({ block: "center", behavior: "smooth" }); }
3247
3309
  catch (e) { el.scrollIntoView(); }
3248
- if (el.dataset.log) toggleLog(el.dataset.runId || S.detailRunId, el.dataset.log);
3310
+ if (el.dataset.log) toggleLog(el.dataset.runId || S.detailRunId, el.dataset.log);
3249
3311
  setTimeout(() => el.classList.remove("flash"), 1800);
3250
3312
  }
3251
3313
 
@@ -3366,16 +3428,16 @@ function rdTabsSync(ctx) {
3366
3428
  const sig = (S.detailTaskKey || S.detailRunId || "") + "|" + (ctx.status || "") +
3367
3429
  "|" + (ctx.gitState || "") + "|" + (ctx.running ? 1 : 0) + "|" + (chatAvail ? 1 : 0);
3368
3430
  if (S.rdTabSig !== sig) {
3369
- S.rdTabSig = sig;
3370
- const avail = rdTabAvail();
3371
- const direct = chatAvail && chatEngineIsDirect(S.lastRun);
3372
- // 直连任务的主问题是「继续聊什么、上一轮回答是什么」——默认把对话
3373
- // 放在第一视线;编排/代码任务才默认落蜂巢,先看阶段与在岗步骤。
3374
- // 没跑到终态时成果分区是空的,避免打开详情先看到白板。
3375
- const finishing = !ctx.running && ["done", "failed", "cancelled", "timeout"].includes(ctx.status);
3376
- S.rdTab = (direct && avail.chat ? "chat" : null)
3377
- || (avail.hive ? "hive" : null)
3378
- || (avail.chat ? "chat" : null)
3431
+ S.rdTabSig = sig;
3432
+ const avail = rdTabAvail();
3433
+ const direct = chatAvail && chatEngineIsDirect(S.lastRun);
3434
+ // 直连任务的主问题是「继续聊什么、上一轮回答是什么」——默认把对话
3435
+ // 放在第一视线;编排/代码任务才默认落蜂巢,先看阶段与在岗步骤。
3436
+ // 没跑到终态时成果分区是空的,避免打开详情先看到白板。
3437
+ const finishing = !ctx.running && ["done", "failed", "cancelled", "timeout"].includes(ctx.status);
3438
+ S.rdTab = (direct && avail.chat ? "chat" : null)
3439
+ || (avail.hive ? "hive" : null)
3440
+ || (avail.chat ? "chat" : null)
3379
3441
  || (ctx.running ? (avail.hive ? "hive" : "steps")
3380
3442
  : (ctx.gitState === "isolated" && avail.git ? "git"
3381
3443
  : (ctx.hasResult && finishing ? "result" : "steps")));
@@ -3385,12 +3447,26 @@ function rdTabsSync(ctx) {
3385
3447
  rdTabBadges();
3386
3448
  }
3387
3449
 
3450
+ /* 详情头部的按需信息面板:统计和任务概览不再常驻挤占蜂巢/对话空间。 */
3451
+ function setRdMetaOpen(open) {
3452
+ const detail = $("run-detail");
3453
+ const pop = $("rd-meta-popover");
3454
+ const toggle = $("rd-more-toggle");
3455
+ if (!pop || !toggle) return;
3456
+ const on = !!open;
3457
+ pop.classList.toggle("hidden", !on);
3458
+ if (detail) detail.classList.toggle("rd-info-open", on);
3459
+ toggle.setAttribute("aria-expanded", on ? "true" : "false");
3460
+ }
3461
+
3388
3462
  /* 换一个详情目标时清空选卡状态:下一次渲染按新目标自动落位 */
3389
3463
  function rdTabReset(pin) {
3390
3464
  S.rdTab = pin || null;
3391
3465
  S.rdTabSig = "";
3392
3466
  S.rdTabPin = !!pin;
3393
3467
  S._rdCtx = {};
3468
+ syncChatLogSpace(false);
3469
+ setRdMetaOpen(false);
3394
3470
  }
3395
3471
 
3396
3472
  async function renderRunDetail() {
@@ -3411,13 +3487,14 @@ async function renderRunDetail() {
3411
3487
  S.cancelTargetRunId = active ? run.id : null;
3412
3488
  $("btn-delete").classList.toggle("hidden", active);
3413
3489
  $("btn-retry").classList.toggle("hidden", !(run.task_id && (run.status === "failed" || run.status === "cancelled")));
3490
+ $("btn-talk").classList.toggle("hidden", !(run.task_id && !chatEngineIsDirect(run)));
3414
3491
  const rcTask = ((S.state || {}).tasks || []).find((x) => x.id === run.task_id);
3415
3492
  $("btn-continue").classList.toggle("hidden",
3416
3493
  !(rcTask && rcTask.serial && run.status !== "running" && run.status !== "queued"));
3417
3494
  setEditRetry(run.task_id, run.status, !!rcTask);
3418
- S.lastRun = run;
3419
- renderRunOutcome(run);
3420
- renderDetailOverview(run, [run], rcTask);
3495
+ S.lastRun = run;
3496
+ renderRunOutcome(run);
3497
+ renderDetailOverview(run, [run], rcTask);
3421
3498
  const bp = $("btn-pause");
3422
3499
  if (bp) { bp.classList.toggle("hidden", !active);
3423
3500
  bp.textContent = run.paused ? t("继续执行") : t("暂停"); }
@@ -3433,20 +3510,21 @@ async function renderRunDetail() {
3433
3510
  '<span class="stat tasksum hidden" id="rd-meta-task"></span>';
3434
3511
  if (S.detailSide) fillMetaTask(S.detailSide.stats || {}); // 缓存命中:轮询重画不闪丢累计组
3435
3512
  renderPlan(run);
3436
- // 详情页优先展示最近一步,排查运行中的任务时无需滚到底部;
3437
- // 蜂巢泳道仍按原始流程顺序呈现,避免破坏阶段语义。
3438
- $("rd-steps").innerHTML = (run.steps || []).slice().reverse().map((s) =>
3439
- '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(run.id) + '" data-log="' + esc(s.log || "") +
3440
- '" title="' + esc(s.note || "") + '">' +
3513
+ // 详情页优先展示最近一步,排查运行中的任务时无需滚到底部;
3514
+ // 蜂巢泳道仍按原始流程顺序呈现,避免破坏阶段语义。
3515
+ $("rd-steps").innerHTML = (run.steps || []).slice().reverse().map((s) =>
3516
+ '<div class="step" data-n="' + Number(s.n) + '" data-run-id="' + esc(run.id) + '" data-log="' + esc(s.log || "") +
3517
+ '" title="' + esc(s.note || "") + '">' +
3441
3518
  '<span class="n">' + String(s.n).padStart(2, "0") + "</span>" +
3442
3519
  '<span class="role">' + esc(s.role) + "</span>" +
3443
- '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3444
- '<span class="sum">' + esc((s.note ? "" + s.note + " — " : "") + (s.summary || "")) + "</span>" +
3445
- '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3446
- statusChip(s.status) +
3447
- (s.log ? '<button class="step-log-btn" type="button" data-log-run="' + esc(run.id) + '" data-log-rel="' + esc(s.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button>" : "") +
3448
- "</div>"
3449
- ).join("") || '<div class="empty">' + t("尚无步骤") + '</div>';
3520
+ '<span class="who">' + esc(t(s.agent_label || s.agent)) + "</span>" +
3521
+ (s.model ? '<span class="st-model" title="' + esc(t("实际派发模型")) + '">' + esc(s.model) + "</span>" : "") +
3522
+ '<span class="sum">' + esc((s.note ? "◆ " + s.note + "" : "") + (s.summary || "")) + "</span>" +
3523
+ '<span class="dur">' + (s.duration_s != null ? s.duration_s + "s" : "") + "</span>" +
3524
+ statusChip(s.status) +
3525
+ (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>" : "") +
3526
+ "</div>"
3527
+ ).join("") || '<div class="empty">' + t("尚无步骤") + '</div>';
3450
3528
  applyStepFocus();
3451
3529
  // 报告
3452
3530
  if (run.status === "done" || run.report) {
@@ -3924,6 +4002,8 @@ function renderGitWbMain(d, task) {
3924
4002
  '<button class="ghost"' + (noRemote || ro ? " disabled" : "") + ' onclick="gitWbFetch()">' + t("抓取") + "</button>" +
3925
4003
  '<button class="ghost"' + (noRemote || ro ? " disabled" : "") + ' onclick="gitWbPull()">' + t("拉取") + "</button>" +
3926
4004
  '<button class="ghost"' + (noRemote || ro ? " disabled" : "") + ' onclick="gitWbPush()">' + t("推送") + "</button>" +
4005
+ '<button class="ghost"' + (noRemote || ro || d.detached ? " disabled" : "") + ' onclick="gitWbPrCreate()" title="' +
4006
+ esc(t("推送当前分支并用 gh 创建 PR(目标分支自动取 main/master)")) + '">' + t("建 PR") + "</button>" +
3927
4007
  '<button class="ghost"' + (!hasChg || ro ? " disabled" : "") + ' onclick="gitWbStageAll()">' + t("全部暂存") + "</button>" +
3928
4008
  '<button class="ghost"' + (!hasChg || ro ? " disabled" : "") + ' onclick="gitWbStash()" title="' +
3929
4009
  esc(t("把未提交改动收进 stash(不含 _attachments)")) + '">' + t("收起改动") + "</button>" +
@@ -3985,6 +4065,7 @@ function renderGitWbMain(d, task) {
3985
4065
  window.gitWbDiff = async function (path) {
3986
4066
  const key = detailSideTaskKey();
3987
4067
  if (!key || !path) return;
4068
+ _fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; _fpCopyText = null; // 复用单例弹窗,别带上一个文件的编辑态
3988
4069
  _fpOpen(String(path).split("/").pop(), "",
3989
4070
  '<button class="ghost" onclick="copyFPText()">' + esc(t("复制")) + "</button>");
3990
4071
  let d;
@@ -3992,6 +4073,7 @@ window.gitWbDiff = async function (path) {
3992
4073
  catch (e) { _fpSetBody('<div class="fp-hint">' + esc(t("diff 读取失败:") + e.message) + "</div>"); return; }
3993
4074
  if (!filePopIsOpen()) return; // 读取期间被 Esc 关掉:别把内容又糊上去
3994
4075
  const lines = String(d.diff || "").split("\n");
4076
+ _fpCopyText = (d.diff && lines.length) ? lines.join("\n") : ""; // 复制给 diff 原文,行号槽不进剪贴板
3995
4077
  _fpSetBody((d.diff && lines.length)
3996
4078
  ? '<div class="fp-diff">' + lines.map((ln, i) => _fpDiffLine(ln, i + 1)).join("") + "</div>"
3997
4079
  : '<div class="fp-hint">' + esc(t("该文件当前没有未提交的变更。")) + "</div>");
@@ -4016,6 +4098,13 @@ window.gitWbStageAll = function () { return _gitWbPost("stage_all", null, t("已
4016
4098
  window.gitWbFetch = function () { return _gitWbPost("fetch", null, t("已抓取远程更新")); };
4017
4099
  window.gitWbPull = function () { return _gitWbPost("pull", null, t("已拉取远程更新")); };
4018
4100
  window.gitWbPush = function () { return _gitWbPost("push", null, t("已推送到远程")); };
4101
+ window.gitWbPrCreate = async function () {
4102
+ if (!await uiConfirm(t("把当前分支推送到远程并用 gh 创建 PR?(目标分支自动取 main/master)"), { ok: t("建 PR") })) return null;
4103
+ const res = await _gitWbPost("pr_create", null,
4104
+ (r) => (r && r.url ? t("PR 已创建:") + r.url : t("PR 已创建")));
4105
+ if (res && res.url) window.open(res.url, "_blank", "noopener");
4106
+ return res;
4107
+ };
4019
4108
  window.gitWbCheckout = function (br) {
4020
4109
  if (!br) return;
4021
4110
  return _gitWbPost("checkout", { branch: br }, t("已切换到 ") + br);
@@ -4321,8 +4410,8 @@ function drawInspector() {
4321
4410
  ? arts.map((f) =>
4322
4411
  '<a class="file-chip" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4323
4412
  encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
4324
- esc(f.name + " · " + fmtSize(f.size)) + '" onclick="artPopup(\'' + esc(runId) + "', '" +
4325
- esc(f.name) + "', " + (Number(f.size) || 0) + ');return false">' +
4413
+ esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(runId) +
4414
+ '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4326
4415
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
4327
4416
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>").join("")
4328
4417
  : '<span class="insp-hint">' + esc(t("本次运行没有在工作目录里产出新文件。")) + "</span>";
@@ -4367,8 +4456,10 @@ window.inspDiffFile = async function (path) {
4367
4456
  let _fpUrls = []; // 图片预览的 objectURL,关窗统一回收
4368
4457
  /* 编辑态:_fpRawText=开窗时取到的原文(json 展示会美化,编辑始终用原文);
4369
4458
  * _fpSaveCtx={dir,name,mtime} 非空=该弹窗可编辑(目录文件弹窗专属,成品只读);
4370
- * _fpDirty=编辑框有改动未保存——关窗/退出编辑先确认,防误丢。 */
4371
- let _fpRawText = "", _fpSaveCtx = null, _fpDirty = false;
4459
+ * _fpDirty=编辑框有改动未保存——关窗/退出编辑先确认,防误丢;
4460
+ * _fpCopyText=复制按钮要吐的原文(正文是行号表格,innerText 会连行号列一起带出);
4461
+ * null=非文本内容,回退 innerText。 */
4462
+ let _fpRawText = "", _fpSaveCtx = null, _fpDirty = false, _fpCopyText = null;
4372
4463
 
4373
4464
  function filePopIsOpen() {
4374
4465
  const el = document.getElementById("file-pop");
@@ -4382,7 +4473,7 @@ window.filePopClose = async function () {
4382
4473
  const ok = await uiConfirm(t("有未保存的修改,确定关闭?"));
4383
4474
  if (!ok) return;
4384
4475
  }
4385
- _fpDirty = false; _fpSaveCtx = null; _fpRawText = "";
4476
+ _fpDirty = false; _fpSaveCtx = null; _fpRawText = ""; _fpCopyText = null;
4386
4477
  el.classList.add("hidden");
4387
4478
  el.querySelector(".fp-body").innerHTML = "";
4388
4479
  el.querySelector(".fp-acts").innerHTML = "";
@@ -4429,10 +4520,15 @@ function _fpSetBody(html) {
4429
4520
  el.querySelector(".fp-body").innerHTML = html;
4430
4521
  }
4431
4522
 
4432
- window.copyFPText = function () {
4523
+ /* 复制源:文本类正文优先给预留原文(不带行号/着色槽),其余回退 innerText */
4524
+ window.fpCopySource = function () {
4433
4525
  const body = document.querySelector("#file-pop .fp-body");
4434
- if (!body) return;
4435
- try { navigator.clipboard.writeText(body.innerText || ""); toast(t("已复制")); } catch (e) { /* 剪贴板不可用则忽略 */ }
4526
+ if (!body) return "";
4527
+ return _fpCopyText !== null ? _fpCopyText : (body.innerText || "");
4528
+ };
4529
+
4530
+ window.copyFPText = function () {
4531
+ try { navigator.clipboard.writeText(window.fpCopySource() || ""); toast(t("已复制")); } catch (e) { /* 剪贴板不可用则忽略 */ }
4436
4532
  };
4437
4533
 
4438
4534
  /* diff 行着色:+/− 上色、@@ 小节、头部落灰;"--- "/"+++ " 先判,避免内容行误染。
@@ -4454,7 +4550,7 @@ function _fpDiffLine(ln, no) {
4454
4550
  window.fileDiffPopup = async function (path, runId) {
4455
4551
  runId = runId || ((S.inspData || {}).run || {}).id || S.detailRunId || (S.lastRun || {}).id || "";
4456
4552
  if (!runId) return;
4457
- _fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; // diff 弹窗只读,别带上一个目录文件的编辑态
4553
+ _fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; _fpCopyText = null; // diff 弹窗只读,别带上一个目录文件的编辑态
4458
4554
  let sub = "";
4459
4555
  for (const fs of [((S.inspData || {}).changes || {}).files, ((S.lastRun || {}).changes || {}).files]) {
4460
4556
  const f = (fs || []).find((x) => x.path === path);
@@ -4466,6 +4562,7 @@ window.fileDiffPopup = async function (path, runId) {
4466
4562
  catch (e) { _fpSetBody('<div class="fp-hint">' + esc(t("diff 读取失败:") + e.message) + "</div>"); return; }
4467
4563
  if (!filePopIsOpen()) return; // 读取期间被 Esc 关掉:别把内容又糊上去
4468
4564
  const lines = _sliceDiff(((d.run || {}).changes || {}).diff || "", path);
4565
+ _fpCopyText = lines.length ? lines.join("\n") : ""; // 复制给切片原文,行号槽(.fp-no)不进剪贴板
4469
4566
  _fpSetBody(lines.length
4470
4567
  ? '<div class="fp-diff">' + lines.map((ln, idx) => _fpDiffLine(ln, idx + 1)).join("") + "</div>"
4471
4568
  : '<div class="fp-hint">' + esc(t("暂无该文件的已保存 diff(运行结束后生成变更快照,或该运行没有保存变更内容)。")) + "</div>");
@@ -4485,7 +4582,7 @@ async function _fpPreviewUrl(url, name, size, opts) {
4485
4582
  opts = opts || {};
4486
4583
  const ext = _fpExt(name);
4487
4584
  _fpSaveCtx = opts.save || null;
4488
- _fpRawText = ""; _fpDirty = false;
4585
+ _fpRawText = ""; _fpDirty = false; _fpCopyText = null;
4489
4586
  _fpOpen(String(name).split("/").pop(), size ? "<i>" + esc(fmtSize(size)) + "</i>" : "",
4490
4587
  '<a class="ghost" href="' + url + '" download="' + esc(String(name).split("/").pop()) + '">' + esc(t("下载")) + "</a>");
4491
4588
  const el = _fpEnsure();
@@ -4506,9 +4603,10 @@ async function _fpPreviewUrl(url, name, size, opts) {
4506
4603
  if (!filePopIsOpen()) return;
4507
4604
  _fpRawText = text;
4508
4605
  if (_fpSaveCtx) _fpSaveCtx.mtime = r.headers.get("X-Tutti-Mtime") || "";
4509
- const codeHtml = ext === "json"
4510
- ? (() => { let pretty = text; try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 坏 json 原样展示 */ } return codeBlockHTML(pretty); })()
4511
- : codeBlockHTML(text);
4606
+ let shown = text;
4607
+ if (ext === "json") { try { shown = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 坏 json 原样展示 */ } }
4608
+ _fpCopyText = shown; // 复制=看到的原文;行号列不进剪贴板(编辑/保存仍用 _fpRawText 原文)
4609
+ const codeHtml = codeBlockHTML(shown);
4512
4610
  const body = '<div class="fp-code">' + '<div class="fp-vp">' + codeHtml + "</div>" +
4513
4611
  (_fpSaveCtx ? '<textarea class="fp-edit hidden" spellcheck="false"></textarea>' : "") + "</div>";
4514
4612
  _fpSetBody(body);
@@ -4574,6 +4672,7 @@ window.fpSaveEdit = async function () {
4574
4672
  dir: _fpSaveCtx.dir, name: _fpSaveCtx.name, content: ta.value, mtime: _fpSaveCtx.mtime,
4575
4673
  }) });
4576
4674
  _fpRawText = ta.value;
4675
+ _fpCopyText = ta.value;
4577
4676
  _fpSaveCtx.mtime = String(d.mtime || "");
4578
4677
  _fpDirty = false;
4579
4678
  // 回到预览层并重画为保存后的内容(同一个框,只换里子)
@@ -4653,10 +4752,10 @@ function bindInspector() {
4653
4752
  const det = e.target.closest(".stask");
4654
4753
  if (det) sideRowActivate(det);
4655
4754
  });
4656
- $("side-tasks").addEventListener("keydown", (e) => {
4657
- if (e.key !== "Enter" && e.key !== " ") return;
4658
- if (e.target.closest("button, a, input, select, textarea, summary")) return;
4659
- const det = e.target.closest(".stask");
4755
+ $("side-tasks").addEventListener("keydown", (e) => {
4756
+ if (e.key !== "Enter" && e.key !== " ") return;
4757
+ if (e.target.closest("button, a, input, select, textarea, summary")) return;
4758
+ const det = e.target.closest(".stask");
4660
4759
  if (det) { e.preventDefault(); sideRowActivate(det); }
4661
4760
  });
4662
4761
  }
@@ -4676,13 +4775,13 @@ function artifactsChips(runId, files) {
4676
4775
  return files.map((f) => {
4677
4776
  const isTxt = FP_TXT.has(_fpExt(f.name)); // 文本/代码都有弹窗预览(按代码格式展示)
4678
4777
  return '<span class="fc-row">' +
4679
- '<a class="file-chip artifact-file-open" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4680
- encodeURIComponent(f.name) + '" target="_blank" rel="noopener" ' +
4681
- 'title="' + esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4778
+ '<a class="file-chip artifact-file-open" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4779
+ encodeURIComponent(f.name) + '" target="_blank" rel="noopener" ' +
4780
+ '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) + '">' +
4682
4781
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
4683
4782
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>" +
4684
- (isTxt ? '<a class="file-chip prev artifact-file-preview" title="' + esc(t("查看内容")) +
4685
- '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4783
+ (isTxt ? '<a class="file-chip prev artifact-file-preview" title="' + esc(t("查看内容")) +
4784
+ '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4686
4785
  '<svg class="ico" aria-hidden="true"><use href="#i-book"/></svg>' + t("预览") + "</a>" : "") +
4687
4786
  "</span>";
4688
4787
  }).join("");
@@ -4733,14 +4832,14 @@ window.copyText = function (t) {
4733
4832
  } catch (e) { /* 剪贴板不可用则忽略 */ }
4734
4833
  };
4735
4834
 
4736
- let currentLog = null; // { runId, rel }:同一路径在不同轮次也必须能切换
4737
-
4738
- /* 当前打开的日志是否仍属于这批步骤(重画后日志路径消失说明步骤被重建,才收起) */
4739
- function stepsMatch(runs, current) {
4740
- if (!current) return false;
4741
- return (runs || []).some((r) => String(r.id || "") === String(current.runId || "") &&
4742
- (r.steps || []).some((s) => s.log === current.rel));
4743
- }
4835
+ let currentLog = null; // { runId, rel }:同一路径在不同轮次也必须能切换
4836
+
4837
+ /* 当前打开的日志是否仍属于这批步骤(重画后日志路径消失说明步骤被重建,才收起) */
4838
+ function stepsMatch(runs, current) {
4839
+ if (!current) return false;
4840
+ return (runs || []).some((r) => String(r.id || "") === String(current.runId || "") &&
4841
+ (r.steps || []).some((s) => s.log === current.rel));
4842
+ }
4744
4843
 
4745
4844
  function renderPlan(run) {
4746
4845
  const box = $("rd-plan");
@@ -4791,8 +4890,17 @@ function rdLogStepLabel(runId, rel) {
4791
4890
  return rel || "";
4792
4891
  }
4793
4892
 
4893
+ /* 直连任务的输入框只在日志抽屉真正打开时才让出底部空间。
4894
+ * 日志和聊天 pane 位于同一个主区,不能靠普通兄弟选择器反向联动,
4895
+ * 所以用详情根节点的状态类做显式同步。 */
4896
+ function syncChatLogSpace(open) {
4897
+ const detail = $("run-detail");
4898
+ if (detail) detail.classList.toggle("chat-log-open", !!open);
4899
+ }
4900
+
4794
4901
  window.rdLogClose = function () {
4795
4902
  const box = $("rd-log");
4903
+ syncChatLogSpace(false);
4796
4904
  if (!box) return;
4797
4905
  box.classList.add("hidden");
4798
4906
  currentLog = null;
@@ -4801,24 +4909,25 @@ window.rdLogClose = function () {
4801
4909
  if (st) st.textContent = "";
4802
4910
  };
4803
4911
 
4804
- async function toggleLog(runId, rel) {
4805
- const box = $("rd-log"), pre = $("rd-log-text");
4806
- if (currentLog && currentLog.runId === runId && currentLog.rel === rel && !box.classList.contains("hidden")) {
4912
+ async function toggleLog(runId, rel) {
4913
+ const box = $("rd-log"), pre = $("rd-log-text");
4914
+ if (currentLog && currentLog.runId === runId && currentLog.rel === rel && !box.classList.contains("hidden")) {
4807
4915
  window.rdLogClose(); return;
4808
4916
  }
4809
4917
  try {
4810
4918
  const r = await api("/api/runs/" + encodeURIComponent(runId) + "/log?step=" + encodeURIComponent(rel) + "&pretty=1");
4811
4919
  // 拉取期间可能切详情:过期日志不落盘(A 的步骤输出写进 B 详情的抽屉)。
4812
4920
  // 检查器蜂巢/步骤点开日志同款:run 属检查器当前 run 也算在场
4813
- const owned = S.detailRunId === runId ||
4814
- // 任务级直连时间线会跨多轮 run 回放;按钮上的 run 可能不是最新一轮,
4815
- // 只按 latest.id 判断会让旧轮次的「CLI 日志」点击后悄悄失效。
4816
- (S.detailTaskKey && S.lastRun && S.lastRun.task_id === S.detailTaskKey) ||
4817
- ((S.inspData || {}).run || {}).id === runId;
4921
+ const owned = S.detailRunId === runId ||
4922
+ // 任务级直连时间线会跨多轮 run 回放;按钮上的 run 可能不是最新一轮,
4923
+ // 只按 latest.id 判断会让旧轮次的「CLI 日志」点击后悄悄失效。
4924
+ (S.detailTaskKey && S.lastRun && S.lastRun.task_id === S.detailTaskKey) ||
4925
+ ((S.inspData || {}).run || {}).id === runId;
4818
4926
  if (!owned) return;
4819
4927
  pre.textContent = r.log || t("(等待输出…)");
4820
4928
  box.classList.remove("hidden");
4821
- currentLog = { runId, rel };
4929
+ syncChatLogSpace(true);
4930
+ currentLog = { runId, rel };
4822
4931
  const st = $("rd-log-step");
4823
4932
  if (st) st.textContent = rdLogStepLabel(runId, rel);
4824
4933
  pre.scrollTop = pre.scrollHeight; // 打开即看最新输出
@@ -4828,10 +4937,10 @@ async function toggleLog(runId, rel) {
4828
4937
  stopLogLive();
4829
4938
  if (!live) return;
4830
4939
  S.logLive = setInterval(async () => {
4831
- if (!currentLog || currentLog.runId !== runId || currentLog.rel !== rel) return stopLogLive();
4940
+ if (!currentLog || currentLog.runId !== runId || currentLog.rel !== rel) return stopLogLive();
4832
4941
  try {
4833
4942
  const rr = await api("/api/runs/" + encodeURIComponent(runId) + "/log?step=" + encodeURIComponent(rel) + "&pretty=1");
4834
- if (currentLog && currentLog.runId === runId && currentLog.rel === rel) {
4943
+ if (currentLog && currentLog.runId === runId && currentLog.rel === rel) {
4835
4944
  // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰
4836
4945
  const stick = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48;
4837
4946
  pre.textContent = rr.log || t("(等待输出…)");
@@ -4845,6 +4954,7 @@ async function toggleLog(runId, rel) {
4845
4954
  } catch (e) { /* 网络抖动保留上一帧 */ }
4846
4955
  }, 2500);
4847
4956
  } catch (e) { pre.textContent = t("日志读取失败: ") + e.message; box.classList.remove("hidden");
4957
+ syncChatLogSpace(true);
4848
4958
  const st = $("rd-log-step"); if (st) st.textContent = rdLogStepLabel(runId, rel);
4849
4959
  logLiveBadge(false); }
4850
4960
  }
@@ -4920,10 +5030,10 @@ window.renderHive = function (run) {
4920
5030
  const issues = steps.filter((s) => ["failed", "timeout", "cancelled"].includes(String(s.status || "").toLowerCase())).length;
4921
5031
  sub.innerHTML = '<span class="hive-count hive-count-live"><i class="live-dot"></i>' +
4922
5032
  esc(t("在岗")) + " " + running.length + '</span>' +
4923
- '<span class="hive-count hive-count-done">✓ ' + done + '</span>' +
4924
- (issues ? '<span class="hive-count hive-count-issue">! ' + issues + '</span>' : "") +
4925
- '<span class="hive-count hive-count-total">' + steps.length + ' ' + esc(t("步骤")) + '</span>' +
4926
- '<span class="hive-hint">' + esc(t("点击步骤卡片查看 CLI 日志")) + '</span>';
5033
+ '<span class="hive-count hive-count-done">✓ ' + done + '</span>' +
5034
+ (issues ? '<span class="hive-count hive-count-issue">! ' + issues + '</span>' : "") +
5035
+ '<span class="hive-count hive-count-total">' + steps.length + ' ' + esc(t("步骤")) + '</span>' +
5036
+ '<span class="hive-hint">' + esc(t("点击步骤卡片查看 CLI 日志")) + '</span>';
4927
5037
  }
4928
5038
  const activeIdx = lanes.map((l) => byStage[l].some((s) => s.status === "running")).lastIndexOf(true);
4929
5039
  const cell = (s, laneIdx, cellIdx) => {
@@ -4945,11 +5055,11 @@ window.renderHive = function (run) {
4945
5055
  (s.note ? "◆ " + s.note : "")].filter(Boolean).join(" · ")
4946
5056
  + (concl && st !== "running" ? "\n" + t("结论:") + concl : "");
4947
5057
  // --d:入场瀑布逐格延迟(泳道间 110ms + 同泳道逐格 45ms),样式端消费
4948
- return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '" role="button" tabindex="0"' +
4949
- ' data-hive-run="' + esc(run.id) + '" data-hive-log="' + esc(s.log || "") + '"' +
4950
- ' style="--d:' + (laneIdx * 110 + cellIdx * 45) + 'ms"' +
4951
- ' title="' + esc(title) + '" ' +
4952
- '>' +
5058
+ return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '" role="button" tabindex="0"' +
5059
+ ' data-hive-run="' + esc(run.id) + '" data-hive-log="' + esc(s.log || "") + '"' +
5060
+ ' style="--d:' + (laneIdx * 110 + cellIdx * 45) + 'ms"' +
5061
+ ' title="' + esc(title) + '" ' +
5062
+ '>' +
4953
5063
  '<div class="hc-head">' +
4954
5064
  '<img class="hc-bee" src="icons/bee.svg" alt="" aria-hidden="true">' +
4955
5065
  '<span class="hc-role">' + esc(s.role || "") + "</span>" +
@@ -4959,10 +5069,10 @@ window.renderHive = function (run) {
4959
5069
  '<div class="hc-meta"><span class="hc-elapsed" data-started="' + esc(s.started_at || "") + '">' +
4960
5070
  (s.duration_s != null ? s.duration_s + "s" : hiveElapsed(s.started_at)) + "</span>" +
4961
5071
  (st === "running" ? '<span class="hc-live"><i class="live-dot"></i>' + t("工作中") + "</span>" : "") +
4962
- (st === "timeout" ? '<span class="hc-dead">⏱ ' + t("超时") + "</span>" : "") +
4963
- (st === "cancelled" ? '<span class="hc-dead">' + t("已取消") + "</span>" : "") +
4964
- (s.log ? '<span class="hc-log-link">' + esc(t("CLI 日志")) + "</span>" : "") +
4965
- "</div></div>";
5072
+ (st === "timeout" ? '<span class="hc-dead">⏱ ' + t("超时") + "</span>" : "") +
5073
+ (st === "cancelled" ? '<span class="hc-dead">' + t("已取消") + "</span>" : "") +
5074
+ (s.log ? '<span class="hc-log-link">' + esc(t("CLI 日志")) + "</span>" : "") +
5075
+ "</div></div>";
4966
5076
  };
4967
5077
  const dots = (list, laneIdx) => list.slice(-12).map((s, di) => {
4968
5078
  const dc = s.status === "running" ? "run" : s.status === "failed" ? "fail"
@@ -5206,6 +5316,74 @@ window.dirRetract = async function (msgId) {
5206
5316
  } catch (e) { /* 下一轮轮询兜底 */ }
5207
5317
  };
5208
5318
 
5319
+ /* 人工干预(非直连任务):运行中=递话入箱,下一个步骤开始前送达主智能体
5320
+ * (它判断后引导执行方向);已结束=发送后自动断点续跑,主智能体带着
5321
+ * 反馈重新规划。后端链路:信箱 → retry 继承未消费消息 → 规划 peek/执行 drain。 */
5322
+ window.rdTalkToggle = function () {
5323
+ const box = $("rd-talk");
5324
+ if (!box) return;
5325
+ const show = box.classList.contains("hidden");
5326
+ box.classList.toggle("hidden", !show);
5327
+ const hint = $("rd-talk-hint");
5328
+ if (hint) {
5329
+ const run = S.lastRun;
5330
+ const active = !!run && (run.status === "running" || run.status === "queued");
5331
+ hint.textContent = active ? t("运行中:下一个步骤开始前送达主智能体")
5332
+ : t("已结束:发送后自动断点续跑,主智能体带着反馈重新规划");
5333
+ }
5334
+ if (show) { const ta = $("rd-talk-input"); if (ta) setTimeout(() => ta.focus(), 0); }
5335
+ };
5336
+
5337
+ window.rdTalkSend = async function () {
5338
+ const run = S.lastRun;
5339
+ const ta = $("rd-talk-input");
5340
+ const text = ((ta && ta.value) || "").trim();
5341
+ if (!run || !run.id) return;
5342
+ if (!text && !talkAtts.length) { if (ta) ta.focus(); return; }
5343
+ const btn = $("rd-talk-send");
5344
+ btn.disabled = true;
5345
+ try {
5346
+ await api("/api/runs/" + encodeURIComponent(run.id) + "/messages",
5347
+ { method: "POST", body: JSON.stringify({ text, attachments: talkAtts.map((a) => a.id) }) });
5348
+ if (ta) ta.value = "";
5349
+ talkAtts = []; drawTalkAtts();
5350
+ const box = $("rd-talk");
5351
+ if (box) box.classList.add("hidden");
5352
+ const active = run.status === "running" || run.status === "queued";
5353
+ if (!active && run.task_id) {
5354
+ await retryTask(run.task_id);
5355
+ toast(t("已转交:带着你的反馈断点续跑,主智能体会重新规划"));
5356
+ } else {
5357
+ toast(t("已递话:主智能体在下一个步骤开始前会看到"));
5358
+ }
5359
+ } catch (e) { toast(t("发送失败:") + e.message, true); }
5360
+ finally { btn.disabled = false; }
5361
+ };
5362
+
5363
+ /* 递话附件:与运行中指挥同一落盘通道(/api/attachments 待提交区 → 随消息入箱) */
5364
+ let talkAtts = [];
5365
+ async function talkUploadFiles(files) {
5366
+ for (const f of files) {
5367
+ if (talkAtts.length >= DIR_ATT_MAX) { toast(t("附件最多 ") + DIR_ATT_MAX + t(" 个"), true); break; }
5368
+ if (f.size > DIR_ATT_BYTES) { toast(f.name + t(":超过 8MB,已跳过"), true); continue; }
5369
+ try {
5370
+ const b64 = await fileToB64(f);
5371
+ const d = await api("/api/attachments", { method: "POST",
5372
+ body: JSON.stringify({ name: f.name, data: b64 }) });
5373
+ if (d.attachment) talkAtts.push(d.attachment);
5374
+ } catch (e) { toast(t("附件上传失败:") + e.message, true); }
5375
+ }
5376
+ drawTalkAtts();
5377
+ }
5378
+ function drawTalkAtts() {
5379
+ const box = $("rd-talk-atts");
5380
+ if (!box) return;
5381
+ box.innerHTML = talkAtts.map((a, i) =>
5382
+ '<span class="att-chip2">' + esc(a.name) + "<b onclick=\"talkRemoveAtt(" + i + ')" title="' +
5383
+ t("移除") + "\">×</b></span>").join("");
5384
+ }
5385
+ window.talkRemoveAtt = function (i) { talkAtts.splice(i, 1); drawTalkAtts(); };
5386
+
5209
5387
  function bindDirector() {
5210
5388
  $("rd-attach-btn").addEventListener("click", () => $("rd-attach-file").click());
5211
5389
  $("btn-pause").addEventListener("click", window.dirTogglePause);
@@ -5242,8 +5420,11 @@ let chatAtts = [];
5242
5420
  let chatSig = "";
5243
5421
 
5244
5422
  function chatEngineIsDirect(run) {
5245
- const t = ((S.state || {}).tasks || []).find((x) => x.id === (run && run.task_id));
5246
- return !!(t && t.engine === "direct");
5423
+ const st = S.state || {};
5424
+ const all = (st.tasks || []).concat(st.archived_tasks || []);
5425
+ const task = all.find((x) => x.id === (run && run.task_id));
5426
+ return !!((task && task.engine === "direct") ||
5427
+ (!task && run && (run.engine === "direct" || run.type === "direct")));
5247
5428
  }
5248
5429
 
5249
5430
  /* 对话正文:``` 围栏渲染成真代码块(复用 codeBlockHTML:行号+高亮+代码主题),
@@ -5284,6 +5465,8 @@ async function renderChat(run, active) {
5284
5465
  // 只对直连任务启用(其它流程有自己的蜂巢/步骤视图,不抢默认选卡)
5285
5466
  if (!run || !chatEngineIsDirect(run)) { box.classList.add("hidden"); return; }
5286
5467
  box.classList.remove("hidden");
5468
+ const continueNote = $("rd-chat-continue-note");
5469
+ if (continueNote) continueNote.classList.toggle("hidden", !!active);
5287
5470
  if (chatRunId !== run.id) { chatRunId = run.id; chatAtts = []; drawChatAtts(); chatSig = ""; }
5288
5471
  const sig = run.id + "|" + run.status + "|" + (run.steps || []).length +
5289
5472
  "|" + ((run.messages || []).length);
@@ -5332,8 +5515,15 @@ async function renderChat(run, active) {
5332
5515
  : "") +
5333
5516
  "</div></div>";
5334
5517
  }
5335
- // 协议尾行是给编排器判收的机器标记,不该出现在聊天正文里(真源在提示词约定)
5336
- const body = String(it.text || "").replace(/\n*DIRECT_DONE:.*\s*$/s, "").trim();
5518
+ // 协议尾行是给编排器判收的机器标记,不该出现在聊天正文里(真源在提示词约定);
5519
+ // <followups> 块后端已剥并转成结构化字段,这里再兜一次底防老数据/漏剥;
5520
+ // 开场「本轮做了什么:…」+「回复:」是模型复读协议措辞的元描述(提示词已禁,
5521
+ // 这里兜历史数据),只剥消息开头的第一行
5522
+ const body = String(it.text || "")
5523
+ .replace(/^\s*本轮做了什么\s*[::][^\n]*\n+(回复\s*[::]\s*\n?)?/, "")
5524
+ .replace(/\n*DIRECT_DONE:.*\s*$/s, "")
5525
+ .replace(/\n*<followups>[\s\S]*?<\/followups>\s*$/s, "")
5526
+ .trim();
5337
5527
  // 运行中还没有正文:三点打字动画(终态无正文才落「无文本输出」占位)
5338
5528
  const bodyHtml = body ? chatBodyHTML(body)
5339
5529
  : (it.status === "running"
@@ -5342,15 +5532,20 @@ async function renderChat(run, active) {
5342
5532
  // 元信息只留 名字+时间(+失败标记):路由/供应商细节属于「步骤」页签,塞这里就是噪音
5343
5533
  const metaBad = it.status === "failed" || it.status === "cancelled";
5344
5534
  // 智能体回复:头像 + 整幅正文(不套气泡框),元信息小字落在正文下方
5345
- return '<div class="chat-row">' +
5346
- '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
5347
- '<div class="chat-bubble agent">' +
5348
- '<div class="chat-body">' + bodyHtml + "</div>" +
5349
- '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5350
- (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div>" +
5351
- (it.log && it.run ? '<div class="chat-actions"><button class="chat-log-btn" type="button" data-chat-log-run="' +
5352
- esc(it.run) + '" data-chat-log-rel="' + esc(it.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button></div>" : "") +
5353
- "</div></div>";
5535
+ return '<div class="chat-row">' +
5536
+ '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
5537
+ '<div class="chat-bubble agent">' +
5538
+ '<div class="chat-body">' + bodyHtml + "</div>" +
5539
+ '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5540
+ (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div>" +
5541
+ (it.log && it.run ? '<div class="chat-actions"><button class="chat-log-btn" type="button" data-chat-log-run="' +
5542
+ esc(it.run) + '" data-chat-log-rel="' + esc(it.log) + '" title="' + esc(t("查看日志")) + '">' + esc(t("CLI 日志")) + "</button></div>" : "") +
5543
+ (Array.isArray(it.followups) && it.followups.length
5544
+ ? '<div class="chat-fups">' + it.followups.map((f) =>
5545
+ '<button type="button" class="chat-fup-btn" data-fup="' + esc(f.prompt || "") +
5546
+ '" title="' + esc(f.prompt || "") + '">' + esc(f.label || "") + "</button>").join("") + "</div>"
5547
+ : "") +
5548
+ "</div></div>";
5354
5549
  }).join("");
5355
5550
  // 时间线收尾的「执行结果」卡:run 终态后的确定性摘要(成没成/耗时/执行者/
5356
5551
  // 产出文件)——不依赖模型最后一句话自觉交代(用户反馈:输"1"跑完 55 秒
@@ -5379,9 +5574,9 @@ function chatResultHTML(run, res) {
5379
5574
  if (res.duration_s != null) meta.push(chatDurTxt(res.duration_s));
5380
5575
  const files = res.files || [];
5381
5576
  const chips = files.map((f) =>
5382
- '<a class="file-chip chat-file-open" href="/api/runs/' + encodeURIComponent(run.id) + "/file?name=" +
5383
- encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
5384
- esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(run.id) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
5577
+ '<a class="file-chip chat-file-open" href="/api/runs/' + encodeURIComponent(run.id) + "/file?name=" +
5578
+ encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
5579
+ esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(run.id) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
5385
5580
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
5386
5581
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>").join("");
5387
5582
  return '<div class="chat-row">' +
@@ -5400,125 +5595,122 @@ function chatResultHTML(run, res) {
5400
5595
  "</div></div>";
5401
5596
  }
5402
5597
 
5403
- function chatDurTxt(s) {
5404
- s = Number(s) || 0;
5405
- if (s < 60) return s + " " + t("秒");
5406
- const m = Math.floor(s / 60), r = s % 60;
5407
- return m + " " + t("分") + (r ? " " + r + " " + t("秒") : "");
5408
- }
5409
-
5410
- /* 代码/编排任务的确定性结果摘要:报告是长文,步骤是过程,用户还需要一眼
5411
- * 知道这次到底成没成、做了多少步、用了多久,以及失败原因。这个卡片不依赖
5412
- * 智能体是否在最后一句话里主动总结;直连任务也能在「成果」页签看到同一口径。 */
5413
- function runDurationSeconds(run) {
5414
- if (!run) return null;
5415
- if (Number.isFinite(Number(run.duration_s))) return Number(run.duration_s);
5416
- const steps = (run.steps || []).filter((s) => Number.isFinite(Number(s.duration_s)));
5417
- if (steps.length) return steps.reduce((a, s) => a + Number(s.duration_s || 0), 0);
5418
- const a = Date.parse(String(run.started_at || "").replace(" ", "T"));
5419
- const b = Date.parse(String(run.ended_at || "").replace(" ", "T"));
5420
- return Number.isFinite(a) && Number.isFinite(b) && b >= a ? (b - a) / 1000 : null;
5421
- }
5422
-
5423
- function runOutcomeSummary(run) {
5424
- if (!run) return "";
5425
- const last = (run.steps || []).slice().reverse().find((s) => s.output || s.summary);
5426
- const raw = run.summary || (last && (last.output || last.summary)) || run.error || "";
5427
- return String(raw).replace(/\s+/g, " ").trim().slice(0, 520);
5428
- }
5429
-
5430
- function renderRunOutcome(run, runs) {
5431
- const box = $("rd-outcome");
5432
- if (!box) return;
5433
- if (!run) { box.classList.add("hidden"); box.innerHTML = ""; return; }
5434
- const all = runs && runs.length ? runs : [run];
5435
- const steps = (run.steps || []);
5436
- const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5437
- const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5438
- !["running", "queued"].includes(String(s.status || ""))).length, 0);
5439
- const status = String(run.status || "");
5440
- const active = status === "running" || status === "queued";
5441
- const bad = status === "failed" || status === "cancelled" || status === "timeout";
5442
- const label = active ? (status === "queued" ? t("排队中") : t("运行中"))
5443
- : (status === "done" ? t("完成") : status === "failed" ? t("失败") : status === "cancelled" ? t("已取消") : status || t("未知状态"));
5444
- const icon = status === "done" ? "#i-check" : bad ? "#i-x" : "#i-gauge";
5445
- const duration = runDurationSeconds(run);
5446
- const latest = steps.slice().reverse().find((s) => s.agent_label || s.agent || s.role);
5447
- const route = run.route && typeof run.route === "object" ? run.route.implementer : "";
5448
- // route.implementer 是路由评分说明,不是用户真正关心的 CLI 名称;优先
5449
- // 展示步骤上的 agent_label,只有旧数据没有步骤执行者时才回退到路由说明。
5450
- const executor = run.executor || run.implementer || (latest && (latest.agent_label || latest.agent)) || route || "";
5451
- const summary = runOutcomeSummary(run);
5452
- const meta = [];
5453
- if (executor) meta.push('<span><b>' + esc(t("执行者")) + '</b> ' + esc(executor) + "</span>");
5454
- if (total) meta.push('<span><b>' + esc(t("步骤")) + '</b> ' + settled + "/" + total + "</span>");
5455
- if (duration != null) meta.push('<span><b>' + esc(t("耗时")) + '</b> ' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + "</span>");
5456
- return (box.classList.remove("hidden"), box.innerHTML =
5457
- '<div class="outcome-top">' +
5458
- '<div class="outcome-title ' + (bad ? "bad" : active ? "live" : "ok") + '">' +
5459
- '<svg class="ico" aria-hidden="true"><use href="' + icon + '"></use></svg>' + esc(t("执行结果")) +
5460
- '<span class="outcome-status">' + esc(label) + "</span></div>" +
5461
- (meta.length ? '<div class="outcome-meta">' + meta.join("") + "</div>" : "") +
5462
- "</div>" +
5463
- (summary ? '<div class="outcome-summary">' + esc(summary) + "</div>" : "") +
5464
- (run.error ? '<div class="outcome-error">' + esc(run.error) + "</div>" : "") +
5465
- (total ? '<div class="outcome-actions"><button class="ghost" type="button" onclick="rdChatNavGo(\'steps\')">' + esc(t("查看执行步骤")) + "</button></div>" : "")
5466
- );
5467
- }
5468
-
5469
- /* 任务详情首屏概览:把“这次任务做了什么、做到哪、最近发生了什么”放在
5470
- * 蜂巢上方。直连任务仍以对话为主,由 chat-mode 隐藏概览,避免它抢走输入空间。 */
5471
- function renderDetailOverview(run, runs, task) {
5472
- const box = $("rd-overview");
5473
- if (!box || !run) return;
5474
- if (chatEngineIsDirect(run)) {
5475
- box.classList.add("hidden");
5476
- box.innerHTML = "";
5477
- return;
5478
- }
5479
- const all = runs && runs.length ? runs : [run];
5480
- const steps = (run.steps || []);
5481
- const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5482
- const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5483
- !["running", "queued"].includes(String(s.status || ""))).length, 0);
5484
- const pct = total ? Math.max(0, Math.min(100, Math.round((settled / total) * 100))) : 0;
5485
- const status = String(run.status || "");
5486
- const active = status === "running" || status === "queued";
5487
- const bad = status === "failed" || status === "cancelled" || status === "timeout";
5488
- const statusText = active ? (status === "queued" ? t("排队中") : t("运行中"))
5489
- : status === "done" ? t("完成") : status === "failed" ? t("失败")
5490
- : status === "cancelled" ? t("已取消") : status || t("未知状态");
5491
- const last = steps.slice().reverse().find((s) => s.summary || s.output || s.note) || steps[steps.length - 1];
5492
- const lastText = last ? String(last.summary || last.output || last.note || "").replace(/\s+/g, " ").trim().slice(0, 240) : "";
5493
- const executor = run.executor || run.implementer || (last && (last.agent_label || last.agent)) || "";
5494
- const duration = runDurationSeconds(run);
5495
- const goal = (task && task.goal) || run.goal || run.title || "";
5496
- const actions = [];
5497
- if (total) actions.push('<button type="button" class="ghost" data-overview-tab="steps">' + esc(t("查看步骤")) + "</button>");
5498
- if (last && last.log) actions.push('<button type="button" class="ghost" data-overview-run="' + esc(run.id) + '" data-overview-log="' + esc(last.log) + '">' + esc(t("打开 CLI 日志")) + "</button>");
5499
- if (status === "done" || run.report) actions.push('<button type="button" class="ghost" data-overview-tab="result">' + esc(t("查看成果")) + "</button>");
5500
- box.innerHTML =
5501
- '<div class="rd-overview-main">' +
5502
- '<div class="rd-overview-kicker">' + esc(t("任务概览")) + '</div>' +
5503
- '<div class="rd-overview-title" title="' + esc(run.title || "") + '">' + esc(run.title || "") + '</div>' +
5504
- (goal ? '<div class="rd-overview-goal" title="' + esc(goal) + '">' + esc(goal) + '</div>' : "") +
5505
- '</div>' +
5506
- '<div class="rd-overview-state">' +
5507
- '<span class="state-label">' + esc(t("当前状态")) + '</span>' +
5508
- '<strong class="state-value ' + (bad ? "bad" : status === "done" ? "ok" : "") + '">' + esc(statusText) + '</strong>' +
5509
- '</div>' +
5510
- '<div class="rd-overview-progress" aria-label="' + esc(pct + "%") + '"><i style="width:' + pct + '%"></i></div>' +
5511
- '<div class="rd-overview-facts">' +
5512
- '<span><b>' + settled + "/" + total + '</b> ' + esc(t("步骤已完成")) + '</span>' +
5513
- (executor ? '<span>' + esc(t("执行者")) + ' <b>' + esc(executor) + '</b></span>' : "") +
5514
- (duration != null ? '<span>' + esc(t("耗时")) + ' <b>' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + '</b></span>' : "") +
5515
- '</div>' +
5516
- (lastText ? '<div class="rd-overview-last"><b>' + esc(t("最近一步")) + '</b><span>' + esc(lastText) + '</span></div>' : "") +
5517
- (actions.length ? '<div class="rd-overview-actions">' + actions.join("") + '</div>' : "");
5518
- box.classList.remove("hidden");
5519
- }
5520
-
5521
- function drawChatAtts() {
5598
+ function chatDurTxt(s) {
5599
+ s = Number(s) || 0;
5600
+ if (s < 60) return s + " " + t("秒");
5601
+ const m = Math.floor(s / 60), r = s % 60;
5602
+ return m + " " + t("分") + (r ? " " + r + " " + t("秒") : "");
5603
+ }
5604
+
5605
+ /* 代码/编排任务的确定性结果摘要:报告是长文,步骤是过程,用户还需要一眼
5606
+ * 知道这次到底成没成、做了多少步、用了多久,以及失败原因。这个卡片不依赖
5607
+ * 智能体是否在最后一句话里主动总结;直连任务也能在「成果」页签看到同一口径。 */
5608
+ function runDurationSeconds(run) {
5609
+ if (!run) return null;
5610
+ if (Number.isFinite(Number(run.duration_s))) return Number(run.duration_s);
5611
+ const steps = (run.steps || []).filter((s) => Number.isFinite(Number(s.duration_s)));
5612
+ if (steps.length) return steps.reduce((a, s) => a + Number(s.duration_s || 0), 0);
5613
+ const a = Date.parse(String(run.started_at || "").replace(" ", "T"));
5614
+ const b = Date.parse(String(run.ended_at || "").replace(" ", "T"));
5615
+ return Number.isFinite(a) && Number.isFinite(b) && b >= a ? (b - a) / 1000 : null;
5616
+ }
5617
+
5618
+ function runOutcomeSummary(run) {
5619
+ if (!run) return "";
5620
+ const last = (run.steps || []).slice().reverse().find((s) => s.output || s.summary);
5621
+ const raw = run.summary || (last && (last.output || last.summary)) || run.error || "";
5622
+ return String(raw).replace(/\s+/g, " ").trim().slice(0, 520);
5623
+ }
5624
+
5625
+ function renderRunOutcome(run, runs) {
5626
+ const box = $("rd-outcome");
5627
+ if (!box) return;
5628
+ if (!run) { box.classList.add("hidden"); box.innerHTML = ""; return; }
5629
+ const all = runs && runs.length ? runs : [run];
5630
+ const steps = (run.steps || []);
5631
+ const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5632
+ const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5633
+ !["running", "queued"].includes(String(s.status || ""))).length, 0);
5634
+ const status = String(run.status || "");
5635
+ const active = status === "running" || status === "queued";
5636
+ const bad = status === "failed" || status === "cancelled" || status === "timeout";
5637
+ const label = active ? (status === "queued" ? t("排队中") : t("运行中"))
5638
+ : (status === "done" ? t("完成") : status === "failed" ? t("失败") : status === "cancelled" ? t("已取消") : status || t("未知状态"));
5639
+ const icon = status === "done" ? "#i-check" : bad ? "#i-x" : "#i-gauge";
5640
+ const duration = runDurationSeconds(run);
5641
+ const latest = steps.slice().reverse().find((s) => s.agent_label || s.agent || s.role);
5642
+ const route = run.route && typeof run.route === "object" ? run.route.implementer : "";
5643
+ // route.implementer 是路由评分说明,不是用户真正关心的 CLI 名称;优先
5644
+ // 展示步骤上的 agent_label,只有旧数据没有步骤执行者时才回退到路由说明。
5645
+ const executor = run.executor || run.implementer || (latest && (latest.agent_label || latest.agent)) || route || "";
5646
+ const summary = runOutcomeSummary(run);
5647
+ const meta = [];
5648
+ if (executor) meta.push('<span><b>' + esc(t("执行者")) + '</b> ' + esc(executor) + "</span>");
5649
+ if (total) meta.push('<span><b>' + esc(t("步骤")) + '</b> ' + settled + "/" + total + "</span>");
5650
+ if (duration != null) meta.push('<span><b>' + esc(t("耗时")) + '</b> ' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + "</span>");
5651
+ return (box.classList.remove("hidden"), box.innerHTML =
5652
+ '<div class="outcome-top">' +
5653
+ '<div class="outcome-title ' + (bad ? "bad" : active ? "live" : "ok") + '">' +
5654
+ '<svg class="ico" aria-hidden="true"><use href="' + icon + '"></use></svg>' + esc(t("执行结果")) +
5655
+ '<span class="outcome-status">' + esc(label) + "</span></div>" +
5656
+ (meta.length ? '<div class="outcome-meta">' + meta.join("") + "</div>" : "") +
5657
+ "</div>" +
5658
+ (summary ? '<div class="outcome-summary">' + esc(summary) + "</div>" : "") +
5659
+ (run.error ? '<div class="outcome-error">' + esc(run.error) + "</div>" : "") +
5660
+ (total ? '<div class="outcome-actions"><button class="ghost" type="button" onclick="rdChatNavGo(\'steps\')">' + esc(t("查看执行步骤")) + "</button></div>" : "")
5661
+ );
5662
+ }
5663
+
5664
+ /* 任务详情首屏概览:把“这次任务做了什么、做到哪、最近发生了什么”放在
5665
+ * 蜂巢上方。直连任务仍以对话为主,由 chat-mode 隐藏概览,避免它抢走输入空间。 */
5666
+ function renderDetailOverview(run, runs, task) {
5667
+ const box = $("rd-overview");
5668
+ if (!box || !run) return;
5669
+ const direct = chatEngineIsDirect(run);
5670
+ box.classList.toggle("direct-overview", direct);
5671
+ const all = runs && runs.length ? runs : [run];
5672
+ const steps = (run.steps || []);
5673
+ const total = all.reduce((n, r) => n + ((r.steps || []).length), 0);
5674
+ const settled = all.reduce((n, r) => n + (r.steps || []).filter((s) =>
5675
+ !["running", "queued"].includes(String(s.status || ""))).length, 0);
5676
+ const pct = total ? Math.max(0, Math.min(100, Math.round((settled / total) * 100))) : 0;
5677
+ const status = String(run.status || "");
5678
+ const active = status === "running" || status === "queued";
5679
+ const bad = status === "failed" || status === "cancelled" || status === "timeout";
5680
+ const statusText = active ? (status === "queued" ? t("排队中") : t("运行中"))
5681
+ : status === "done" ? t("完成") : status === "failed" ? t("失败")
5682
+ : status === "cancelled" ? t("已取消") : status || t("未知状态");
5683
+ const last = steps.slice().reverse().find((s) => s.summary || s.output || s.note) || steps[steps.length - 1];
5684
+ const lastText = last ? String(last.summary || last.output || last.note || "").replace(/\s+/g, " ").trim().slice(0, 240) : "";
5685
+ const executor = run.executor || run.implementer || (last && (last.agent_label || last.agent)) || "";
5686
+ const duration = runDurationSeconds(run);
5687
+ const goal = (task && task.goal) || run.goal || run.title || "";
5688
+ const actions = [];
5689
+ if (total) actions.push('<button type="button" class="ghost" data-overview-tab="steps">' + esc(t("查看步骤")) + "</button>");
5690
+ 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>");
5691
+ if (status === "done" || run.report) actions.push('<button type="button" class="ghost" data-overview-tab="result">' + esc(t("查看成果")) + "</button>");
5692
+ box.innerHTML =
5693
+ '<div class="rd-overview-main">' +
5694
+ '<div class="rd-overview-kicker">' + esc(t("任务概览")) + '</div>' +
5695
+ '<div class="rd-overview-title" title="' + esc(run.title || "") + '">' + esc(run.title || "") + '</div>' +
5696
+ (goal ? '<div class="rd-overview-goal" title="' + esc(goal) + '">' + esc(goal) + '</div>' : "") +
5697
+ '</div>' +
5698
+ '<div class="rd-overview-state">' +
5699
+ '<span class="state-label">' + esc(t("当前状态")) + '</span>' +
5700
+ '<strong class="state-value ' + (bad ? "bad" : status === "done" ? "ok" : "") + '">' + esc(statusText) + '</strong>' +
5701
+ '</div>' +
5702
+ '<div class="rd-overview-progress" aria-label="' + esc(pct + "%") + '"><i style="width:' + pct + '%"></i></div>' +
5703
+ '<div class="rd-overview-facts">' +
5704
+ '<span><b>' + settled + "/" + total + '</b> ' + esc(t("步骤已完成")) + '</span>' +
5705
+ (executor ? '<span>' + esc(t("执行者")) + ' <b>' + esc(executor) + '</b></span>' : "") +
5706
+ (duration != null ? '<span>' + esc(t("耗时")) + ' <b>' + esc(chatDurTxt(duration < 1 ? Number(duration.toFixed(1)) : Math.round(duration))) + '</b></span>' : "") +
5707
+ '</div>' +
5708
+ (lastText ? '<div class="rd-overview-last"><b>' + esc(t("最近一步")) + '</b><span>' + esc(lastText) + '</span></div>' : "") +
5709
+ (actions.length ? '<div class="rd-overview-actions">' + actions.join("") + '</div>' : "");
5710
+ box.classList.remove("hidden");
5711
+ }
5712
+
5713
+ function drawChatAtts() {
5522
5714
  const box = $("rd-chat-att-list");
5523
5715
  if (!box) return;
5524
5716
  // 点名字开预览(待提交区原文走 /api/attachments/<id>),× 才是移除
@@ -5605,6 +5797,13 @@ function bindChat() {
5605
5797
  const btn = $("rd-chat-attach"), file = $("rd-chat-file"), send = $("rd-chat-send"),
5606
5798
  ta = $("rd-chat-input");
5607
5799
  if (!btn || !file || !send || !ta) return;
5800
+ const focusBtn = $("rd-chat-focus");
5801
+ if (focusBtn) focusBtn.addEventListener("click", () => {
5802
+ S.rdTab = "chat";
5803
+ S.rdTabPin = true;
5804
+ applyRdTabs();
5805
+ setTimeout(() => ta.focus(), 0);
5806
+ });
5608
5807
  btn.addEventListener("click", () => file.click());
5609
5808
  file.addEventListener("change", (e) => {
5610
5809
  chatUploadFiles(Array.from(e.target.files || []));
@@ -5633,16 +5832,27 @@ function bindChat() {
5633
5832
  // 附件胶囊点击预览:委托绑在静态容器上——时间线轮询整块重建 innerHTML、
5634
5833
  // 输入条胶囊增删重画,都不丢监听。时间线胶囊走已落盘文件,输入条胶囊
5635
5834
  // (× 移除按钮除外)走待提交区
5636
- $("rd-chat-flow").addEventListener("click", (e) => {
5637
- const logBtn = e.target.closest("[data-chat-log-run][data-chat-log-rel]");
5638
- if (logBtn) { e.stopPropagation(); toggleLog(logBtn.dataset.chatLogRun, logBtn.dataset.chatLogRel); return; }
5639
- const file = e.target.closest(".chat-file-open[data-file-run]");
5640
- if (file) {
5641
- e.preventDefault();
5642
- artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
5643
- return;
5644
- }
5645
- const chip = e.target.closest(".att-open");
5835
+ $("rd-chat-flow").addEventListener("click", (e) => {
5836
+ const logBtn = e.target.closest("[data-chat-log-run][data-chat-log-rel]");
5837
+ if (logBtn) { e.stopPropagation(); toggleLog(logBtn.dataset.chatLogRun, logBtn.dataset.chatLogRel); return; }
5838
+ // 建议追问芯片:点击把整句回填输入框(不直接发送——用户可改后再发),焦点落输入框
5839
+ const fup = e.target.closest(".chat-fup-btn[data-fup]");
5840
+ if (fup) {
5841
+ const ta = $("rd-chat-input");
5842
+ if (ta) {
5843
+ ta.value = fup.dataset.fup;
5844
+ ta.dispatchEvent(new Event("input", { bubbles: true }));
5845
+ ta.focus();
5846
+ }
5847
+ return;
5848
+ }
5849
+ const file = e.target.closest(".chat-file-open[data-file-run]");
5850
+ if (file) {
5851
+ e.preventDefault();
5852
+ artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
5853
+ return;
5854
+ }
5855
+ const chip = e.target.closest(".att-open");
5646
5856
  if (chip) window.chatAttPopup(chip.dataset.att);
5647
5857
  });
5648
5858
  const attBox = $("rd-chat-att-list");
@@ -5985,11 +6195,20 @@ function protoLabel(p) {
5985
6195
  * 通过的 wire_caps(auto 供应商全靠它)。返回命中的协议名,不适配返回空串。
5986
6196
  * 与 modelhub._entry_endpoint 同规则——它同时就是「注入本 CLI 实际走的 wire」,
5987
6197
  * 勾选面板据此标死解析时会被跳过的条目。 */
5988
- function provAdaptedProto(p, allow) {
6198
+ function provAdaptedProto(p, allow, kind) {
5989
6199
  if (!p) return "";
5990
- if (allow.indexOf(p.protocol) >= 0) return p.protocol;
6200
+ // codex 0.154+ 只讲 responses wire:chat-only 端点(显式 wire_api=chat 或
6201
+ // wire_caps 实测为 chat)对 codex 等于没有可用协议——与后端 resolve_binding
6202
+ // 的剔除规则同源(2026-09-18 重写任务案:链显示健康、运行时必剔死的假象)
6203
+ const codexOnly = /codex/.test(String(kind || "").toLowerCase());
6204
+ const chatBlocked = (w) => codexOnly && (w || "responses") === "chat";
6205
+ if (allow.indexOf(p.protocol) >= 0) {
6206
+ if (chatBlocked(p.wire_api)) return "";
6207
+ return p.protocol;
6208
+ }
5991
6209
  const caps = p.wire_caps || {};
5992
- return allow.find((pr) => (caps[pr] || {}).base) || "";
6210
+ return allow.find((pr) => (caps[pr] || {}).base
6211
+ && !chatBlocked((caps[pr] || {}).wire_api)) || "";
5993
6212
  }
5994
6213
 
5995
6214
  /* CLI 允许注入的供应商 wire 协议——与 modelhub.resolve_binding 的 allowed 规则
@@ -6010,7 +6229,7 @@ function modelSelectHtml(id, current, kind) {
6010
6229
  const allow = bindAllowedProtocols(kind);
6011
6230
  const groups = modelGroups().filter((g) => {
6012
6231
  const p = (S.providers || []).find((x) => x.id === g.id);
6013
- return provAdaptedProto(p, allow);
6232
+ return provAdaptedProto(p, allow, kind);
6014
6233
  });
6015
6234
  let opts = '<option value="">' + t("(未设置)") + '</option>';
6016
6235
  if (cur && !groups.some((g) => g.models.includes(cur))) {
@@ -6089,7 +6308,7 @@ function recommendFor(c) {
6089
6308
  const allow = bindAllowedProtocols(c.orch_kind || c.id);
6090
6309
  const badDefault = (p) => (p.models || []).some((m) => m.name === p.model &&
6091
6310
  (m.hidden || m.enabled === false));
6092
- const provs = (S.providers || []).filter((p) => !badDefault(p) && chainProvUsable(p, allow));
6311
+ const provs = (S.providers || []).filter((p) => !badDefault(p) && chainProvUsable(p, allow, c.orch_kind || c.id));
6093
6312
  if (!provs.length) return null;
6094
6313
  const score = (p) => (allow.indexOf(p.protocol) >= 0 ? 0 : 1) * 1000 + (p.priority || 0);
6095
6314
  provs.sort((a, b) => score(a) - score(b));
@@ -6101,9 +6320,9 @@ function recommendFor(c) {
6101
6320
 
6102
6321
  /* 供应商对某 CLI 是否「推荐可用」:启用、协议适配,且至少有一把不在冷却期的
6103
6322
  * KEY(全冷却=欠费失效,推荐了也白推荐——解析层照样失败)。 */
6104
- function chainProvUsable(p, allow) {
6323
+ function chainProvUsable(p, allow, kind) {
6105
6324
  if (!p || p.enabled === false) return false;
6106
- if (!provAdaptedProto(p, allow)) return false;
6325
+ if (!provAdaptedProto(p, allow, kind)) return false;
6107
6326
  const ks = p.keys || [];
6108
6327
  if (!ks.length) return true; // 老数据单 KEY(api_key 镜像):无 keys 结构视为可用
6109
6328
  return ks.some((k) => k.enabled !== false && !k.cooling);
@@ -6118,7 +6337,7 @@ function chainDeadReasons(c, chain) {
6118
6337
  const pv = (S.providers || []).find((p) => p.id === x.p);
6119
6338
  if (!pv) return t("供应商已删除");
6120
6339
  if (pv.enabled === false) return t("供应商已停用");
6121
- if (!provAdaptedProto(pv, allow)) return t("协议不匹配");
6340
+ if (!provAdaptedProto(pv, allow, c.orch_kind)) return t("协议不匹配");
6122
6341
  const ks = pv.keys || [];
6123
6342
  if (ks.length && !ks.some((k) => k.enabled !== false && !k.cooling))
6124
6343
  return t("密钥全部冷却中");
@@ -6250,7 +6469,7 @@ function bindModelBox(c, provId) {
6250
6469
  ? st.chain.map((c2, i) => {
6251
6470
  const pname = c2.p ? provName(c2.p) : t("CLI 默认凭据");
6252
6471
  const pv = c2.p ? (S.providers || []).find((p) => p.id === c2.p) : null;
6253
- const adapted = provAdaptedProto(pv, allow);
6472
+ const adapted = provAdaptedProto(pv, allow, c.orch_kind);
6254
6473
  const dead = c2.p && !adapted;
6255
6474
  // auto 供应商按实测 wire 注入是常态,说「自动」;显式协议靠适配才通的
6256
6475
  // 才叫「已适配」——两者含义不同,别混成一句。
@@ -6290,7 +6509,7 @@ function bindPanel(c) {
6290
6509
  const allow = bindAllowedProtocols(c.orch_kind);
6291
6510
  const all = modelGroups();
6292
6511
  // 原生协议匹配,或适配测试过 allow 里某条 wire 的供应商都可勾选
6293
- const groups = all.filter((g) => provAdaptedProto(g, allow));
6512
+ const groups = all.filter((g) => provAdaptedProto(g, allow, c.orch_kind));
6294
6513
  const hiddenN = all.length - groups.length;
6295
6514
  if (!groups.length) {
6296
6515
  return '<div class="ohint">' + esc(all.length
@@ -6305,12 +6524,12 @@ function bindPanel(c) {
6305
6524
  '<div class="ogroup"><div class="ogname">' + esc(g.name) +
6306
6525
  ' <span class="tag">' + (g.protocol === "auto"
6307
6526
  ? t("自动 · 注入该厂商凭据")
6308
- : (provAdaptedProto(g, allow) === g.protocol
6527
+ : (provAdaptedProto(g, allow, c.orch_kind) === g.protocol
6309
6528
  ? t("注入该厂商凭据") : t("已适配 · 注入该厂商凭据"))) + '</span></div>' +
6310
6529
  g.models.map((m) => {
6311
6530
  // 本供应商注入本 CLI 实际走的 wire(与后端 _entry_endpoint 同规则);
6312
6531
  // 无命中 = 勾了也会被解析层整条跳过,标死防止白配
6313
- const wire = provAdaptedProto(g, allow);
6532
+ const wire = provAdaptedProto(g, allow, c.orch_kind);
6314
6533
  const dead = !wire;
6315
6534
  const has = st.chain.some((x) => x.p === g.id && x.m === m);
6316
6535
  return '<label class="oitem' + (dead ? " dim" : "") + '"><input type="checkbox" value="' + esc(m) + '" data-p="' + esc(g.id) + '"' +
@@ -6380,11 +6599,19 @@ function renderCatalog() {
6380
6599
  });
6381
6600
  }
6382
6601
 
6602
+ /* 该条目有管理操作在跑(queued/running)时,写操作按钮置灰防重复触发;
6603
+ 页面刷新后按钮恢复,后端同条目去重闸仍然兜底 */
6604
+ function mgmtBusy(id) {
6605
+ const st = (S.mgmt || {})[id];
6606
+ return !!st && (st.status === "queued" || st.status === "running");
6607
+ }
6608
+
6383
6609
  function card(c) {
6384
6610
  const orchBox = c.orch_kind
6385
6611
  ? '<label class="toggle"><input type="checkbox" ' + (c.orch_enabled ? "checked" : "") +
6386
6612
  ' onchange="toggleOrch(\'' + esc(c.id) + '\', this.checked)">' + t(" 参与编排") + '</label>'
6387
6613
  : '<span class="tag">' + t("仅管理") + '</span>';
6614
+ const dis = mgmtBusy(c.id) ? " disabled" : "";
6388
6615
  const hasModels = modelGroups().length > 0;
6389
6616
  const modelBox = c.config_writable
6390
6617
  ? (hasModels
@@ -6398,10 +6625,10 @@ function card(c) {
6398
6625
  // 一键打开(web 类开浏览器 / console 类新终端跑 TUI):已安装且配了 launch 才出
6399
6626
  c.installed && c.launch ? '<button class="primary small" onclick="openAgent(\'' + esc(c.id) + '\')">' +
6400
6627
  (c.launch.kind === "web" ? t("打开网页") : t("打开")) + "</button>" : "",
6401
- c.installed && c.orch_kind ? '<button class="ghost small" onclick="mgmt(\'' + esc(c.id) + '\', \'smoke\')">' + t("冒烟测试") + '</button>' : "",
6402
- c.installed && c.has_upgrade ? '<button class="ghost small" onclick="mgmt(\'' + esc(c.id) + '\', \'upgrade\')">' + t("升级") + '</button>' : "",
6628
+ c.installed && c.orch_kind ? '<button class="ghost small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'smoke\')">' + t("冒烟测试") + '</button>' : "",
6629
+ c.installed && c.has_upgrade ? '<button class="ghost small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'upgrade\')">' + t("升级") + '</button>' : "",
6403
6630
  c.installed && c.has_upgrade ? '<button class="ghost small" onclick="checkUpdate(\'' + esc(c.id) + '\')">' + t("检查更新") + '</button>' : "",
6404
- !c.installed && c.has_install ? '<button class="ghost small" onclick="mgmt(\'' + esc(c.id) + '\', \'install\')">' + t("安装") + '</button>' : "",
6631
+ !c.installed && c.has_install ? '<button class="ghost small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'install\')">' + t("安装") + '</button>' : "",
6405
6632
  c.installed ? '<button class="ghost small" onclick="showMgmtLog(\'' + esc(c.id) + '\')">' + t("日志") + '</button>' : "",
6406
6633
  !c.installed && !c.has_install ? '<span class="hint">' + t("安装命令待配置(编辑 data/catalog.json)") + '</span>' : "",
6407
6634
  ].join("");
@@ -6409,7 +6636,7 @@ function card(c) {
6409
6636
  '<div class="head"><span class="name">' + esc(c.name) + "</span>" +
6410
6637
  (c.installed ? statusChip("done") : '<span class="tag">' + t("未安装") + '</span>') + updateChip(c) +
6411
6638
  // 卸载放卡片右上角(与状态徽标同行),不再吊在操作行尾部
6412
- (c.installed && c.uninstall_cmd ? '<button class="danger small" onclick="mgmt(\'' + esc(c.id) + '\', \'uninstall\')">' + t("卸载") + '</button>' : "") +
6639
+ (c.installed && c.uninstall_cmd ? '<button class="danger small"' + dis + ' onclick="mgmt(\'' + esc(c.id) + '\', \'uninstall\')">' + t("卸载") + '</button>' : "") +
6413
6640
  "</div>" +
6414
6641
  '<div class="note">' + esc(t(c.note || "")) + "</div>" +
6415
6642
  '<div class="facts">' + t("版本 ") + '<b>' + esc(c.version || "-") + "</b>" + t(" 模型 ") + "<b>" + esc(fmtModel(c.model) || "-") + "</b>" +
@@ -6538,6 +6765,7 @@ async function mgmt(id, op) {
6538
6765
  return;
6539
6766
  }
6540
6767
  S.mgmt[id].runId = r.run_id;
6768
+ if (r.deduped) toast(t("该条目已有进行中的任务,已转为跟踪该任务"));
6541
6769
  S.catSig = null; renderCatalog();
6542
6770
  pollMgmt(id, r.run_id); // 就地跟踪,不再跳转页面
6543
6771
  }
@@ -7427,6 +7655,8 @@ async function loadSettings() {
7427
7655
  S.settings = await api("/api/settings");
7428
7656
  const inp = $("set-workers");
7429
7657
  if (inp && S.settings) inp.value = S.settings.max_concurrent_jobs;
7658
+ const tel = $("set-telemetry");
7659
+ if (tel && S.settings) tel.checked = S.settings.telemetry_errors !== false;
7430
7660
  const wd = $("set-workdir"), hint = $("set-workdir-hint");
7431
7661
  if (wd && S.settings) wd.value = S.settings.default_workdir_effective || "";
7432
7662
  if (hint && S.settings) {
@@ -7434,9 +7664,13 @@ async function loadSettings() {
7434
7664
  hint.textContent = custom ? t("当前生效:") + S.settings.default_workdir_effective + t("(自定义)")
7435
7665
  : t("当前生效:") + S.settings.default_workdir_effective + t("(内置默认,尚未自定义)");
7436
7666
  }
7437
- // 新任务表单:目录留空即落到默认路径,给一句话提示
7438
- const fhint = $("f-workdir-hint");
7439
- if (fhint && S.settings) fhint.textContent = t("留空则保存到:") + (S.settings.default_workdir_effective || "");
7667
+ // 新任务表单:默认就「选中」默认路径(参考工作区选择器的默认选中态);
7668
+ // 手动清空输入仍表示跟随默认。f-workdir-hint 保留给「继续会话/克隆任务」的覆盖警告
7669
+ const wdInput = $("f-workdir");
7670
+ if (wdInput && S.settings && !wdInput.value.trim()) {
7671
+ wdInput.value = S.settings.default_workdir_effective || "";
7672
+ queueGitProbe(); // 目录在场即探测代码版本,点亮分支胶囊
7673
+ }
7440
7674
  } catch (e) { /* 忽略 */ }
7441
7675
  }
7442
7676
 
@@ -7582,6 +7816,36 @@ function maybeWhatsnew() {
7582
7816
  }
7583
7817
  }
7584
7818
 
7819
+ /* ===== 首次启动欢迎引导:产品是什么 + 装完三步开工 =====
7820
+ * localStorage orch.welcomed 记账,只自动弹一次;「关于与更新」页可手动重开。
7821
+ * 令牌门在场时让位(远端新设备先输令牌,下次启动引导照常弹)。 */
7822
+ function welcomeOpen() {
7823
+ const w = $("welcome");
7824
+ if (!w) return;
7825
+ if (!$("token-gate").classList.contains("hidden")) return;
7826
+ w.classList.remove("hidden");
7827
+ document.body.classList.add("welcome-open");
7828
+ }
7829
+ function welcomeClose() {
7830
+ const w = $("welcome");
7831
+ if (!w) return;
7832
+ w.classList.add("hidden");
7833
+ document.body.classList.remove("welcome-open");
7834
+ try { localStorage.setItem("orch.welcomed", "1"); } catch (e) { /* 存储不可用则下次再弹 */ }
7835
+ }
7836
+ function welcomeGo(tab) {
7837
+ welcomeClose();
7838
+ switchTab(tab); // 直达「模型接入」/「CLI 绑定」
7839
+ }
7840
+ function maybeWelcome() {
7841
+ let seen = "";
7842
+ try { seen = localStorage.getItem("orch.welcomed"); } catch (e) { /* ignore */ }
7843
+ if (!seen) welcomeOpen();
7844
+ }
7845
+ window.welcomeOpen = welcomeOpen;
7846
+ window.welcomeClose = welcomeClose;
7847
+ window.welcomeGo = welcomeGo;
7848
+
7585
7849
  function renderSu() {
7586
7850
  const info = $("su-info");
7587
7851
  if (!info) return;
@@ -7626,6 +7890,57 @@ function updDismiss() {
7626
7890
  toast(t("已忽略该版本提醒,可随时在「关于与更新」里升级"));
7627
7891
  }
7628
7892
 
7893
+ /* 匿名错误报告开关:改完立即生效(后端每轮上报前都会读一次设置) */
7894
+ async function saveTelemetry(on) {
7895
+ try {
7896
+ const r = await api("/api/settings", { method: "POST", body: JSON.stringify({ telemetry_errors: !!on }) });
7897
+ S.settings = r.settings;
7898
+ toast(on ? t("已开启匿名错误报告,感谢帮助改进 CodeBee")
7899
+ : t("已关闭匿名错误报告,数据不再离开本机"));
7900
+ } catch (e) {
7901
+ const tel = $("set-telemetry");
7902
+ if (tel) tel.checked = !on; // 存失败弹回旧值,不让 UI 与后端各说各话
7903
+ toast(e.message || t("保存失败"), true);
7904
+ }
7905
+ }
7906
+
7907
+ /* 导出诊断包:脱敏错误台账 + 用量统计 + 版本环境信息(zip),贴 Issue 用 */
7908
+ async function exportDiagBundle() {
7909
+ const b = $("diag-export");
7910
+ if (b) b.disabled = true;
7911
+ try {
7912
+ const r = await fetch("/api/diagnostics/bundle", { headers: authHeaders() });
7913
+ if (!r.ok) throw new Error("HTTP " + r.status);
7914
+ const u = URL.createObjectURL(await r.blob());
7915
+ const a = document.createElement("a");
7916
+ a.href = u;
7917
+ a.download = "codebee-diag-" + new Date().toISOString().slice(0, 10) + ".zip";
7918
+ document.body.appendChild(a);
7919
+ a.click();
7920
+ a.remove();
7921
+ setTimeout(() => URL.revokeObjectURL(u), 5000);
7922
+ toast(t("诊断包已开始下载,反馈问题时可附在 Issue 里"));
7923
+ } catch (e) {
7924
+ toast(t("诊断包导出失败:") + e.message, true);
7925
+ } finally {
7926
+ if (b) b.disabled = false;
7927
+ }
7928
+ }
7929
+
7930
+ /* 一键反馈 Issue:拉脱敏摘要 → 预填 GitHub Issue 新建页(用户亲手提交,不自动回传) */
7931
+ async function reportIssue() {
7932
+ try {
7933
+ const s = await api("/api/diagnostics/issue-summary");
7934
+ const url = "https://github.com/Vercel-By-WXP/CodeBee/issues/new" +
7935
+ "?title=" + encodeURIComponent(s.title || "") +
7936
+ "&body=" + encodeURIComponent(s.body || "");
7937
+ window.open(url, "_blank");
7938
+ toast(t("已打开 GitHub 反馈页,内容已预填,可直接提交(也可附上诊断包 zip)"));
7939
+ } catch (e) {
7940
+ toast(t("反馈摘要生成失败:") + e.message, true);
7941
+ }
7942
+ }
7943
+
7629
7944
  async function suCheck() {
7630
7945
  const b = $("su-check");
7631
7946
  if (b) b.disabled = true;
@@ -7984,31 +8299,31 @@ function usageMultilineSvg(byDay, byDayModel) {
7984
8299
  "<title>" + esc(days[i] + "\n" + s.name + " " + fmtTok(s.vals[i]) + t(" tokens")) + "</title></circle>";
7985
8300
  });
7986
8301
  });
7987
- const grid = [0.25, 0.5, 0.75].map((f) =>
7988
- '<line class="uc-grid" x1="' + padL + '" y1="' + (padT + ih * f).toFixed(1) +
7989
- '" x2="' + (W - padR) + '" y2="' + (padT + ih * f).toFixed(1) + '"/>').join("") +
7990
- '<line class="uc-base" x1="' + padL + '" y1="' + (padT + ih) + '" x2="' + (W - padR) + '" y2="' + (padT + ih) + '"/>';
7991
- // 底层活动量柱保留每日总量轮廓;折线仍表达各模型趋势,双层信息比单一曲线
7992
- // 更容易看出“哪天整体有调用、哪天只是某个模型占比变化”。
7993
- const dailyTotals = days.map((_, i) => series.reduce((sum, s) => sum + (s.vals[i] || 0), 0));
7994
- const dayMax = Math.max(1, ...dailyTotals);
7995
- const barW = Math.max(3, (iw / n) * 0.52);
7996
- const bars = dailyTotals.map((v, i) => {
7997
- const h = ih * v / dayMax;
7998
- const x = xOf(i) - barW / 2;
7999
- const y = padT + ih - h;
8000
- return '<rect class="uc-bar" x="' + x.toFixed(1) + '" y="' + y.toFixed(1) +
8001
- '" width="' + barW.toFixed(1) + '" height="' + Math.max(0.5, h).toFixed(1) +
8002
- '" rx="2"><title>' + esc(days[i] + " · " + fmtTok(v) + t(" tokens")) + "</title></rect>";
8003
- }).join("");
8302
+ const grid = [0.25, 0.5, 0.75].map((f) =>
8303
+ '<line class="uc-grid" x1="' + padL + '" y1="' + (padT + ih * f).toFixed(1) +
8304
+ '" x2="' + (W - padR) + '" y2="' + (padT + ih * f).toFixed(1) + '"/>').join("") +
8305
+ '<line class="uc-base" x1="' + padL + '" y1="' + (padT + ih) + '" x2="' + (W - padR) + '" y2="' + (padT + ih) + '"/>';
8306
+ // 底层活动量柱保留每日总量轮廓;折线仍表达各模型趋势,双层信息比单一曲线
8307
+ // 更容易看出“哪天整体有调用、哪天只是某个模型占比变化”。
8308
+ const dailyTotals = days.map((_, i) => series.reduce((sum, s) => sum + (s.vals[i] || 0), 0));
8309
+ const dayMax = Math.max(1, ...dailyTotals);
8310
+ const barW = Math.max(3, (iw / n) * 0.52);
8311
+ const bars = dailyTotals.map((v, i) => {
8312
+ const h = ih * v / dayMax;
8313
+ const x = xOf(i) - barW / 2;
8314
+ const y = padT + ih - h;
8315
+ return '<rect class="uc-bar" x="' + x.toFixed(1) + '" y="' + y.toFixed(1) +
8316
+ '" width="' + barW.toFixed(1) + '" height="' + Math.max(0.5, h).toFixed(1) +
8317
+ '" rx="2"><title>' + esc(days[i] + " · " + fmtTok(v) + t(" tokens")) + "</title></rect>";
8318
+ }).join("");
8004
8319
  const legend = '<div class="um-legend">' +
8005
8320
  series.map((s, si) =>
8006
8321
  '<span><i style="background:' + usageColor(si) + '"></i>' +
8007
8322
  esc(s.name.length > 22 ? s.name.slice(0, 21) + "…" : s.name) + "</span>").join("") +
8008
8323
  '<span class="um-peak">' + esc(t("峰值 ") + fmtTok(max)) + "</span></div>";
8009
- return legend +
8010
- '<svg class="usage-svg" viewBox="0 0 ' + W + " " + H + '" role="img" preserveAspectRatio="xMidYMid meet">' +
8011
- grid + bars + body + labels + "</svg>";
8324
+ return legend +
8325
+ '<svg class="usage-svg" viewBox="0 0 ' + W + " " + H + '" role="img" preserveAspectRatio="xMidYMid meet">' +
8326
+ grid + bars + body + labels + "</svg>";
8012
8327
  }
8013
8328
 
8014
8329
  /* Token 活动热力图:GitHub 贡献图风格。窗口自适应——覆盖全部活跃历史(封顶
@@ -8239,10 +8554,10 @@ function renderUsage() {
8239
8554
  // 不提示的话「输入 0 · 输出 0」会被误读成统计坏了
8240
8555
  const noBreakdown = tot.tokens > 0 && !tot.input && !tot.output && !tot.cached;
8241
8556
  $("usage-kpis").innerHTML = [
8242
- kpiCard(t("累计 Token 数"), fmtTok(tot.tokens),
8243
- t("调用 ") + fmtTok(tot.calls) + t(" 次 · 成功率 ") +
8244
- (tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " +
8245
- t("失败 ") + fmtTok(tot.failed || 0) + t(" · ") + fmtUsd(tot.cost_usd),
8557
+ kpiCard(t("累计 Token 数"), fmtTok(tot.tokens),
8558
+ t("调用 ") + fmtTok(tot.calls) + t(" 次 · 成功率 ") +
8559
+ (tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " +
8560
+ t("失败 ") + fmtTok(tot.failed || 0) + t(" · ") + fmtUsd(tot.cost_usd),
8246
8561
  true, "i-sigma"),
8247
8562
  kpiCard(t("峰值 Token 数"), fmtTok(tot.peak_tokens),
8248
8563
  t("单日最高 · 日均 ") + fmtTok(activeDays ? tot.tokens / activeDays : 0), false, "i-gauge"),
@@ -8345,6 +8660,7 @@ function collapseDrawerIfMobile() {
8345
8660
 
8346
8661
  function switchTab(name) {
8347
8662
  if (name === "__phone") { openPhoneConnect(); return; } // 手机连接是弹框,不切页
8663
+ if (name === "__guide") { welcomeOpen(); return; } // 使用引导是弹层,不切页(设置导航「软件」组)
8348
8664
  // 导航收进「设置」:进设置后左栏整体换成设置导航,内容铺满
8349
8665
  S.tab = name;
8350
8666
  if (SET_TABS.has(name)) localStorage.setItem("orch.setTab", name);
@@ -8516,47 +8832,61 @@ document.addEventListener("DOMContentLoaded", () => {
8516
8832
  b.addEventListener("click", () => setUsageHeatMode(b.dataset.heat)));
8517
8833
  $("btn-back").addEventListener("click", closeRun);
8518
8834
  $("btn-cancel").addEventListener("click", cancelRun);
8519
- bindDirector();
8520
- bindChat();
8521
- const bindArtifactClicks = (root) => {
8522
- if (!root) return;
8523
- root.addEventListener("click", (e) => {
8524
- const file = e.target.closest("[data-file-run][data-file-name]");
8525
- if (!file) return;
8526
- e.preventDefault();
8527
- artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
8528
- });
8529
- };
8530
- bindArtifactClicks($("rd-arts"));
8531
- bindArtifactClicks($("insp-artifacts"));
8532
- // 动态详情区统一用 data 属性委托,日志路径/文件名不再拼进 inline JS。
8533
- const detailOverview = $("rd-overview");
8534
- if (detailOverview) detailOverview.addEventListener("click", (e) => {
8535
- const log = e.target.closest("[data-overview-log]");
8536
- if (log) { toggleLog(log.dataset.overviewRun, log.dataset.overviewLog); return; }
8537
- const tab = e.target.closest("[data-overview-tab]");
8538
- if (tab) { S.rdTab = tab.dataset.overviewTab; S.rdTabPin = true; applyRdTabs(); }
8539
- });
8540
- const stepBox = $("rd-steps");
8541
- if (stepBox) stepBox.addEventListener("click", (e) => {
8542
- const btn = e.target.closest("[data-log-run][data-log-rel]");
8543
- const row = e.target.closest(".step[data-log]");
8544
- if (btn) { e.stopPropagation(); toggleLog(btn.dataset.logRun, btn.dataset.logRel); return; }
8545
- if (row && row.dataset.log) toggleLog(row.dataset.runId || S.detailRunId, row.dataset.log);
8546
- });
8547
- const hiveBox = $("rd-hive-cells");
8548
- if (hiveBox) {
8549
- hiveBox.addEventListener("click", (e) => {
8550
- const cell = e.target.closest("[data-hive-run][data-hive-log]");
8551
- if (cell) hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog);
8552
- });
8553
- hiveBox.addEventListener("keydown", (e) => {
8554
- if (e.key !== "Enter" && e.key !== " ") return;
8555
- const cell = e.target.closest("[data-hive-run][data-hive-log]");
8556
- if (cell) { e.preventDefault(); hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog); }
8557
- });
8558
- }
8559
- // 详情标签页:手点即切并钉住——状态变化触发的自动选卡不再抢用户的手选
8835
+ bindDirector();
8836
+ bindChat();
8837
+ const bindArtifactClicks = (root) => {
8838
+ if (!root) return;
8839
+ root.addEventListener("click", (e) => {
8840
+ const file = e.target.closest("[data-file-run][data-file-name]");
8841
+ if (!file) return;
8842
+ e.preventDefault();
8843
+ artPopup(file.dataset.fileRun, file.dataset.fileName || "", Number(file.dataset.fileSize) || 0);
8844
+ });
8845
+ };
8846
+ bindArtifactClicks($("rd-arts"));
8847
+ bindArtifactClicks($("insp-artifacts"));
8848
+ // 动态详情区统一用 data 属性委托,日志路径/文件名不再拼进 inline JS。
8849
+ const detailOverview = $("rd-overview");
8850
+ if (detailOverview) detailOverview.addEventListener("click", (e) => {
8851
+ const log = e.target.closest("[data-overview-log]");
8852
+ if (log) { toggleLog(log.dataset.overviewRun, log.dataset.overviewLog); return; }
8853
+ const tab = e.target.closest("[data-overview-tab]");
8854
+ if (tab) { S.rdTab = tab.dataset.overviewTab; S.rdTabPin = true; applyRdTabs(); }
8855
+ });
8856
+ const rdMore = $("rd-more-toggle"), rdMetaPop = $("rd-meta-popover"), rdMoreClose = $("rd-more-close");
8857
+ if (rdMore) rdMore.addEventListener("click", (e) => {
8858
+ e.stopPropagation();
8859
+ setRdMetaOpen(rdMetaPop && rdMetaPop.classList.contains("hidden"));
8860
+ });
8861
+ if (rdMoreClose) rdMoreClose.addEventListener("click", () => setRdMetaOpen(false));
8862
+ document.addEventListener("click", (e) => {
8863
+ if (!rdMetaPop || rdMetaPop.classList.contains("hidden")) return;
8864
+ if (rdMetaPop.contains(e.target) || (rdMore && rdMore.contains(e.target))) return;
8865
+ setRdMetaOpen(false);
8866
+ });
8867
+ document.addEventListener("keydown", (e) => {
8868
+ if (e.key === "Escape" && rdMetaPop && !rdMetaPop.classList.contains("hidden")) setRdMetaOpen(false);
8869
+ });
8870
+ const stepBox = $("rd-steps");
8871
+ if (stepBox) stepBox.addEventListener("click", (e) => {
8872
+ const btn = e.target.closest("[data-log-run][data-log-rel]");
8873
+ const row = e.target.closest(".step[data-log]");
8874
+ if (btn) { e.stopPropagation(); toggleLog(btn.dataset.logRun, btn.dataset.logRel); return; }
8875
+ if (row && row.dataset.log) toggleLog(row.dataset.runId || S.detailRunId, row.dataset.log);
8876
+ });
8877
+ const hiveBox = $("rd-hive-cells");
8878
+ if (hiveBox) {
8879
+ hiveBox.addEventListener("click", (e) => {
8880
+ const cell = e.target.closest("[data-hive-run][data-hive-log]");
8881
+ if (cell) hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog);
8882
+ });
8883
+ hiveBox.addEventListener("keydown", (e) => {
8884
+ if (e.key !== "Enter" && e.key !== " ") return;
8885
+ const cell = e.target.closest("[data-hive-run][data-hive-log]");
8886
+ if (cell) { e.preventDefault(); hiveOpenLog(cell.dataset.hiveRun, cell.dataset.hiveLog); }
8887
+ });
8888
+ }
8889
+ // 详情标签页:手点即切并钉住——状态变化触发的自动选卡不再抢用户的手选
8560
8890
  $("rd-tabs").addEventListener("click", (e) => {
8561
8891
  const b = e.target.closest(".rd-tab");
8562
8892
  if (!b || b.classList.contains("hidden")) return;
@@ -8589,6 +8919,60 @@ document.addEventListener("DOMContentLoaded", () => {
8589
8919
  sync();
8590
8920
  })();
8591
8921
  $("btn-retry").addEventListener("click", () => { const r = S.lastRun; if (r && r.task_id) retryTask(r.task_id); });
8922
+ /* 日志控制台高度:顶边手柄拖拽调整(localStorage 记忆),双击复位为默认弹性高度 */
8923
+ (() => {
8924
+ const grip = $("rd-log-grip"), box = $("rd-log");
8925
+ if (!grip || !box) return;
8926
+ const KEY = "orch.rdLogH";
8927
+ const saved = parseInt(localStorage.getItem(KEY), 10);
8928
+ /* 记忆值是「期望高度」:flex 允许收缩(shrink 1),窗口变矮时自动缩回视口内,
8929
+ 永不把抽屉顶出窗口底(用户反馈「还是超长了」的根因就是固定 px 不收缩) */
8930
+ if (saved >= 140) box.style.flex = "0 1 " + saved + "px";
8931
+ grip.addEventListener("dblclick", () => {
8932
+ localStorage.removeItem(KEY);
8933
+ box.style.flex = "";
8934
+ });
8935
+ grip.addEventListener("pointerdown", (e) => {
8936
+ e.preventDefault();
8937
+ try { grip.setPointerCapture(e.pointerId); } catch (err) { /* 老浏览器降级 */ }
8938
+ const startY = e.clientY;
8939
+ const startH = box.getBoundingClientRect().height;
8940
+ const pre = $("rd-log-text");
8941
+ const stick = pre ? pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48 : false;
8942
+ let lastH = Math.round(Math.max(startH, 140));
8943
+ document.body.classList.add("rd-log-resizing");
8944
+ const onMove = (ev) => {
8945
+ lastH = Math.round(Math.min(Math.max(startH + (startY - ev.clientY), 140), window.innerHeight * 0.72));
8946
+ box.style.flex = "0 1 " + lastH + "px";
8947
+ };
8948
+ const onUp = (ev) => {
8949
+ try { grip.releasePointerCapture(ev.pointerId); } catch (err) { /* 已释放 */ }
8950
+ document.body.classList.remove("rd-log-resizing");
8951
+ grip.removeEventListener("pointermove", onMove);
8952
+ grip.removeEventListener("pointerup", onUp);
8953
+ grip.removeEventListener("pointercancel", onUp);
8954
+ localStorage.setItem(KEY, String(lastH)); // 存钳制后的目标值,隐藏态 rect 是 0 不能用
8955
+ if (stick && pre) pre.scrollTop = pre.scrollHeight; // 拖完仍在贴底状态则继续跟最新
8956
+ };
8957
+ grip.addEventListener("pointermove", onMove);
8958
+ grip.addEventListener("pointerup", onUp);
8959
+ grip.addEventListener("pointercancel", onUp);
8960
+ });
8961
+ })();
8962
+ if ($("btn-talk")) $("btn-talk").addEventListener("click", window.rdTalkToggle);
8963
+ if ($("rd-talk-send")) $("rd-talk-send").addEventListener("click", window.rdTalkSend);
8964
+ if ($("rd-talk-input")) $("rd-talk-input").addEventListener("keydown", (e) => {
8965
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); window.rdTalkSend(); }
8966
+ });
8967
+ if ($("rd-talk-attach")) $("rd-talk-attach").addEventListener("click", () => $("rd-talk-file").click());
8968
+ if ($("rd-talk-file")) $("rd-talk-file").addEventListener("change", (e) => {
8969
+ talkUploadFiles(Array.from(e.target.files || []));
8970
+ e.target.value = "";
8971
+ });
8972
+ if ($("rd-talk-input")) $("rd-talk-input").addEventListener("paste", (e) => {
8973
+ const files = Array.from((e.clipboardData || {}).files || []);
8974
+ if (files.length) { e.preventDefault(); talkUploadFiles(files); }
8975
+ });
8592
8976
  $("btn-continue").addEventListener("click", () => {
8593
8977
  const tid = (S.lastRun && S.lastRun.task_id) || S.detailTaskKey || "";
8594
8978
  if (tid) continueSerial(tid);
@@ -8674,8 +9058,8 @@ document.addEventListener("DOMContentLoaded", () => {
8674
9058
  $("f-resume-agent").addEventListener("change", loadSessions);
8675
9059
  $("f-resume-session").addEventListener("change", showResumeHint);
8676
9060
  /* 工作目录不再用「上次用过」的旧记忆预填:那样改默认保存路径后,新建任务仍会被
8677
- * 旧目录盖过(留空才是真正跟随默认)。显式选目录的入口(向导/克隆/右键新建任务到该目录)
8678
- * 各自直接写字段值,不跨刷新持久化。清掉旧版残留键,避免误解为默认选中。 */
9061
+ * 旧目录盖过。首屏默认选中设置里的默认路径(loadSettings 落值),克隆/右键新建
9062
+ * 等显式入口各自直接写字段值,不跨刷新持久化。清掉旧版残留键,避免误解为默认选中。 */
8679
9063
  localStorage.removeItem("orch.workdir");
8680
9064
  // 附件:按钮选文件 + 目标框粘贴截图;工作目录变化时探测 git 仓库(代码版本下拉)
8681
9065
  $("btn-attach").addEventListener("click", () => $("f-attach-file").click());
@@ -8683,6 +9067,15 @@ document.addEventListener("DOMContentLoaded", () => {
8683
9067
  addAttachFiles(Array.from(e.target.files || []));
8684
9068
  e.target.value = ""; // 允许重复选同一个文件
8685
9069
  });
9070
+ const attChipBox = $("att-chips");
9071
+ if (attChipBox) attChipBox.addEventListener("click", (e) => {
9072
+ const remove = e.target.closest("[data-att-remove]");
9073
+ if (remove) {
9074
+ e.preventDefault();
9075
+ e.stopPropagation();
9076
+ removeAtt(remove.dataset.attRemove || "");
9077
+ }
9078
+ });
8686
9079
  $("f-goal").addEventListener("paste", onGoalPaste);
8687
9080
  // Composer 手感:Enter 直接发送,Shift+Enter 换行(输入法组词中不劫持)
8688
9081
  $("f-goal").addEventListener("keydown", (e) => {
@@ -8692,6 +9085,18 @@ document.addEventListener("DOMContentLoaded", () => {
8692
9085
  $("f-workdir").addEventListener("change", queueGitProbe);
8693
9086
  // 点工作目录输入框直接进文件夹选择(远端设备被 403 守卫拦下,静默继续手输)
8694
9087
  $("f-workdir").addEventListener("click", () => window.pickFolder("f-workdir", true));
9088
+ // 最近文件夹菜单:条目点击统一走 wdMenuPick;点外面收起
9089
+ const wdMenu = $("wd-menu");
9090
+ if (wdMenu) wdMenu.addEventListener("click", (e) => {
9091
+ const item = e.target.closest(".wd-item");
9092
+ if (!item) return;
9093
+ e.stopPropagation();
9094
+ window.wdMenuPick(item);
9095
+ });
9096
+ document.addEventListener("click", (e) => {
9097
+ if (wdMenu && !wdMenu.classList.contains("hidden") &&
9098
+ !e.target.closest("#wd-menu, #f-workdir-menu-btn")) wdMenu.classList.add("hidden");
9099
+ });
8695
9100
  $("set-workdir").addEventListener("click", () => window.pickFolder("set-workdir", true));
8696
9101
  $("f-git-rev").addEventListener("change", renderGitHint);
8697
9102
  if ($("f-workdir").value) queueGitProbe(); // 回填的目录:进页面即探测代码版本
@@ -8736,6 +9141,7 @@ document.addEventListener("DOMContentLoaded", () => {
8736
9141
  && e.target.tagName !== "TEXTAREA") { _askClose(true); return; }
8737
9142
  // 文件内容弹窗(240) 夹在 ask(250) 与 modal(200) 之间,Esc 同样逐层关
8738
9143
  if (e.key === "Escape" && filePopIsOpen()) { window.filePopClose(); return; }
9144
+ if (e.key === "Escape" && !$("welcome").classList.contains("hidden")) { welcomeClose(); return; }
8739
9145
  if (e.key === "Escape" && !$("modal").classList.contains("hidden")) closeModal();
8740
9146
  // 日志抽屉:Esc 收起(最底层,放在弹窗之后)
8741
9147
  if (e.key === "Escape" && !$("rd-log").classList.contains("hidden")) window.rdLogClose();
@@ -8749,7 +9155,7 @@ document.addEventListener("DOMContentLoaded", () => {
8749
9155
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
8750
9156
  if (!typing && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === "n" || e.key === "N")
8751
9157
  && $("modal").classList.contains("hidden") && $("ask").classList.contains("hidden")
8752
- && filePopIsOpen() === false && $("cmdk-mask").classList.contains("hidden")
9158
+ && $("welcome").classList.contains("hidden") && filePopIsOpen() === false && $("cmdk-mask").classList.contains("hidden")
8753
9159
  && !document.body.classList.contains("settings-mode")
8754
9160
  && !document.body.classList.contains("files-mode")) {
8755
9161
  $("btn-new-task").click();
@@ -8811,8 +9217,9 @@ document.addEventListener("DOMContentLoaded", () => {
8811
9217
  loadFlows(); // 任务类型下拉(内置 + 自定义流程)
8812
9218
  refreshSessionAgents(); // 继续会话下拉的工具集合(服务端 60s 缓存,开销小)
8813
9219
  loadOrchestrator(); // 侧栏左下角的编排者供应商指示(进入编排设置页时会再拉一次)
8814
- loadSettings(); // 启动即拉设置:新建表单的「留空则保存到:<默认路径>」提示依赖它
9220
+ loadSettings(); // 启动即拉设置:新建表单「默认选中默认路径」依赖它
8815
9221
  suStartupCheck(); // 静默查一次新版本(有新版 toast 提醒,同版本只提一次)
9222
+ maybeWelcome(); // 首次启动弹欢迎引导(orch.welcomed 记账,只弹一次)
8816
9223
  });
8817
9224
 
8818
9225
  /* ===== 命令面板(Ctrl+K / 侧栏搜索行):任务直达 + 快捷命令 ===== */
@@ -8827,6 +9234,7 @@ function cmdkOps() {
8827
9234
  { icon: "i-cpu", label: t("智能体管理"), run: () => switchTab("agents") },
8828
9235
  { icon: "i-chart", label: t("用量统计"), run: () => switchTab("usage") },
8829
9236
  { icon: "i-gear", label: t("设置"), run: () => enterSettings() },
9237
+ { icon: "i-bee", label: t("使用引导"), run: () => welcomeOpen() },
8830
9238
  { icon: "i-phone", label: t("手机连接"), run: () => switchTab("__phone") },
8831
9239
  ];
8832
9240
  }