codebee 0.1.48 → 0.1.50

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
@@ -10,7 +10,7 @@ function codebeeDocumentTitle(lang) {
10
10
  document.title = codebeeDocumentTitle((localStorage.getItem("orch.lang") || "zh").toLowerCase());
11
11
 
12
12
  const $ = (id) => document.getElementById(id);
13
- 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: {}, lastPrefs: null, prefsApplied: false };
13
+ const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", mainPage: "", /* 主栏正显示的全局页("" = 新建任务表单);左栏是任务树还是设置导航看 body.settings-mode */ 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: {}, lastPrefs: null, prefsApplied: false, sideUsage: null, sideUsageAt: 0, ovUsage: null, ovDays: undefined };
14
14
 
15
15
  /* ---------------------------------------------------------- 任务类型(流程) */
16
16
  async function loadFlows() {
@@ -301,7 +301,8 @@ function onTypeChange() {
301
301
  const bibleCreate = $("bible-create-field");
302
302
  const isSerialReview = isReview && !!(flow && flow.serial);
303
303
  if (bibleCreate) bibleCreate.classList.toggle("hidden", !isSerialReview);
304
- const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible"].forEach((id) => {
304
+ const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible",
305
+ "f-vol-chapters", "f-vol-spec"].forEach((id) => {
305
306
  const el = $(id);
306
307
  if (el) el.value = "";
307
308
  });
@@ -330,6 +331,12 @@ function onTypeChange() {
330
331
  if (flow.serial) {
331
332
  if (!$("f-chapters").value) $("f-chapters").value = lp.chapters || flow.serial.chapters || "";
332
333
  if (!$("f-words-per-ch").value) $("f-words-per-ch").value = lp.words_per_chapter || flow.serial.words_per_chapter || "";
334
+ // 分卷开箱即用:没填过就预填一个常见每卷章数(用户可改;清空 = 不分卷)。
335
+ // 卷边界由后端按全书章号推导,续写批次自动接上同一卷。
336
+ if (!$("f-vol-chapters").value && !$("f-vol-spec").value) {
337
+ $("f-vol-chapters").value = (lp.volume_chapters != null && lp.volume_chapters !== "")
338
+ ? lp.volume_chapters : (flow.serial.volume_chapters || 20);
339
+ }
333
340
  } else clearSerialFields();
334
341
  } else {
335
342
  // 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
@@ -1086,12 +1093,12 @@ async function healthOp(op) {
1086
1093
  }
1087
1094
  }
1088
1095
 
1089
- /* 模型级禁用:链降级(resolve_binding 的 _model_bindable)会自动跳过它。
1096
+ /* 模型级禁用:绑定链里它的条目由后端自动剔除(modelhub 停用即出调度)。
1090
1097
  只对出问题的那一格生效——厂商其它模型照常可用。 */
1091
1098
  async function healthDisableModel(pid, model) {
1092
1099
  if (!pid || !model) { closeModal(); return; }
1093
1100
  const yes = await uiConfirm(
1094
- t("确认禁用模型 {0}?链降级将自动跳过它,其余模型不受影响;可在模型调度页重新启用。").replace("{0}", pid + " · " + model),
1101
+ t("确认禁用模型 {0}?调度链里它的条目会自动移除,其余模型不受影响;可在模型调度页重新启用。").replace("{0}", pid + " · " + model),
1095
1102
  { title: t("禁用模型"), danger: true, ok: t("禁用") });
1096
1103
  if (!yes) return;
1097
1104
  try {
@@ -1106,8 +1113,7 @@ async function healthDisableModel(pid, model) {
1106
1113
  S.modelCatalog = models.catalog || [];
1107
1114
  S.provSig = "";
1108
1115
  closeModal(); render(); loadOrchestrator();
1109
- toast(t("已禁用模型 {0} · {1},链降级自动跳过;绑定页可重新启用").replace("{0}", pid).replace("{1}", model));
1110
- autoRebindSoon();
1116
+ toast(t("已禁用模型 {0} · {1},调度链已自动移除它;绑定页可重新启用").replace("{0}", pid).replace("{1}", model));
1111
1117
  } catch (e) {
1112
1118
  toast(t("操作失败:") + e.message, true);
1113
1119
  }
@@ -1116,7 +1122,7 @@ async function healthDisableModel(pid, model) {
1116
1122
  async function healthDisableProvider(pid) {
1117
1123
  if (!pid) { closeModal(); return; }
1118
1124
  const yes = await uiConfirm(
1119
- t("确认禁用该厂商?禁用后链降级自动跳过它,恢复后可在模型调度页重新启用。"),
1125
+ t("确认禁用该厂商?调度链里它的条目会自动移除,恢复后可在模型调度页重新启用。"),
1120
1126
  { title: t("禁用厂商"), danger: true, ok: t("禁用") });
1121
1127
  if (!yes) return;
1122
1128
  try {
@@ -1130,8 +1136,7 @@ async function healthDisableProvider(pid) {
1130
1136
  S.providers = models.providers; S.bindings = models.bindings;
1131
1137
  S.provSig = "";
1132
1138
  closeModal(); render(); loadOrchestrator();
1133
- toast(t("已禁用厂商 {0}:链降级自动跳过,绑定页可重新启用").replace("{0}", pid));
1134
- autoRebindSoon();
1139
+ toast(t("已禁用厂商 {0}:调度链已自动移除它的条目,绑定页可重新启用").replace("{0}", pid));
1135
1140
  } catch (e) {
1136
1141
  toast(t("操作失败:") + e.message, true);
1137
1142
  }
@@ -1443,7 +1448,6 @@ async function batchProvOp(op) {
1443
1448
  try {
1444
1449
  await api("/api/models/provider-op", { method: "POST",
1445
1450
  body: JSON.stringify({ ids, op }) });
1446
- if (op !== "duplicate") autoRebindSoon();
1447
1451
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1448
1452
  S.selProvs = {};
1449
1453
  S.modelsSig = null;
@@ -1634,7 +1638,6 @@ async function keyOpCall(pid, op, keyId, key, label) {
1634
1638
  try {
1635
1639
  await api("/api/models/key-op", { method: "POST",
1636
1640
  body: JSON.stringify({ provider_id: pid, op, key_id: keyId, key, label }) });
1637
- autoRebindSoon(); // 密钥启停/删除/解冻会改变厂商「推荐可用」判定,自动补绑一次
1638
1641
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1639
1642
  // 操作期间轮询照跑(uiPrompt 弹框不阻塞轮询),S.modelsSig 还是旧值:必须
1640
1643
  // 清签名强制重绘,否则 KEY 写盘成功界面也看不到。
@@ -1863,7 +1866,6 @@ async function modelOp(pid, name, op) {
1863
1866
  try {
1864
1867
  await api("/api/models/model-op", { method: "POST",
1865
1868
  body: JSON.stringify({ provider_id: pid, name, op }) });
1866
- autoRebindSoon(); // 模型停用/启用/删除会改变推荐源与死链判定,自动补绑一次
1867
1869
  const s = modelSel(pid); // 单行操作后同步清掉该行勾选
1868
1870
  if (s[name]) { delete modelSelSet(pid)[name]; }
1869
1871
  } catch (e) { toast(t("操作失败:") + e.message, true); }
@@ -1930,7 +1932,6 @@ async function batchModelOp(pid, op) {
1930
1932
  try {
1931
1933
  await api("/api/models/model-op", { method: "POST",
1932
1934
  body: JSON.stringify({ provider_id: pid, names, op }) });
1933
- autoRebindSoon();
1934
1935
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1935
1936
  clearModelSel(pid);
1936
1937
  poll();
@@ -1949,7 +1950,6 @@ async function toggleProviderEnabled(pid, enabled) {
1949
1950
  try {
1950
1951
  await api("/api/models/provider-op", { method: "POST",
1951
1952
  body: JSON.stringify({ ids: [pid], op: off ? "enable" : "disable" }) });
1952
- autoRebindSoon();
1953
1953
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1954
1954
  S.modelsSig = null;
1955
1955
  poll();
@@ -1974,7 +1974,6 @@ async function refreshAllModels() {
1974
1974
  btn.title = t("全部重新拉取模型列表");
1975
1975
  }, 1500);
1976
1976
  setTimeout(poll, 2500);
1977
- setTimeout(autoRebindSoon, 8000); // 模型列表落盘后推荐源才完整,补绑一次
1978
1977
  }
1979
1978
 
1980
1979
  /* 手工添加模型:有的厂商列表接口调不通(或只返回部分),直接填模型名也能进列表。
@@ -2003,7 +2002,6 @@ async function addModelManual(pid) {
2003
2002
  toast(t("已添加模型 ") + name);
2004
2003
  S.modelsSig = null; // 新模型落盘后签名必变,这里主动置空确保立刻重绘
2005
2004
  poll();
2006
- setTimeout(autoRebindSoon, 4000); // 模型可用后推荐源更完整,补绑一次
2007
2005
  } catch (e) { toast(t("添加失败:") + e.message, true); }
2008
2006
  }
2009
2007
 
@@ -2016,7 +2014,6 @@ async function refreshProviderModels(id) {
2016
2014
  else toast(t("已获取模型列表 · wire 适配测试后台进行中"));
2017
2015
  } catch (e) { toast(t("获取失败:") + e.message, true); }
2018
2016
  poll();
2019
- setTimeout(autoRebindSoon, 4000); // 模型列表落盘后补绑一次(后台 wire 探测同步进行)
2020
2017
  }
2021
2018
 
2022
2019
  /* 供应商编辑卡:协议可选「自动」;显式选定时它是主协议(用于需要唯一协议的
@@ -2072,7 +2069,6 @@ async function delProvider(id) {
2072
2069
  try {
2073
2070
  await api("/api/models/provider-op", { method: "POST",
2074
2071
  body: JSON.stringify({ ids: [id], op: "delete" }) });
2075
- autoRebindSoon();
2076
2072
  } catch (e) { toast(t("操作失败:") + e.message, true); }
2077
2073
  if (S.selModels) delete S.selModels[id];
2078
2074
  if (S.selProvs) delete S.selProvs[id];
@@ -2136,7 +2132,6 @@ async function doAddProvider() {
2136
2132
  S.modelsSig = null;
2137
2133
  closeModal();
2138
2134
  poll();
2139
- autoRebindSoon(); // 新增即启用:马上给空链/死链一次推荐机会
2140
2135
  if (p && body.api_key) refreshProviderModels(p.id); // 有密钥才自动拉模型列表
2141
2136
  } catch (e) {
2142
2137
  res.textContent = t("保存失败:") + e.message;
@@ -2232,7 +2227,7 @@ async function doImport() {
2232
2227
  btn.textContent = t("完成");
2233
2228
  btn.disabled = false;
2234
2229
  btn.onclick = closeModal;
2235
- if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); autoRebindSoon(); }
2230
+ if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); }
2236
2231
  } catch (e) {
2237
2232
  $("import-result").innerHTML = '<div class="msg bad">' + t("导入失败:") + esc(e.message) + "</div>";
2238
2233
  btn.disabled = false; btn.textContent = t("导入选中");
@@ -2465,6 +2460,12 @@ async function createTask() {
2465
2460
  words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
2466
2461
  variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
2467
2462
  };
2463
+ // 分卷:显式卷结构文本优先(后端解析成卷表),否则用每卷章数。
2464
+ // 两个都留空 = 不分卷(后端收到 0/缺字段即不分卷)。
2465
+ const volSpec = ($("f-vol-spec") || {}).value ? $("f-vol-spec").value.trim() : "";
2466
+ const volPer = parseInt(($("f-vol-chapters") || {}).value, 10);
2467
+ if (volSpec) payload.serial.volumes_text = volSpec;
2468
+ else if (volPer >= 2) payload.serial.volume_chapters = volPer;
2468
2469
  } else if (flow.serial) {
2469
2470
  // 显式 null 覆盖 serial_novel 的流程默认,空章节就是单稿件。
2470
2471
  payload.serial = null;
@@ -3139,6 +3140,16 @@ function newFromTask(id) {
3139
3140
  $("f-chapters").value = tk.serial ? tk.serial.chapters : "";
3140
3141
  $("f-words-per-ch").value = tk.serial ? tk.serial.words_per_chapter : "";
3141
3142
  $("f-variants").value = tk.serial && tk.serial.variants ? tk.serial.variants : "";
3143
+ // 分卷:还原每卷章数;指定卷结构还原成可读文本(卷名 + 章数/章号范围)
3144
+ $("f-vol-chapters").value = tk.serial && tk.serial.volume_chapters ? tk.serial.volume_chapters : "";
3145
+ $("f-vol-spec").value = tk.serial && Array.isArray(tk.serial.volumes)
3146
+ ? tk.serial.volumes.map((v) => {
3147
+ const ttl = (v && v.title) || "未命名";
3148
+ if (v && v.start && v.end) return ttl + " 第" + v.start + "-" + v.end + "章";
3149
+ if (v && v.chapters) return ttl + " " + v.chapters + "章";
3150
+ return ttl;
3151
+ }).join(";")
3152
+ : "";
3142
3153
  const want = new Set(tk.critics || []);
3143
3154
  $("f-critics").querySelectorAll("input").forEach((i) => { i.checked = want.has(i.value); });
3144
3155
  // 同目录同稿件名再开一篇会覆盖旧产出——提醒但不阻止(有意重写也合理)
@@ -3335,17 +3346,22 @@ function renderRunList() {
3335
3346
  "</div>";
3336
3347
  box.innerHTML = tools + runs.map((r) => {
3337
3348
  const can = runDeletable(r);
3349
+ // 行结构照参考站的「主副标题 + 右侧状态与相对时间」:标题与摘要竖排成一块,
3350
+ // 状态点用侧栏同款字形(staskGlyph),时间用相对档(完整时间戳挪进悬停提示)
3351
+ const sub = String(r.summary || r.error || (r.steps ? r.steps.length + t(" 个步骤") : "")).trim();
3338
3352
  return '<div class="item" onclick="openRun(\'' + esc(r.id) + '\')">' +
3339
3353
  '<div class="t">' +
3340
3354
  '<input type="checkbox" class="rcheck"' + (S.selRuns[r.id] ? " checked" : "") + (can ? "" : " disabled") +
3341
3355
  ' title="' + (can ? t("勾选以批量删除") : t("运行中的记录不可删除,请先取消")) + '"' +
3342
3356
  ' onclick="event.stopPropagation()" onchange="toggleRunSel(\'' + esc(r.id) + '\', this.checked)">' +
3343
- runKindTag(r.kind) +
3344
- '<span class="name">' + esc(r.title) + "</span>" +
3357
+ '<span class="rglyph">' + staskGlyph(r.status || "") + "</span>" +
3358
+ '<div class="rt">' +
3359
+ '<span class="name">' + esc(r.title) + runKindTag(r.kind) + "</span>" +
3360
+ (sub ? '<span class="desc">' + esc(t(sub)) + "</span>" : "") +
3361
+ "</div>" +
3345
3362
  '<span class="chip ' + esc(r.status || "") + '">' + esc(runStatusText(r)) + "</span>" +
3346
- '<span class="time">' + esc(r.created_at) + "</span>" +
3347
- '<button class="danger small" title="' + t("删除该记录") + '" onclick="event.stopPropagation(); deleteRun(\'' + esc(r.id) + '\')">' + t("删除") + '</button></div>' +
3348
- '<div class="desc">' + esc(t(r.summary || r.error || (r.steps ? r.steps.length + t(" 个步骤") : ""))) + "</div></div>";
3363
+ '<span class="time" title="' + esc(r.created_at) + '">' + esc(relTime(r.created_at)) + "</span>" +
3364
+ '<button class="danger small" title="' + t("删除该记录") + '" onclick="event.stopPropagation(); deleteRun(\'' + esc(r.id) + '\')">' + t("删除") + '</button></div></div>';
3349
3365
  }).join("");
3350
3366
  }
3351
3367
 
@@ -3632,8 +3648,7 @@ function showDetailInMain() {
3632
3648
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-runs"));
3633
3649
  document.querySelectorAll(".set-item").forEach((b) => b.classList.remove("active"));
3634
3650
  document.body.classList.remove("settings-mode");
3635
- const title = $("page-title");
3636
- if (title) title.textContent = t("运行详情");
3651
+ renderPageCrumb();
3637
3652
  collapseDrawerIfMobile();
3638
3653
  syncInspectorVis(); // 从设置子页点进运行详情:任务上下文,检查器跟着回来
3639
3654
  }
@@ -3644,7 +3659,7 @@ function showDetailInMain() {
3644
3659
  function jumpToRun(id) {
3645
3660
  S.focusStep = 0;
3646
3661
  S.focusDone = false;
3647
- if (document.body.classList.contains("settings-mode")) switchTab("runs");
3662
+ if (document.body.classList.contains("settings-mode")) switchTab("runs", "settings"); // 已在设置里:留在设置侧栏
3648
3663
  else showDetailInMain();
3649
3664
  openRun(id);
3650
3665
  }
@@ -3900,6 +3915,7 @@ function drawTaskDetail(key, runs) {
3900
3915
  };
3901
3916
  drawReport();
3902
3917
  loadArtifacts(latest.id); // 成品文件双入口同源:主栏「成果」分区 + 检查器(列表上下文)
3918
+ renderPreview(latest.id); // 网页成品预览:入口页 + 可运行 iframe(无成品则整块隐藏)
3903
3919
  // 任务级详情:git 面板吃任务级 side(实时/最新快照),side 未到先以最新 run 快照落位
3904
3920
  const tk = ((S.state || {}).tasks || []).find((x) => x.id === key);
3905
3921
  S.lastRunTask = tk || null;
@@ -3973,6 +3989,7 @@ async function clearRuns() {
3973
3989
  async function openRun(id, pinTab) {
3974
3990
  S.detailRunId = id;
3975
3991
  S.detailTaskKey = null;
3992
+ renderPageCrumb(); // 标题「运行详情」;必须在赋值后画——showDetailInMain 先于本函数跑,那边画不到
3976
3993
  rdTabReset(pinTab || null); // sideOpenRun 带步骤号时钉住步骤分区
3977
3994
  document.querySelector("#sub-runs .panel:first-child").classList.add("hidden");
3978
3995
  $("run-detail").classList.remove("hidden");
@@ -3992,14 +4009,22 @@ function closeRun() {
3992
4009
  detailSideReset();
3993
4010
  $("run-detail").classList.add("hidden");
3994
4011
  renderChatNav(); // 解除 main.chat-fill(对话为主的固定高度),恢复外层滚动
3995
- if (document.body.classList.contains("settings-mode")) {
4012
+ if (document.body.classList.contains("settings-mode") || S.mainPage === "runs") {
4013
+ // 回运行列表:设置导航里点进来的,或主栏本就停在运行记录页(快捷导航/概览的入口)
3996
4014
  document.querySelector("#sub-runs .panel:first-child").classList.remove("hidden");
4015
+ renderPageCrumb(); // 详情关了,标题从「运行详情」回到当前页名
4016
+ } else if (S.mainPage) {
4017
+ // 主栏停在别的快捷导航页(概览/自动化):返回点开详情前那一页,左栏任务树不动
4018
+ switchTab(S.mainPage, "main");
3997
4019
  } else {
3998
- // 从主视图(侧栏点任务行)进来的详情:返回直接回任务页,不露出运行列表
4020
+ // 从主视图(侧栏点任务行)进来的详情:返回直接回任务页,不露出运行列表。
4021
+ // S.tab 还停在 "runs"(showDetailInMain 设的),视图既然回到任务页就一并归位,
4022
+ // 否则标题会拿 "runs" 的页名显示成「运行记录」
4023
+ S.tab = "tasks";
3999
4024
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
4000
4025
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
4001
- const title = $("page-title");
4002
- if (title) title.textContent = tabTitle("tasks");
4026
+ document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
4027
+ renderPageCrumb();
4003
4028
  }
4004
4029
  syncInspectorVis(); // 离开详情:回设置运行列表时检查器按各上下文规则重新落位
4005
4030
  }
@@ -4036,13 +4061,14 @@ function rdTabAvail() {
4036
4061
  git: !$("rd-git").classList.contains("hidden"),
4037
4062
  bible: !$("rd-bible").classList.contains("hidden"),
4038
4063
  bookmeta: !$("rd-bookmeta").classList.contains("hidden"),
4064
+ preview: !$("rd-preview").classList.contains("hidden"),
4039
4065
  };
4040
4066
  }
4041
4067
 
4042
4068
  function applyRdTabs() {
4043
4069
  const avail = rdTabAvail();
4044
4070
  if (!S.rdTab || !avail[S.rdTab]) {
4045
- S.rdTab = ["chat", "hive", "steps", "result", "git", "bible", "bookmeta"]
4071
+ S.rdTab = ["chat", "hive", "steps", "result", "preview", "git", "bible", "bookmeta"]
4046
4072
  .find((k) => avail[k]) || "steps";
4047
4073
  }
4048
4074
  document.querySelectorAll("#rd-tabs .rd-tab").forEach((b) => {
@@ -4082,10 +4108,10 @@ function renderChatNav() {
4082
4108
  if (mainEl) mainEl.classList.toggle("chat-fill", direct);
4083
4109
  if (!direct) { nav.classList.add("hidden"); nav.innerHTML = ""; return; }
4084
4110
  const labels = { hive: t("蜂巢"), steps: t("步骤"), result: t("成果"),
4085
- git: "Git", bible: t("圣经"), bookmeta: t("作品信息") };
4111
+ preview: t("预览"), git: "Git", bible: t("圣经"), bookmeta: t("作品信息") };
4086
4112
  const avail = rdTabAvail();
4087
4113
  let pills = "";
4088
- for (const k of ["hive", "steps", "result", "git", "bible", "bookmeta"]) {
4114
+ for (const k of ["preview", "hive", "steps", "result", "git", "bible", "bookmeta"]) {
4089
4115
  if (!avail[k]) continue;
4090
4116
  // 徽章直接镜像隐藏页签上的(蜂巢在岗数/步骤数/待裁决/成果数),不另设状态源
4091
4117
  const badge = document.querySelector('#rd-tabs .rd-tab[data-tab="' + k + '"] .rd-badge');
@@ -4127,16 +4153,18 @@ function rdTabBadges() {
4127
4153
  set("git", c.gitState === "isolated" ? t("待裁决") : "", "verdict");
4128
4154
  }
4129
4155
 
4130
- /* ctx 可省略:省略时只刷新可用性/徽章/pane(renderHive/renderGitPanel 等收尾调用)。
4131
- * 带 ctx 时先做自动选卡判断,再落徽章。 */
4156
+ /* ctx 可省略:省略时沿用上次的 _rdCtx 做自动选卡判断(renderHive/renderGitPanel/
4157
+ * renderPreview 等收尾调用——预览可用性是异步翻过来的,翻过来这次必须重选卡)。 */
4132
4158
  function rdTabsSync(ctx) {
4133
4159
  if (ctx) S._rdCtx = ctx;
4134
- if (ctx && !S.rdTabPin) {
4135
- // 对话页签可用性入签名:刚建的任务要等 state 刷进来 direct 才判定成立,
4136
- // chat 从不可用变可用时必须触发一次重新选卡(否则落在步骤/蜂巢不跳对话)
4160
+ const c = ctx || S._rdCtx || {};
4161
+ if (!S.rdTabPin) {
4137
4162
  const chatAvail = $("rd-chat") && !$("rd-chat").classList.contains("hidden");
4138
- const sig = (S.detailTaskKey || S.detailRunId || "") + "|" + (ctx.status || "") +
4139
- "|" + (ctx.gitState || "") + "|" + (ctx.running ? 1 : 0) + "|" + (chatAvail ? 1 : 0);
4163
+ // 预览可用性入签名:网页成品是跑完才落盘的,从不可用变可用时要重选一次卡
4164
+ const pvAvail = $("rd-preview") && !$("rd-preview").classList.contains("hidden");
4165
+ const sig = (S.detailTaskKey || S.detailRunId || "") + "|" + (c.status || "") +
4166
+ "|" + (c.gitState || "") + "|" + (c.running ? 1 : 0) + "|" + (chatAvail ? 1 : 0) +
4167
+ "|" + (pvAvail ? 1 : 0);
4140
4168
  if (S.rdTabSig !== sig) {
4141
4169
  S.rdTabSig = sig;
4142
4170
  const avail = rdTabAvail();
@@ -4144,13 +4172,17 @@ function rdTabsSync(ctx) {
4144
4172
  // 直连任务的主问题是「继续聊什么、上一轮回答是什么」——默认把对话
4145
4173
  // 放在第一视线;编排/代码任务才默认落蜂巢,先看阶段与在岗步骤。
4146
4174
  // 没跑到终态时成果分区是空的,避免打开详情先看到白板。
4147
- const finishing = !ctx.running && ["done", "failed", "cancelled", "timeout"].includes(ctx.status);
4175
+ const finishing = !c.running && ["done", "failed", "cancelled", "timeout"].includes(c.status);
4148
4176
  S.rdTab = (direct && avail.chat ? "chat" : null)
4177
+ // 跑出了能打开的网页成品:第一视线给「预览」——这正是用户做完一个
4178
+ // 前端任务最想看到的(借鉴对话式编程产品:跑完先看东西跑起来)。
4179
+ // 直连任务的对话、待裁决的版本仍排在它前面:那两个是"要用户说话/动手"。
4180
+ || (finishing && avail.preview ? "preview" : null)
4149
4181
  || (avail.hive ? "hive" : null)
4150
4182
  || (avail.chat ? "chat" : null)
4151
- || (ctx.running ? (avail.hive ? "hive" : "steps")
4152
- : (ctx.gitState === "isolated" && avail.git ? "git"
4153
- : (ctx.hasResult && finishing ? "result" : "steps")));
4183
+ || (c.running ? (avail.hive ? "hive" : "steps")
4184
+ : (c.gitState === "isolated" && avail.git ? "git"
4185
+ : (c.hasResult && finishing ? "result" : "steps")));
4154
4186
  }
4155
4187
  }
4156
4188
  applyRdTabs();
@@ -4175,6 +4207,7 @@ function rdTabReset(pin) {
4175
4207
  S.rdTabSig = "";
4176
4208
  S.rdTabPin = !!pin;
4177
4209
  S._rdCtx = {};
4210
+ pvReset(); // 预览也随详情目标清空:别家的 iframe/页签绝不带到新详情
4178
4211
  syncChatLogSpace(false);
4179
4212
  setRdMetaOpen(false);
4180
4213
  }
@@ -4269,6 +4302,9 @@ async function renderRunDetail() {
4269
4302
  : esc(t("本次运行没有生成报告") + (st ? t("(状态:") + st + t(")") : "") + t(";各步骤日志在「步骤」页签"))) +
4270
4303
  "</div>";
4271
4304
  }
4305
+ // 网页成品预览:与成果同源双入口都走这里;无成功报告时也要重新判定可用性
4306
+ // (换到没有网页成品的 run 必须把「预览」页签收起来,别留着上一家的 iframe)
4307
+ renderPreview(id);
4272
4308
  // 成品文件双入口同源:主栏「成果」分区 + 检查器成品 TAB(列表上下文),
4273
4309
  // 详情上下文检查器让位后主栏是唯一可见面
4274
4310
  S.lastRunTask = rcTask || null;
@@ -4726,8 +4762,11 @@ window.pbUploadChapter = async function (taskId, platform) {
4726
4762
  if (!box.classList.contains("hidden")) { box.classList.add("hidden"); return; }
4727
4763
  box.classList.remove("hidden");
4728
4764
  box.innerHTML = '<div class="pb-loading">' + esc(t("正在列出章节文件…")) + "</div>";
4729
- let wd = "";
4730
- try { const tk = ((S.state || {}).tasks || []).find((x) => x.id === taskId); wd = (tk && tk.workdir) || ""; } catch (e) { /* 兜底空 */ }
4765
+ let wd = "", pubTask = null;
4766
+ try {
4767
+ pubTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
4768
+ wd = (pubTask && pubTask.workdir) || "";
4769
+ } catch (e) { /* 兜底空 */ }
4731
4770
  if (!wd) { box.innerHTML = '<div class="pb-err">' + esc(t("找不到任务工作目录")) + "</div>"; return; }
4732
4771
  let files = [];
4733
4772
  try {
@@ -4740,11 +4779,33 @@ window.pbUploadChapter = async function (taskId, platform) {
4740
4779
  }
4741
4780
  if (!files.length) { box.innerHTML = '<div class="pb-err">' + esc(t("工作目录里没有 .md 章稿")) + "</div>"; return; }
4742
4781
  files.sort((a, b) => (a.name > b.name ? 1 : -1));
4743
- box.innerHTML = '<div class="pb-hint">' + esc(t("选一章发送(表单填好后停,人工确认提交);已成功发过的章会被台账拦下:")) + "</div>" +
4744
- files.map((f) =>
4745
- '<button class="ghost pb-ch-item" onclick="pbSendChapter(\'' + esc(taskId) + "', '" + platform +
4746
- '\',\'' + esc(f.name) + '\')" title="' + esc(f.name) + '">' + esc(f.name.split("/").pop()) + "</button>"
4747
- ).join("");
4782
+ const item = (f) =>
4783
+ '<button class="ghost pb-ch-item" onclick="pbSendChapter(\'' + esc(taskId) + "', '" + platform +
4784
+ '\',\'' + esc(f.name) + '\')" title="' + esc(f.name) + '">' + esc(f.name.split("/").pop()) + "</button>";
4785
+ const hint = '<div class="pb-hint">' + esc(t("选一章发送(表单填好后停,人工确认提交);已成功发过的章会被台账拦下:")) + "</div>";
4786
+ // 最高章号:让卷规划表铺到最后一章(否则超出显式卷表的章节会掉进「其他」)
4787
+ let maxCh = 0;
4788
+ files.forEach((f) => {
4789
+ const n = chapterNumOf(f.name);
4790
+ if (n > maxCh) maxCh = n;
4791
+ });
4792
+ const plan = volumePlanOf(pubTask, maxCh);
4793
+ if (!plan.length) { box.innerHTML = hint + files.map(item).join(""); return; }
4794
+ // 分卷:按卷分组列出可发章节(章号取自 chapter-NN.md)。未落入卷的文件归「其他」。
4795
+ const groups = plan.map((v) => ({ v: v, files: [] }));
4796
+ const rest = [];
4797
+ files.forEach((f) => {
4798
+ const n = chapterNumOf(f.name);
4799
+ if (!n) { rest.push(f); return; }
4800
+ const g = groups.find((x) => n >= x.v.first && (x.v.last == null || n <= x.v.last));
4801
+ if (g) g.files.push(f); else rest.push(f);
4802
+ });
4803
+ box.innerHTML = hint + groups.filter((g) => g.files.length).map((g) => {
4804
+ const label = t("第") + " " + g.v.vol + " " + t("卷") + (g.v.title ? " 《" + g.v.title + "》" : "");
4805
+ return '<div class="pb-vol"><div class="pb-vol-head">' + esc(label) + "</div>" +
4806
+ g.files.map(item).join("") + "</div>";
4807
+ }).join("") + (rest.length ? '<div class="pb-vol"><div class="pb-vol-head">' +
4808
+ esc(t("其他文件")) + "</div>" + rest.map(item).join("") + "</div>" : "");
4748
4809
  };
4749
4810
 
4750
4811
  window.pbSendChapter = async function (taskId, platform, relName) {
@@ -5232,7 +5293,7 @@ window.toggleInspector = function () {
5232
5293
  openInspector(key);
5233
5294
  };
5234
5295
 
5235
- /* 检查器只属于「列表快捷预览」上下文:设置子页、新建表单、运行/任务详情一律收起。
5296
+ /* 检查器只属于「列表快捷预览」上下文:设置子页、快捷导航页、新建表单、运行/任务详情一律收起。
5236
5297
  * 详情页已铺开全部信息(蜂巢/步骤/成果/版本/圣经/指挥),检查器留着只会同屏
5237
5298
  * 出现两份标题/成品/Git——让它让位,但不丢选中,回到列表上下文自动滑回 */
5238
5299
  function syncInspectorVis() {
@@ -5244,11 +5305,12 @@ function syncInspectorVis() {
5244
5305
  // 主视图下正在看「要完成什么?」新建表单:不是任务上下文,不自动滑出。
5245
5306
  // (本函数只在模式切换时调用,不在轮询里——用户在表单页手动点树里的任务,
5246
5307
  // openInspector 直接开,不会被这里关掉)
5247
- const onComposer = !document.body.classList.contains("settings-mode") &&
5308
+ const onComposer = !S.mainPage && !document.body.classList.contains("settings-mode") &&
5248
5309
  !$("sub-tasks").classList.contains("hidden");
5249
5310
  // 主栏正开着运行/任务详情:详情页是全功能视图,检查器同屏只会重复
5250
5311
  const onDetail = !$("run-detail").classList.contains("hidden");
5251
- if (document.body.classList.contains("settings-mode") || onComposer || onDetail ||
5312
+ // 主栏停在快捷导航页(概览/运行记录/自动化):同设置子页,检查器让位不抢版面
5313
+ if (document.body.classList.contains("settings-mode") || S.mainPage || onComposer || onDetail ||
5252
5314
  !S.inspKey || runsGone) {
5253
5315
  document.body.classList.remove("inspector-open");
5254
5316
  insp.classList.add("hidden");
@@ -5836,6 +5898,131 @@ async function loadArtifacts(runId) {
5836
5898
  mainBox.innerHTML = head + '<div class="file-chips">' + chips + "</div>"; }
5837
5899
  }
5838
5900
 
5901
+ /* ---------------- 网页成品预览(借鉴对话式编程产品的预览窗) ----------------
5902
+ * 代码任务跑完,用户要的是「看到东西跑起来」,而不是逐个点开文件读代码。
5903
+ * 左「运行」= 入口 HTML 的 iframe(后端 /preview/<run>/<令牌>/ 只读挂载工作
5904
+ * 目录,令牌走路径段让 style.css / script.js 这些相对子资源也带上);
5905
+ * 右各文件页签 = 源码视图(复用 codeBlockHTML:行号+高亮,与成果预览同款)。
5906
+ * 「刷新」重挂 iframe(改完文件不用退出详情页),「打开新窗口」给真全屏。
5907
+ * 页签在无网页成品时整体隐藏(见 rdTabAvail),不打扰小说/普通任务。 */
5908
+ let pvRunId = null; // 当前预览归属的 run(换 run 必须重挑入口与页签)
5909
+ let pvApp = null; // 后端 /api/runs/<id>/preview 的返回(入口 + 文件清单 + base)
5910
+ let pvView = "run"; // "run" = 运行视图;否则是文件名
5911
+ let pvCodeSig = ""; // 代码视图去抖签名(轮询重画不重取同一个文件)
5912
+
5913
+ function pvReset() {
5914
+ pvRunId = null; pvApp = null; pvView = "run"; pvCodeSig = "";
5915
+ }
5916
+
5917
+ /* 预览 iframe 的地址:base 已带令牌与尾部斜杠,相对路径交给 <base href> 解析 */
5918
+ function pvRunUrl() {
5919
+ if (!pvApp || !pvApp.base) return "";
5920
+ return urlAuth(pvApp.base + (pvApp.entry || ""));
5921
+ }
5922
+
5923
+ function pvTabsHTML() {
5924
+ const f = (pvApp && pvApp.files) || [];
5925
+ const mk = (key, label, title) =>
5926
+ '<button class="pv-tab' + (pvView === key ? " on" : "") + '" role="tab" ' +
5927
+ 'aria-selected="' + (pvView === key ? "true" : "false") + '" data-pv="' + esc(key) + '" ' +
5928
+ 'title="' + esc(title || label) + '">' + esc(label) + "</button>";
5929
+ let h = mk("run", t("运行视图"), t("实时运行入口页面"));
5930
+ for (const it of f) h += mk(it.name, it.name.split("/").pop(), it.name);
5931
+ return h;
5932
+ }
5933
+
5934
+ function pvBodyHTML() {
5935
+ if (!pvApp || !pvApp.ok) return "";
5936
+ if (pvView === "run") {
5937
+ // 每次重挂 iframe 都换 src 尾参,绕开浏览器缓存:用户点「刷新」就是要看新代码
5938
+ const src = pvRunUrl();
5939
+ return '<iframe class="pv-frame" id="rd-pv-frame" src="' + esc(src) +
5940
+ '" title="' + esc(t("运行预览")) + '" sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-downloads"></iframe>';
5941
+ }
5942
+ return '<div class="pv-code"><div class="fp-vp" id="rd-pv-code">' +
5943
+ '<div class="fp-hint">' + esc(t("正在读取文件…")) + "</div></div></div>";
5944
+ }
5945
+
5946
+ async function pvLoadCode(name) {
5947
+ const el = $("rd-pv-code");
5948
+ if (!el) return;
5949
+ const url = "/api/runs/" + encodeURIComponent(pvRunId) + "/file?name=" + encodeURIComponent(name);
5950
+ try {
5951
+ const r = await fetch(urlAuth(url), { headers: authHeaders() });
5952
+ if (!r.ok) throw new Error("HTTP " + r.status);
5953
+ const text = await r.text();
5954
+ if (!el.isConnected || pvView !== name) return; // 拉取期间切走了:过期响应不落盘
5955
+ el.innerHTML = codeBlockHTML(text);
5956
+ } catch (e) {
5957
+ if (el.isConnected && pvView === name)
5958
+ el.innerHTML = '<div class="fp-hint">' + esc(t("内容读取失败:") + (e.message || e)) + "</div>";
5959
+ }
5960
+ }
5961
+
5962
+ function pvDraw() {
5963
+ const box = $("rd-preview");
5964
+ if (!box) return;
5965
+ if (!pvApp || !pvApp.ok) {
5966
+ box.classList.add("hidden");
5967
+ // iframe 一并摘掉:hidden 只是不可见,留在 DOM 里旧页面还在跑(定时器/
5968
+ // 音频不会停);换成无预览的 run 时更不能留上一家的残影
5969
+ const body = $("rd-pv-body");
5970
+ if (body) body.innerHTML = "";
5971
+ return;
5972
+ }
5973
+ box.classList.remove("hidden");
5974
+ $("rd-pv-tabs").innerHTML = pvTabsHTML();
5975
+ const open = $("rd-pv-open");
5976
+ if (open) open.href = urlAuth(pvRunUrl());
5977
+ $("rd-pv-body").innerHTML = pvBodyHTML();
5978
+ const hint = $("rd-pv-hint");
5979
+ if (hint) {
5980
+ // 工作目录路径给出来:预览的是哪个目录一目了然(与「成果」同款可点复制)
5981
+ hint.innerHTML = '<span data-i18n-x>' + esc(t("预览工作目录:")) + "</span>" +
5982
+ '<span class="pv-wd" title="' + esc(t("点击复制")) + '" onclick="copyText(this.textContent)">' +
5983
+ esc(pvApp.workdir || "") + "</span>";
5984
+ hint.classList.remove("hidden");
5985
+ }
5986
+ if (pvView !== "run") { pvCodeSig = pvView; pvLoadCode(pvView); }
5987
+ }
5988
+
5989
+ window.pvGo = function (view) {
5990
+ pvView = view;
5991
+ pvCodeSig = "";
5992
+ pvDraw();
5993
+ };
5994
+ window.pvReload = function () {
5995
+ // 重挂 iframe:加时间戳绕缓存;代码视图则重取当前文件
5996
+ if (pvView === "run") {
5997
+ const f = $("rd-pv-frame");
5998
+ if (f) f.src = urlAuth(pvRunUrl()) + (String(pvRunUrl()).includes("?") ? "&" : "?") + "_=" + Date.now();
5999
+ } else {
6000
+ pvLoadCode(pvView);
6001
+ }
6002
+ };
6003
+
6004
+ async function renderPreview(runId) {
6005
+ const box = $("rd-preview");
6006
+ if (!box) return;
6007
+ if (!runId) { box.classList.add("hidden"); rdTabsSync(); return; }
6008
+ if (pvRunId !== runId) { pvRunId = runId; pvApp = null; pvView = "run"; pvCodeSig = ""; }
6009
+ let d = null;
6010
+ try { d = await api("/api/runs/" + encodeURIComponent(runId) + "/preview"); }
6011
+ catch (e) { d = null; }
6012
+ // 过期闸门:拉取期间切了详情就丢弃响应(同 loadArtifacts 的双入口闸门)
6013
+ if (pvRunId !== runId) return;
6014
+ if (!$("run-detail") || $("run-detail").classList.contains("hidden")) { rdTabsSync(); return; }
6015
+ pvApp = d || { ok: false };
6016
+ const badge = document.querySelector('#rd-tabs .rd-tab[data-tab="preview"] .rd-badge');
6017
+ if (badge) {
6018
+ const n = pvApp && pvApp.ok ? (pvApp.files || []).length : 0;
6019
+ badge.textContent = n ? String(n) : "";
6020
+ badge.className = "rd-badge" + (n ? "" : " hidden");
6021
+ }
6022
+ pvDraw();
6023
+ rdTabsSync(); // 预览可用性(rdTabAvail 认 #rd-preview 的显隐)随之更新
6024
+ }
6025
+
5839
6026
  window.copyText = function (t) {
5840
6027
  // writeText 返回 promise,无头/非安全上下文会拒绝——必须挂 catch,否则
5841
6028
  // 未处理的 promise 拒绝会以控制台错误冒出来(测试断言 0 错误会被它打爆)
@@ -5854,36 +6041,117 @@ function stepsMatch(runs, current) {
5854
6041
  (r.steps || []).some((s) => s.log === current.rel));
5855
6042
  }
5856
6043
 
6044
+ /* 从章稿文件名取全书章号:chapter-07.md → 7;非章稿(或解析不出)返回 0。
6045
+ * 只认「chapter-数字.md」这一约定(与后端 _CH_FILE_RE 一致)。 */
6046
+ function chapterNumOf(name) {
6047
+ const base = String(name || "").split("/").pop();
6048
+ if (!/^chapter-\d{1,4}\.md$/i.test(base)) return 0;
6049
+ const digits = base.slice("chapter-".length, -3);
6050
+ const n = parseInt(digits, 10);
6051
+ return n > 0 ? n : 0;
6052
+ }
6053
+
6054
+ /* 分卷规划表(前端只读副本):把任务的 serial 分卷配置还原成
6055
+ * [{vol, title, first, last}],供发布页按卷分组列章。后端 volumes.py 是唯一
6056
+ * 真源(写作与成书都走它);这里只做「列清单」这一用途的最小推导,取不到
6057
+ * 分卷配置时返回 [](调用方退化为平铺列表)。
6058
+ * serial.volumes 显式卷表:[{title, chapters|start/end}](优先)
6059
+ * serial.volume_chapters 每卷章数:等长切卷(兜底)
6060
+ * last=null 表示开放到书末。 */
6061
+ function volumePlanOf(task, upto) {
6062
+ const s = (task || {}).serial || {};
6063
+ const spec = Array.isArray(s.volumes) ? s.volumes : [];
6064
+ let per = parseInt(s.volume_chapters, 10);
6065
+ if (!(per >= 2)) per = 0;
6066
+ if (!spec.length && !per) return [];
6067
+ const plan = [];
6068
+ let prevLast = 0;
6069
+ const push = (title, first, last) => plan.push({ vol: plan.length + 1, title: title || "", first: first, last: last });
6070
+ for (const it of spec) {
6071
+ if (!it) continue;
6072
+ const first = (it.start > 0) ? it.start : prevLast + 1;
6073
+ let last = null;
6074
+ if (it.end > 0) last = it.end;
6075
+ else if (it.chapters > 0) last = first + it.chapters - 1;
6076
+ if (last != null && last < first) last = first;
6077
+ push(it.title, first, last);
6078
+ if (last == null) return plan; // 开放卷:后面不再有卷
6079
+ prevLast = last;
6080
+ }
6081
+ // 显式卷表用尽后按每卷章数(或末卷长度)续卷,保证覆盖到 upto(与后端
6082
+ // volumes.build_plan 同规则)——否则第 21 章起会掉进「其他文件」分组。
6083
+ const fb = per || (plan.length && plan[plan.length - 1].last != null
6084
+ ? plan[plan.length - 1].last - plan[plan.length - 1].first + 1 : 0);
6085
+ if (fb > 0) {
6086
+ let cursor = prevLast + 1;
6087
+ const limit = (upto > 0) ? upto : (plan.length ? prevLast : fb);
6088
+ while (cursor <= limit && plan.length < 200) {
6089
+ push("", cursor, cursor + fb - 1);
6090
+ cursor += fb;
6091
+ }
6092
+ }
6093
+ return plan;
6094
+ }
6095
+
6096
+
5857
6097
  /* 连载章节评审卡:每章均分/是否达标/字数 + 逐项目标审稿结果(event_check,
5858
6098
  * 借鉴 AI-Novel-Writer:大纲要点逐项判定+正文证据)。非连载 run 无
5859
- * chapter_scores 保持隐藏。 */
6099
+ * chapter_scores 保持隐藏。
6100
+ * 分卷:章节卡按卷分组显示(卷名来自 run.volumes / chapter_scores[].vol_title),
6101
+ * 不分卷时退化为原来的平铺列表,视觉零变化。 */
5860
6102
  function renderChapterScores(run) {
5861
6103
  const box = $("rd-chapters");
5862
6104
  if (!box) return;
5863
6105
  const cs = run && run.chapter_scores;
5864
6106
  if (!Array.isArray(cs) || !cs.length) { box.classList.add("hidden"); return; }
5865
6107
  box.classList.remove("hidden");
5866
- box.innerHTML = '<h3 class="sec-title">' + t("章节评审") +
5867
- '<span class="tag">' + cs.filter((c) => c.passed).length + "/" + cs.length + " " + t("达标") + "</span></h3>" +
5868
- cs.map((c) => {
5869
- const means = c.means || {};
5870
- const dims = Object.keys(means).map((d) =>
5871
- '<span class="ch-dim' + (means[d] >= 7 ? "" : " low") + '">' + esc(d) + " " + means[d] + "</span>").join("");
5872
- const ev = (c.event_check || []).map((line) => {
5873
- const ok = /已完成/.test(line), bad = /未完成/.test(line);
5874
- return '<div class="ch-ev' + (ok ? " ok" : bad ? " bad" : "") + '">' + esc(line) + "</div>";
5875
- }).join("");
5876
- return '<div class="ch-row' + (c.passed ? "" : " fail") + '">' +
5877
- '<div class="ch-head"><b>' + esc(t("第") + " " + c.chapter + " " + t("章")) + '</b>' +
5878
- '<span class="ch-title">' + esc(c.title || "") + "</span>" +
5879
- '<span class="tag">' + (c.passed ? t("达标") : t("未达标")) + "</span>" +
5880
- (c.reused ? '<span class="tag">' + t("沿用") + "</span>" : "") +
5881
- '<span class="ch-meta">' + Number(c.words || 0) + t(" 字") +
5882
- (c.rounds > 1 ? " · " + c.rounds + t(" 轮") : "") + "</span></div>" +
5883
- (dims ? '<div class="ch-dims">' + dims + "</div>" : "") +
5884
- (ev ? '<div class="ch-evs"><span class="hint">' + t("逐项目标核对:") + "</span>" + ev + "</div>" : "") +
5885
- "</div>";
6108
+ // 卷名表:优先 run.volumes(全书卷规划),退到章节自带的 vol_title
6109
+ const volTitles = {};
6110
+ (run.volumes || []).forEach((v) => { volTitles[v.vol] = v.title || ""; });
6111
+ cs.forEach((c) => {
6112
+ if (c.vol && c.vol_title && !volTitles[c.vol]) volTitles[c.vol] = c.vol_title;
6113
+ });
6114
+ const rowHtml = (c) => {
6115
+ const means = c.means || {};
6116
+ const dims = Object.keys(means).map((d) =>
6117
+ '<span class="ch-dim' + (means[d] >= 7 ? "" : " low") + '">' + esc(d) + " " + means[d] + "</span>").join("");
6118
+ const ev = (c.event_check || []).map((line) => {
6119
+ const ok = /已完成/.test(line), bad = /未完成/.test(line);
6120
+ return '<div class="ch-ev' + (ok ? " ok" : bad ? " bad" : "") + '">' + esc(line) + "</div>";
5886
6121
  }).join("");
6122
+ return '<div class="ch-row' + (c.passed ? "" : " fail") + '">' +
6123
+ '<div class="ch-head"><b>' + esc(t("第") + " " + c.chapter + " " + t("章")) + '</b>' +
6124
+ '<span class="ch-title">' + esc(c.title || "") + "</span>" +
6125
+ '<span class="tag">' + (c.passed ? t("达标") : t("未达标")) + "</span>" +
6126
+ (c.reused ? '<span class="tag">' + t("沿用") + "</span>" : "") +
6127
+ '<span class="ch-meta">' + Number(c.words || 0) + t(" 字") +
6128
+ (c.rounds > 1 ? " · " + c.rounds + t(" 轮") : "") + "</span></div>" +
6129
+ (dims ? '<div class="ch-dims">' + dims + "</div>" : "") +
6130
+ (ev ? '<div class="ch-evs"><span class="hint">' + t("逐项目标核对:") + "</span>" + ev + "</div>" : "") +
6131
+ "</div>";
6132
+ };
6133
+ const head = '<h3 class="sec-title">' + t("章节评审") +
6134
+ '<span class="tag">' + cs.filter((c) => c.passed).length + "/" + cs.length + " " + t("达标") + "</span></h3>";
6135
+ const hasVol = cs.some((c) => c.vol);
6136
+ if (!hasVol) { box.innerHTML = head + cs.map(rowHtml).join(""); return; }
6137
+ // 按卷分组(章号顺序,卷内保持原序)
6138
+ const groups = [];
6139
+ cs.forEach((c) => {
6140
+ const v = c.vol || 0;
6141
+ let g = groups.find((x) => x.vol === v);
6142
+ if (!g) { g = { vol: v, rows: [] }; groups.push(g); }
6143
+ g.rows.push(c);
6144
+ });
6145
+ box.innerHTML = head + groups.map((g) => {
6146
+ const title = volTitles[g.vol] || "";
6147
+ const nPass = g.rows.filter((c) => c.passed).length;
6148
+ const label = g.vol ? (t("第") + " " + g.vol + " " + t("卷") + (title ? " 《" + title + "》" : "")) : t("未分卷");
6149
+ return '<div class="ch-vol"><div class="ch-vol-head"><b>' + esc(label) + "</b>" +
6150
+ '<span class="tag">' + nPass + "/" + g.rows.length + " " + t("达标") + "</span>" +
6151
+ '<span class="ch-meta">' + t("第 ") + g.rows[0].chapter + "–" +
6152
+ g.rows[g.rows.length - 1].chapter + t(" 章") + "</span></div>" +
6153
+ g.rows.map(rowHtml).join("") + "</div>";
6154
+ }).join("");
5887
6155
  }
5888
6156
 
5889
6157
  function renderPlan(run) {
@@ -6038,7 +6306,11 @@ async function toggleLog(runId, rel) {
6038
6306
  (S.detailTaskKey && S.lastRun && S.lastRun.task_id === S.detailTaskKey) ||
6039
6307
  ((S.inspData || {}).run || {}).id === runId;
6040
6308
  if (!owned) return;
6041
- pre.textContent = r.log || t("(等待输出…)");
6309
+ // 占位符跟状态走:运行中才「等待输出」,终态没产出就是「无输出」——
6310
+ // 否则内置合成器这类不落 CLI 日志的步骤结束后,一直挂「等待输出…」
6311
+ // 假装还在产字(2026-09-22 merge 卡已结束却等待输出案)
6312
+ pre.textContent = r.log ||
6313
+ (r.step_status === "running" ? t("(等待输出…)") : t("(无输出)"));
6042
6314
  box.classList.remove("hidden");
6043
6315
  syncChatLogSpace(true);
6044
6316
  currentLog = { runId, rel };
@@ -6057,7 +6329,9 @@ async function toggleLog(runId, rel) {
6057
6329
  if (currentLog && currentLog.runId === runId && currentLog.rel === rel) {
6058
6330
  // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰
6059
6331
  const stick = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48;
6060
- pre.textContent = rr.log || t("(等待输出…)");
6332
+ // 最后一帧常在步骤翻终态后才拉到:此刻仍无输出就该换「无输出」
6333
+ pre.textContent = rr.log ||
6334
+ (rr.step_status === "running" ? t("(等待输出…)") : t("(无输出)"));
6061
6335
  if (stick) pre.scrollTop = pre.scrollHeight;
6062
6336
  logLiveBadge(true); // 每帧都打时间戳:内容没变也能证明通道活着
6063
6337
  if (rr.step_status && rr.step_status !== "running") {
@@ -6543,6 +6817,9 @@ function chatCleanText(raw) {
6543
6817
  .trim();
6544
6818
  }
6545
6819
  let chatSig = "";
6820
+ let chatLiveSig = ""; // 时间线实时增量签名(思考/正文长度变才重建 DOM)
6821
+ let chatLiveTimer = null; // 运行中快轮询句柄(1.2s,见 scheduleChatLive)
6822
+ let chatLiveRunId = null;
6546
6823
 
6547
6824
  function chatEngineIsDirect(run) {
6548
6825
  const st = S.state || {};
@@ -6552,6 +6829,54 @@ function chatEngineIsDirect(run) {
6552
6829
  (!task && run && (run.engine === "direct" || run.type === "direct")));
6553
6830
  }
6554
6831
 
6832
+ /* 思考过程块(2026-09-22 用户诉求「把思考过程打印出来」):内置智能体流式抓的
6833
+ * 模型思维链 + 工具活动。运行中=常开实时块(逐句长出来);结束后=折叠摘要
6834
+ * (点开回看),不再让整轮只有三点打字动画。thinking 最长 2 万字(后端已截)。 */
6835
+ function chatThinkHTML(it) {
6836
+ const running = it.status === "running";
6837
+ const think = String(it.thinking || "").trim();
6838
+ const acts = running ? (it.activity || []).slice(-4) : [];
6839
+ if (!think && !acts.length) return "";
6840
+ const shown = think.length > 12000 ? "…" + think.slice(-12000) : think;
6841
+ if (running) {
6842
+ return '<div class="chat-think live" data-think-live="1">' +
6843
+ '<div class="ct-head"><span class="ct-dot" aria-hidden="true"></span>' +
6844
+ esc(t("思考过程")) + '<span class="ct-live">' + esc(t("实时")) + "</span></div>" +
6845
+ (shown ? '<div class="ct-body">' + esc(shown) + "</div>" : "") +
6846
+ (acts.length ? '<div class="ct-acts">' +
6847
+ acts.map((a) => "<div>" + esc(a) + "</div>").join("") + "</div>" : "") +
6848
+ "</div>";
6849
+ }
6850
+ return '<details class="chat-think"><summary>' + esc(t("思考过程")) +
6851
+ '<span class="ct-len">' + think.length + " " + esc(t("字")) + "</span></summary>" +
6852
+ '<div class="ct-body">' + esc(think) + "</div></details>";
6853
+ }
6854
+
6855
+ /* 运行中直连对话的快轮询:SSE/轮询最密 2s、闲时 8s,思考是逐句吐的——
6856
+ * 1.2s 直拉时间线把增量打印出来。run 终态、切详情、分区隐藏即停。 */
6857
+ function stopChatLive() {
6858
+ if (chatLiveTimer) { clearInterval(chatLiveTimer); chatLiveTimer = null; }
6859
+ chatLiveRunId = null;
6860
+ }
6861
+
6862
+ function scheduleChatLive(runId) {
6863
+ if (chatLiveTimer && chatLiveRunId === runId) return;
6864
+ stopChatLive();
6865
+ chatLiveRunId = runId;
6866
+ chatLiveTimer = setInterval(async () => {
6867
+ const run = S.lastRun;
6868
+ const flow = $("rd-chat-flow");
6869
+ if (!run || run.id !== runId || !flow ||
6870
+ !$("run-detail") || $("run-detail").classList.contains("hidden") ||
6871
+ (run.status !== "running" && run.status !== "queued")) return stopChatLive();
6872
+ try {
6873
+ const data = await api("/api/runs/" + encodeURIComponent(runId) + "/timeline");
6874
+ if (!S.lastRun || S.lastRun.id !== runId) return; // 拉取期间切了详情
6875
+ drawChatFlow(run, data, true);
6876
+ } catch (e) { /* 网络抖动保留上一帧 */ }
6877
+ }, 1200);
6878
+ }
6879
+
6555
6880
  /* 对话正文:``` 围栏渲染成真代码块(复用 codeBlockHTML:行号+高亮+代码主题),
6556
6881
  * 其余文本保持 pre-wrap 原样(行内 `code` 与 **加粗** 轻量翻译)。模型输出先
6557
6882
  * 全量 esc 再插标记;未闭合围栏按代码块收尾,流式输出中途也不撒裸反引号。 */
@@ -6592,7 +6917,7 @@ async function renderChat(run, active) {
6592
6917
  box.classList.remove("hidden");
6593
6918
  const continueNote = $("rd-chat-continue-note");
6594
6919
  if (continueNote) continueNote.classList.toggle("hidden", !!active);
6595
- if (chatRunId !== run.id) { chatRunId = run.id; chatAtts = []; drawChatAtts(); chatSig = ""; }
6920
+ if (chatRunId !== run.id) { chatRunId = run.id; chatAtts = []; drawChatAtts(); chatSig = ""; chatLiveSig = ""; }
6596
6921
  // 三件套回填:任务对象来自 S.state(渲染时已就位);只在任务未在跑时回显,
6597
6922
  // 防止轮询把用户正在改的值弹回(select change 重绘弹回教训)
6598
6923
  if (run.status !== "running" && run.status !== "queued" && S.state) {
@@ -6607,12 +6932,29 @@ async function renderChat(run, active) {
6607
6932
  let data = null;
6608
6933
  try { data = await api("/api/runs/" + encodeURIComponent(run.id) + "/timeline"); }
6609
6934
  catch (e) { data = null; }
6610
- const items = (data && data.items) || [];
6611
6935
  if (!$("run-detail") || $("run-detail").classList.contains("hidden")) return;
6612
6936
  // 过期闸门:run 级(renderRunDetail)与任务级(drawTaskDetail)都经 S.lastRun
6613
6937
  // 指向当前详情正在展示的那条 run——拉取期间切了详情就丢弃响应
6614
6938
  if (!S.lastRun || S.lastRun.id !== runForFetch) return;
6939
+ drawChatFlow(run, data, active);
6940
+ }
6941
+
6942
+ /* 时间线渲染本体(renderChat 与运行中快轮询共用):items → 气泡流。
6943
+ * force=true 时跳过增量签名闸门(快轮询拿到的一定是新帧)。 */
6944
+ function drawChatFlow(run, data, active) {
6945
+ const items = (data && data.items) || [];
6615
6946
  const flow = $("rd-chat-flow");
6947
+ if (!flow) return;
6948
+ // 增量闸门:时间线任何可见输入(状态/正文/思考/实时流/活动/追问/已送达)
6949
+ // 都没变才跳过重建——保滚动位置与折叠态。维度少了不行:只看 live 长度的话,
6950
+ // 没开思考的 CLI 步骤 running→done 两态签名相同,气泡会永远停在打字动画
6951
+ const liveSig = items.reduce((a, it) => a + "|" + (it.kind || "") + ":" +
6952
+ (it.status || "") + ":" + String(it.text || "").length + ":" +
6953
+ String(it.thinking || "").length + ":" + String(it.stream || "").length + ":" +
6954
+ (it.activity || []).length + ":" + (it.followups || []).length +
6955
+ ":" + (it.consumed ? 1 : 0), "") + "|" + ((data && data.result) ? "r" : "");
6956
+ if (liveSig === chatLiveSig && flow.childElementCount) return;
6957
+ chatLiveSig = liveSig;
6616
6958
  // 时间显示统一收敛到 HH:MM(日期在 meta 条里,全量戳塞气泡就是噪音);
6617
6959
  // 附件兼容 字符串路径 / {name|path} 对象 两种形态(对象直接拼会成 [object Object])
6618
6960
  const chatTime = (v) => { const s = String(v || "");
@@ -6663,11 +7005,14 @@ async function renderChat(run, active) {
6663
7005
  // 开场「本轮做了什么:…」+「回复:」是模型复读协议措辞的元描述(提示词已禁,
6664
7006
  // 这里兜历史数据),只剥消息开头的第一行
6665
7007
  const body = chatCleanText(it.text);
6666
- // 运行中还没有正文:三点打字动画(终态无正文才落「无文本输出」占位)
7008
+ // 运行中还没有正文:先给思考过程块(有思维链/工具活动时),正文位退化为
7009
+ // 实时预览(stream 累计值);两者皆无才是三点打字动画(终态无正文才落占位)
7010
+ const liveText = it.status === "running" ? String(it.stream || "").trim() : "";
6667
7011
  const bodyHtml = body ? chatBodyHTML(body)
7012
+ : (liveText ? '<div class="chat-body chat-live-text">' + esc(liveText.slice(-4000)) + "</div>"
6668
7013
  : (it.status === "running"
6669
7014
  ? '<span class="chat-typing" aria-label="' + esc(t("正在执行…")) + '"><i></i><i></i><i></i></span>'
6670
- : esc(t("(本轮无文本输出)")));
7015
+ : esc(t("(本轮无文本输出)"))));
6671
7016
  // 元信息只留 名字+时间(+失败标记):路由/供应商细节属于「步骤」页签,塞这里就是噪音
6672
7017
  const metaBad = it.status === "failed" || it.status === "cancelled";
6673
7018
  // 智能体回复:头像 + 整幅正文(不套气泡框),元信息小字落在正文下方;
@@ -6678,6 +7023,7 @@ async function renderChat(run, active) {
6678
7023
  return '<div class="chat-row">' +
6679
7024
  '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
6680
7025
  '<div class="chat-bubble agent">' +
7026
+ chatThinkHTML(it) +
6681
7027
  '<div class="chat-body">' + bodyHtml + "</div>" +
6682
7028
  '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
6683
7029
  (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + actsA + "</div>" +
@@ -6699,8 +7045,14 @@ async function renderChat(run, active) {
6699
7045
  if (hint) hint.textContent = active
6700
7046
  ? t("运行中:新消息会排队,本轮回答完后依次送达")
6701
7047
  : t("已结束:发送后将自动开新一轮接着做");
7048
+ // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰
7049
+ const stick = flow.scrollHeight - flow.scrollTop - flow.clientHeight < 80;
6702
7050
  flow.innerHTML = html || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
6703
- flow.scrollTop = flow.scrollHeight;
7051
+ if (stick) flow.scrollTop = flow.scrollHeight;
7052
+ // 有运行中的直连步骤 → 快轮询把思考过程/正文增量打印出来;终态即停
7053
+ const hasRunning = items.some((it) => it.kind === "agent" && it.status === "running");
7054
+ if (active && hasRunning && run && run.id) scheduleChatLive(run.id);
7055
+ else stopChatLive();
6704
7056
  }
6705
7057
 
6706
7058
  /* 执行结果卡(时间线收尾):run 终态后的确定性摘要——成没成、跑多久、谁执行
@@ -7797,43 +8149,9 @@ function autoBindAll() {
7797
8149
  }
7798
8150
  }
7799
8151
 
7800
- /* 厂商/模型停用·启用·删除后自动修复显式绑定:
7801
- * 空链保持自动调度,不写入绑定;已有链失效时才直接落盘替代链。
7802
- * 没有合适的推荐就保持原样,什么都不绑。绑定页上用户手改中的草稿(dirty)
7803
- * 不碰;一处都没改成静默返回,不打扰停用/启用的操作反馈。 */
7804
- let _autoRebindRunning = false, _autoRebindAgain = false;
7805
- async function autoRebindSoon() {
7806
- if (_autoRebindRunning) { _autoRebindAgain = true; return; }
7807
- _autoRebindRunning = true;
7808
- try {
7809
- await poll(); // 拿停用/启用落盘后的最新 providers / bindings / catalog
7810
- let fixed = 0;
7811
- for (const c of (S.catalog || []).filter((x) => x.installed && x.orch_kind)) {
7812
- const st = bindSelById(c.id);
7813
- if (st.dirty) continue; // 用户手改中,不覆盖草稿
7814
- const act = bindRepairAction(c, st, false);
7815
- if (!act) continue;
7816
- const b = (S.bindings || {})[c.id] || {};
7817
- try {
7818
- await api("/api/models/binding", { method: "POST", busy: false, body: JSON.stringify({
7819
- agent_id: c.id, provider_id: act[0].p,
7820
- chain: act.map((x) => ({ provider_id: x.p, model: x.m })),
7821
- difficulty_routing: !!b.difficulty_routing }) });
7822
- st.chain = act; st.dirty = false; st.key = chainKey(act);
7823
- fixed++;
7824
- } catch (e) { /* 单条失败不打断,等下次变更再补 */ }
7825
- }
7826
- if (fixed) {
7827
- S.catSig = null; S.bindSig = null;
7828
- render();
7829
- toast(t("厂商/模型变动,已自动重绑 %1 处。").replace("%1", fixed));
7830
- }
7831
- } catch (e) { /* 自动补绑失败静默:不打断用户的停用/启用操作 */ }
7832
- finally {
7833
- _autoRebindRunning = false;
7834
- if (_autoRebindAgain) { _autoRebindAgain = false; autoRebindSoon(); }
7835
- }
7836
- }
8152
+ /* 厂商/模型停用·删除后不再自动改写绑定链(2026-09-22 用户拍板:不要去动
8153
+ * 模型调度)——后端在停用/删除时已把死条目从链里剔除(modelhub._prune_binding_chains),
8154
+ * 空链回落 CLI 默认;要补推荐用「一键推荐绑定」手动来。 */
7837
8155
 
7838
8156
  async function saveAllBindings() {
7839
8157
  const targets = (S.catalog || []).filter((c) => c.installed && c.orch_kind);
@@ -8355,6 +8673,7 @@ function flowForm(fid) {
8355
8673
  '<div class="field"><label>' + t("连载章节数(留空 = 单稿件)") + '</label><input id="fl-chapters" type="number" min="2" max="20" value="' + (f && f.serial ? f.serial.chapters : "") + '" placeholder="' + t("例:8") + '"></div>' +
8356
8674
  '<div class="field"><label>' + t("每章约字数") + '</label><input id="fl-words-per-ch" type="number" min="500" max="8000" step="100" value="' + (f && f.serial ? f.serial.words_per_chapter : "") + '" placeholder="' + t("例:2500") + '"></div>' +
8357
8675
  '<div class="field"><label>' + t("同章赛马稿件数") + '</label><input id="fl-variants" type="number" min="1" max="3" step="1" value="' + (f && f.serial ? (f.serial.variants || 1) : 1) + '" placeholder="' + t("1 = 关闭") + '"></div>' +
8676
+ '<div class="field"><label>' + t("分卷:每卷章数(留空 = 不分卷)") + '</label><input id="fl-vol-chapters" type="number" min="2" max="200" step="1" value="' + (f && f.serial && f.serial.volume_chapters ? f.serial.volume_chapters : "") + '" placeholder="' + t("例:20") + '"></div>' +
8358
8677
  "</div>" +
8359
8678
  '<div class="field"><label>' + t("起草提示词(可选,占位符 __FILE__ __GOAL__ __CONTEXT__ __SKILLS__)") + '</label><textarea id="fl-draft" rows="3" placeholder="' + t("留空 = 内置通用模板") + '">' + esc(f && f.draft_prompt ? f.draft_prompt : "") + "</textarea></div>" +
8360
8679
  '<div class="field"><label>' + t("评审提示词(可选,占位符 __DIMKEYS__ __MANUSCRIPT__)") + '</label><textarea id="fl-critique" rows="3" placeholder="' + t("留空 = 内置通用模板") + '">' + esc(f && f.critique_prompt ? f.critique_prompt : "") + "</textarea></div>" +
@@ -8394,11 +8713,15 @@ async function saveFlow() {
8394
8713
  const rb = $("fl-rubric").value.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
8395
8714
  if (rb.length) payload.rubric = rb;
8396
8715
  const ch = parseInt($("fl-chapters").value, 10);
8397
- if (ch >= 2) payload.serial = {
8398
- chapters: ch,
8399
- words_per_chapter: parseInt($("fl-words-per-ch").value, 10) || 2500,
8400
- variants: Math.max(1, Math.min(3, parseInt($("fl-variants").value, 10) || 1)),
8401
- };
8716
+ if (ch >= 2) {
8717
+ payload.serial = {
8718
+ chapters: ch,
8719
+ words_per_chapter: parseInt($("fl-words-per-ch").value, 10) || 2500,
8720
+ variants: Math.max(1, Math.min(3, parseInt($("fl-variants").value, 10) || 1)),
8721
+ };
8722
+ const vp = parseInt(($("fl-vol-chapters") || {}).value, 10);
8723
+ if (vp >= 2) payload.serial.volume_chapters = vp; // 流程默认分卷(任务级可覆盖)
8724
+ }
8402
8725
  const dp = $("fl-draft").value.trim();
8403
8726
  if (dp) payload.draft_prompt = dp;
8404
8727
  const cp = $("fl-critique").value.trim();
@@ -8695,6 +9018,26 @@ function autoStatusTag(tsk) {
8695
9018
  return "";
8696
9019
  }
8697
9020
 
9021
+ /* 运行偏好标签:只显示「偏离默认」的那些(编排默认 auto、思考默认 standard),
9022
+ * 否则每张卡都挂两颗没有信息量的胶囊,反而盖住真正要看的节奏与流程 */
9023
+ function autoPrefTags(tsk) {
9024
+ const MODE = { auto: "自动", fast: "快速", expert: "专家", manual: "手动" };
9025
+ const TH = { auto: "自动", low: "快速", standard: "标准", high: "深度" };
9026
+ const out = [];
9027
+ if (tsk.mode && tsk.mode !== "auto" && MODE[tsk.mode]) {
9028
+ out.push(t("编排模式") + ":" + t(MODE[tsk.mode]));
9029
+ }
9030
+ if (tsk.thinking && tsk.thinking !== "standard" && TH[tsk.thinking]) {
9031
+ out.push(t("思考程度") + ":" + t(TH[tsk.thinking]));
9032
+ }
9033
+ if (tsk.direct_provider_id) {
9034
+ const p = directProviders().find((x) => x.id === tsk.direct_provider_id);
9035
+ out.push(t("对话模型") + ":" + (p ? (p.name || p.id) : tsk.direct_provider_id) +
9036
+ "/" + (tsk.direct_model || t("随厂商推荐")));
9037
+ }
9038
+ return out.map((s) => '<span class="tag">' + esc(s) + "</span>").join("");
9039
+ }
9040
+
8698
9041
  function autoCardHtml(tsk) {
8699
9042
  const flow = tsk.flow ? flowById(tsk.flow) : null;
8700
9043
  return '<div class="card' + (tsk.enabled ? "" : " off") + '"><div class="head">' +
@@ -8704,7 +9047,8 @@ function autoCardHtml(tsk) {
8704
9047
  ' onchange="autoToggle(\'' + esc(tsk.id) + '\', this.checked)"><span>' + (tsk.enabled ? t("启用中") : t("已停用")) + "</span></label>" +
8705
9048
  "</div>" +
8706
9049
  '<div class="auto-kind">' + esc(autoKindText(tsk)) +
8707
- (flow ? '<span class="tag">' + t("流程:") + esc(t(flow.name)) + "</span>" : "") + "</div>" +
9050
+ (flow ? '<span class="tag">' + t("流程:") + esc(t(flow.name)) + "</span>" : "") +
9051
+ autoPrefTags(tsk) + "</div>" +
8708
9052
  '<div class="auto-prompt">' + esc(tsk.prompt) + "</div>" +
8709
9053
  '<div class="auto-meta">' +
8710
9054
  (tsk.enabled && tsk.next_run ? "<span>" + t("下次运行 ") + esc(tsk.next_run) + "</span>" : "") +
@@ -8747,6 +9091,8 @@ async function autoForm(task, tpl) {
8747
9091
  const f = task || {};
8748
9092
  const kind = f.kind || (tpl && tpl.suggested_kind) || "daily";
8749
9093
  const curFlow = f.flow || (tpl && tpl.suggested_flow) || "";
9094
+ const mode = f.mode || "auto";
9095
+ const thinking = f.thinking || "standard";
8750
9096
  const wdOpts = AUTO_WD.map((w, i) =>
8751
9097
  '<option value="' + i + '"' + (Number(f.weekday) === i || (!task && tpl && tpl.suggested_weekday === i) ? " selected" : "") + ">" + t(w) + "</option>").join("");
8752
9098
  const runAt = f.run_at ? String(f.run_at).replace(" ", "T").slice(0, 16) : "";
@@ -8770,6 +9116,18 @@ async function autoForm(task, tpl) {
8770
9116
  '<option value="' + esc(fl.id) + '"' + (fl.id === curFlow ? " selected" : "") + ">" + esc(t(fl.name)) + "</option>").join("") +
8771
9117
  "</select></div>" +
8772
9118
  "</div>" +
9119
+ /* 运行偏好:与新建任务 Composer 的三颗胶囊同一套取值(编排模式/思考程度/
9120
+ 对话模型),到点拉起的运行与手动建的任务完全等价 */
9121
+ '<div class="grid-2">' +
9122
+ '<div class="field"><label>' + t("编排模式") + "</label>" +
9123
+ autoPillHtml("au-mode", AUTO_MODES, mode) + "</div>" +
9124
+ '<div class="field"><label>' + t("思考程度") + "</label>" +
9125
+ autoPillHtml("au-thinking", AUTO_THINKINGS, thinking) + "</div>" +
9126
+ "</div>" +
9127
+ '<div id="au-model-field" class="field hidden"><label>' + t("对话模型(可选)") + '</label><div class="grid-2">' +
9128
+ '<select id="au-direct-provider" aria-label="' + t("对话厂商") + '"></select>' +
9129
+ '<select id="au-direct-model-name" aria-label="' + t("对话模型") + '"></select>' +
9130
+ '</div><p class="hint">' + t("只对「直接执行」类流程生效;留「自动推荐」时按系统路由挑模型。") + "</p></div>" +
8773
9131
  '<div class="grid-2">' +
8774
9132
  '<div class="field au-fld" data-k="time"><label>' + t("时间") + '</label><input id="au-time" type="time" value="' +
8775
9133
  esc(f.time || (tpl && tpl.suggested_time) || "09:00") + '"></div>' +
@@ -8789,6 +9147,71 @@ async function autoForm(task, tpl) {
8789
9147
  kindSel.addEventListener("change", autoFormSyncKind);
8790
9148
  autoFormSyncKind();
8791
9149
  }
9150
+ ["au-mode", "au-thinking"].forEach((id) => {
9151
+ const sel = $(id);
9152
+ if (sel) sel.addEventListener("change", () => syncCmpSelFace(id));
9153
+ syncCmpSelFace(id); // 弹框是动态插入的:选项文字要在这里同步一次
9154
+ });
9155
+ renderAuModelPicker(f.direct_provider_id || "", f.direct_model || "");
9156
+ const flowSel = $("au-flow");
9157
+ if (flowSel) {
9158
+ flowSel.addEventListener("change", autoFormSyncFlow);
9159
+ autoFormSyncFlow();
9160
+ }
9161
+ }
9162
+
9163
+ /* 运行偏好取值(真源 = automation.py 的 MODES/THINKINGS,选项文案与 Composer 共用) */
9164
+ const AUTO_MODES = [
9165
+ { v: "auto", label: "自动(推荐):按任务类型与风险动态决定步骤" },
9166
+ { v: "fast", label: "快速:少步骤优先,先做确定性验收" },
9167
+ { v: "expert", label: "专家:完整规划、多评审、失败修复与换将" },
9168
+ { v: "manual", label: "手动:自行指定实现者与评审组" },
9169
+ ];
9170
+ const AUTO_THINKINGS = [
9171
+ { v: "standard", label: "标准:平衡质量与速度" },
9172
+ { v: "auto", label: "自动:随任务难度调整" },
9173
+ { v: "low", label: "快速:少推理、优先响应" },
9174
+ { v: "high", label: "深度:更多推理与检查" },
9175
+ ];
9176
+
9177
+ /* 偏好胶囊:外观与 Composer 同款(透明原生 select 铺满整颗,短名由
9178
+ * syncCmpSelFace 从选中 option 文本截取——语言切换后短名跟着换) */
9179
+ function autoPillHtml(id, opts, cur) {
9180
+ return '<span class="cmp-sel"><select id="' + id + '" class="cmp-sel-native" aria-label="' +
9181
+ esc(id === "au-mode" ? t("编排模式") : t("思考程度")) + '">' +
9182
+ opts.map((o) => '<option value="' + o.v + '"' + (o.v === cur ? " selected" : "") +
9183
+ ' data-i18n="' + esc(o.label) + '">' + esc(t(o.label)) + "</option>").join("") +
9184
+ '</select><span class="cmp-sel-face" id="' + id + '-face"></span>' +
9185
+ '<svg class="ico chev" aria-hidden="true"><use href="#i-chevron-r"></use></svg></span>';
9186
+ }
9187
+
9188
+ /* 对话模型(仅 direct 流程):复用 Composer 的厂商/模型数据源与重建规则 */
9189
+ function renderAuModelPicker(pid, model) {
9190
+ const ps = $("au-direct-provider"), ms = $("au-direct-model-name");
9191
+ if (!ps || !ms) return;
9192
+ const provs = directProviders();
9193
+ const keepP = pid != null ? pid : ps.value;
9194
+ ps.innerHTML = '<option value="">' + t("自动推荐") + "</option>" + provs.map((p) =>
9195
+ '<option value="' + esc(p.id) + '">' + esc(p.name || p.id) + "</option>").join("");
9196
+ ps.value = provs.some((p) => p.id === keepP) ? keepP : "";
9197
+ const fillModels = (keepM) => {
9198
+ const p = provs.find((x) => x.id === ps.value);
9199
+ const names = p ? (p.models || []).filter((m) => !m.hidden)
9200
+ .map((m) => m.name).filter(Boolean) : [];
9201
+ ms.innerHTML = '<option value="">' + t("随厂商推荐") + "</option>" + names.map((name) =>
9202
+ '<option value="' + esc(name) + '">' + esc(name) + "</option>").join("");
9203
+ ms.value = names.includes(keepM) ? keepM : "";
9204
+ };
9205
+ fillModels(model != null ? model : ms.value);
9206
+ // 换厂商 = 旧模型名多半不在新厂商名录里:回「随厂商推荐」,不留悬空值
9207
+ ps.onchange = () => fillModels("");
9208
+ }
9209
+
9210
+ /* 流程 → 对话模型字段显隐:只有 direct 引擎流程用得上(其余流程模型由系统路由) */
9211
+ function autoFormSyncFlow() {
9212
+ const flow = flowById(($("au-flow") || {}).value);
9213
+ const box = $("au-model-field");
9214
+ if (box) box.classList.toggle("hidden", !(flow && flow.engine === "direct"));
8792
9215
  }
8793
9216
 
8794
9217
  /* 类型 → 字段联动:daily/weekly→时间;weekly→星期几;interval→N 小时;once→未来时间 */
@@ -8813,6 +9236,17 @@ async function saveAutoForm(id) {
8813
9236
  if (!prompt) { toast(t("请填写执行内容"), true); return; }
8814
9237
  const kind = $("au-kind").value;
8815
9238
  const payload = { name, prompt, workdir: ($("au-workdir").value || "").trim(), kind, flow: $("au-flow").value };
9239
+ // 运行偏好:与 Composer 同名字段,后端 _apply_run_prefs 再归一一次
9240
+ payload.mode = $("au-mode") ? $("au-mode").value : "";
9241
+ payload.thinking = $("au-thinking") ? $("au-thinking").value : "";
9242
+ if (!$("au-model-field") || !$("au-model-field").classList.contains("hidden")) {
9243
+ payload.direct_provider_id = $("au-direct-provider") ? $("au-direct-provider").value : "";
9244
+ payload.direct_model = $("au-direct-model-name") ? $("au-direct-model-name").value : "";
9245
+ if (payload.direct_model && !payload.direct_provider_id) {
9246
+ toast(t("选择对话模型前请先选厂商"), true);
9247
+ return;
9248
+ }
9249
+ }
8816
9250
  if (kind === "daily" || kind === "weekly") payload.time = $("au-time").value;
8817
9251
  if (kind === "weekly") payload.weekday = parseInt($("au-weekday").value, 10);
8818
9252
  if (kind === "interval") payload.interval_hours = parseInt($("au-interval").value, 10) || 0;
@@ -8891,7 +9325,7 @@ async function loadZentao() {
8891
9325
  tg("zt-autoresolve", cfg.auto_resolve);
8892
9326
  tg("zt-automerge", cfg.auto_merge);
8893
9327
  tg("zt-poll", cfg.poll_enabled);
8894
- set("zt-interval", cfg.interval_hours || 2);
9328
+ set("zt-interval", cfg.interval_minutes || 5);
8895
9329
  S.ztProfiles = JSON.parse(JSON.stringify(cfg.product_profiles || []));
8896
9330
  renderZentaoProfiles();
8897
9331
  renderZentaoStatus();
@@ -9158,26 +9592,26 @@ function renderHooks() {
9158
9592
  if (!box) return;
9159
9593
  const list = S.hooks || [];
9160
9594
  if (empty) empty.classList.toggle("hidden", list.length > 0);
9595
+ const evLabel = { task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") };
9161
9596
  box.innerHTML = list.map((h, i) => {
9162
- const evName = { task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") }[h.event] || h.event;
9163
9597
  return '<div class="card hook-card" data-i="' + i + '">' +
9164
- '<div class="hook-head">' +
9165
- '<input class="hook-name" value="' + esc(h.name || "") + '" placeholder="' + esc(t("名称")) + '" data-hf="name">' +
9598
+ '<div class="hook-row">' +
9599
+ '<input class="hook-name" value="' + esc(h.name || "") + '" placeholder="' + esc(t("钩子名称")) + '" data-hf="name">' +
9166
9600
  '<select class="hook-event" data-hf="event">' +
9167
9601
  ["task_start", "message_submit", "run_end"].map((ev) =>
9168
9602
  '<option value="' + ev + '"' + (h.event === ev ? " selected" : "") + ">" +
9169
- esc({ task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") }[ev]) + "</option>").join("") +
9603
+ esc(evLabel[ev]) + "</option>").join("") +
9170
9604
  "</select>" +
9171
- '<label class="switch" title="' + esc(t("启用")) + '"><input type="checkbox" data-hf="enabled"' +
9172
- (h.enabled ? " checked" : "") + '><span></span></label>' +
9173
- '<button type="button" class="ghost small" onclick="removeHook(' + i + ')">' + t("删除") + "</button>" +
9174
9605
  "</div>" +
9175
9606
  '<input class="hook-cmd mono" value="' + esc(h.cmd || "") + '" placeholder="' +
9176
- esc(t('命令,例:python E:\\proj\\hook.py --event {event}')) + '" data-hf="cmd">' +
9177
- '<div class="hook-foot">' +
9178
- '<span class="hint">' + esc(t("超时(秒)")) + '</span><input class="hook-timeout" type="number" min="1" max="120" value="' +
9179
- Number(h.timeout_s || 10) + '" data-hf="timeout_s">' +
9607
+ esc(t('命令:python D:\\proj\\hook.py(stdin 收 JSON,stdout 回 {"inject":"文本"})')) + '" data-hf="cmd">' +
9608
+ '<div class="hook-row hook-foot">' +
9609
+ '<label class="switch" title="' + esc(t("启用")) + '"><input type="checkbox" data-hf="enabled"' +
9610
+ (h.enabled ? " checked" : "") + '><span></span><em>' + esc(t("启用")) + '</em></label>' +
9611
+ '<span class="hook-foot-lab">' + esc(t("超时(秒)")) + '</span>' +
9612
+ '<input class="hook-timeout" type="number" min="1" max="120" value="' + Number(h.timeout_s || 10) + '" data-hf="timeout_s">' +
9180
9613
  '<button type="button" class="ghost small" data-hook-test="' + i + '">' + t("测试") + "</button>" +
9614
+ '<button type="button" class="ghost small" onclick="removeHook(' + i + ')">' + t("删除") + "</button>" +
9181
9615
  '<span class="hint hook-test-out"></span>' +
9182
9616
  "</div></div>";
9183
9617
  }).join("");
@@ -9262,6 +9696,13 @@ function zentaoTriText(c) {
9262
9696
  return t("排查:") + t(NAME[tri.side] || tri.side) + (by ? "(" + by + ")" : "");
9263
9697
  }
9264
9698
 
9699
+ /* 扫描间隔(分钟)→ 人类可读:整小时显示「2 小时」,否则「5 分钟」 */
9700
+ function ztIntervalText(mins) {
9701
+ const n = Math.max(1, parseInt(mins, 10) || 5);
9702
+ if (n % 60 === 0) return (n / 60) + t(" 小时一次");
9703
+ return n + t(" 分钟一次");
9704
+ }
9705
+
9265
9706
  function renderZentaoStatus() {
9266
9707
  const box = $("zentao-status");
9267
9708
  if (!box) return;
@@ -9270,7 +9711,7 @@ function renderZentaoStatus() {
9270
9711
  const cfg = v.config || {};
9271
9712
  const parts = [];
9272
9713
  parts.push(cfg.poll_enabled
9273
- ? (t("定时扫描已开启,每 ") + (cfg.interval_hours || 2) + t(" 小时一次")
9714
+ ? (t("定时扫描已开启,每 ") + ztIntervalText(cfg.interval_minutes)
9274
9715
  + (v.next_scan ? t(",下次 ") + esc(v.next_scan) : ""))
9275
9716
  : t("定时扫描未开启(仍可手动「立即扫描」)"));
9276
9717
  if (v.last_scan) parts.push(t("上次扫描 ") + esc(v.last_scan));
@@ -9314,7 +9755,7 @@ function ztFormPayload() {
9314
9755
  auto_resolve: $("zt-autoresolve").checked,
9315
9756
  auto_merge: $("zt-automerge").checked,
9316
9757
  poll_enabled: $("zt-poll").checked,
9317
- interval_hours: parseInt($("zt-interval").value, 10) || 2,
9758
+ interval_minutes: Math.max(5, parseInt($("zt-interval").value, 10) || 5),
9318
9759
  };
9319
9760
  const pw = ($("zt-password").value || "").trim();
9320
9761
  if (pw) payload.password = pw;
@@ -9734,6 +10175,8 @@ async function loadSettings() {
9734
10175
  if (tel && S.settings) tel.checked = S.settings.telemetry_errors !== false;
9735
10176
  const pet = $("set-pet");
9736
10177
  if (pet && S.settings) pet.checked = S.settings.pet_enabled !== false;
10178
+ const cs = $("set-claude-sync");
10179
+ if (cs && S.settings) cs.checked = S.settings.claude_config_sync !== false;
9737
10180
  syncPetModeSeg();
9738
10181
  syncPetSkinSeg();
9739
10182
  const wd = $("set-workdir"), hint = $("set-workdir-hint");
@@ -9918,7 +10361,8 @@ async function saveSettings() {
9918
10361
  const msg = $("settings-msg");
9919
10362
  try {
9920
10363
  const r = await api("/api/settings", { method: "POST", body: JSON.stringify({
9921
- max_concurrent_jobs: parseInt($("set-workers").value, 10) }) });
10364
+ max_concurrent_jobs: parseInt($("set-workers").value, 10),
10365
+ claude_config_sync: !$("set-claude-sync") || $("set-claude-sync").checked }) });
9922
10366
  S.settings = r.settings;
9923
10367
  if (msg) { msg.className = "msg ok"; msg.textContent = t("已保存:最大并发 ") + r.workers + t(" 个任务"); }
9924
10368
  } catch (e) {
@@ -10027,6 +10471,9 @@ function helpChapterBody(id) {
10027
10471
  '<p class="help-p">' + t("新建任务选「连载写作」类型,目标里写清书名、题材和计划篇幅(如「都市异能,先写 20 章」)。目标写得太笼统时,蜂群会先反问几个澄清问题再开工。开书前会先准备作品资料:书简介与分类标签可一键生成,按平台官方选项实抓,不用自己去查规则。") + '</p>' +
10028
10472
  '<h3 class="help-h3">' + t("每章怎么跑") + '</h3>' +
10029
10473
  '<p class="help-p">' + t("大纲确认后分章推进:起草 → 跨厂商评审 → 打磨 → 定稿。评审不过会自动换将重写,不带着问题过关;需要你拍板的地方会亮起「待裁决」。写作时详情页可实时预览章节,蜂巢页每个智能体一格,点开能看谁在干什么。") + '</p>' +
10474
+ '<h3 class="help-h3">' + t("分卷(卷结构)") + '</h3>' +
10475
+ '<p class="help-p">' + t("长篇建议分卷。两种给法:填「每卷章数」(默认预填 20,清空 = 不分卷),系统按全书章号自动切卷;或者直接写明各卷——在「指定卷结构」框里,也可以在任务目标里写「第一卷 少年初入江湖 第1-20章;卷二 风云再起 21-40章」,会自动识别。给了卷名的会原样沿用,不会被改写。") + '</p>' +
10476
+ '<p class="help-p">' + t("分卷会贯穿全程:大纲按卷给卷名与卷弧光(本卷主线冲突与卷末高潮);每章起草和评审都带着「第几卷第几章」,靠近卷末会提醒收拢支线、卷末章必须写出本卷高潮与卷末钩子;成书自动插入「第 X 卷《卷名》」分隔;详情页章节卡与发布页清单按卷分组,发章时一卷一卷地发。卷边界只看全书章号,所以续写批次会自动接上同一卷,不会把一卷劈成两半。") + '</p>' +
10030
10477
  '<h3 class="help-h3">' + t("中断了怎么办") + '</h3>' +
10031
10478
  '<p class="help-p">' + t("任务随时可续跑:已写章节都落了盘,续跑接着往下写,不会从头再来。单章失败会自动退避重试,任务状态里会显示「将于 HH:MM 自动续跑」,不用盯着等。") + '</p>' +
10032
10479
  '<h3 class="help-h3">' + t("递话与经验") + '</h3>' +
@@ -10458,12 +10905,51 @@ async function suStartupCheck() {
10458
10905
 
10459
10906
  /* ---------------------------------------------------------- 页签 & 初始化 */
10460
10907
  const TAB_TITLES = { overview: "概览", tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
10461
- const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 设置导航里的子页(__phone 是弹框,不算)
10908
+ const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 全部设置子页(__phone 是弹框,不算)
10462
10909
 
10463
10910
  function tabTitle(name) {
10464
10911
  return t(TAB_TITLES[name]) || t("设置");
10465
10912
  }
10466
10913
 
10914
+ /* 顶栏标题 + 面包屑:页名永远落在 #page-title(多处测试断言它的 textContent
10915
+ * 精确等于页名,所以组名绝不能并进去),父级组名单独放 #crumb-root。
10916
+ * 组名从左栏设置导航现读——找选中项上方最近的分组标签,HTML 增删导航项时
10917
+ * 这里自动跟上,不需要维护一份硬编码映射。全页统一切标题只走这一个函数。 */
10918
+ function renderPageCrumb() {
10919
+ const el = $("page-title");
10920
+ if (!el) return;
10921
+ const crumb = $("crumb-root");
10922
+ const inDetail = !!(S.detailRunId || S.detailTaskKey); // 详情自成语境,不挂父级
10923
+ let group = "";
10924
+ if (!inDetail && document.body.classList.contains("settings-mode")) {
10925
+ const active = document.querySelector(".side-settings .set-item.active");
10926
+ let n = active ? active.previousElementSibling : null;
10927
+ while (n && !(n.classList && n.classList.contains("set-group"))) n = n.previousElementSibling;
10928
+ if (n) group = (n.textContent || "").trim();
10929
+ }
10930
+ if (crumb) {
10931
+ crumb.textContent = group;
10932
+ crumb.classList.toggle("hidden", !group);
10933
+ }
10934
+ el.textContent = inDetail ? t("运行详情") : tabTitle(S.tab);
10935
+ }
10936
+
10937
+ /* 左栏两套导航各管哪些页,一律从 index.html 现读——HTML 改了 JS 自动跟上,
10938
+ * 不再各存一份常量、两处漂移:
10939
+ * .side-quick 快捷导航(概览/运行记录/自动化)→ main 形态:只换主栏内容,左栏任务树不动
10940
+ * .side-settings 设置导航 → settings 形态:左栏整体换成设置导航
10941
+ * 同一个页只应挂在其中一边(重复挂就是两处入口)。 */
10942
+ function mainPageSubs() {
10943
+ const s = new Set();
10944
+ document.querySelectorAll(".side-quick .qitem").forEach((b) => { if (b.dataset.page) s.add(b.dataset.page); });
10945
+ return s;
10946
+ }
10947
+ function settingsNavSubs() {
10948
+ const s = new Set();
10949
+ document.querySelectorAll(".side-settings .set-item").forEach((b) => { if (b.dataset.sub) s.add(b.dataset.sub); });
10950
+ return s;
10951
+ }
10952
+
10467
10953
 
10468
10954
 
10469
10955
  /* ---------------------------------------------------------- 用量统计 */
@@ -10628,9 +11114,8 @@ function setLangBtn(lang) {
10628
11114
  paintHelpChrome();
10629
11115
  renderHelp();
10630
11116
  }
10631
- // 重画还在缓存里的页面标题
10632
- const title = $("page-title");
10633
- if (title && S.tab) title.textContent = tabTitle(S.tab);
11117
+ // 重画还在缓存里的页面标题(语言切换后组名也要跟着换)
11118
+ renderPageCrumb();
10634
11119
  // 同步顶栏的连接状态文案(applyI18n 不会处理 textContent 写入的)
10635
11120
  const cn = $("conn");
10636
11121
  if (cn) {
@@ -11289,11 +11774,79 @@ function ovRenderRecent() {
11289
11774
  }
11290
11775
 
11291
11776
  /* 运行状态经 SSE 推来:停在概览页时只重画依赖 runs 的两处(概览语 + 最近运行),
11292
- * 不重新拉用量(台账不会因为一次运行状态变化就变) */
11777
+ * 不重新拉用量(台账不会因为一次运行状态变化就变)。
11778
+ * 侧栏常驻件(用量条 + 计数徽章)不跟着 S.tab 走,每次状态推送都要顺手刷新。 */
11293
11779
  function ovMaybeRefresh() {
11294
- if (S.tab !== "overview") return;
11295
- ovRenderHead();
11296
- ovRenderRecent();
11780
+ if (S.tab === "overview") {
11781
+ ovRenderHead();
11782
+ ovRenderRecent();
11783
+ }
11784
+ sideUsageRefresh();
11785
+ }
11786
+
11787
+ /* ---------------------------------------------------------- 侧栏常驻:用量条 + 计数徽章 */
11788
+ /* 用量条的数字来自真实台账,口径写死为「本月」(自然月,从 1 号起)。
11789
+ * 刻意不做「用量 68%」那种进度条:CodeBee 没有套餐配额,没有分母可除,
11790
+ * 编一个上限出来就是假数。这里陈列的是能直接去用量页核对的事实。 */
11791
+ async function sideUsageLoad() {
11792
+ if (S.sideUsage && S.sideUsageAt && Date.now() - S.sideUsageAt < 60000) return; // 1 分钟节流
11793
+ try {
11794
+ const u = await api("/api/usage?days=30");
11795
+ S.sideUsage = u || null;
11796
+ S.sideUsageAt = Date.now();
11797
+ } catch (e) { /* 拉不到就保留上次的值,不把侧栏清空 */ }
11798
+ sideUsageRender();
11799
+ }
11800
+
11801
+ function sideUsageRefresh() {
11802
+ sideUsageRender(); // 计数徽章靠 S.state,同步重画
11803
+ sideUsageLoad(); // 台账按节流异步补
11804
+ }
11805
+
11806
+ function sideUsageRender() {
11807
+ sideUsageRenderRuns();
11808
+ const box = $("side-usage");
11809
+ if (!box) return;
11810
+ const u = S.sideUsage;
11811
+ const tok = $("su-tok"), cost = $("su-cost"), spark = $("su-spark");
11812
+ if (!tok || !cost || !spark) return;
11813
+ if (!u || !u.by_day || !u.by_day.length) {
11814
+ tok.textContent = t("本月还没有调用");
11815
+ cost.textContent = "";
11816
+ spark.innerHTML = "";
11817
+ return;
11818
+ }
11819
+ // 口径=自然月:days=31 只是取数窗口(任一自然月的已过天数都不超过它),
11820
+ // 展示前必须按当前年月再滤一遍——不滤的话月初那几天会把上个月的尾巴算进"本月",
11821
+ // 标签写着本月、数字却是滚动 30 天,就是假口径
11822
+ const now = new Date();
11823
+ const ym = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
11824
+ const monthDays = u.by_day.filter((d) => String(d.day || "").slice(0, 7) === ym);
11825
+ const sum = (k) => monthDays.reduce((s, d) => s + (Number(d[k]) || 0), 0);
11826
+ const calls = sum("calls");
11827
+ if (!calls) {
11828
+ tok.textContent = t("本月还没有调用");
11829
+ cost.textContent = "";
11830
+ spark.innerHTML = "";
11831
+ return;
11832
+ }
11833
+ tok.textContent = fmtTok(sum("tokens")) + t(" tokens · ") + fmtTok(calls) + t(" 次");
11834
+ cost.textContent = fmtUsd(sum("cost_usd"));
11835
+ // 走势不受月界影响:近 14 天日序列,只是节奏参考,by_day 连续补零直接取尾
11836
+ const days = u.by_day.slice(-14).map((d) => d.tokens || 0);
11837
+ spark.innerHTML = days.length > 1 ? kpiSparkSvg(days, "accent") : "";
11838
+ }
11839
+
11840
+ /* 计数徽章:挂在「运行记录」快捷导航项上,数当前在跑 + 排队的 run 数。
11841
+ * 数据取自 S.state.runs(SSE 推送),不额外请求;为 0 时收起徽章不留空位。 */
11842
+ function sideUsageRenderRuns() {
11843
+ const el = $("nav-badge-runs");
11844
+ if (!el) return;
11845
+ const runs = (S.state && S.state.runs) || [];
11846
+ const n = runs.filter((r) => r.status === "running" || r.status === "queued").length;
11847
+ el.textContent = n > 99 ? "99+" : String(n);
11848
+ el.classList.toggle("hidden", n === 0);
11849
+ el.title = n ? t("正在运行或排队 ") + n + t(" 个任务") : "";
11297
11850
  }
11298
11851
 
11299
11852
  /* ---------------------------------------------------------- 手机连接(扫码) */
@@ -11553,19 +12106,25 @@ function collapseDrawerIfMobile() {
11553
12106
  if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
11554
12107
  }
11555
12108
 
11556
- function switchTab(name) {
12109
+ /* 切页:主栏换成目标子页。shell 决定左栏形态——"main" 保持任务树、"settings" 换成
12110
+ * 设置导航,缺省按目标页归哪套导航自动选(见 mainPageSubs/settingsNavSubs)。 */
12111
+ function switchTab(name, shell) {
11557
12112
  if (name === "__phone") { openPhoneConnect(); return; } // 手机连接是弹框,不切页
11558
12113
  if (name === "__guide") { welcomeOpen(); return; } // 帮助中心是弹层,不切页(设置导航「软件」组)
11559
- // 导航收进「设置」:进设置后左栏整体换成设置导航,内容铺满
12114
+ // 形态由目标页决定:快捷导航页(概览/运行记录/自动化)走 main——只换主栏内容、
12115
+ // 左栏任务树原地不动;其余页进设置导航。调用方可用 shell 显式覆盖(如已在设置里
12116
+ // 点运行详情,仍要留在设置侧栏)。用户主动进设置时左栏一定会换成设置导航。
12117
+ const inMain = (shell || (mainPageSubs().has(name) ? "main" : "settings")) === "main";
11560
12118
  S.tab = name;
11561
- if (SET_TABS.has(name)) localStorage.setItem("orch.setTab", name);
11562
- document.body.classList.add("settings-mode");
11563
- document.body.classList.remove("files-mode"); // 文件浏览页与设置导航互斥,别叠在左栏
12119
+ S.mainPage = inMain ? name : ""; // 主栏停在哪个全局页;closeRun 靠它回到点开详情前那一页
12120
+ if (settingsNavSubs().has(name)) localStorage.setItem("orch.setTab", name);
12121
+ document.body.classList.toggle("settings-mode", !inMain);
12122
+ document.body.classList.remove("files-mode"); // 文件浏览页与两套导航都互斥,别叠在左栏
11564
12123
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
11565
- document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === name));
12124
+ document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", !inMain && b.dataset.sub === name));
12125
+ document.querySelectorAll(".qitem").forEach((b) => b.classList.toggle("active", inMain && b.dataset.page === name));
11566
12126
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-" + name));
11567
- const title = $("page-title");
11568
- if (title) title.textContent = tabTitle(name);
12127
+ renderPageCrumb(); // 页名 + 面包屑组名一起刷新(组名从左栏导航现读)
11569
12128
  if (name === "runs" && !S.detailRunId) closeRun();
11570
12129
  if (name === "agents") autoCheckUpdates(); // 进目录页自动查各 CLI 新版本
11571
12130
  if (name === "orch") { loadOrchestrator(); loadSettings(); } // 进编排设置页拉取配置
@@ -11587,26 +12146,29 @@ function switchTab(name) {
11587
12146
  syncInspectorVis(); // 检查器只属于任务上下文:进设置子页自动收起
11588
12147
  }
11589
12148
 
11590
- /* 退出设置:左栏恢复任务树,内容回到任务页 */
12149
+ /* 退出设置/回到新建任务表单:左栏恢复任务树,主栏回到任务页 */
11591
12150
  function exitSettings() {
11592
12151
  S.tab = "tasks";
12152
+ S.mainPage = ""; // 主栏回到表单:不再是快捷导航页
11593
12153
  stopAutoPoll(); // 离开设置视图:自动化页轮询一并停掉
11594
12154
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
11595
12155
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
11596
12156
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
12157
+ document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
11597
12158
  document.body.classList.remove("settings-mode");
11598
12159
  document.body.classList.remove("files-mode");
11599
12160
  cmpGreeting(); // 回到任务页:问候语刷新
11600
- const title = $("page-title");
11601
- if (title) title.textContent = tabTitle("tasks");
12161
+ renderPageCrumb();
11602
12162
  collapseDrawerIfMobile();
11603
12163
  syncInspectorVis(); // 回到新建表单:不是任务上下文,检查器收起(点运行详情才会滑回)
11604
12164
  }
11605
12165
 
11606
- /* 侧栏底部齿轮:直接进设置(左栏换成设置导航,不再弹菜单)。回到上次看的那页,默认编排设置 */
12166
+ /* 侧栏底部齿轮:直接进设置(左栏换成设置导航,不再弹菜单)。回到上次看的那页,默认编排设置。
12167
+ * 只认设置导航里真实存在的子页——概览/运行记录/自动化已升为快捷导航,落回它们会停在
12168
+ * 一个没有导航项高亮的设置页。 */
11607
12169
  function enterSettings() {
11608
12170
  const saved = localStorage.getItem("orch.setTab") || "";
11609
- switchTab(SET_TABS.has(saved) ? saved : "orch");
12171
+ switchTab(settingsNavSubs().has(saved) ? saved : "orch", "settings");
11610
12172
  }
11611
12173
 
11612
12174
  window.openRun = openRun;
@@ -11634,6 +12196,7 @@ window.mgmt = mgmt;
11634
12196
  window.switchTab = switchTab;
11635
12197
  window.setUsageDays = setUsageDays;
11636
12198
  window.setOvDays = setOvDays;
12199
+ window.renderPageCrumb = renderPageCrumb; // i18n.js 换语言后补画标题用
11637
12200
  window.jumpToRun = jumpToRun;
11638
12201
  window.saveProvider = saveProvider;
11639
12202
  window.delProvider = delProvider;
@@ -11748,6 +12311,14 @@ document.addEventListener("DOMContentLoaded", () => {
11748
12311
  };
11749
12312
  bindArtifactClicks($("rd-arts"));
11750
12313
  bindArtifactClicks($("insp-artifacts"));
12314
+ // 预览子页签(运行 / 各文件):委托一次,页签条整块重画也不用重绑
12315
+ const pvTabs = $("rd-pv-tabs");
12316
+ if (pvTabs) pvTabs.addEventListener("click", (e) => {
12317
+ const b = e.target.closest(".pv-tab");
12318
+ if (b && b.dataset.pv) window.pvGo(b.dataset.pv);
12319
+ });
12320
+ const pvReload = $("rd-pv-reload");
12321
+ if (pvReload) pvReload.addEventListener("click", () => window.pvReload());
11751
12322
  // 动态详情区统一用 data 属性委托,日志路径/文件名不再拼进 inline JS。
11752
12323
  const detailOverview = $("rd-overview");
11753
12324
  if (detailOverview) detailOverview.addEventListener("click", (e) => {
@@ -11901,6 +12472,8 @@ document.addEventListener("DOMContentLoaded", () => {
11901
12472
  $("btn-notify-toggle").addEventListener("click", toggleNotifySound);
11902
12473
  // 命令面板:侧栏搜索行 / Ctrl+K 唤起(函数若尚未落地,跳过而不炸整个初始化)
11903
12474
  if (typeof bindCmdK === "function") bindCmdK();
12475
+ // 侧栏常驻用量条:首屏先取一次台账(之后由 applyState 的节流刷新接手)
12476
+ sideUsageRefresh();
11904
12477
  // 文件夹全部展开/收起(写回 localStorage,与手点单个 folder 同一套持久化)
11905
12478
  $("btn-side-expand").addEventListener("click", () => {
11906
12479
  const dlist = Array.from($("side-tasks").querySelectorAll("details.sdir"));
@@ -11955,10 +12528,11 @@ document.addEventListener("DOMContentLoaded", () => {
11955
12528
  if (e.target.closest("button, summary, .stask")) collapseDrawerIfMobile();
11956
12529
  });
11957
12530
  $("btn-new-task").addEventListener("click", exitSettings);
11958
- // 主侧栏快捷入口:直达概览 / 自动化 / 插件市场(switchTab 自己会切设置模式并挂载页面)
11959
- $("btn-q-overview").addEventListener("click", () => switchTab("overview"));
11960
- $("btn-q-automation").addEventListener("click", () => switchTab("automation"));
11961
- $("btn-q-market").addEventListener("click", () => switchTab("market"));
12531
+ // 侧栏快捷导航:主栏换成目标页,左栏任务树不动(shell="main")。
12532
+ // 页面清单以 index.html .side-quick 的 data-page 为准,这里逐项绑 id。
12533
+ $("btn-q-overview").addEventListener("click", () => switchTab("overview", "main"));
12534
+ $("btn-q-runs").addEventListener("click", () => switchTab("runs", "main"));
12535
+ $("btn-q-automation").addEventListener("click", () => switchTab("automation", "main"));
11962
12536
  bindCtxMenus();
11963
12537
  bindInspector();
11964
12538
  if (window.innerWidth < 900) document.body.classList.add("side-collapsed");