codebee 0.1.15 → 0.1.16

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
@@ -412,7 +412,7 @@ function runStatusText(run) {
412
412
  if (st === "queued" && run && run.resume_enqueue_at) {
413
413
  const at = Date.parse(String(run.resume_enqueue_at).replace(" ", "T"));
414
414
  if (!isNaN(at) && Date.now() < at)
415
- return t("将于 ") + String(run.resume_enqueue_at).slice(11, 16) + t(" 自动续跑");
415
+ return t("将于 {0} 自动续跑", String(run.resume_enqueue_at).slice(11, 16));
416
416
  }
417
417
  return { queued: t("排队中"), running: t("运行中"), done: t("完成"),
418
418
  failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") }[st] || st;
@@ -822,6 +822,11 @@ function renderBindings() {
822
822
  if (sig === S.bindSig) return;
823
823
  S.bindSig = sig;
824
824
  syncSaveAllBtn();
825
+ if (!targets.length) {
826
+ bbox.innerHTML = '<div class="empty">' + t("还没有装好的 CLI 智能体(codex / claude / kimi 等)。") +
827
+ '<a href="#" onclick="return helpOpenTopic(\'quickstart\')">' + t("看看快速上手") + '</a></div>';
828
+ return;
829
+ }
825
830
  bbox.innerHTML = targets.map((c) => {
826
831
  const b = (S.bindings || {})[c.id] || {};
827
832
  const opts = '<option value="">' + t("不绑定(用 CLI 自身的凭据与配置)") + '</option>' + bindable.map((p) => {
@@ -900,6 +905,7 @@ function renderProvList() {
900
905
  '<div class="pi-body"><div class="pi-top"><div class="pi-name">' + esc(p.name) + "</div>" +
901
906
  '<span class="pi-n">' + st + "</span></div>" +
902
907
  '<div class="pi-meta"><span class="tag">' + esc(protoLabel(p)) + "</span>" +
908
+ (p.protocol === "auto" ? "" : wireCapTags(p, p.protocol)) +
903
909
  ((p.keys || []).length > 1 ? '<span class="tag">' + t("%1 把密钥").replace("%1", (p.keys || []).length) + "</span>" : "") +
904
910
  (off ? '<span class="tag">' + t("已停用") + "</span>" : "") +
905
911
  "</div></div></div>";
@@ -970,17 +976,18 @@ function renderProvDetail() {
970
976
  const p = (S.providers || []).find((x) => x.id === S.selProv);
971
977
  if (!p) {
972
978
  box.innerHTML = '<div class="empty">' + t("左侧选择供应商;还没有供应商时点左上角「导入」,") +
973
- t("可从 CCSwitch / Codex / Claude Code / ZCode / Qwen / Gemini / OpenCode / Continue / Cursor / Trae 扫描带入。") + '</div>';
979
+ t("可从 CCSwitch / Codex / Claude Code / ZCode / Qwen / Gemini / OpenCode / Continue / Cursor / Trae 扫描带入。") +
980
+ '<br><a href="#" onclick="return helpOpenTopic(\'models\')">' + t("还没配过模型?看「模型接入与绑定」帮助") + '</a></div>';
974
981
  return;
975
982
  }
976
983
  const tp = (S.testProvState || {})[p.id];
977
984
  const tpHtml = tp ? (tp.ok
978
- ? '<span class="badge ok">' + t("✓ 连通 ") + tp.latency_ms + "ms · " + tp.count + t(" 个模型") + "</span>"
985
+ ? '<span class="badge ok"' + (tp.note ? ' title="' + esc(tp.note) + '"' : "") + '>' +
986
+ t("✓ 连通 ") + tp.latency_ms + "ms" +
987
+ (tp.note ? " · " + t("无模型列表接口") : " · " + tp.count + t(" 个模型")) + "</span>"
979
988
  : '<span class="badge bad" title="' + esc(tp.error || "") + '">✗ ' + esc((tp.error || t("失败")).slice(0, 60)) + "</span>") : "";
980
- // 适配测试通过的 wire(同密钥实测可注入的另一条协议面)
981
- const capTags = Object.entries(p.wire_caps || {}).map(([pr, cap]) =>
982
- '<span class="tag ok" title="' + esc(t("适配测试通过:同密钥实测支持该协议注入") +
983
- (cap.checked_at ? " · " + cap.checked_at : "")) + '">' + esc(pr) + " ✓</span>").join("");
989
+ // 适配测试通过的 wire(同密钥实测可注入的另一条协议面);形态随徽章标出
990
+ const capTags = wireCapTags(p, "");
984
991
  const pb = (S.probeState || {})[p.id];
985
992
  const pbHtml = !pb ? "" : (pb.busy
986
993
  ? '<span class="badge">' + esc(t("适配测试中…")) + "</span>"
@@ -1161,6 +1168,24 @@ async function duplicateProvider(pid) {
1161
1168
  poll();
1162
1169
  }
1163
1170
 
1171
+ /* 多 wire 网关的「另一条协议面」徽章:适配测试实测通过、又不是当前分组的 wire。
1172
+ * 同一份模型列表在两条 wire 上都能注入(解析走适配端点),列表不重复摆两份,
1173
+ * 但得把另一面亮出来——否则页面看上去只有一种协议能用,OpenAI 面永远被忽略。
1174
+ * 形态必须标出:chat 形态除 codex 外都能用(codex 0.154+ 只讲 responses),
1175
+ * 不标的话模型接入页一片 ✓、codex 链上却 ⚠ 协议不匹配,两个页面自相矛盾。 */
1176
+ function wireCapTags(p, excludeProto) {
1177
+ return Object.keys((p || {}).wire_caps || {}).filter((pr) => pr !== excludeProto).map((pr) => {
1178
+ const cap = (p.wire_caps || {})[pr] || {};
1179
+ const chat = (cap.wire_api || "responses") === "chat";
1180
+ const tip = (chat
1181
+ ? t("适配测试实测:该面为 chat 形态——除 codex 外的 openai 系 CLI 可用;codex 只讲 responses,链上注入会被跳过")
1182
+ : t("适配测试实测:同密钥也可走 {0} wire——这组模型同样可注入 {0} 系 CLI(codex/dsh 等),运行时自动走适配端点", pr)) +
1183
+ (cap.checked_at ? " · " + cap.checked_at : "");
1184
+ return '<span class="tag ok" title="' + esc(tip) + '">' +
1185
+ esc(pr) + " ✓" + (chat ? " · chat" : "") + "</span>";
1186
+ }).join("");
1187
+ }
1188
+
1164
1189
  /* 模型分组区 HTML(全量重绘与过滤重绘共用;过滤时暂停拖拽排序) */
1165
1190
  function provModelGroupsHtml(p) {
1166
1191
  const kw = ((S.modelFilter || {})[p.id] || "").trim().toLowerCase();
@@ -1187,6 +1212,7 @@ function provModelGroupsHtml(p) {
1187
1212
  '<label class="pcheck-all"><input type="checkbox"' + (allSel ? " checked" : "") +
1188
1213
  ' onchange="toggleGroupSel(\'' + esc(p.id) + '\', \'' + esc(g) + '\', this.checked)">' + t("全选") + '</label>' +
1189
1214
  esc(g) + t(" 协议 · ") + gm.length + t(" 个") +
1215
+ wireCapTags(p, g) +
1190
1216
  (kw ? t("(过滤中,拖拽排序暂停)") : t("(拖动 ☰ 调序;启用的排最前)")) + "</div>";
1191
1217
  for (const m of gm) html += provModelRow(p.id, m, g);
1192
1218
  html += "</div>";
@@ -1497,6 +1523,7 @@ async function refreshProviderModels(id) {
1497
1523
  try {
1498
1524
  const r = await api("/api/models/refresh", { method: "POST", body: JSON.stringify({ id }) });
1499
1525
  if (!r.ok && r.message) toast(t("获取失败:") + r.message, true);
1526
+ else if (r.note) toast(r.note); // 网关无模型列表接口(404):良性提示,不算失败
1500
1527
  else toast(t("已获取模型列表 · wire 适配测试后台进行中"));
1501
1528
  } catch (e) { toast(t("获取失败:") + e.message, true); }
1502
1529
  poll();
@@ -1803,6 +1830,26 @@ function renderClarify(questions, goal, resetSubmit) {
1803
1830
  ta.value = ta.value.trim() + (ta.value.includes(line) ? "" : (ta.value ? "\n" : "") + line);
1804
1831
  b.classList.add("picked");
1805
1832
  b.disabled = true;
1833
+ // spec 分段签核(借鉴 superpowers「分段短块逐段消化」):全部问题选完后
1834
+ // 显示已确认摘要条 + 「确认开跑」——用户看一眼再动手,不蒙头就创建
1835
+ const allPicked = questions.every((it) =>
1836
+ Array.from(box.querySelectorAll(".cl-opt")).some(
1837
+ (x) => x.dataset.q === it.q && x.classList.contains("picked")));
1838
+ let bar = box.querySelector("#clarify-confirm");
1839
+ if (allPicked && !bar) {
1840
+ bar = document.createElement("div");
1841
+ bar.id = "clarify-confirm";
1842
+ bar.className = "cl-confirm";
1843
+ bar.innerHTML = '<button type="button" class="primary small" id="clarify-go">' +
1844
+ esc(t("已确认,开跑")) + "</button></div>";
1845
+ box.appendChild(bar);
1846
+ bar.querySelector("#clarify-go").addEventListener("click", () => {
1847
+ box.classList.add("hidden");
1848
+ box.innerHTML = "";
1849
+ S.clarifyDone = true;
1850
+ $("btn-create").click();
1851
+ });
1852
+ }
1806
1853
  }));
1807
1854
  function it0safe(q, o) { return q + ":" + o; }
1808
1855
  const skip = $("clarify-skip");
@@ -3266,12 +3313,12 @@ function drawTaskDetail(key, runs) {
3266
3313
  } catch (e) {
3267
3314
  // 拉取失败不再静默留白(手机端「报告空空」的来源之一):给可见提示,
3268
3315
  // 不落缓存,下一轮轮询签名变化时自动重试
3269
- hint(t("报告读取失败(") + ((e || {}).message || t("网络异常")) + t("),稍后自动重试"));
3316
+ hint(t("报告读取失败({0}),稍后自动重试", (e || {}).message || t("网络异常")));
3270
3317
  }
3271
3318
  } else if (latest.status === "running" || latest.status === "queued") {
3272
3319
  hint(t("本次运行进行中:报告结束后在这里生成,实时进度看「步骤」页签"));
3273
3320
  } else {
3274
- const st = { failed: "失败", cancelled: "已取消", timeout: "超时" }[latest.status] || latest.status || "";
3321
+ const st = { failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") }[latest.status] || latest.status || "";
3275
3322
  hint(t("本次运行没有生成报告") + (st ? t("(状态:") + st + t(")") : "") +
3276
3323
  t(";各步骤日志在「步骤」页签"));
3277
3324
  }
@@ -3630,11 +3677,11 @@ async function renderRunDetail() {
3630
3677
  // 拉取失败不再静默留白:给可见提示(下轮轮询重画会重试)
3631
3678
  if (S.detailRunId === id && !S.detailTaskKey)
3632
3679
  $("rd-report").innerHTML = '<div class="hint">' +
3633
- esc(t("报告读取失败(") + ((e || {}).message || t("网络异常")) + t("),稍后自动重试")) + "</div>";
3680
+ esc(t("报告读取失败({0}),稍后自动重试", (e || {}).message || t("网络异常"))) + "</div>";
3634
3681
  }
3635
3682
  loadArtifacts(id);
3636
3683
  } else {
3637
- const st = { failed: "失败", cancelled: "已取消", timeout: "超时" }[run.status] || run.status || "";
3684
+ const st = { failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") }[run.status] || run.status || "";
3638
3685
  $("rd-report").innerHTML = '<div class="hint">' +
3639
3686
  (run.status === "running" || run.status === "queued"
3640
3687
  ? esc(t("本次运行进行中:报告结束后在这里生成,实时进度看「步骤」页签"))
@@ -3854,14 +3901,24 @@ function renderBookMetaPanel(task) {
3854
3901
  pbBlock(task, p.id) +
3855
3902
  "</div>";
3856
3903
  }).join("") + "</div>";
3857
- // 封面卡(covergen,借鉴 oh-story 封面图环节):curl 落盘运行目录,done 后可点开预览
3904
+ // 封面卡(covergen,借鉴 oh-story 封面图环节):curl 落盘运行目录,
3905
+ // done 后卡片内直接嵌缩略图(/api/runs/<id>/cover 专用通道),点开新页看大图
3858
3906
  const cg = task.cover_gen || {};
3859
3907
  const cgSt = cg.status || "";
3860
3908
  let cgBody = "";
3861
3909
  if (cgSt === "running") {
3862
3910
  cgBody = '<div class="bm-empty"><svg class="ico spin" aria-hidden="true"><use href="#i-refresh"/></svg> ' + esc(t("生成中…")) + "</div>";
3911
+ } else if (cgSt === "done" && cg.run_id) {
3912
+ const covUrl = "/api/runs/" + encodeURIComponent(cg.run_id) + "/cover" +
3913
+ (cg.at ? "?ts=" + encodeURIComponent(cg.at) : ""); // at 当缓存戳,重生成后缩略图跟着换
3914
+ cgBody = '<div class="bm-cover-wrap">' +
3915
+ '<a href="' + esc(urlAuth(covUrl)) + '" target="_blank" rel="noopener" title="' + esc(t("点击查看大图")) + '">' +
3916
+ '<img class="bm-cover-img" src="' + esc(urlAuth(covUrl)) + '" alt="cover.png" ' +
3917
+ 'onerror="this.closest(&quot;.bm-cover-wrap&quot;).classList.add(&quot;noimg&quot;)"></a>' +
3918
+ '<span class="bm-cover-fallback">' + esc(t("cover.png 已不在运行目录")) + "</span>" +
3919
+ '<div class="bm-cover-meta">' + esc([cg.model, cg.provider, cg.size].filter(Boolean).join(" · ")) + "</div></div>";
3863
3920
  } else if (cgSt === "done") {
3864
- cgBody = '<div class="bm-empty">' + esc(t("封面已生成(cover.png),在「成果」页签查看")) + "</div>";
3921
+ cgBody = '<div class="bm-empty">' + esc(t("封面已生成,但缺少运行记录归属")) + "</div>";
3865
3922
  } else if (cgSt === "failed") {
3866
3923
  cgBody = '<div class="bm-errhint">' + esc(cg.error || t("生成失败")) + "</div>";
3867
3924
  } else {
@@ -4076,7 +4133,7 @@ window.pbUploadChapter = async function (taskId, platform) {
4076
4133
  };
4077
4134
 
4078
4135
  window.pbSendChapter = async function (taskId, platform, relName) {
4079
- if (!confirm(t("将把《" + relName + "》填进平台章节编辑器,填好后由你人工提交。继续?"))) return;
4136
+ if (!confirm(t("将把《{0}》填进平台章节编辑器,填好后由你人工提交。继续?", relName))) return;
4080
4137
  try {
4081
4138
  await api("/api/publish/task/" + encodeURIComponent(taskId) + "/chapter",
4082
4139
  { method: "POST", body: JSON.stringify({ platform, file: relName }) });
@@ -4091,8 +4148,7 @@ window.pbPublishAll = async function (taskId, platform) {
4091
4148
  const au = (((S.pubAuto && S.pubAuto.books) || [])
4092
4149
  .find((b) => b.platform === platform)) || {};
4093
4150
  if (!au.pending) return;
4094
- if (!confirm(t("将从最靠前的待发章节开始填稿(共 " + au.pending +
4095
- " 章待发)。本轮只填一章并停在表单页,由你在浏览器里确认提交;提交后再点一次即发下一章。护栏(每日上限/连续失败暂停)生效。继续?"))) return;
4151
+ if (!confirm(t("将从最靠前的待发章节开始填稿(共 {0} 章待发)。本轮只填一章并停在表单页,由你在浏览器里确认提交;提交后再点一次即发下一章。护栏(每日上限/连续失败暂停)生效。继续?", au.pending))) return;
4096
4152
  try {
4097
4153
  await api("/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
4098
4154
  { method: "POST", body: JSON.stringify({ platform }) });
@@ -6548,6 +6604,17 @@ function provAdaptedProto(p, allow, kind) {
6548
6604
  && !chatBlocked((caps[pr] || {}).wire_api)) || "";
6549
6605
  }
6550
6606
 
6607
+ /* 死因细化:协议适配失败里最常见的一种是「openai 面实测只探到 chat 形态」
6608
+ * (codex 0.154+ 只讲 responses,其余 CLI 不受影响)。按真实死因说话——
6609
+ * 模型接入页明明亮着 openai ✓,链上却笼统说「协议不匹配」,看着就像自相矛盾。 */
6610
+ function chatShapedFace(pv, allow) {
6611
+ if (!pv) return false;
6612
+ const chat = (w) => (w || "responses") === "chat";
6613
+ if (allow.indexOf(pv.protocol) >= 0 && chat(pv.wire_api)) return true;
6614
+ const caps = pv.wire_caps || {};
6615
+ return allow.some((pr) => (caps[pr] || {}).base && chat((caps[pr] || {}).wire_api));
6616
+ }
6617
+
6551
6618
  /* CLI 允许注入的供应商 wire 协议——与 modelhub.resolve_binding 的 allowed 规则
6552
6619
  * 保持一致(codex 只吃 openai wire、claude 只吃 anthropic、dsh 只吃 OpenAI
6553
6620
  * 兼容端点,其余两种皆可)。后端会跳过不匹配的链条目,这里在选择层就挡住。 */
@@ -6674,7 +6741,10 @@ function chainDeadReasons(c, chain) {
6674
6741
  const pv = (S.providers || []).find((p) => p.id === x.p);
6675
6742
  if (!pv) return t("供应商已删除");
6676
6743
  if (pv.enabled === false) return t("供应商已停用");
6677
- if (!provAdaptedProto(pv, allow, c.orch_kind)) return t("协议不匹配");
6744
+ if (!provAdaptedProto(pv, allow, c.orch_kind))
6745
+ return chatShapedFace(pv, allow)
6746
+ ? t("openai 面仅 chat 形态(codex 只讲 responses)")
6747
+ : t("协议不匹配");
6678
6748
  const ks = pv.keys || [];
6679
6749
  if (ks.length && !ks.some((k) => k.enabled !== false && !k.cooling))
6680
6750
  return t("密钥全部冷却中");
@@ -6820,7 +6890,9 @@ function bindModelBox(c, provId) {
6820
6890
  "<b>" + (i === 0 ? t("主") : t("备")) + "</b>" + esc(pname) + " · " + esc(c2.m) +
6821
6891
  (modelHasImage(c2.p, c2.m)
6822
6892
  ? ' <span class="tag ok" title="' + esc(t("支持图片输入")) + '">' + t("图") + "</span>" : "") +
6823
- (dead ? ' <span class="hint warn">⚠ ' + esc(t("协议不匹配,解析时跳过")) + "</span>" : badge) +
6893
+ (dead ? ' <span class="hint warn">⚠ ' + esc(chatShapedFace(pv, allow)
6894
+ ? t("openai 面仅 chat 形态(codex 只讲 responses),解析时跳过")
6895
+ : t("协议不匹配,解析时跳过")) + "</span>" : badge) +
6824
6896
  (i > 0 ? '<button class="mini" data-m="' + esc(c2.m) + '" data-p="' + esc(c2.p) +
6825
6897
  '" title="' + t("设为主模型") + '" onclick="bindPromote(\'' + esc(c.id) + '\', this)">' +
6826
6898
  '<svg class="ico" aria-hidden="true"><use href="#i-arrow-up"></use></svg></button>' : "") +
@@ -7683,8 +7755,8 @@ function autoTemplate(tplId) {
7683
7755
  }
7684
7756
 
7685
7757
  /* ---------------------------------------------------------- 禅道 Bug 自动修复 */
7686
- /* /api/zentao → {config(脱敏), claims, last_scan, next_scan, last_error}。
7687
- * 进页拉一次回填表单;保存/测试/扫描各自 POST 后重拉。 */
7758
+ /* /api/zentao → {config(脱敏,含 product_profiles), claims, last_scan, next_scan, last_error}。
7759
+ * 产品档案卡:结构增删走「先采集 DOM S.ztProfiles → 改 → 重渲染」,保住已输入值。 */
7688
7760
 
7689
7761
  async function loadZentao() {
7690
7762
  let v = null;
@@ -7696,35 +7768,180 @@ async function loadZentao() {
7696
7768
  set("zt-account", cfg.account);
7697
7769
  const pw = $("zt-password");
7698
7770
  if (pw) { pw.value = ""; pw.placeholder = cfg.has_password ? t("已保存(不改就留空)") : ""; }
7699
- set("zt-products", (cfg.products || []).join(","));
7700
- set("zt-assigned", cfg.assigned_to);
7701
- const sev = $("zt-sev"); if (sev) sev.value = String(cfg.severity_cap || 0);
7702
- set("zt-workdir", cfg.workdir);
7703
- set("zt-gitrev", cfg.git_rev);
7704
- set("zt-verify", cfg.verify_command);
7705
7771
  const tg = (id, on) => { const el = $(id); if (el) el.checked = !!on; };
7772
+ tg("zt-triageai", cfg.triage_ai);
7706
7773
  tg("zt-autoresolve", cfg.auto_resolve);
7707
7774
  tg("zt-automerge", cfg.auto_merge);
7708
7775
  tg("zt-poll", cfg.poll_enabled);
7709
7776
  set("zt-interval", cfg.interval_hours || 2);
7777
+ S.ztProfiles = JSON.parse(JSON.stringify(cfg.product_profiles || []));
7778
+ renderZentaoProfiles();
7710
7779
  renderZentaoStatus();
7711
7780
  renderZentaoClaims();
7712
7781
  }
7713
7782
 
7783
+ /* ---- 产品档案:渲染与采集 ---- */
7784
+
7785
+ const ZT_SIDES = ["backend", "frontend"];
7786
+
7787
+ function ztRepoHtml(i, side) {
7788
+ const cn = side === "backend" ? t("后端") : t("前端");
7789
+ const p = (S.ztProfiles[i] || {});
7790
+ const repo = (p.repos || {})[side] || {};
7791
+ const hint = (p.repo_hints || {})[side] || "";
7792
+ return '<label>' + cn + t("仓库·工作目录") + '</label><input class="zt-p-wd" data-side="' + side + '" value="' + esc(repo.workdir || "") + '" placeholder="' + t("空 = 不用该端") + '">' +
7793
+ '<label>' + cn + t("仓库·基线分支") + '</label><input class="zt-p-rev" data-side="' + side + '" value="' + esc(repo.git_rev || "") + '" placeholder="' + t("如 main;空 = 直接改工作目录") + '">' +
7794
+ '<label>' + cn + t("仓库·验证命令") + '</label><input class="zt-p-verify" data-side="' + side + '" value="' + esc(repo.verify_command || "") + '" placeholder="' + t("如 npm test;空 = 靠评审把关") + '">' +
7795
+ '<label>' + cn + t("仓库·一句话描述") + '</label><input class="zt-p-hint" data-side="' + side + '" value="' + esc(hint) + '" placeholder="' + t("给 AI 排查看,如:Vue3 管理台前端") + '">';
7796
+ }
7797
+
7798
+ function ztProfCardHtml(p, i) {
7799
+ const ours = p.our_sides || [];
7800
+ const routes = p.module_routes || [];
7801
+ const SIDE_OPTS = [["backend", "后端"], ["frontend", "前端"], ["both", "双端"], ["not_ours", "非我方"]];
7802
+ const rows = routes.map((r, j) =>
7803
+ '<div class="zt-mr-row" data-j="' + j + '">' +
7804
+ '<input class="zt-mr-module zt-num" list="zt-mods-' + i + '" placeholder="' + t("模块 ID") + '" value="' + esc(r.module || "") + '">' +
7805
+ '<select class="zt-mr-side">' + SIDE_OPTS.map((o) =>
7806
+ '<option value="' + o[0] + '"' + (r.side === o[0] ? " selected" : "") + ">" + t(o[1]) + "</option>").join("") + "</select>" +
7807
+ '<input class="zt-mr-account" placeholder="' + t("转给谁(禅道账号,可空)") + '" value="' + esc(r.account || "") + '">' +
7808
+ '<button class="ghost small" onclick="ztMrDel(' + i + "," + j + ')">' + t("删") + "</button>" +
7809
+ "</div>").join("");
7810
+ const owners = p.owners || {};
7811
+ return '<div class="card zt-prof" data-i="' + i + '"><div class="head">' +
7812
+ '<span class="name">' + t("产品") + ' <input class="zt-p-product zt-num" type="number" min="1" value="' + esc(p.product || "") + '" placeholder="ID"></span>' +
7813
+ '<button class="danger small" onclick="ztProfDel(' + i + ')">' + t("删除产品") + "</button></div>" +
7814
+ '<div class="zt-grid">' +
7815
+ '<label data-i18n="只认领指派给">只认领指派给</label><input class="zt-p-assigned" value="' + esc(p.assigned_to || "") + '" placeholder="' + t("禅道账号名,空 = 不按指派过滤") + '">' +
7816
+ '<label data-i18n="严重度上限">严重度上限</label><select class="zt-p-sev">' +
7817
+ [0, 1, 2, 3, 4].map((n) => '<option value="' + n + '"' + (Number(p.severity_cap || 0) === n ? " selected" : "") + ">" +
7818
+ (n === 0 ? t("不限") : n + (n === 1 ? t("(最严重)") : "")) + "</option>").join("") + "</select>" +
7819
+ "</div>" +
7820
+ '<div class="zt-ours"><span>' + t("我方端(自动修):") + "</span>" +
7821
+ ZT_SIDES.map((s) =>
7822
+ '<label class="toggle"><input type="checkbox" class="zt-p-ours" data-side="' + s + '"' + (ours.indexOf(s) >= 0 ? " checked" : "") + "><span>" +
7823
+ (s === "backend" ? t("后端") : t("前端")) + "</span></label>").join("") +
7824
+ '<span class="hint">' + t("都不勾 = 该产品只排查转派,不自动修") + "</span></div>" +
7825
+ '<div class="zt-grid">' + ZT_SIDES.map((s) => ztRepoHtml(i, s)).join("") + "</div>" +
7826
+ '<div class="zt-grid">' +
7827
+ '<label data-i18n="后端负责人">后端负责人</label><input class="zt-p-owner" data-side="backend" value="' + esc(owners.backend || "") + '" placeholder="' + t("禅道账号,转派/升级用") + '">' +
7828
+ '<label data-i18n="前端负责人">前端负责人</label><input class="zt-p-owner" data-side="frontend" value="' + esc(owners.frontend || "") + '" placeholder="' + t("禅道账号,转派/升级用") + '">' +
7829
+ '<label data-i18n="非我方转派给">非我方转派给</label><input class="zt-p-owner" data-side="not_ours" value="' + esc(owners.not_ours || "") + '" placeholder="' + t("空 = 指回报告人") + '">' +
7830
+ "</div>" +
7831
+ '<div class="zt-mrs"><div class="zt-mr-head">' + t("模块路由(模块 → 端/人,排查优先级最高)") +
7832
+ '<button class="ghost small" onclick="ztFetchMods(' + i + ')">' + t("拉取模块清单") + "</button>" +
7833
+ '<button class="ghost small" onclick="ztMrAdd(' + i + ')">' + t("+ 加路由") + "</button></div>" +
7834
+ (rows || '<div class="hint">' + t("未配路由——模块不在路由里的 Bug 走 AI 排查") + "</div>") +
7835
+ '<datalist id="zt-mods-' + i + '"></datalist>' +
7836
+ "</div></div>";
7837
+ }
7838
+
7839
+ function renderZentaoProfiles() {
7840
+ const box = $("zt-profiles");
7841
+ if (!box) return;
7842
+ box.innerHTML = (S.ztProfiles || []).map((p, i) => ztProfCardHtml(p, i)).join("") ||
7843
+ '<div class="empty">' + t("还没有产品档案——点「+ 添加产品」,填产品 ID 与两端仓库。") + "</div>";
7844
+ }
7845
+
7846
+ /* DOM 卡片 → profiles 数组(结构变化前采集,保住已输入值) */
7847
+ function ztHarvestProfiles() {
7848
+ return Array.from(document.querySelectorAll("#zt-profiles .zt-prof")).map((card) => {
7849
+ const gv = (sel) => { const el = card.querySelector(sel); return el ? (el.value || "").trim() : ""; };
7850
+ const routes = Array.from(card.querySelectorAll(".zt-mr-row")).map((row) => ({
7851
+ module: parseInt((row.querySelector(".zt-mr-module").value || ""), 10) || 0,
7852
+ side: row.querySelector(".zt-mr-side").value,
7853
+ account: (row.querySelector(".zt-mr-account").value || "").trim(),
7854
+ })).filter((r) => r.module > 0);
7855
+ return {
7856
+ product: parseInt(gv(".zt-p-product"), 10) || 0,
7857
+ assigned_to: gv(".zt-p-assigned"),
7858
+ severity_cap: parseInt(gv(".zt-p-sev"), 10) || 0,
7859
+ our_sides: Array.from(card.querySelectorAll(".zt-p-ours:checked")).map((el) => el.dataset.side),
7860
+ repos: {
7861
+ backend: { workdir: gv('.zt-p-wd[data-side="backend"]'), git_rev: gv('.zt-p-rev[data-side="backend"]'), verify_command: gv('.zt-p-verify[data-side="backend"]') },
7862
+ frontend: { workdir: gv('.zt-p-wd[data-side="frontend"]'), git_rev: gv('.zt-p-rev[data-side="frontend"]'), verify_command: gv('.zt-p-verify[data-side="frontend"]') },
7863
+ },
7864
+ repo_hints: {
7865
+ backend: gv('.zt-p-hint[data-side="backend"]'),
7866
+ frontend: gv('.zt-p-hint[data-side="frontend"]'),
7867
+ },
7868
+ owners: {
7869
+ backend: gv('.zt-p-owner[data-side="backend"]'),
7870
+ frontend: gv('.zt-p-owner[data-side="frontend"]'),
7871
+ not_ours: gv('.zt-p-owner[data-side="not_ours"]'),
7872
+ },
7873
+ module_routes: routes,
7874
+ };
7875
+ });
7876
+ }
7877
+
7878
+ function ztProfAdd() {
7879
+ S.ztProfiles = ztHarvestProfiles();
7880
+ S.ztProfiles.push({ product: 0, assigned_to: "", severity_cap: 0, our_sides: ["backend"],
7881
+ repos: { backend: {}, frontend: {} }, repo_hints: { backend: "", frontend: "" },
7882
+ owners: { backend: "", frontend: "", not_ours: "" }, module_routes: [] });
7883
+ renderZentaoProfiles();
7884
+ }
7885
+
7886
+ function ztProfDel(i) {
7887
+ S.ztProfiles = ztHarvestProfiles();
7888
+ S.ztProfiles.splice(i, 1);
7889
+ renderZentaoProfiles();
7890
+ }
7891
+
7892
+ function ztMrAdd(i) {
7893
+ S.ztProfiles = ztHarvestProfiles();
7894
+ (S.ztProfiles[i].module_routes = S.ztProfiles[i].module_routes || []).push({ module: "", side: "backend", account: "" });
7895
+ renderZentaoProfiles();
7896
+ }
7897
+
7898
+ function ztMrDel(i, j) {
7899
+ S.ztProfiles = ztHarvestProfiles();
7900
+ S.ztProfiles[i].module_routes.splice(j, 1);
7901
+ renderZentaoProfiles();
7902
+ }
7903
+
7904
+ async function ztFetchMods(i) {
7905
+ const pid = parseInt(($("zt-profiles").querySelectorAll(".zt-prof")[i]?.querySelector(".zt-p-product").value) || "", 10);
7906
+ if (!pid) { toast(t("先填产品 ID 再拉模块清单"), true); return; }
7907
+ let r;
7908
+ try {
7909
+ r = await api("/api/zentao/modules", { method: "POST", body: JSON.stringify({ product: pid }) });
7910
+ } catch (e) { toast(e.message, true); return; }
7911
+ if (!r.ok) { toast(r.error || t("拉取失败"), true); return; }
7912
+ const dl = $("zt-mods-" + i);
7913
+ if (dl) dl.innerHTML = (r.modules || []).map((m) =>
7914
+ '<option value="' + esc(m.id) + '">' + esc(m.name || "") + "</option>").join("");
7915
+ toast(t("模块清单已拉到 ") + (r.modules || []).length + t(" 条,输入框可下拉选择"));
7916
+ }
7917
+
7918
+ /* ---- 状态行 / 修复记录 ---- */
7919
+
7714
7920
  function zentaoStateTag(st) {
7715
7921
  const MAP = {
7716
7922
  fixing: ["修复中", "zt-tag-fix"],
7717
7923
  resolved: ["已解决", "zt-tag-ok"],
7924
+ transferred: ["已转派", "zt-tag-fix"],
7925
+ escalated: ["已升级转派", "zt-tag-warn"],
7718
7926
  merge_failed: ["合并失败", "zt-tag-warn"],
7719
7927
  commented: ["已评论留人工", "zt-tag-warn"],
7720
7928
  resolve_failed: ["回写失败", "zt-tag-warn"],
7721
7929
  done_manual: ["待人工确认", "zt-tag-warn"],
7930
+ need_manual: ["需人工", "zt-tag-warn"],
7722
7931
  lost: ["记录丢失", "zt-tag-err"],
7723
7932
  };
7724
7933
  const m = MAP[st] || [st || "?", ""];
7725
7934
  return '<span class="tag ' + m[1] + '">' + t(m[0]) + "</span>";
7726
7935
  }
7727
7936
 
7937
+ function zentaoTriText(c) {
7938
+ const tri = c.triage || {};
7939
+ if (!tri.side) return "";
7940
+ const NAME = { backend: "后端问题", frontend: "前端问题", both: "双端问题", not_ours: "非我方", unknown: "待人工" };
7941
+ const by = tri.by === "rule" ? t("模块路由") : tri.by === "ai" ? "AI" : "";
7942
+ return t("排查:") + t(NAME[tri.side] || tri.side) + (by ? "(" + by + ")" : "");
7943
+ }
7944
+
7728
7945
  function renderZentaoStatus() {
7729
7946
  const box = $("zentao-status");
7730
7947
  if (!box) return;
@@ -7745,37 +7962,35 @@ function renderZentaoClaims() {
7745
7962
  const box = $("zentao-claims");
7746
7963
  if (!box) return;
7747
7964
  const claims = (S.zentao && S.zentao.claims) || [];
7748
- box.innerHTML = claims.map((c) =>
7749
- '<div class="card"><div class="head"><span class="name">#' + esc(c.bug_id) + " " + esc(c.title || "") + "</span>" +
7750
- zentaoStateTag(c.state) + "</div>" +
7751
- '<div class="auto-meta">' +
7752
- "<span>" + t("修复任务:") + '<a href="#" onclick="zentaoOpenTask(\'' + esc(c.task_id || "") + '\');return false;">' + esc(c.task_id || "?") + "</a></span>" +
7753
- (c.claimed_at ? "<span>" + t("认领于 ") + esc(c.claimed_at) + "</span>" : "") +
7754
- "</div>" +
7755
- (c.note ? '<div class="note">' + esc(c.note) + "</div>" : "") +
7756
- "</div>"
7757
- ).join("") || '<div class="empty">' + t("还没有认领过 Bug——配置好连接与产品 ID 后点「立即扫描」。") + "</div>";
7965
+ box.innerHTML = claims.map((c) => {
7966
+ const tasks = (c.tasks || []).map((tk) =>
7967
+ "<span>【" + (tk.side === "frontend" ? t("前端") : t("后端")) + "" +
7968
+ '<a href="#" onclick="zentaoOpenRun(\'' + esc(tk.run_id || "") + '\');return false;">' + esc(tk.task_id || "?") + "</a></span>").join("");
7969
+ return '<div class="card"><div class="head"><span class="name">#' + esc(c.bug_id) + " " + esc(c.title || "") + "</span>" +
7970
+ zentaoStateTag(c.state) + "</div>" +
7971
+ '<div class="auto-meta">' +
7972
+ (zentaoTriText(c) ? "<span>" + esc(zentaoTriText(c)) + "</span>" : "") +
7973
+ (tasks || "") +
7974
+ (c.claimed_at ? "<span>" + t("认领于 ") + esc(c.claimed_at) + "</span>" : "") +
7975
+ "</div>" +
7976
+ (c.note ? '<div class="note">' + esc(c.note) + "</div>" : "") +
7977
+ "</div>";
7978
+ }).join("") || '<div class="empty">' + t("还没有认领过 Bug——配置好连接与产品档案后点「立即扫描」。") + "</div>";
7758
7979
  }
7759
7980
 
7760
- function zentaoOpenTask(taskId) {
7761
- const c = ((S.zentao && S.zentao.claims) || []).find((x) => x.task_id === taskId);
7762
- if (c && c.run_id) { openRun(c.run_id); return; }
7981
+ function zentaoOpenRun(runId) {
7982
+ if (runId) { openRun(runId); return; }
7763
7983
  switchTab("tasks");
7764
7984
  }
7765
7985
 
7766
7986
  /* 表单 → 保存配置。password 只在用户输入了才提交(空 = 保持已存密码)。 */
7767
7987
  async function saveZentao() {
7768
7988
  const val = (id) => (($(id).value || "").trim());
7769
- const products = val("zt-products").split(/[,,\s]+/).filter(Boolean).map(Number);
7770
7989
  const payload = {
7771
7990
  base_url: val("zt-base-url"),
7772
7991
  account: val("zt-account"),
7773
- products,
7774
- assigned_to: val("zt-assigned"),
7775
- severity_cap: parseInt($("zt-sev").value, 10) || 0,
7776
- workdir: val("zt-workdir"),
7777
- git_rev: val("zt-gitrev"),
7778
- verify_command: val("zt-verify"),
7992
+ product_profiles: ztHarvestProfiles(),
7993
+ triage_ai: $("zt-triageai").checked,
7779
7994
  auto_resolve: $("zt-autoresolve").checked,
7780
7995
  auto_merge: $("zt-automerge").checked,
7781
7996
  poll_enabled: $("zt-poll").checked,
@@ -8352,13 +8567,139 @@ function maybeWhatsnew() {
8352
8567
  }
8353
8568
  }
8354
8569
 
8355
- /* ===== 首次启动欢迎引导:产品是什么 + 装完三步开工 =====
8356
- * localStorage orch.welcomed 记账,只自动弹一次;「关于与更新」页可手动重开。
8357
- * 令牌门在场时让位(远端新设备先输令牌,下次启动引导照常弹)。 */
8358
- function welcomeOpen() {
8570
+ /* ===== 帮助中心(首启欢迎引导升级版):左目录右内容 =====
8571
+ * 首启自动弹(orch.welcomed 记账只弹一次,徽章「首次启动」,落在「快速上手」章);
8572
+ * 手动开(设置导航/关于页/Ctrl+K)徽章「帮助中心」。加新章 = HELP_CHAPTERS
8573
+ * 追加一条 + helpChapterBody 加一个分支;todo:true 的章自动渲染「整理中」占位。 */
8574
+ var HELP_CHAPTERS = [
8575
+ { id: "quickstart", icon: "i-rocket", label: "快速上手" },
8576
+ { id: "models", icon: "i-blocks", label: "模型接入与绑定" },
8577
+ { id: "features", icon: "i-bee", label: "功能一览" },
8578
+ { id: "serial", icon: "i-book-open", label: "连载创作流程" },
8579
+ { id: "publish", icon: "i-clapper", label: "发布上架" },
8580
+ { id: "auto", icon: "i-calendar-days", label: "自动化与技能市场" },
8581
+ { id: "faq", icon: "i-search", label: "常见问题" },
8582
+ ];
8583
+ var helpCur = "quickstart";
8584
+ var helpFirst = false; // 本次打开是否首启自动弹(决定徽章文案;语言切换时也要用)
8585
+
8586
+ function helpChapterBody(id) {
8587
+ if (id === "quickstart") {
8588
+ return '<div class="welcome-steps">' +
8589
+ '<div class="wstep"><b>1</b><span>' + t("添加模型供应商:设置 → 模型接入,填入 API Key") + '</span></div>' +
8590
+ '<div class="wstep"><b>2</b><span>' + t("绑定 CLI 智能体:点「一键推荐绑定」自动配好") + '</span></div>' +
8591
+ '<div class="wstep"><b>3</b><span>' + t("新建任务:选工作目录、写目标,蜂群开工") + '</span></div>' +
8592
+ '</div>' +
8593
+ '<p class="help-note">' + t("任务跑起来后,详情页能看到步骤、蜂巢、成果文件与 Git 版本;「直接执行」类任务还能像聊天一样边跑边追加消息。") + '</p>' +
8594
+ '<div class="welcome-acts">' +
8595
+ '<button class="wl-btn primary" onclick="welcomeGo(\'models\')"><span>' + t("开始配置模型") + '</span><svg class="ico" aria-hidden="true"><use href="#i-chevron-r"></use></svg></button>' +
8596
+ '<button class="wl-btn" onclick="welcomeGo(\'bindings\')"><span>' + t("绑定 CLI 智能体") + '</span><svg class="ico" aria-hidden="true"><use href="#i-chevron-r"></use></svg></button>' +
8597
+ '<button class="wl-btn ghost" onclick="welcomeClose()"><span>' + t("先跳过,直接体验") + '</span></button>' +
8598
+ '</div>';
8599
+ }
8600
+ if (id === "models") {
8601
+ return '<h3 class="help-h3">' + t("第一步:添加供应商") + '</h3>' +
8602
+ '<p class="help-p">' + t("「设置 → 模型接入 → 添加供应商」:填名称、API Key、接口地址(一般用默认)。保存后点「获取模型列表」自动拉取该厂商的模型;网关不提供列表接口时直接手工填模型名即可。同一厂商可配多把 Key,按顺序轮换,某把欠费自动切下一把。") + '</p>' +
8603
+ '<h3 class="help-h3">' + t("第二步:认识协议徽章") + '</h3>' +
8604
+ '<p class="help-p">' + t("新供应商默认自动识别协议,保存后在后台实测支持哪些调用形态,结果以徽章标在卡片上。带「· chat」表示该模型只适合站内直连对话;codex 这类只讲 responses 协议的 CLI 用不了它,绑定页会自动剔除,不用自己排查。") + '</p>' +
8605
+ '<h3 class="help-h3">' + t("第三步:绑定 CLI 智能体") + '</h3>' +
8606
+ '<p class="help-p">' + t("「设置 → CLI 绑定」给每个智能体配一条模型链,运行时按顺序故障转移。「一键推荐绑定」只填空缺,不覆盖手工配置;删除或停用厂商后链会自动补绑。") + '</p>' +
8607
+ '<h3 class="help-h3">' + t("报错速查") + '</h3>' +
8608
+ '<ul class="help-list">' +
8609
+ '<li><b>401</b><span>' + t("Key 无效或过期——检查或更换 Key。") + '</span></li>' +
8610
+ '<li><b>404</b><span>' + t("模型名不对;若发生在「获取模型列表」,是网关不提供列表接口,能正常对话就不用管。") + '</span></li>' +
8611
+ '<li><b>503</b><span>' + t("该模型当前无可用渠道——查余额或换一个模型。") + '</span></li>' +
8612
+ '<li><b>' + t("冷却中") + '</b><span>' + t("这把 Key 连续失败被暂停 30 分钟,到期自动恢复,也可在密钥旁手动重置。") + '</span></li>' +
8613
+ '</ul>' +
8614
+ '<p class="help-note">' + t("想零成本先跑通:挑有免费档位的厂商配一个模型,跑通第一个任务后再按需加码。") + '</p>';
8615
+ }
8616
+ if (id === "features") {
8617
+ return '<ul class="help-feats">' +
8618
+ '<li><svg class="ico" aria-hidden="true"><use href="#i-workflow"></use></svg><div><b>' + t("多智能体编排") + '</b><span>' + t("规划 · 实现 · 评审 · 打磨,接棒交付") + '</span></div></li>' +
8619
+ '<li><svg class="ico" aria-hidden="true"><use href="#i-book-open"></use></svg><div><b>' + t("连载写作") + '</b><span>' + t("断点续跑 · 经验沉淀 · 作品资料") + '</span></div></li>' +
8620
+ '<li><svg class="ico" aria-hidden="true"><use href="#i-clapper"></use></svg><div><b>' + t("发布上架") + '</b><span>' + t("一键发布到番茄、七猫,含封面与作品资料") + '</span></div></li>' +
8621
+ '<li><svg class="ico" aria-hidden="true"><use href="#i-git-branch"></use></svg><div><b>' + t("版本工作台") + '</b><span>' + t("任务分支隔离,改动可审可合") + '</span></div></li>' +
8622
+ '<li><svg class="ico" aria-hidden="true"><use href="#i-calendar-days"></use></svg><div><b>' + t("自动化") + '</b><span>' + t("定时任务,无人值守") + '</span></div></li>' +
8623
+ '<li><svg class="ico" aria-hidden="true"><use href="#i-blocks"></use></svg><div><b>' + t("插件市场") + '</b><span>' + t("600+ 技能,一键安装") + '</span></div></li>' +
8624
+ '</ul>';
8625
+ }
8626
+ if (id === "serial") {
8627
+ return '<h3 class="help-h3">' + t("开一本新书") + '</h3>' +
8628
+ '<p class="help-p">' + t("新建任务选「连载写作」类型,目标里写清书名、题材和计划篇幅(如「都市异能,先写 20 章」)。目标写得太笼统时,蜂群会先反问几个澄清问题再开工。开书前会先准备作品资料:书简介与分类标签可一键生成,按平台官方选项实抓,不用自己去查规则。") + '</p>' +
8629
+ '<h3 class="help-h3">' + t("每章怎么跑") + '</h3>' +
8630
+ '<p class="help-p">' + t("大纲确认后分章推进:起草 → 跨厂商评审 → 打磨 → 定稿。评审不过会自动换将重写,不带着问题过关;需要你拍板的地方会亮起「待裁决」。写作时详情页可实时预览章节,蜂巢页每个智能体一格,点开能看谁在干什么。") + '</p>' +
8631
+ '<h3 class="help-h3">' + t("中断了怎么办") + '</h3>' +
8632
+ '<p class="help-p">' + t("任务随时可续跑:已写章节都落了盘,续跑接着往下写,不会从头再来。单章失败会自动退避重试,任务状态里会显示「将于 HH:MM 自动续跑」,不用盯着等。") + '</p>' +
8633
+ '<h3 class="help-h3">' + t("递话与经验") + '</h3>' +
8634
+ '<p class="help-p">' + t("跑的过程中可以在详情页给蜂群递话(文字/截图/附件),下一轮生效。每轮结束复盘官会把踩过的坑沉淀进经验库,后续任务自动复用,越用越顺手。") + '</p>';
8635
+ }
8636
+ if (id === "publish") {
8637
+ return '<h3 class="help-h3">' + t("第一次发布前:登录平台") + '</h3>' +
8638
+ '<p class="help-p">' + t("目前支持番茄小说与七猫。第一次发布会引导你在浏览器里登录平台账号(扫码或账密),登录态保存在本机,之后发布免登录;过期了重新登一次即可。") + '</p>' +
8639
+ '<h3 class="help-h3">' + t("建书:资料与封面") + '</h3>' +
8640
+ '<p class="help-p">' + t("连载任务的建书面板里,作品简介、分类标签可以按平台官方选项一键生成;封面点「生成封面」自动出竖版图(cover.png 落在任务运行目录)。") + '</p>' +
8641
+ '<h3 class="help-h3">' + t("发章与确认") + '</h3>' +
8642
+ '<p class="help-p">' + t("章稿定稿后一键发布:自动填标题正文、处理平台的各类弹层,但提交前会停下来让你确认——绝不静默替你上架。发布完成后任务详情里能看到结果与章节链接。") + '</p>' +
8643
+ '<h3 class="help-h3">' + t("发布失败") + '</h3>' +
8644
+ '<p class="help-p">' + t("平台改版或登录态过期会导致失败,失败信息会写明原因。重新登录后再点重试即可;反复失败可以到 GitHub 提 Issue 附上失败截图。") + '</p>';
8645
+ }
8646
+ if (id === "auto") {
8647
+ return '<h3 class="help-h3">' + t("定时任务") + '</h3>' +
8648
+ '<p class="help-p">' + t("设置 → 自动化:把任意任务类型挂上时间表(每天/每周固定时刻),到点自动按真实运行链开跑,结果照常落盘。配合群机器人(钉钉/飞书/企微自动适配)可以把完成状态推到群里。") + '</p>' +
8649
+ '<h3 class="help-h3">' + t("技能市场") + '</h3>' +
8650
+ '<p class="help-p">' + t("设置 → 插件市场:600+ 技能一键安装,来源包括内置库和 ZCode、Anthropic 等多个外部目录。装上的技能会注入智能体能力,按任务类型生效。") + '</p>' +
8651
+ '<h3 class="help-h3">' + t("用量台账") + '</h3>' +
8652
+ '<p class="help-p">' + t("设置 → 用量:每次调用的模型、token 与费用按多个维度聚合展示,历史运行会在启动时自动回填——每个任务花了多少,一目了然。") + '</p>';
8653
+ }
8654
+ if (id === "faq") {
8655
+ return '<div class="help-qa"><b>' + t("点「获取模型列表」报 404?") + '</b><p>' + t("部分网关不提供模型列表接口,属正常现象;只要能正常对话就不用管,模型名手工填即可。") + '</p></div>' +
8656
+ '<div class="help-qa"><b>' + t("报 503 / 无可用渠道?") + '</b><p>' + t("该模型当前没有可用渠道,最常见是余额耗尽。查一下余额或换个模型;同一厂商配了多把 Key 会自动轮换。") + '</p></div>' +
8657
+ '<div class="help-qa"><b>' + t("「冷却中」是什么意思?") + '</b><p>' + t("一把 Key 连续失败会被暂停 30 分钟,防止反复撞墙烧钱,到期自动恢复;也可以在密钥旁手动重置。") + '</p></div>' +
8658
+ '<div class="help-qa"><b>' + t("任务一直显示「排队」?") + '</b><p>' + t("本地默认最多 6 个任务同时跑,排满就排队。排队时可以打开详情看前面还有几个;个别卡死的任务,看门狗会自动清理补队。") + '</p></div>' +
8659
+ '<div class="help-qa"><b>' + t("状态里写着「将于 HH:MM 自动续跑」?") + '</b><p>' + t("这一步失败了,正在退避等待自动重试,到点会接着跑,不需要手动干预;等不及也可以在详情页手动重试。") + '</p></div>' +
8660
+ '<div class="help-qa"><b>' + t("生成的文件在哪?") + '</b><p>' + t("写作类任务的章节、封面、报告都落在任务的运行目录,详情页「成果」页签可浏览和预览。代码类任务则在仓库的任务分支上改代码,详情页「版本」里审阅后再合并。") + '</p></div>' +
8661
+ '<div class="help-qa"><b>' + t("忘了令牌 / 手机打不开页面?") + '</b><p>' + t("服务启动日志里有带令牌的完整访问地址;远程设备必须用带令牌的 URL 打开(或在令牌门里输入一次),否则会一直要求授权。") + '</p></div>' +
8662
+ '<div class="help-qa"><b>' + t("升级后数据会丢吗?") + '</b><p>' + t("不会。任务、配置、用量台账都在独立的数据目录里,升级只替换程序本体;老版本装新版会自动沿用原数据目录。") + '</p></div>' +
8663
+ '<div class="help-qa"><b>' + t("页面打不开 / 服务起不来?") + '</b><p>' + t("多半是默认端口 8765 被旧进程占着:结束旧的 python 进程,或启动时用 --port 换个端口。还不行就到 GitHub 提 Issue。") + '</p></div>';
8664
+ }
8665
+ const ch = HELP_CHAPTERS.find((c) => c.id === id);
8666
+ if (ch && ch.todo) {
8667
+ return '<div class="help-todo"><b>' + t(ch.label || "") + '</b><p>' +
8668
+ t("本章节正在整理,可先看「快速上手」与「模型接入与绑定」,或到 GitHub 提 Issue 提问。") + '</p></div>';
8669
+ }
8670
+ return "";
8671
+ }
8672
+
8673
+ function renderHelp() {
8674
+ const toc = $("help-toc");
8675
+ if (toc) {
8676
+ toc.innerHTML = HELP_CHAPTERS.map((c) =>
8677
+ '<button class="hitem' + (c.id === helpCur ? " active" : "") + (c.todo ? " todo" : "") +
8678
+ '" data-ch="' + c.id + '" onclick="helpGo(\'' + c.id + '\')">' +
8679
+ '<svg class="ico" aria-hidden="true"><use href="#' + c.icon + '"></use></svg><span>' + t(c.label) + '</span>' +
8680
+ (c.todo ? '<em class="h-todo">' + t("整理中") + '</em>' : "") +
8681
+ '</button>').join("");
8682
+ }
8683
+ const body = $("help-body");
8684
+ if (body) {
8685
+ body.innerHTML = helpChapterBody(helpCur);
8686
+ }
8687
+ }
8688
+ function setHelpChapter(id) {
8689
+ helpCur = HELP_CHAPTERS.some((c) => c.id === id) ? id : "quickstart";
8690
+ renderHelp();
8691
+ }
8692
+ function helpGo(id) { setHelpChapter(id); }
8693
+ function welcomeOpen(opts) {
8359
8694
  const w = $("welcome");
8360
8695
  if (!w) return;
8361
8696
  if (!$("token-gate").classList.contains("hidden")) return;
8697
+ helpFirst = !!(opts && opts.firstRun);
8698
+ if (helpFirst) helpCur = "quickstart";
8699
+ else if (opts && opts.topic && HELP_CHAPTERS.some((c) => c.id === opts.topic)) helpCur = opts.topic;
8700
+ const badge = $("welcome-badge");
8701
+ if (badge) badge.textContent = t(helpFirst ? "首次启动" : "帮助中心");
8702
+ renderHelp();
8362
8703
  w.classList.remove("hidden");
8363
8704
  document.body.classList.add("welcome-open");
8364
8705
  }
@@ -8376,11 +8717,22 @@ function welcomeGo(tab) {
8376
8717
  function maybeWelcome() {
8377
8718
  let seen = "";
8378
8719
  try { seen = localStorage.getItem("orch.welcomed"); } catch (e) { /* ignore */ }
8379
- if (!seen) welcomeOpen();
8720
+ if (!seen) welcomeOpen({ firstRun: true });
8380
8721
  }
8381
8722
  window.welcomeOpen = welcomeOpen;
8382
8723
  window.welcomeClose = welcomeClose;
8383
8724
  window.welcomeGo = welcomeGo;
8725
+ window.helpGo = helpGo;
8726
+ // 空状态里的「看帮助」链接用:打开帮助中心对应章并吞掉默认跳转
8727
+ window.helpOpenTopic = function (topic) { welcomeOpen({ topic: topic }); return false; };
8728
+
8729
+ /* 就地帮助问号:页面里任何 .hhelp(data-help-topic)点击直达帮助中心对应章。
8730
+ * 事件委托挂在 document 上,动态渲染出来的问号无需逐个接线。 */
8731
+ document.addEventListener("click", (e) => {
8732
+ const btn = e.target && e.target.closest ? e.target.closest(".hhelp") : null;
8733
+ if (!btn) return;
8734
+ welcomeOpen({ topic: btn.dataset.helpTopic });
8735
+ });
8384
8736
 
8385
8737
  function renderSu() {
8386
8738
  const info = $("su-info");
@@ -8485,7 +8837,7 @@ async function suCheck() {
8485
8837
  finally { if (b) b.disabled = false; }
8486
8838
  if (!s) { toast(t("检查更新失败:服务未连接"), true); return; }
8487
8839
  if (s.has_update) toast(t("发现新版本 v") + s.latest + t(",点「升级到新版」即可"));
8488
- else if (s.note) toast(s.note);
8840
+ else if (s.note) toast(t(s.note));
8489
8841
  else toast(t("已是最新版"));
8490
8842
  }
8491
8843
 
@@ -8718,6 +9070,12 @@ function setLangBtn(lang) {
8718
9070
  paintArchToggle();
8719
9071
  syncSideExpandBtn();
8720
9072
  renderCodePreviews(); // 预览徽章是动态文案:语言切换时若停在皮肤页要跟着换
9073
+ // 帮助中心若开着:徽章/目录/正文跟着换语言重画
9074
+ if (!$("welcome").classList.contains("hidden")) {
9075
+ const wbadge = $("welcome-badge");
9076
+ if (wbadge) wbadge.textContent = t(helpFirst ? "首次启动" : "帮助中心");
9077
+ renderHelp();
9078
+ }
8721
9079
  // 重画还在缓存里的页面标题
8722
9080
  const title = $("page-title");
8723
9081
  if (title && S.tab) title.textContent = tabTitle(S.tab);
@@ -9715,6 +10073,7 @@ document.addEventListener("DOMContentLoaded", () => {
9715
10073
  $("btn-zentao-test").addEventListener("click", testZentao);
9716
10074
  $("btn-zentao-scan").addEventListener("click", scanZentao);
9717
10075
  $("btn-zentao-save").addEventListener("click", saveZentao);
10076
+ $("btn-zentao-addprofile").addEventListener("click", ztProfAdd);
9718
10077
  $("auto-filter").addEventListener("click", (e) => {
9719
10078
  const b = e.target.closest("[data-f]");
9720
10079
  if (!b) return;