codebee 0.1.49 → 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", 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 };
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() {
@@ -1093,12 +1093,12 @@ async function healthOp(op) {
1093
1093
  }
1094
1094
  }
1095
1095
 
1096
- /* 模型级禁用:链降级(resolve_binding 的 _model_bindable)会自动跳过它。
1096
+ /* 模型级禁用:绑定链里它的条目由后端自动剔除(modelhub 停用即出调度)。
1097
1097
  只对出问题的那一格生效——厂商其它模型照常可用。 */
1098
1098
  async function healthDisableModel(pid, model) {
1099
1099
  if (!pid || !model) { closeModal(); return; }
1100
1100
  const yes = await uiConfirm(
1101
- t("确认禁用模型 {0}?链降级将自动跳过它,其余模型不受影响;可在模型调度页重新启用。").replace("{0}", pid + " · " + model),
1101
+ t("确认禁用模型 {0}?调度链里它的条目会自动移除,其余模型不受影响;可在模型调度页重新启用。").replace("{0}", pid + " · " + model),
1102
1102
  { title: t("禁用模型"), danger: true, ok: t("禁用") });
1103
1103
  if (!yes) return;
1104
1104
  try {
@@ -1113,8 +1113,7 @@ async function healthDisableModel(pid, model) {
1113
1113
  S.modelCatalog = models.catalog || [];
1114
1114
  S.provSig = "";
1115
1115
  closeModal(); render(); loadOrchestrator();
1116
- toast(t("已禁用模型 {0} · {1},链降级自动跳过;绑定页可重新启用").replace("{0}", pid).replace("{1}", model));
1117
- autoRebindSoon();
1116
+ toast(t("已禁用模型 {0} · {1},调度链已自动移除它;绑定页可重新启用").replace("{0}", pid).replace("{1}", model));
1118
1117
  } catch (e) {
1119
1118
  toast(t("操作失败:") + e.message, true);
1120
1119
  }
@@ -1123,7 +1122,7 @@ async function healthDisableModel(pid, model) {
1123
1122
  async function healthDisableProvider(pid) {
1124
1123
  if (!pid) { closeModal(); return; }
1125
1124
  const yes = await uiConfirm(
1126
- t("确认禁用该厂商?禁用后链降级自动跳过它,恢复后可在模型调度页重新启用。"),
1125
+ t("确认禁用该厂商?调度链里它的条目会自动移除,恢复后可在模型调度页重新启用。"),
1127
1126
  { title: t("禁用厂商"), danger: true, ok: t("禁用") });
1128
1127
  if (!yes) return;
1129
1128
  try {
@@ -1137,8 +1136,7 @@ async function healthDisableProvider(pid) {
1137
1136
  S.providers = models.providers; S.bindings = models.bindings;
1138
1137
  S.provSig = "";
1139
1138
  closeModal(); render(); loadOrchestrator();
1140
- toast(t("已禁用厂商 {0}:链降级自动跳过,绑定页可重新启用").replace("{0}", pid));
1141
- autoRebindSoon();
1139
+ toast(t("已禁用厂商 {0}:调度链已自动移除它的条目,绑定页可重新启用").replace("{0}", pid));
1142
1140
  } catch (e) {
1143
1141
  toast(t("操作失败:") + e.message, true);
1144
1142
  }
@@ -1450,7 +1448,6 @@ async function batchProvOp(op) {
1450
1448
  try {
1451
1449
  await api("/api/models/provider-op", { method: "POST",
1452
1450
  body: JSON.stringify({ ids, op }) });
1453
- if (op !== "duplicate") autoRebindSoon();
1454
1451
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1455
1452
  S.selProvs = {};
1456
1453
  S.modelsSig = null;
@@ -1641,7 +1638,6 @@ async function keyOpCall(pid, op, keyId, key, label) {
1641
1638
  try {
1642
1639
  await api("/api/models/key-op", { method: "POST",
1643
1640
  body: JSON.stringify({ provider_id: pid, op, key_id: keyId, key, label }) });
1644
- autoRebindSoon(); // 密钥启停/删除/解冻会改变厂商「推荐可用」判定,自动补绑一次
1645
1641
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1646
1642
  // 操作期间轮询照跑(uiPrompt 弹框不阻塞轮询),S.modelsSig 还是旧值:必须
1647
1643
  // 清签名强制重绘,否则 KEY 写盘成功界面也看不到。
@@ -1870,7 +1866,6 @@ async function modelOp(pid, name, op) {
1870
1866
  try {
1871
1867
  await api("/api/models/model-op", { method: "POST",
1872
1868
  body: JSON.stringify({ provider_id: pid, name, op }) });
1873
- autoRebindSoon(); // 模型停用/启用/删除会改变推荐源与死链判定,自动补绑一次
1874
1869
  const s = modelSel(pid); // 单行操作后同步清掉该行勾选
1875
1870
  if (s[name]) { delete modelSelSet(pid)[name]; }
1876
1871
  } catch (e) { toast(t("操作失败:") + e.message, true); }
@@ -1937,7 +1932,6 @@ async function batchModelOp(pid, op) {
1937
1932
  try {
1938
1933
  await api("/api/models/model-op", { method: "POST",
1939
1934
  body: JSON.stringify({ provider_id: pid, names, op }) });
1940
- autoRebindSoon();
1941
1935
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1942
1936
  clearModelSel(pid);
1943
1937
  poll();
@@ -1956,7 +1950,6 @@ async function toggleProviderEnabled(pid, enabled) {
1956
1950
  try {
1957
1951
  await api("/api/models/provider-op", { method: "POST",
1958
1952
  body: JSON.stringify({ ids: [pid], op: off ? "enable" : "disable" }) });
1959
- autoRebindSoon();
1960
1953
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1961
1954
  S.modelsSig = null;
1962
1955
  poll();
@@ -1981,7 +1974,6 @@ async function refreshAllModels() {
1981
1974
  btn.title = t("全部重新拉取模型列表");
1982
1975
  }, 1500);
1983
1976
  setTimeout(poll, 2500);
1984
- setTimeout(autoRebindSoon, 8000); // 模型列表落盘后推荐源才完整,补绑一次
1985
1977
  }
1986
1978
 
1987
1979
  /* 手工添加模型:有的厂商列表接口调不通(或只返回部分),直接填模型名也能进列表。
@@ -2010,7 +2002,6 @@ async function addModelManual(pid) {
2010
2002
  toast(t("已添加模型 ") + name);
2011
2003
  S.modelsSig = null; // 新模型落盘后签名必变,这里主动置空确保立刻重绘
2012
2004
  poll();
2013
- setTimeout(autoRebindSoon, 4000); // 模型可用后推荐源更完整,补绑一次
2014
2005
  } catch (e) { toast(t("添加失败:") + e.message, true); }
2015
2006
  }
2016
2007
 
@@ -2023,7 +2014,6 @@ async function refreshProviderModels(id) {
2023
2014
  else toast(t("已获取模型列表 · wire 适配测试后台进行中"));
2024
2015
  } catch (e) { toast(t("获取失败:") + e.message, true); }
2025
2016
  poll();
2026
- setTimeout(autoRebindSoon, 4000); // 模型列表落盘后补绑一次(后台 wire 探测同步进行)
2027
2017
  }
2028
2018
 
2029
2019
  /* 供应商编辑卡:协议可选「自动」;显式选定时它是主协议(用于需要唯一协议的
@@ -2079,7 +2069,6 @@ async function delProvider(id) {
2079
2069
  try {
2080
2070
  await api("/api/models/provider-op", { method: "POST",
2081
2071
  body: JSON.stringify({ ids: [id], op: "delete" }) });
2082
- autoRebindSoon();
2083
2072
  } catch (e) { toast(t("操作失败:") + e.message, true); }
2084
2073
  if (S.selModels) delete S.selModels[id];
2085
2074
  if (S.selProvs) delete S.selProvs[id];
@@ -2143,7 +2132,6 @@ async function doAddProvider() {
2143
2132
  S.modelsSig = null;
2144
2133
  closeModal();
2145
2134
  poll();
2146
- autoRebindSoon(); // 新增即启用:马上给空链/死链一次推荐机会
2147
2135
  if (p && body.api_key) refreshProviderModels(p.id); // 有密钥才自动拉模型列表
2148
2136
  } catch (e) {
2149
2137
  res.textContent = t("保存失败:") + e.message;
@@ -2239,7 +2227,7 @@ async function doImport() {
2239
2227
  btn.textContent = t("完成");
2240
2228
  btn.disabled = false;
2241
2229
  btn.onclick = closeModal;
2242
- if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); autoRebindSoon(); }
2230
+ if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); }
2243
2231
  } catch (e) {
2244
2232
  $("import-result").innerHTML = '<div class="msg bad">' + t("导入失败:") + esc(e.message) + "</div>";
2245
2233
  btn.disabled = false; btn.textContent = t("导入选中");
@@ -3358,17 +3346,22 @@ function renderRunList() {
3358
3346
  "</div>";
3359
3347
  box.innerHTML = tools + runs.map((r) => {
3360
3348
  const can = runDeletable(r);
3349
+ // 行结构照参考站的「主副标题 + 右侧状态与相对时间」:标题与摘要竖排成一块,
3350
+ // 状态点用侧栏同款字形(staskGlyph),时间用相对档(完整时间戳挪进悬停提示)
3351
+ const sub = String(r.summary || r.error || (r.steps ? r.steps.length + t(" 个步骤") : "")).trim();
3361
3352
  return '<div class="item" onclick="openRun(\'' + esc(r.id) + '\')">' +
3362
3353
  '<div class="t">' +
3363
3354
  '<input type="checkbox" class="rcheck"' + (S.selRuns[r.id] ? " checked" : "") + (can ? "" : " disabled") +
3364
3355
  ' title="' + (can ? t("勾选以批量删除") : t("运行中的记录不可删除,请先取消")) + '"' +
3365
3356
  ' onclick="event.stopPropagation()" onchange="toggleRunSel(\'' + esc(r.id) + '\', this.checked)">' +
3366
- runKindTag(r.kind) +
3367
- '<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>" +
3368
3362
  '<span class="chip ' + esc(r.status || "") + '">' + esc(runStatusText(r)) + "</span>" +
3369
- '<span class="time">' + esc(r.created_at) + "</span>" +
3370
- '<button class="danger small" title="' + t("删除该记录") + '" onclick="event.stopPropagation(); deleteRun(\'' + esc(r.id) + '\')">' + t("删除") + '</button></div>' +
3371
- '<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>';
3372
3365
  }).join("");
3373
3366
  }
3374
3367
 
@@ -3655,8 +3648,7 @@ function showDetailInMain() {
3655
3648
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-runs"));
3656
3649
  document.querySelectorAll(".set-item").forEach((b) => b.classList.remove("active"));
3657
3650
  document.body.classList.remove("settings-mode");
3658
- const title = $("page-title");
3659
- if (title) title.textContent = t("运行详情");
3651
+ renderPageCrumb();
3660
3652
  collapseDrawerIfMobile();
3661
3653
  syncInspectorVis(); // 从设置子页点进运行详情:任务上下文,检查器跟着回来
3662
3654
  }
@@ -3923,6 +3915,7 @@ function drawTaskDetail(key, runs) {
3923
3915
  };
3924
3916
  drawReport();
3925
3917
  loadArtifacts(latest.id); // 成品文件双入口同源:主栏「成果」分区 + 检查器(列表上下文)
3918
+ renderPreview(latest.id); // 网页成品预览:入口页 + 可运行 iframe(无成品则整块隐藏)
3926
3919
  // 任务级详情:git 面板吃任务级 side(实时/最新快照),side 未到先以最新 run 快照落位
3927
3920
  const tk = ((S.state || {}).tasks || []).find((x) => x.id === key);
3928
3921
  S.lastRunTask = tk || null;
@@ -3996,6 +3989,7 @@ async function clearRuns() {
3996
3989
  async function openRun(id, pinTab) {
3997
3990
  S.detailRunId = id;
3998
3991
  S.detailTaskKey = null;
3992
+ renderPageCrumb(); // 标题「运行详情」;必须在赋值后画——showDetailInMain 先于本函数跑,那边画不到
3999
3993
  rdTabReset(pinTab || null); // sideOpenRun 带步骤号时钉住步骤分区
4000
3994
  document.querySelector("#sub-runs .panel:first-child").classList.add("hidden");
4001
3995
  $("run-detail").classList.remove("hidden");
@@ -4018,16 +4012,19 @@ function closeRun() {
4018
4012
  if (document.body.classList.contains("settings-mode") || S.mainPage === "runs") {
4019
4013
  // 回运行列表:设置导航里点进来的,或主栏本就停在运行记录页(快捷导航/概览的入口)
4020
4014
  document.querySelector("#sub-runs .panel:first-child").classList.remove("hidden");
4015
+ renderPageCrumb(); // 详情关了,标题从「运行详情」回到当前页名
4021
4016
  } else if (S.mainPage) {
4022
4017
  // 主栏停在别的快捷导航页(概览/自动化):返回点开详情前那一页,左栏任务树不动
4023
4018
  switchTab(S.mainPage, "main");
4024
4019
  } else {
4025
- // 从主视图(侧栏点任务行)进来的详情:返回直接回任务页,不露出运行列表
4020
+ // 从主视图(侧栏点任务行)进来的详情:返回直接回任务页,不露出运行列表。
4021
+ // S.tab 还停在 "runs"(showDetailInMain 设的),视图既然回到任务页就一并归位,
4022
+ // 否则标题会拿 "runs" 的页名显示成「运行记录」
4023
+ S.tab = "tasks";
4026
4024
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
4027
4025
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
4028
4026
  document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
4029
- const title = $("page-title");
4030
- if (title) title.textContent = tabTitle("tasks");
4027
+ renderPageCrumb();
4031
4028
  }
4032
4029
  syncInspectorVis(); // 离开详情:回设置运行列表时检查器按各上下文规则重新落位
4033
4030
  }
@@ -4064,13 +4061,14 @@ function rdTabAvail() {
4064
4061
  git: !$("rd-git").classList.contains("hidden"),
4065
4062
  bible: !$("rd-bible").classList.contains("hidden"),
4066
4063
  bookmeta: !$("rd-bookmeta").classList.contains("hidden"),
4064
+ preview: !$("rd-preview").classList.contains("hidden"),
4067
4065
  };
4068
4066
  }
4069
4067
 
4070
4068
  function applyRdTabs() {
4071
4069
  const avail = rdTabAvail();
4072
4070
  if (!S.rdTab || !avail[S.rdTab]) {
4073
- S.rdTab = ["chat", "hive", "steps", "result", "git", "bible", "bookmeta"]
4071
+ S.rdTab = ["chat", "hive", "steps", "result", "preview", "git", "bible", "bookmeta"]
4074
4072
  .find((k) => avail[k]) || "steps";
4075
4073
  }
4076
4074
  document.querySelectorAll("#rd-tabs .rd-tab").forEach((b) => {
@@ -4110,10 +4108,10 @@ function renderChatNav() {
4110
4108
  if (mainEl) mainEl.classList.toggle("chat-fill", direct);
4111
4109
  if (!direct) { nav.classList.add("hidden"); nav.innerHTML = ""; return; }
4112
4110
  const labels = { hive: t("蜂巢"), steps: t("步骤"), result: t("成果"),
4113
- git: "Git", bible: t("圣经"), bookmeta: t("作品信息") };
4111
+ preview: t("预览"), git: "Git", bible: t("圣经"), bookmeta: t("作品信息") };
4114
4112
  const avail = rdTabAvail();
4115
4113
  let pills = "";
4116
- for (const k of ["hive", "steps", "result", "git", "bible", "bookmeta"]) {
4114
+ for (const k of ["preview", "hive", "steps", "result", "git", "bible", "bookmeta"]) {
4117
4115
  if (!avail[k]) continue;
4118
4116
  // 徽章直接镜像隐藏页签上的(蜂巢在岗数/步骤数/待裁决/成果数),不另设状态源
4119
4117
  const badge = document.querySelector('#rd-tabs .rd-tab[data-tab="' + k + '"] .rd-badge');
@@ -4155,16 +4153,18 @@ function rdTabBadges() {
4155
4153
  set("git", c.gitState === "isolated" ? t("待裁决") : "", "verdict");
4156
4154
  }
4157
4155
 
4158
- /* ctx 可省略:省略时只刷新可用性/徽章/pane(renderHive/renderGitPanel 等收尾调用)。
4159
- * 带 ctx 时先做自动选卡判断,再落徽章。 */
4156
+ /* ctx 可省略:省略时沿用上次的 _rdCtx 做自动选卡判断(renderHive/renderGitPanel/
4157
+ * renderPreview 等收尾调用——预览可用性是异步翻过来的,翻过来这次必须重选卡)。 */
4160
4158
  function rdTabsSync(ctx) {
4161
4159
  if (ctx) S._rdCtx = ctx;
4162
- if (ctx && !S.rdTabPin) {
4163
- // 对话页签可用性入签名:刚建的任务要等 state 刷进来 direct 才判定成立,
4164
- // chat 从不可用变可用时必须触发一次重新选卡(否则落在步骤/蜂巢不跳对话)
4160
+ const c = ctx || S._rdCtx || {};
4161
+ if (!S.rdTabPin) {
4165
4162
  const chatAvail = $("rd-chat") && !$("rd-chat").classList.contains("hidden");
4166
- const sig = (S.detailTaskKey || S.detailRunId || "") + "|" + (ctx.status || "") +
4167
- "|" + (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);
4168
4168
  if (S.rdTabSig !== sig) {
4169
4169
  S.rdTabSig = sig;
4170
4170
  const avail = rdTabAvail();
@@ -4172,13 +4172,17 @@ function rdTabsSync(ctx) {
4172
4172
  // 直连任务的主问题是「继续聊什么、上一轮回答是什么」——默认把对话
4173
4173
  // 放在第一视线;编排/代码任务才默认落蜂巢,先看阶段与在岗步骤。
4174
4174
  // 没跑到终态时成果分区是空的,避免打开详情先看到白板。
4175
- const finishing = !ctx.running && ["done", "failed", "cancelled", "timeout"].includes(ctx.status);
4175
+ const finishing = !c.running && ["done", "failed", "cancelled", "timeout"].includes(c.status);
4176
4176
  S.rdTab = (direct && avail.chat ? "chat" : null)
4177
+ // 跑出了能打开的网页成品:第一视线给「预览」——这正是用户做完一个
4178
+ // 前端任务最想看到的(借鉴对话式编程产品:跑完先看东西跑起来)。
4179
+ // 直连任务的对话、待裁决的版本仍排在它前面:那两个是"要用户说话/动手"。
4180
+ || (finishing && avail.preview ? "preview" : null)
4177
4181
  || (avail.hive ? "hive" : null)
4178
4182
  || (avail.chat ? "chat" : null)
4179
- || (ctx.running ? (avail.hive ? "hive" : "steps")
4180
- : (ctx.gitState === "isolated" && avail.git ? "git"
4181
- : (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")));
4182
4186
  }
4183
4187
  }
4184
4188
  applyRdTabs();
@@ -4203,6 +4207,7 @@ function rdTabReset(pin) {
4203
4207
  S.rdTabSig = "";
4204
4208
  S.rdTabPin = !!pin;
4205
4209
  S._rdCtx = {};
4210
+ pvReset(); // 预览也随详情目标清空:别家的 iframe/页签绝不带到新详情
4206
4211
  syncChatLogSpace(false);
4207
4212
  setRdMetaOpen(false);
4208
4213
  }
@@ -4297,6 +4302,9 @@ async function renderRunDetail() {
4297
4302
  : esc(t("本次运行没有生成报告") + (st ? t("(状态:") + st + t(")") : "") + t(";各步骤日志在「步骤」页签"))) +
4298
4303
  "</div>";
4299
4304
  }
4305
+ // 网页成品预览:与成果同源双入口都走这里;无成功报告时也要重新判定可用性
4306
+ // (换到没有网页成品的 run 必须把「预览」页签收起来,别留着上一家的 iframe)
4307
+ renderPreview(id);
4300
4308
  // 成品文件双入口同源:主栏「成果」分区 + 检查器成品 TAB(列表上下文),
4301
4309
  // 详情上下文检查器让位后主栏是唯一可见面
4302
4310
  S.lastRunTask = rcTask || null;
@@ -5890,6 +5898,131 @@ async function loadArtifacts(runId) {
5890
5898
  mainBox.innerHTML = head + '<div class="file-chips">' + chips + "</div>"; }
5891
5899
  }
5892
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
+
5893
6026
  window.copyText = function (t) {
5894
6027
  // writeText 返回 promise,无头/非安全上下文会拒绝——必须挂 catch,否则
5895
6028
  // 未处理的 promise 拒绝会以控制台错误冒出来(测试断言 0 错误会被它打爆)
@@ -6173,7 +6306,11 @@ async function toggleLog(runId, rel) {
6173
6306
  (S.detailTaskKey && S.lastRun && S.lastRun.task_id === S.detailTaskKey) ||
6174
6307
  ((S.inspData || {}).run || {}).id === runId;
6175
6308
  if (!owned) return;
6176
- 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("(无输出)"));
6177
6314
  box.classList.remove("hidden");
6178
6315
  syncChatLogSpace(true);
6179
6316
  currentLog = { runId, rel };
@@ -6192,7 +6329,9 @@ async function toggleLog(runId, rel) {
6192
6329
  if (currentLog && currentLog.runId === runId && currentLog.rel === rel) {
6193
6330
  // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰
6194
6331
  const stick = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 48;
6195
- pre.textContent = rr.log || t("(等待输出…)");
6332
+ // 最后一帧常在步骤翻终态后才拉到:此刻仍无输出就该换「无输出」
6333
+ pre.textContent = rr.log ||
6334
+ (rr.step_status === "running" ? t("(等待输出…)") : t("(无输出)"));
6196
6335
  if (stick) pre.scrollTop = pre.scrollHeight;
6197
6336
  logLiveBadge(true); // 每帧都打时间戳:内容没变也能证明通道活着
6198
6337
  if (rr.step_status && rr.step_status !== "running") {
@@ -6678,6 +6817,9 @@ function chatCleanText(raw) {
6678
6817
  .trim();
6679
6818
  }
6680
6819
  let chatSig = "";
6820
+ let chatLiveSig = ""; // 时间线实时增量签名(思考/正文长度变才重建 DOM)
6821
+ let chatLiveTimer = null; // 运行中快轮询句柄(1.2s,见 scheduleChatLive)
6822
+ let chatLiveRunId = null;
6681
6823
 
6682
6824
  function chatEngineIsDirect(run) {
6683
6825
  const st = S.state || {};
@@ -6687,6 +6829,54 @@ function chatEngineIsDirect(run) {
6687
6829
  (!task && run && (run.engine === "direct" || run.type === "direct")));
6688
6830
  }
6689
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
+
6690
6880
  /* 对话正文:``` 围栏渲染成真代码块(复用 codeBlockHTML:行号+高亮+代码主题),
6691
6881
  * 其余文本保持 pre-wrap 原样(行内 `code` 与 **加粗** 轻量翻译)。模型输出先
6692
6882
  * 全量 esc 再插标记;未闭合围栏按代码块收尾,流式输出中途也不撒裸反引号。 */
@@ -6727,7 +6917,7 @@ async function renderChat(run, active) {
6727
6917
  box.classList.remove("hidden");
6728
6918
  const continueNote = $("rd-chat-continue-note");
6729
6919
  if (continueNote) continueNote.classList.toggle("hidden", !!active);
6730
- if (chatRunId !== run.id) { chatRunId = run.id; chatAtts = []; drawChatAtts(); chatSig = ""; }
6920
+ if (chatRunId !== run.id) { chatRunId = run.id; chatAtts = []; drawChatAtts(); chatSig = ""; chatLiveSig = ""; }
6731
6921
  // 三件套回填:任务对象来自 S.state(渲染时已就位);只在任务未在跑时回显,
6732
6922
  // 防止轮询把用户正在改的值弹回(select change 重绘弹回教训)
6733
6923
  if (run.status !== "running" && run.status !== "queued" && S.state) {
@@ -6742,12 +6932,29 @@ async function renderChat(run, active) {
6742
6932
  let data = null;
6743
6933
  try { data = await api("/api/runs/" + encodeURIComponent(run.id) + "/timeline"); }
6744
6934
  catch (e) { data = null; }
6745
- const items = (data && data.items) || [];
6746
6935
  if (!$("run-detail") || $("run-detail").classList.contains("hidden")) return;
6747
6936
  // 过期闸门:run 级(renderRunDetail)与任务级(drawTaskDetail)都经 S.lastRun
6748
6937
  // 指向当前详情正在展示的那条 run——拉取期间切了详情就丢弃响应
6749
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) || [];
6750
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;
6751
6958
  // 时间显示统一收敛到 HH:MM(日期在 meta 条里,全量戳塞气泡就是噪音);
6752
6959
  // 附件兼容 字符串路径 / {name|path} 对象 两种形态(对象直接拼会成 [object Object])
6753
6960
  const chatTime = (v) => { const s = String(v || "");
@@ -6798,11 +7005,14 @@ async function renderChat(run, active) {
6798
7005
  // 开场「本轮做了什么:…」+「回复:」是模型复读协议措辞的元描述(提示词已禁,
6799
7006
  // 这里兜历史数据),只剥消息开头的第一行
6800
7007
  const body = chatCleanText(it.text);
6801
- // 运行中还没有正文:三点打字动画(终态无正文才落「无文本输出」占位)
7008
+ // 运行中还没有正文:先给思考过程块(有思维链/工具活动时),正文位退化为
7009
+ // 实时预览(stream 累计值);两者皆无才是三点打字动画(终态无正文才落占位)
7010
+ const liveText = it.status === "running" ? String(it.stream || "").trim() : "";
6802
7011
  const bodyHtml = body ? chatBodyHTML(body)
7012
+ : (liveText ? '<div class="chat-body chat-live-text">' + esc(liveText.slice(-4000)) + "</div>"
6803
7013
  : (it.status === "running"
6804
7014
  ? '<span class="chat-typing" aria-label="' + esc(t("正在执行…")) + '"><i></i><i></i><i></i></span>'
6805
- : esc(t("(本轮无文本输出)")));
7015
+ : esc(t("(本轮无文本输出)"))));
6806
7016
  // 元信息只留 名字+时间(+失败标记):路由/供应商细节属于「步骤」页签,塞这里就是噪音
6807
7017
  const metaBad = it.status === "failed" || it.status === "cancelled";
6808
7018
  // 智能体回复:头像 + 整幅正文(不套气泡框),元信息小字落在正文下方;
@@ -6813,6 +7023,7 @@ async function renderChat(run, active) {
6813
7023
  return '<div class="chat-row">' +
6814
7024
  '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
6815
7025
  '<div class="chat-bubble agent">' +
7026
+ chatThinkHTML(it) +
6816
7027
  '<div class="chat-body">' + bodyHtml + "</div>" +
6817
7028
  '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
6818
7029
  (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + actsA + "</div>" +
@@ -6834,8 +7045,14 @@ async function renderChat(run, active) {
6834
7045
  if (hint) hint.textContent = active
6835
7046
  ? t("运行中:新消息会排队,本轮回答完后依次送达")
6836
7047
  : t("已结束:发送后将自动开新一轮接着做");
7048
+ // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰
7049
+ const stick = flow.scrollHeight - flow.scrollTop - flow.clientHeight < 80;
6837
7050
  flow.innerHTML = html || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
6838
- 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();
6839
7056
  }
6840
7057
 
6841
7058
  /* 执行结果卡(时间线收尾):run 终态后的确定性摘要——成没成、跑多久、谁执行
@@ -7932,43 +8149,9 @@ function autoBindAll() {
7932
8149
  }
7933
8150
  }
7934
8151
 
7935
- /* 厂商/模型停用·启用·删除后自动修复显式绑定:
7936
- * 空链保持自动调度,不写入绑定;已有链失效时才直接落盘替代链。
7937
- * 没有合适的推荐就保持原样,什么都不绑。绑定页上用户手改中的草稿(dirty)
7938
- * 不碰;一处都没改成静默返回,不打扰停用/启用的操作反馈。 */
7939
- let _autoRebindRunning = false, _autoRebindAgain = false;
7940
- async function autoRebindSoon() {
7941
- if (_autoRebindRunning) { _autoRebindAgain = true; return; }
7942
- _autoRebindRunning = true;
7943
- try {
7944
- await poll(); // 拿停用/启用落盘后的最新 providers / bindings / catalog
7945
- let fixed = 0;
7946
- for (const c of (S.catalog || []).filter((x) => x.installed && x.orch_kind)) {
7947
- const st = bindSelById(c.id);
7948
- if (st.dirty) continue; // 用户手改中,不覆盖草稿
7949
- const act = bindRepairAction(c, st, false);
7950
- if (!act) continue;
7951
- const b = (S.bindings || {})[c.id] || {};
7952
- try {
7953
- await api("/api/models/binding", { method: "POST", busy: false, body: JSON.stringify({
7954
- agent_id: c.id, provider_id: act[0].p,
7955
- chain: act.map((x) => ({ provider_id: x.p, model: x.m })),
7956
- difficulty_routing: !!b.difficulty_routing }) });
7957
- st.chain = act; st.dirty = false; st.key = chainKey(act);
7958
- fixed++;
7959
- } catch (e) { /* 单条失败不打断,等下次变更再补 */ }
7960
- }
7961
- if (fixed) {
7962
- S.catSig = null; S.bindSig = null;
7963
- render();
7964
- toast(t("厂商/模型变动,已自动重绑 %1 处。").replace("%1", fixed));
7965
- }
7966
- } catch (e) { /* 自动补绑失败静默:不打断用户的停用/启用操作 */ }
7967
- finally {
7968
- _autoRebindRunning = false;
7969
- if (_autoRebindAgain) { _autoRebindAgain = false; autoRebindSoon(); }
7970
- }
7971
- }
8152
+ /* 厂商/模型停用·删除后不再自动改写绑定链(2026-09-22 用户拍板:不要去动
8153
+ * 模型调度)——后端在停用/删除时已把死条目从链里剔除(modelhub._prune_binding_chains),
8154
+ * 空链回落 CLI 默认;要补推荐用「一键推荐绑定」手动来。 */
7972
8155
 
7973
8156
  async function saveAllBindings() {
7974
8157
  const targets = (S.catalog || []).filter((c) => c.installed && c.orch_kind);
@@ -9142,7 +9325,7 @@ async function loadZentao() {
9142
9325
  tg("zt-autoresolve", cfg.auto_resolve);
9143
9326
  tg("zt-automerge", cfg.auto_merge);
9144
9327
  tg("zt-poll", cfg.poll_enabled);
9145
- set("zt-interval", cfg.interval_hours || 2);
9328
+ set("zt-interval", cfg.interval_minutes || 5);
9146
9329
  S.ztProfiles = JSON.parse(JSON.stringify(cfg.product_profiles || []));
9147
9330
  renderZentaoProfiles();
9148
9331
  renderZentaoStatus();
@@ -9513,6 +9696,13 @@ function zentaoTriText(c) {
9513
9696
  return t("排查:") + t(NAME[tri.side] || tri.side) + (by ? "(" + by + ")" : "");
9514
9697
  }
9515
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
+
9516
9706
  function renderZentaoStatus() {
9517
9707
  const box = $("zentao-status");
9518
9708
  if (!box) return;
@@ -9521,7 +9711,7 @@ function renderZentaoStatus() {
9521
9711
  const cfg = v.config || {};
9522
9712
  const parts = [];
9523
9713
  parts.push(cfg.poll_enabled
9524
- ? (t("定时扫描已开启,每 ") + (cfg.interval_hours || 2) + t(" 小时一次")
9714
+ ? (t("定时扫描已开启,每 ") + ztIntervalText(cfg.interval_minutes)
9525
9715
  + (v.next_scan ? t(",下次 ") + esc(v.next_scan) : ""))
9526
9716
  : t("定时扫描未开启(仍可手动「立即扫描」)"));
9527
9717
  if (v.last_scan) parts.push(t("上次扫描 ") + esc(v.last_scan));
@@ -9565,7 +9755,7 @@ function ztFormPayload() {
9565
9755
  auto_resolve: $("zt-autoresolve").checked,
9566
9756
  auto_merge: $("zt-automerge").checked,
9567
9757
  poll_enabled: $("zt-poll").checked,
9568
- interval_hours: parseInt($("zt-interval").value, 10) || 2,
9758
+ interval_minutes: Math.max(5, parseInt($("zt-interval").value, 10) || 5),
9569
9759
  };
9570
9760
  const pw = ($("zt-password").value || "").trim();
9571
9761
  if (pw) payload.password = pw;
@@ -9985,6 +10175,8 @@ async function loadSettings() {
9985
10175
  if (tel && S.settings) tel.checked = S.settings.telemetry_errors !== false;
9986
10176
  const pet = $("set-pet");
9987
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;
9988
10180
  syncPetModeSeg();
9989
10181
  syncPetSkinSeg();
9990
10182
  const wd = $("set-workdir"), hint = $("set-workdir-hint");
@@ -10169,7 +10361,8 @@ async function saveSettings() {
10169
10361
  const msg = $("settings-msg");
10170
10362
  try {
10171
10363
  const r = await api("/api/settings", { method: "POST", body: JSON.stringify({
10172
- 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 }) });
10173
10366
  S.settings = r.settings;
10174
10367
  if (msg) { msg.className = "msg ok"; msg.textContent = t("已保存:最大并发 ") + r.workers + t(" 个任务"); }
10175
10368
  } catch (e) {
@@ -10278,6 +10471,9 @@ function helpChapterBody(id) {
10278
10471
  '<p class="help-p">' + t("新建任务选「连载写作」类型,目标里写清书名、题材和计划篇幅(如「都市异能,先写 20 章」)。目标写得太笼统时,蜂群会先反问几个澄清问题再开工。开书前会先准备作品资料:书简介与分类标签可一键生成,按平台官方选项实抓,不用自己去查规则。") + '</p>' +
10279
10472
  '<h3 class="help-h3">' + t("每章怎么跑") + '</h3>' +
10280
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>' +
10281
10477
  '<h3 class="help-h3">' + t("中断了怎么办") + '</h3>' +
10282
10478
  '<p class="help-p">' + t("任务随时可续跑:已写章节都落了盘,续跑接着往下写,不会从头再来。单章失败会自动退避重试,任务状态里会显示「将于 HH:MM 自动续跑」,不用盯着等。") + '</p>' +
10283
10479
  '<h3 class="help-h3">' + t("递话与经验") + '</h3>' +
@@ -10715,6 +10911,29 @@ function tabTitle(name) {
10715
10911
  return t(TAB_TITLES[name]) || t("设置");
10716
10912
  }
10717
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
+
10718
10937
  /* 左栏两套导航各管哪些页,一律从 index.html 现读——HTML 改了 JS 自动跟上,
10719
10938
  * 不再各存一份常量、两处漂移:
10720
10939
  * .side-quick 快捷导航(概览/运行记录/自动化)→ main 形态:只换主栏内容,左栏任务树不动
@@ -10895,9 +11114,8 @@ function setLangBtn(lang) {
10895
11114
  paintHelpChrome();
10896
11115
  renderHelp();
10897
11116
  }
10898
- // 重画还在缓存里的页面标题
10899
- const title = $("page-title");
10900
- if (title && S.tab) title.textContent = tabTitle(S.tab);
11117
+ // 重画还在缓存里的页面标题(语言切换后组名也要跟着换)
11118
+ renderPageCrumb();
10901
11119
  // 同步顶栏的连接状态文案(applyI18n 不会处理 textContent 写入的)
10902
11120
  const cn = $("conn");
10903
11121
  if (cn) {
@@ -11556,11 +11774,79 @@ function ovRenderRecent() {
11556
11774
  }
11557
11775
 
11558
11776
  /* 运行状态经 SSE 推来:停在概览页时只重画依赖 runs 的两处(概览语 + 最近运行),
11559
- * 不重新拉用量(台账不会因为一次运行状态变化就变) */
11777
+ * 不重新拉用量(台账不会因为一次运行状态变化就变)。
11778
+ * 侧栏常驻件(用量条 + 计数徽章)不跟着 S.tab 走,每次状态推送都要顺手刷新。 */
11560
11779
  function ovMaybeRefresh() {
11561
- if (S.tab !== "overview") return;
11562
- ovRenderHead();
11563
- 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(" 个任务") : "";
11564
11850
  }
11565
11851
 
11566
11852
  /* ---------------------------------------------------------- 手机连接(扫码) */
@@ -11838,8 +12124,7 @@ function switchTab(name, shell) {
11838
12124
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", !inMain && b.dataset.sub === name));
11839
12125
  document.querySelectorAll(".qitem").forEach((b) => b.classList.toggle("active", inMain && b.dataset.page === name));
11840
12126
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-" + name));
11841
- const title = $("page-title");
11842
- if (title) title.textContent = tabTitle(name);
12127
+ renderPageCrumb(); // 页名 + 面包屑组名一起刷新(组名从左栏导航现读)
11843
12128
  if (name === "runs" && !S.detailRunId) closeRun();
11844
12129
  if (name === "agents") autoCheckUpdates(); // 进目录页自动查各 CLI 新版本
11845
12130
  if (name === "orch") { loadOrchestrator(); loadSettings(); } // 进编排设置页拉取配置
@@ -11873,8 +12158,7 @@ function exitSettings() {
11873
12158
  document.body.classList.remove("settings-mode");
11874
12159
  document.body.classList.remove("files-mode");
11875
12160
  cmpGreeting(); // 回到任务页:问候语刷新
11876
- const title = $("page-title");
11877
- if (title) title.textContent = tabTitle("tasks");
12161
+ renderPageCrumb();
11878
12162
  collapseDrawerIfMobile();
11879
12163
  syncInspectorVis(); // 回到新建表单:不是任务上下文,检查器收起(点运行详情才会滑回)
11880
12164
  }
@@ -11912,6 +12196,7 @@ window.mgmt = mgmt;
11912
12196
  window.switchTab = switchTab;
11913
12197
  window.setUsageDays = setUsageDays;
11914
12198
  window.setOvDays = setOvDays;
12199
+ window.renderPageCrumb = renderPageCrumb; // i18n.js 换语言后补画标题用
11915
12200
  window.jumpToRun = jumpToRun;
11916
12201
  window.saveProvider = saveProvider;
11917
12202
  window.delProvider = delProvider;
@@ -12026,6 +12311,14 @@ document.addEventListener("DOMContentLoaded", () => {
12026
12311
  };
12027
12312
  bindArtifactClicks($("rd-arts"));
12028
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());
12029
12322
  // 动态详情区统一用 data 属性委托,日志路径/文件名不再拼进 inline JS。
12030
12323
  const detailOverview = $("rd-overview");
12031
12324
  if (detailOverview) detailOverview.addEventListener("click", (e) => {
@@ -12179,6 +12472,8 @@ document.addEventListener("DOMContentLoaded", () => {
12179
12472
  $("btn-notify-toggle").addEventListener("click", toggleNotifySound);
12180
12473
  // 命令面板:侧栏搜索行 / Ctrl+K 唤起(函数若尚未落地,跳过而不炸整个初始化)
12181
12474
  if (typeof bindCmdK === "function") bindCmdK();
12475
+ // 侧栏常驻用量条:首屏先取一次台账(之后由 applyState 的节流刷新接手)
12476
+ sideUsageRefresh();
12182
12477
  // 文件夹全部展开/收起(写回 localStorage,与手点单个 folder 同一套持久化)
12183
12478
  $("btn-side-expand").addEventListener("click", () => {
12184
12479
  const dlist = Array.from($("side-tasks").querySelectorAll("details.sdir"));