codebee 0.1.4 → 0.1.6

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
@@ -488,10 +488,12 @@ function fmtModel(m) {
488
488
  return typeof m === "object" ? JSON.stringify(m) : String(m);
489
489
  }
490
490
 
491
- /* 极简 Markdown 渲染(标题/加粗/行内码/列表/表格/代码块) */
491
+ /* 极简 Markdown 渲染(标题/加粗/行内码/列表/表格/代码块)。
492
+ * 围栏代码块走 codeBlockHTML(行号表格+高亮+代码主题):裸 <pre> 只有
493
+ * --log-bg 底色没配浅字,暖色皮肤里深底配深字根本读不了。 */
492
494
  function md2html(md) {
493
495
  const lines = String(md || "").split(/\r?\n/);
494
- let html = [], inCode = false, inTable = false, listOpen = false;
496
+ let html = [], inCode = false, codeBuf = null, inTable = false, listOpen = false;
495
497
  const inline = (t) => esc(t)
496
498
  .replace(/`([^`]+)`/g, "<code>$1</code>")
497
499
  .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
@@ -501,10 +503,11 @@ function md2html(md) {
501
503
  const line = raw.replace(/\s+$/, "");
502
504
  if (line.startsWith("```")) {
503
505
  closeList(); closeTable();
504
- html.push(inCode ? "</pre>" : "<pre>");
506
+ if (inCode) { html.push(codeBlockHTML(codeBuf.join("\n"))); codeBuf = null; }
507
+ else codeBuf = [];
505
508
  inCode = !inCode; continue;
506
509
  }
507
- if (inCode) { html.push(esc(raw)); continue; }
510
+ if (inCode) { codeBuf.push(raw); continue; }
508
511
  if (/^\|(.+)\|\s*$/.test(line)) {
509
512
  const cells = line.slice(1, -1).split("|").map((s) => s.trim());
510
513
  if (/^[-: ]+$/.test(cells.join(""))) continue; // 分隔行
@@ -524,7 +527,7 @@ function md2html(md) {
524
527
  html.push("<p>" + inline(line) + "</p>");
525
528
  }
526
529
  closeList(); closeTable();
527
- if (inCode) html.push("</pre>");
530
+ if (inCode && codeBuf) html.push(codeBlockHTML(codeBuf.join("\n"))); // 未闭合围栏也按代码块收尾
528
531
  return html.join("\n");
529
532
  }
530
533
 
@@ -564,7 +567,9 @@ function renderHealthBanner(health) {
564
567
  el.className = "health-banner alerting";
565
568
  el.title = alerts.map((p) =>
566
569
  p.provider + (p.model ? " · " + p.model : "") +
567
- t(":连续失败 ") + p.consecutive_failures + t(" 次(") + (p.last_error || t("未知错误")) + t(")")
570
+ (p.static
571
+ ? t(":") + (p.last_error || t("未知错误"))
572
+ : t(":连续失败 ") + p.consecutive_failures + t(" 次(") + (p.last_error || t("未知错误")) + t(")"))
568
573
  ).join("\n");
569
574
  if (!_healthBeeped) { _healthBeeped = true; beepAttention(); }
570
575
  }
@@ -572,7 +577,7 @@ function renderHealthBanner(health) {
572
577
  function healthBannerText(health) {
573
578
  return ((health && health.alerts) || []).map((p) => {
574
579
  const m = p.model ? " · " + p.model : "";
575
- return "⚠ " + p.provider + m + " " + t("连接异常");
580
+ return "⚠ " + p.provider + m + " " + (p.static ? t("绑定链失效") : t("连接异常"));
576
581
  }).join(t(" "));
577
582
  }
578
583
 
@@ -581,19 +586,21 @@ function healthBannerClick() {
581
586
  if (!alerts.length) return;
582
587
  const rows = alerts.map((p) => {
583
588
  const model = p.model ? " · " + p.model : "";
584
- return "<div style='margin-bottom:10px'>" +
585
- "<b>" + esc(p.provider) + (p.model ? " · " + esc(p.model) : "") + "</b>" +
589
+ const statLine = p.static ? "" :
586
590
  "<div style='opacity:.75;font-size:12px;margin-top:2px'>" +
587
591
  t("连续失败") + " " + p.consecutive_failures + " " + t("次 · 首次失败 ") + (p.first_fail_at || "-") +
588
- "</div>" +
592
+ "</div>";
593
+ return "<div style='margin-bottom:10px'>" +
594
+ "<b>" + esc(p.provider) + (p.model ? " · " + esc(p.model) : "") + "</b>" +
595
+ statLine +
589
596
  "<div style='color:#dc2626;font-size:12px;margin-top:2px;word-break:break-all'>" +
590
597
  esc(p.last_error || "") + "</div></div>";
591
598
  }).join("");
592
599
  const pid0 = alerts[0].provider_id || "";
593
600
  const model0 = alerts[0].model || "";
594
601
  const foot =
595
- (model0 ? "<button class='btn ghost' onclick=\"healthDisableModel('" + esc(pid0) + "','" + esc(model0) + "')\">" + t("禁用该模型") + "</button>" : "") +
596
- "<button class='btn ghost' onclick=\"healthDisableProvider('" + esc(pid0) + "')\">" + t("禁用该厂商") + "</button>" +
602
+ (pid0 && model0 ? "<button class='btn ghost' onclick=\"healthDisableModel('" + esc(pid0) + "','" + esc(model0) + "')\">" + t("禁用该模型") + "</button>" : "") +
603
+ (pid0 ? "<button class='btn ghost' onclick=\"healthDisableProvider('" + esc(pid0) + "')\">" + t("禁用该厂商") + "</button>" : "") +
597
604
  "<button class='btn ghost' onclick=\"healthOp('silence')\">" + t("静默本次告警") + "</button>" +
598
605
  "<button class='btn ghost' onclick=\"healthOp('reset')\">" + t("手动标记恢复") + "</button>" +
599
606
  "<button class='primary' onclick='closeModal()'>" + t("关闭") + "</button>";
@@ -637,6 +644,7 @@ async function healthDisableModel(pid, model) {
637
644
  S.provSig = "";
638
645
  closeModal(); render(); loadOrchestrator();
639
646
  toast(t("已禁用模型 {0} · {1},链降级自动跳过;绑定页可重新启用").replace("{0}", pid).replace("{1}", model));
647
+ autoRebindSoon();
640
648
  } catch (e) {
641
649
  toast(t("操作失败:") + e.message, true);
642
650
  }
@@ -660,6 +668,7 @@ async function healthDisableProvider(pid) {
660
668
  S.provSig = "";
661
669
  closeModal(); render(); loadOrchestrator();
662
670
  toast(t("已禁用厂商 {0}:链降级自动跳过,绑定页可重新启用").replace("{0}", pid));
671
+ autoRebindSoon();
663
672
  } catch (e) {
664
673
  toast(t("操作失败:") + e.message, true);
665
674
  }
@@ -845,19 +854,19 @@ function renderBindings() {
845
854
  }).join("");
846
855
  const bound = provs.find((p) => p.id === b.provider_id);
847
856
  const offWarn = bound && bound.enabled === false
848
- ? '<p class="hint warn">' + t("该供应商已停用:编排时不会注入它,将回落为 CLI 默认配置(模型链也不会生效)。") + '</p>' : "";
857
+ ? '<p class="hint warn">' + t("该供应商已停用:编排时不会注入它,模型链因此失效时相关步骤会直接判失败。") + '</p>' : "";
849
858
  const protoWarn = bound && !bindable.some((p) => p.id === bound.id)
850
859
  ? '<p class="hint warn">' + t("该供应商协议为 ") + esc(protoLabel(bound)) +
851
- t(",当前没有可注入的 CLI,编排时会回落为 CLI 默认配置。") + '</p>' : "";
852
- // 供应商停用但链上勾选了它家模型:链条目在解析时也会被整条跳过,
853
- // 链空了照样回落本机默认——这是 2026-09-16 配额事故的隐藏形态,单说
854
- // 「供应商下拉」警告不够,链本身的死活也要点名。
860
+ t(",当前没有可注入的 CLI,编排时相关步骤会直接判失败。") + '</p>' : "";
861
+ // 供应商停用但链上勾选了它家模型:链条目在解析时也会被整条跳过,链空了
862
+ // 相关步骤会直接判失败(2026-09-17 起;此前的静默回落本机默认正是 2026-09-16
863
+ // 配额事故的隐藏形态)——单说「供应商下拉」警告不够,链本身的死活也要点名。
855
864
  const chainDead = bindChain(b).some((c2) => c2.p && (() => {
856
865
  const pv = provs.find((p) => p.id === c2.p);
857
866
  return !pv || pv.enabled === false;
858
867
  })());
859
868
  const chainWarn = (!bound || bound.enabled !== false) && chainDead
860
- ? '<p class="hint warn">' + t("模型链里有已停用/已删除的供应商:这些条目解析时会被跳过,链可能因此整体失效。") + '</p>' : "";
869
+ ? '<p class="hint warn">' + t("模型链里有已停用/已删除的供应商:这些条目解析时会被跳过,链可能因此整体失效,相关步骤将判失败。") + '</p>' : "";
861
870
  return '<div class="card"><div class="head"><span class="name">' + esc(c.name) + "</span>" +
862
871
  '<span class="tag">' + esc(c.orch_kind) + "</span></div>" +
863
872
  '<div class="field"><label>' + t("供应商") + '</label><select id="bindprov-' + esc(c.id) + '">' + opts + "</select></div>" +
@@ -961,6 +970,7 @@ async function batchProvOp(op) {
961
970
  try {
962
971
  await api("/api/models/provider-op", { method: "POST",
963
972
  body: JSON.stringify({ ids, op }) });
973
+ if (op !== "duplicate") autoRebindSoon();
964
974
  } catch (e) { toast(t("操作失败:") + e.message, true); }
965
975
  S.selProvs = {};
966
976
  S.modelsSig = null;
@@ -1147,6 +1157,7 @@ async function keyOpCall(pid, op, keyId, key, label) {
1147
1157
  try {
1148
1158
  await api("/api/models/key-op", { method: "POST",
1149
1159
  body: JSON.stringify({ provider_id: pid, op, key_id: keyId, key, label }) });
1160
+ autoRebindSoon(); // 密钥启停/删除/解冻会改变厂商「推荐可用」判定,自动补绑一次
1150
1161
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1151
1162
  // 操作期间轮询照跑(uiPrompt 弹框不阻塞轮询),S.modelsSig 还是旧值:必须
1152
1163
  // 清签名强制重绘,否则 KEY 写盘成功界面也看不到。
@@ -1263,6 +1274,10 @@ function provModelRow(pid, m, group) {
1263
1274
  esc((tm.error || "").slice(0, 24)) + "</span>") : "";
1264
1275
  const price = (m.price_in != null && m.price_out != null)
1265
1276
  ? '<span class="hint">¥' + esc(m.price_in) + "/¥" + esc(m.price_out) + "</span>" : "";
1277
+ const imgcap = '<span class="tag imgcap' + (m.image_in ? " ok" : "") + '" title="' +
1278
+ (m.image_in ? t("支持图片输入,点击关闭")
1279
+ : t("纯文本模型,点击开启图片输入(内置智能体传图以此为准)")) + '"' +
1280
+ ' onclick="toggleModelImage(\'' + esc(pid) + '\', \'' + esc(m.name) + '\')">' + t("图") + "</span>";
1266
1281
  const sel = modelSel(pid);
1267
1282
  return '<div class="prow' + (m.enabled ? "" : " off") + '" draggable="true" data-group="' +
1268
1283
  esc(group) + '" data-name="' + esc(m.name) + '">' +
@@ -1272,7 +1287,7 @@ function provModelRow(pid, m, group) {
1272
1287
  '<span class="drag" title="' + t("拖动调整优先级") + '"><svg class="ico" aria-hidden="true"><use href="#i-grip"></use></svg></span>' +
1273
1288
  '<span class="pprio">#' + m.priority + "</span>" +
1274
1289
  '<span class="pname" title="' + esc(m.name) + '">' + esc(m.name) + "</span>" +
1275
- price + tmHtml +
1290
+ price + imgcap + tmHtml +
1276
1291
  '<span class="row-ops">' +
1277
1292
  '<button class="ghost small row-op" onclick="testModelBtn(\'' + esc(pid) + '\', \'' + esc(m.name) + '\')">' + t("测试") + '</button>' +
1278
1293
  '<button class="danger small row-op" title="' + t("从列表删除:刷新/重新导入不会再带回,可在分组底部恢复") + '"' +
@@ -1352,6 +1367,7 @@ async function modelOp(pid, name, op) {
1352
1367
  try {
1353
1368
  await api("/api/models/model-op", { method: "POST",
1354
1369
  body: JSON.stringify({ provider_id: pid, name, op }) });
1370
+ autoRebindSoon(); // 模型停用/启用/删除会改变推荐源与死链判定,自动补绑一次
1355
1371
  const s = modelSel(pid); // 单行操作后同步清掉该行勾选
1356
1372
  if (s[name]) { delete modelSelSet(pid)[name]; }
1357
1373
  } catch (e) { toast(t("操作失败:") + e.message, true); }
@@ -1359,6 +1375,21 @@ async function modelOp(pid, name, op) {
1359
1375
  poll();
1360
1376
  }
1361
1377
 
1378
+ function toggleModelImage(pid, name) {
1379
+ const p = (S.providers || []).find((x) => x.id === pid);
1380
+ const m = p && (p.models || []).find((x) => x.name === name);
1381
+ api("/api/models/model-caps", { method: "POST",
1382
+ body: JSON.stringify({ provider_id: pid, name, image_in: !(m && m.image_in) }) })
1383
+ .then(() => { S.modelsSig = null; poll(); })
1384
+ .catch((e) => toast(t("保存失败:") + e.message, true));
1385
+ }
1386
+
1387
+ /* 反查模型是否声明图片输入(绑定页 chip/列表只读徽标用) */
1388
+ function modelHasImage(pid, name) {
1389
+ const p = (S.providers || []).find((x) => x.id === pid);
1390
+ return !!(p && (p.models || []).some((x) => x.name === name && x.image_in));
1391
+ }
1392
+
1362
1393
  /* ---- 模型批量选择 ---- */
1363
1394
  function toggleModelSel(pid, name, on) {
1364
1395
  const s = modelSelSet(pid);
@@ -1403,6 +1434,7 @@ async function batchModelOp(pid, op) {
1403
1434
  try {
1404
1435
  await api("/api/models/model-op", { method: "POST",
1405
1436
  body: JSON.stringify({ provider_id: pid, names, op }) });
1437
+ autoRebindSoon();
1406
1438
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1407
1439
  clearModelSel(pid);
1408
1440
  poll();
@@ -1421,6 +1453,7 @@ async function toggleProviderEnabled(pid, enabled) {
1421
1453
  try {
1422
1454
  await api("/api/models/provider-op", { method: "POST",
1423
1455
  body: JSON.stringify({ ids: [pid], op: off ? "enable" : "disable" }) });
1456
+ autoRebindSoon();
1424
1457
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1425
1458
  S.modelsSig = null;
1426
1459
  poll();
@@ -1444,7 +1477,8 @@ async function refreshAllModels() {
1444
1477
  btn.disabled = false; btn.classList.remove("loading");
1445
1478
  btn.title = t("全部重新拉取模型列表");
1446
1479
  }, 1500);
1447
- setTimeout(poll, 2500); setTimeout(poll, 8000);
1480
+ setTimeout(poll, 2500);
1481
+ setTimeout(autoRebindSoon, 8000); // 模型列表落盘后推荐源才完整,补绑一次
1448
1482
  }
1449
1483
 
1450
1484
  async function refreshProviderModels(id) {
@@ -1455,7 +1489,7 @@ async function refreshProviderModels(id) {
1455
1489
  else toast(t("已获取模型列表 · wire 适配测试后台进行中"));
1456
1490
  } catch (e) { toast(t("获取失败:") + e.message, true); }
1457
1491
  poll();
1458
- setTimeout(poll, 4000); // 后台探测落盘后补一轮,适配徽标及时出现
1492
+ setTimeout(autoRebindSoon, 4000); // 模型列表落盘后补绑一次(后台 wire 探测同步进行)
1459
1493
  }
1460
1494
 
1461
1495
  /* 供应商编辑卡:协议可选「自动」;显式选定时它是主协议(用于需要唯一协议的
@@ -1511,6 +1545,7 @@ async function delProvider(id) {
1511
1545
  try {
1512
1546
  await api("/api/models/provider-op", { method: "POST",
1513
1547
  body: JSON.stringify({ ids: [id], op: "delete" }) });
1548
+ autoRebindSoon();
1514
1549
  } catch (e) { toast(t("操作失败:") + e.message, true); }
1515
1550
  if (S.selModels) delete S.selModels[id];
1516
1551
  if (S.selProvs) delete S.selProvs[id];
@@ -1574,6 +1609,7 @@ async function doAddProvider() {
1574
1609
  S.modelsSig = null;
1575
1610
  closeModal();
1576
1611
  poll();
1612
+ autoRebindSoon(); // 新增即启用:马上给空链/死链一次推荐机会
1577
1613
  if (p && body.api_key) refreshProviderModels(p.id); // 有密钥才自动拉模型列表
1578
1614
  } catch (e) {
1579
1615
  res.textContent = t("保存失败:") + e.message;
@@ -1669,7 +1705,7 @@ async function doImport() {
1669
1705
  btn.textContent = t("完成");
1670
1706
  btn.disabled = false;
1671
1707
  btn.onclick = closeModal;
1672
- if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); }
1708
+ if (r.imported) { S.selProv = null; S.modelsSig = null; poll(); autoRebindSoon(); }
1673
1709
  } catch (e) {
1674
1710
  $("import-result").innerHTML = '<div class="msg bad">' + t("导入失败:") + esc(e.message) + "</div>";
1675
1711
  btn.disabled = false; btn.textContent = t("导入选中");
@@ -1786,6 +1822,9 @@ async function createTask() {
1786
1822
  try {
1787
1823
  const r = await api("/api/tasks", { method: "POST", body: JSON.stringify(payload) });
1788
1824
  msg.textContent = t("已创建,跳转运行页…");
1825
+ // 先把新任务刷进 state 再跳:chatEngineIsDirect 靠 S.state.tasks 判引擎,
1826
+ // 不刷的话对话页签不会就绪,自动选卡落不到「对话」
1827
+ try { await refreshState(); } catch (e) { /* 刷失败等轮询兜底 */ }
1789
1828
  jumpToRun(r.run_id);
1790
1829
  $("f-goal").value = "";
1791
1830
  S.atts = []; renderAttachChips(); // 附件已移交任务待提交区,清空本地列表
@@ -2083,7 +2122,7 @@ function bindCtxMenus() {
2083
2122
  items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2084
2123
  if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2085
2124
  const st = det.dataset.status || "";
2086
- if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 重试任务"), fn: () => retryTask(taskId) });
2125
+ if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2087
2126
  // 与详情页同一套说法:失败/取消叫「编辑重试」,其余叫「基于此任务新建」
2088
2127
  items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2089
2128
  fn: () => newFromTask(taskId) });
@@ -3093,6 +3132,7 @@ function closeRun() {
3093
3132
  stopHiveTick();
3094
3133
  detailSideReset();
3095
3134
  $("run-detail").classList.add("hidden");
3135
+ renderChatNav(); // 解除 main.chat-fill(对话为主的固定高度),恢复外层滚动
3096
3136
  if (document.body.classList.contains("settings-mode")) {
3097
3137
  document.querySelector("#sub-runs .panel:first-child").classList.remove("hidden");
3098
3138
  } else {
@@ -3152,7 +3192,55 @@ function applyRdTabs() {
3152
3192
  });
3153
3193
  document.querySelectorAll("#run-detail .rd-pane").forEach((p) =>
3154
3194
  p.classList.toggle("hidden", p.dataset.pane !== S.rdTab));
3155
- }
3195
+ // 对话页签不放日志抽屉:会盖住贴底输入条(手动切来/自动选卡都覆盖)
3196
+ if (S.rdTab === "chat" && !$("rd-log").classList.contains("hidden")) window.rdLogClose();
3197
+ renderChatNav();
3198
+ }
3199
+
3200
+ /* 直连任务「对话为主」布局:收起常规页签条,右上角一排小胶囊按需打开
3201
+ * 蜂巢/步骤/成果等分区;离开对话时给「返回对话」入口(用户 2026-09-17 拍板:
3202
+ * 对话场景其它页签用处不大,默认关掉、需要再点开)。 */
3203
+ function renderChatNav() {
3204
+ const nav = $("rd-chat-nav"), detail = $("run-detail");
3205
+ if (!nav || !detail) return;
3206
+ const direct = !detail.classList.contains("hidden") && !!S.lastRun &&
3207
+ chatEngineIsDirect(S.lastRun);
3208
+ detail.classList.toggle("chat-mode", direct);
3209
+ // 对话为主时锁死外层滚动(main 是滚动根);离开详情必须解锚,否则任务列表滚不动
3210
+ const mainEl = document.querySelector("main");
3211
+ if (mainEl) mainEl.classList.toggle("chat-fill", direct);
3212
+ if (!direct) { nav.classList.add("hidden"); nav.innerHTML = ""; return; }
3213
+ const labels = { hive: t("蜂巢"), steps: t("步骤"), result: t("成果"),
3214
+ git: "Git", bible: t("圣经"), bookmeta: t("作品信息") };
3215
+ const avail = rdTabAvail();
3216
+ let pills = "";
3217
+ for (const k of ["hive", "steps", "result", "git", "bible", "bookmeta"]) {
3218
+ if (!avail[k]) continue;
3219
+ // 徽章直接镜像隐藏页签上的(蜂巢在岗数/步骤数/待裁决/成果数),不另设状态源
3220
+ const badge = document.querySelector('#rd-tabs .rd-tab[data-tab="' + k + '"] .rd-badge');
3221
+ pills += '<button class="cn-pill' + (S.rdTab === k ? " on" : "") +
3222
+ '" onclick="rdChatNavGo(\'' + k + '\')">' + labels[k] +
3223
+ (badge && badge.textContent ? "<b>" + esc(badge.textContent) + "</b>" : "") + "</button>";
3224
+ }
3225
+ nav.innerHTML = (S.rdTab !== "chat"
3226
+ ? '<button class="cn-back" onclick="rdChatNavBack()">' +
3227
+ '<svg class="ico" aria-hidden="true"><use href="#i-arrow-left"></use></svg>' +
3228
+ t("返回对话") + "</button>"
3229
+ : "") +
3230
+ '<span class="cn-pills">' + pills + "</span>";
3231
+ nav.classList.remove("hidden");
3232
+ }
3233
+ window.rdChatNavGo = function (tab) {
3234
+ S.rdTab = tab;
3235
+ S.rdTabPin = true; // 用户主动去看其它分区:别被自动选卡拽回对话
3236
+ applyRdTabs();
3237
+ };
3238
+ window.rdChatNavBack = function () {
3239
+ S.rdTab = "chat";
3240
+ S.rdTabPin = false;
3241
+ S.rdTabSig = "";
3242
+ applyRdTabs();
3243
+ };
3156
3244
 
3157
3245
  /* 徽章:蜂巢=在岗数(运行中)、步骤=总步数、版本=待裁决、成果=文件数(loadArtifacts 里刷) */
3158
3246
  function rdTabBadges() {
@@ -3173,8 +3261,11 @@ function rdTabBadges() {
3173
3261
  function rdTabsSync(ctx) {
3174
3262
  if (ctx) S._rdCtx = ctx;
3175
3263
  if (ctx && !S.rdTabPin) {
3264
+ // 对话页签可用性入签名:刚建的任务要等 state 刷进来 direct 才判定成立,
3265
+ // chat 从不可用变可用时必须触发一次重新选卡(否则落在步骤/蜂巢不跳对话)
3266
+ const chatAvail = $("rd-chat") && !$("rd-chat").classList.contains("hidden");
3176
3267
  const sig = (S.detailTaskKey || S.detailRunId || "") + "|" + (ctx.status || "") +
3177
- "|" + (ctx.gitState || "") + "|" + (ctx.running ? 1 : 0);
3268
+ "|" + (ctx.gitState || "") + "|" + (ctx.running ? 1 : 0) + "|" + (chatAvail ? 1 : 0);
3178
3269
  if (S.rdTabSig !== sig) {
3179
3270
  S.rdTabSig = sig;
3180
3271
  const avail = rdTabAvail();
@@ -3370,11 +3461,13 @@ const BOOKMETA_PLATFORMS = [
3370
3461
  ];
3371
3462
  const BOOKMETA_FIELDS = {
3372
3463
  fanqie: [["book_name", "作品名"], ["signing_mode", "签约模式"], ["target_reader", "目标读者"],
3373
- ["category", "分类"], ["tags_theme", "主题标签"], ["tags_role", "角色标签"],
3374
- ["tags_plot", "情节标签"], ["protagonist_1", "主角名1"],
3375
- ["protagonist_2", "主角名2"], ["summary", "作品简介"]],
3464
+ ["category", "主分类"], ["tags_theme", "主题标签"], ["tags_role", "角色标签"],
3465
+ ["tags_plot", "情节标签"], ["content_plot", "内容·情节"], ["content_emotion", "内容·情感"],
3466
+ ["content_character", "内容·人设"], ["content_world", "内容·世界观"],
3467
+ ["protagonist_1", "主角名1"], ["protagonist_2", "主角名2"], ["summary", "作品简介"]],
3376
3468
  qimao: [["book_name", "作品名称"], ["target_reader", "目标读者"], ["category_main", "一级分类"],
3377
- ["category_sub", "二级分类"], ["tags", "作品标签"], ["protagonist_1", "主角名1"],
3469
+ ["category_sub", "二级分类"], ["tags_style", "风格标签"], ["tags_role", "角色标签"],
3470
+ ["tags_plot", "情节标签"], ["tags_bg", "背景标签"], ["protagonist_1", "主角名1"],
3378
3471
  ["protagonist_2", "主角名2"], ["status", "作品状态"], ["summary", "作品简介"]],
3379
3472
  };
3380
3473
 
@@ -3486,6 +3579,7 @@ window.bmGen = async function (taskId, platform) {
3486
3579
  await api("/api/tasks/" + encodeURIComponent(taskId) + "/book-meta",
3487
3580
  { method: "POST", body: JSON.stringify({ platform }) });
3488
3581
  toast(t("已开始生成,完成后这里会自动更新"));
3582
+ await refreshState(); render(); // 立即翻到「生成中」,不等 SSE 推送/下一轮轮询
3489
3583
  } catch (e) { toast(t("生成失败:") + e.message, true); }
3490
3584
  };
3491
3585
 
@@ -3604,7 +3698,7 @@ function _renderGitSnapshot(run, task) {
3604
3698
  '<span class="chip failed">' + t("检出失败") + "</span>" +
3605
3699
  '<code class="git-branch">codebee/' + esc(tid || t("(无主运行)")) + t("(") + esc(t("未创建")) + t(")") + "</code></div>" +
3606
3700
  (lastErr ? '<div class="hint warn">' + esc(lastErr) + "</div>" : "") +
3607
- '<div class="hint">' + esc(t("处理后点「重试任务」,运行会重新检出任务分支。")) + "</div>";
3701
+ '<div class="hint">' + esc(t("处理后点「继续任务」,运行会重新检出任务分支。")) + "</div>";
3608
3702
  rdTabsSync();
3609
3703
  return;
3610
3704
  }
@@ -4021,7 +4115,7 @@ function drawInspector() {
4021
4115
  main.innerHTML = '<div class="insp-branch"><code>codebee/' + esc(d.task.id || "") +
4022
4116
  t("(") + esc(t("未创建")) + t(")") + '</code></div>' +
4023
4117
  (lastErr ? '<div class="hint warn">' + esc(lastErr) + "</div>" : "") +
4024
- '<span class="insp-hint">' + esc(t("处理后点「重试任务」,运行会重新检出任务分支。")) + "</span>";
4118
+ '<span class="insp-hint">' + esc(t("处理后点「继续任务」,运行会重新检出任务分支。")) + "</span>";
4025
4119
  } else {
4026
4120
  // 没指定代码版本:没有任务分支,卡里只留一句说明
4027
4121
  chipEl.classList.add("hidden");
@@ -4650,7 +4744,8 @@ const hiveTails = {}; // "runid|rel" -> 最近一行输出缓
4650
4744
  function stopHiveTick() { if (hiveTimer) { clearInterval(hiveTimer); hiveTimer = null; } }
4651
4745
 
4652
4746
  /* run.steps -> 阶段泳道流水线:规划→起草→评审→修订→打磨→合成,先后关系一眼可见;
4653
- * 运行中蜜蜂摆动+秒表走动;轨道光点流向下一阶段;点格即看实时输出。 */
4747
+ * 运行中蜜蜂摆动+秒表走动+蜜光呼吸;彗尾光点流向下一阶段;点格即看实时输出;
4748
+ * 入场瀑布只在步骤结构真变时重播(签名闸门,轮询空转不重建 DOM)。 */
4654
4749
  function hiveStage(role) {
4655
4750
  const r = String(role || "");
4656
4751
  if (/^(plan|outline)/.test(r)) return t("规划");
@@ -4711,7 +4806,7 @@ window.renderHive = function (run) {
4711
4806
  if (sub) sub.textContent = running.length
4712
4807
  ? t("在岗") + " " + running.length + " / " + steps.length : t("全部空闲");
4713
4808
  const activeIdx = lanes.map((l) => byStage[l].some((s) => s.status === "running")).lastIndexOf(true);
4714
- const cell = (s) => {
4809
+ const cell = (s, laneIdx, cellIdx) => {
4715
4810
  // 五归一:取消/超时的格子不再冒充「完成」——与步骤芯片同三色体系
4716
4811
  const st = s.status === "running" || s.status === "queued" ? "running"
4717
4812
  : s.status === "failed" ? "failed"
@@ -4724,30 +4819,37 @@ window.renderHive = function (run) {
4724
4819
  const key = run.id + "|" + (s.log || "");
4725
4820
  if (st !== "running") delete hiveTails[key];
4726
4821
  const tailText = st === "running" ? (hiveTails[key] || concl) : concl;
4727
- const title = [s.role, who, s.started_at ? t("开始于 ") + s.started_at : "",
4822
+ const title = [s.role, who,
4823
+ (s.duration_s != null ? s.duration_s + "s" : ""),
4824
+ s.started_at ? t("开始于 ") + s.started_at : "",
4728
4825
  (s.note ? "◆ " + s.note : "")].filter(Boolean).join(" · ")
4729
4826
  + (concl && st !== "running" ? "\n" + t("结论:") + concl : "");
4730
- return '<div class="hive-cell st-' + st + '" title="' + esc(title) + '" ' +
4827
+ // --d:入场瀑布逐格延迟(泳道间 110ms + 同泳道逐格 45ms),样式端消费
4828
+ return '<div class="hive-cell st-' + st + (st === "running" ? " hc-breathe" : "") + '"' +
4829
+ ' style="--d:' + (laneIdx * 110 + cellIdx * 45) + 'ms"' +
4830
+ ' title="' + esc(title) + '" ' +
4731
4831
  'onclick="hiveOpenLog(\'' + esc(run.id) + "', '" + esc(s.log || "") + '\')">' +
4732
4832
  '<div class="hc-head">' +
4733
- '<svg class="ico hc-bee" aria-hidden="true"><use href="#i-bee"></use></svg>' +
4833
+ '<img class="hc-bee" src="icons/bee.svg" alt="" aria-hidden="true">' +
4734
4834
  '<span class="hc-role">' + esc(s.role || "") + "</span>" +
4735
4835
  '<span class="hc-who">' + esc(who) + "</span></div>" +
4736
4836
  '<div class="hc-tail" data-log="' + esc(s.log || "") + '">' +
4737
4837
  esc(tailText) + "</div>" +
4738
4838
  '<div class="hc-meta"><span class="hc-elapsed" data-started="' + esc(s.started_at || "") + '">' +
4739
4839
  (s.duration_s != null ? s.duration_s + "s" : hiveElapsed(s.started_at)) + "</span>" +
4740
- (st === "running" ? '<span class="hc-live">● ' + t("工作中") + "</span>" : "") +
4840
+ (st === "running" ? '<span class="hc-live"><i class="live-dot"></i>' + t("工作中") + "</span>" : "") +
4741
4841
  (st === "timeout" ? '<span class="hc-dead">⏱ ' + t("超时") + "</span>" : "") +
4742
4842
  (st === "cancelled" ? '<span class="hc-dead">' + t("已取消") + "</span>" : "") +
4743
4843
  "</div></div>";
4744
4844
  };
4745
- const dots = (list) => list.slice(-12).map((s) => {
4845
+ const dots = (list, laneIdx) => list.slice(-12).map((s, di) => {
4746
4846
  const dc = s.status === "running" ? "run" : s.status === "failed" ? "fail"
4747
4847
  : s.status === "cancelled" ? "cancel" : s.status === "timeout" ? "time" : "done";
4748
4848
  const stx = { running: t("运行中"), failed: t("失败"), cancelled: t("已取消"),
4749
4849
  timeout: t("超时"), done: t("完成") }[dc] || s.status;
4750
- return '<i class="ld ld-' + dc + '" title="' + esc((s.role || "") + " · " + stx) + '"></i>';
4850
+ // --d:与格子同款级联延迟——入场时整条轨道按执行顺序""地点亮(进度回放感)
4851
+ return '<i class="ld ld-' + dc + '" style="--d:' + (laneIdx * 110 + di * 45) + 'ms"' +
4852
+ ' title="' + esc((s.role || "") + " · " + stx) + '"></i>';
4751
4853
  }).join("");
4752
4854
  $("rd-hive-cells").innerHTML = lanes.map((stage, i) => {
4753
4855
  const list = byStage[stage];
@@ -4755,13 +4857,44 @@ window.renderHive = function (run) {
4755
4857
  // 无 running(终态回看):全部视为已完成泳道;有 running 时 activeIdx 之前算完成
4756
4858
  const cls = hasRun ? "lane-active"
4757
4859
  : (activeIdx === -1 || i < activeIdx ? "lane-done" : "lane-idle");
4860
+ // 泳道头视觉 v2:阶段序号徽章(01/02…流水线站序)+ 已完成进度(settled/total)
4861
+ const settled = list.filter((s) => !["running", "queued"].includes(s.status)).length;
4758
4862
  return '<div class="hive-lane ' + cls + '">' +
4759
- '<div class="lane-head"><span class="lane-name">' + esc(stage) + '</span>' +
4863
+ '<div class="lane-head">' +
4864
+ '<span class="lane-idx">' + String(i + 1).padStart(2, "0") + "</span>" +
4865
+ '<span class="lane-name">' + esc(stage) + '</span>' +
4760
4866
  '<span class="lane-n">×' + list.length + "</span>" +
4761
- (hasRun ? '<span class="lane-live">● ' + t("进行中") + "</span>" : "") + "</div>" +
4762
- '<div class="lane-track">' + dots(list) + "</div>" +
4763
- '<div class="lane-cells">' + list.slice(-8).map(cell).join("") + "</div></div>";
4764
- }).join('<div class="lane-flow" aria-hidden="true"></div>');
4867
+ (settled ? '<span class="lane-prog">' + settled + "/" + list.length + "</span>" : "") +
4868
+ (hasRun ? '<span class="lane-live"><i class="live-dot"></i>' + t("进行中") + "</span>" : "") + "</div>" +
4869
+ '<div class="lane-track">' + dots(list, i) + "</div>" +
4870
+ '<div class="lane-cells">' + list.slice(-8).map((s, ci) => cell(s, i, ci)).join("") + "</div></div>";
4871
+ }).join('<div class="lane-flow" aria-hidden="true">'
4872
+ + '<svg class="flow-line flow-line-down" viewBox="0 0 36 44" aria-hidden="true">'
4873
+ + '<path class="flow-path" d="M14 2 C14 16 22 28 22 42"/><path class="flow-tip" d="M16 36 L22 43 L28 36"/></svg>'
4874
+ + '<svg class="flow-line flow-line-up" viewBox="0 0 36 44" aria-hidden="true">'
4875
+ + '<path class="flow-path" d="M22 2 C22 16 14 28 14 42"/><path class="flow-tip" d="M8 36 L14 43 L20 36"/></svg>'
4876
+ + '<span class="flow-bee"><img src="icons/bee.svg" alt="" aria-hidden="true"></span>'
4877
+ + '</div>');
4878
+ // 入场瀑布闸门:只按"结构"(泳道数/格数/状态构成)签名;运行中尾巴/秒表每 2s
4879
+ // 变化不能触重播。签名没变就不动 DOM——格子不闪、动画不重启、悬停不弹手。
4880
+ const sig = lanes.map((l) => l + ":" + byStage[l].length + "(" +
4881
+ byStage[l].map((s) => s.status[0] || "?").join("") + ")").join("|");
4882
+ const cellsBox = $("rd-hive-cells");
4883
+ if (cellsBox.dataset.hiveSig !== sig) {
4884
+ const fresh = cellsBox.dataset.hiveSig == null; // 首次展开详情也照播,先落框架再逐格亮起
4885
+ cellsBox.dataset.hiveSig = sig;
4886
+ // hive-enter 只在下一帧就摘(Rune/Hermes 式一闪而过的瀑布),全程保留会锁死
4887
+ // 入场帧;1400ms 定时是 RAF 被后台标签页冻结时的兜底。
4888
+ if (!fresh) cellsBox.classList.remove("hive-enter");
4889
+ cellsBox.classList.add("hive-enter");
4890
+ if (cellsBox._hiveEnterRAF) cancelAnimationFrame(cellsBox._hiveEnterRAF);
4891
+ cellsBox._hiveEnterRAF = requestAnimationFrame(() => {
4892
+ requestAnimationFrame(() => cellsBox.classList.remove("hive-enter"));
4893
+ cellsBox._hiveEnterRAF = null;
4894
+ });
4895
+ if (cellsBox._hiveEnterT) clearTimeout(cellsBox._hiveEnterT);
4896
+ cellsBox._hiveEnterT = setTimeout(() => cellsBox.classList.remove("hive-enter"), 1400);
4897
+ }
4765
4898
  const live = running.length && (run.status === "running" || run.status === "queued");
4766
4899
  if (hiveClock) { clearInterval(hiveClock); hiveClock = null; }
4767
4900
  if (live) {
@@ -4773,12 +4906,8 @@ window.renderHive = function (run) {
4773
4906
  el.textContent = hiveElapsed(el.dataset.started);
4774
4907
  });
4775
4908
  }, 1000);
4776
- // 点开任务第一眼就在干活:自动展开第一个在岗步骤的实时日志。
4777
- // 每 run 只自动开一次;用户手动收起后不再打扰。
4778
- if (S.hiveAutoLog !== run.id && !currentLog) {
4779
- const first = running.find((s) => s.log);
4780
- if (first) { S.hiveAutoLog = run.id; toggleLog(run.id, first.log); }
4781
- }
4909
+ // 日志抽屉一律手动打开(点蜂巢格/步骤行):自动弹开会盖住对话输入条,
4910
+ // 用户要求所有页面默认不展示日志(2026-09-17)
4782
4911
  } else stopHiveTick();
4783
4912
  rdTabsSync(); // 蜂巢显隐直接决定「蜂巢」标签可用性
4784
4913
  };
@@ -4993,6 +5122,38 @@ function chatEngineIsDirect(run) {
4993
5122
  return !!(t && t.engine === "direct");
4994
5123
  }
4995
5124
 
5125
+ /* 对话正文:``` 围栏渲染成真代码块(复用 codeBlockHTML:行号+高亮+代码主题),
5126
+ * 其余文本保持 pre-wrap 原样(行内 `code` 与 **加粗** 轻量翻译)。模型输出先
5127
+ * 全量 esc 再插标记;未闭合围栏按代码块收尾,流式输出中途也不撒裸反引号。 */
5128
+ function chatBodyHTML(text) {
5129
+ const lines = String(text || "").split(/\r?\n/);
5130
+ const out = [];
5131
+ let txt = [], code = null;
5132
+ const flushTxt = () => {
5133
+ if (!txt.length) return;
5134
+ out.push('<div class="chat-body">' + txt.map((l) => {
5135
+ let h = esc(l);
5136
+ h = h.replace(/`([^`]+)`/g, "<code>$1</code>");
5137
+ h = h.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
5138
+ return h;
5139
+ }).join("\n") + "</div>");
5140
+ txt = [];
5141
+ };
5142
+ for (const raw of lines) {
5143
+ const line = raw.replace(/\s+$/, "");
5144
+ if (line.startsWith("```")) {
5145
+ if (code) { out.push(codeBlockHTML(code.join("\n"))); code = null; }
5146
+ else { flushTxt(); code = []; }
5147
+ continue;
5148
+ }
5149
+ if (code) code.push(raw);
5150
+ else txt.push(raw);
5151
+ }
5152
+ if (code) out.push(codeBlockHTML(code.join("\n")));
5153
+ flushTxt();
5154
+ return out.join("");
5155
+ }
5156
+
4996
5157
  async function renderChat(run, active) {
4997
5158
  const box = $("rd-chat");
4998
5159
  if (!box) return;
@@ -5013,36 +5174,59 @@ async function renderChat(run, active) {
5013
5174
  // 指向当前详情正在展示的那条 run——拉取期间切了详情就丢弃响应
5014
5175
  if (!S.lastRun || S.lastRun.id !== runForFetch) return;
5015
5176
  const flow = $("rd-chat-flow");
5177
+ // 时间显示统一收敛到 HH:MM(日期在 meta 条里,全量戳塞气泡就是噪音);
5178
+ // 附件兼容 字符串路径 / {name|path} 对象 两种形态(对象直接拼会成 [object Object])
5179
+ const chatTime = (v) => { const s = String(v || "");
5180
+ return /^\d{4}-\d{2}-\d{2}[ T]/.test(s) ? s.slice(11, 16) : s; };
5181
+ const attName = (a) => { const p = typeof a === "string" ? a
5182
+ : ((a && (a.name || a.path)) || "");
5183
+ return String(p).split(/[\\/]/).pop() || ""; };
5016
5184
  flow.innerHTML = items.map((it) => {
5017
5185
  if (it.kind === "user") {
5018
- // 用户消息:右侧浅灰圆角块,元信息在块内顶部
5186
+ // 用户消息:右侧浅灰圆角块,元信息在块内顶部。
5187
+ // 附件胶囊可点开预览:data-att 带 _attachments/ 相对路径(老数据裸文件名
5188
+ // 在前端钉回 _attachments/,与服务端 norm_rel 同口径),事件委托绑在
5189
+ // rd-chat-flow 上——时间线整块 innerHTML 重建也不丢监听
5190
+ const atts = (it.attachments || []).map((a) => {
5191
+ const nm = attName(a);
5192
+ if (!nm) return null;
5193
+ let rel = typeof a === "string" ? a : ((a && (a.path || a.name)) || nm);
5194
+ rel = String(rel).replace(/\\/g, "/");
5195
+ if (!rel.startsWith("_attachments/")) rel = "_attachments/" + rel.split("/").pop();
5196
+ return { nm, rel };
5197
+ }).filter(Boolean);
5019
5198
  return '<div class="chat-row me">' +
5020
5199
  '<div class="chat-bubble me">' +
5021
- '<div class="chat-meta">' + esc(it.who || "") + " " + esc(it.at || "") +
5200
+ '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5022
5201
  (it.consumed ? "" : " · " + t("待送达")) + "</div>" +
5023
5202
  esc(it.text || t("(仅附件)")) +
5024
- ((it.attachments || []).length
5025
- ? '<div class="chat-atts">' + it.attachments.map((a) =>
5026
- '<span class="att-chip2">' + esc(String(a).split(/[\\/]/).pop()) + "</span>").join("") + "</div>"
5203
+ (atts.length
5204
+ ? '<div class="chat-atts">' + atts.map((a) =>
5205
+ '<span class="att-chip2 att-open" data-att="' + esc(a.rel) +
5206
+ '" title="' + esc(t("点击查看")) + '">' + esc(a.nm) + "</span>").join("") + "</div>"
5027
5207
  : "") +
5028
5208
  "</div></div>";
5029
5209
  }
5030
5210
  // 协议尾行是给编排器判收的机器标记,不该出现在聊天正文里(真源在提示词约定)
5031
5211
  const body = String(it.text || "").replace(/\n*DIRECT_DONE:.*\s*$/s, "").trim();
5032
- // 运行中的步骤还没有正文(output/summary 都空):给占位,不渲染空气泡
5033
- const shown = body || (it.status === "running" ? t("正在执行…") : t("(本轮无文本输出)"));
5212
+ // 运行中还没有正文:三点打字动画(终态无正文才落「无文本输出」占位)
5213
+ const bodyHtml = body ? chatBodyHTML(body)
5214
+ : (it.status === "running"
5215
+ ? '<span class="chat-typing" aria-label="' + esc(t("正在执行…")) + '"><i></i><i></i><i></i></span>'
5216
+ : esc(t("(本轮无文本输出)")));
5217
+ // 元信息只留 名字+时间(+失败标记):路由/供应商细节属于「步骤」页签,塞这里就是噪音
5218
+ const metaBad = it.status === "failed" || it.status === "cancelled";
5034
5219
  // 智能体回复:头像 + 整幅正文(不套气泡框),元信息小字落在正文下方
5035
5220
  return '<div class="chat-row">' +
5036
5221
  '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="#i-bee"></use></svg></span>' +
5037
5222
  '<div class="chat-bubble agent">' +
5038
- '<pre class="chat-body">' + esc(shown) + "</pre>" +
5039
- '<div class="chat-meta">' + esc(it.who || "") + " " + esc(it.at || "") +
5040
- (it.note ? " · " + esc(it.note) : "") +
5041
- (it.status && it.status !== "done" ? " · " + esc(it.status) : "") + "</div></div></div>";
5223
+ '<div class="chat-body">' + bodyHtml + "</div>" +
5224
+ '<div class="chat-meta">' + esc(it.who || "") + " " + esc(chatTime(it.at)) +
5225
+ (metaBad ? " · " + t(it.status === "failed" ? "失败" : "已取消") : "") + "</div></div></div>";
5042
5226
  }).join("") || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
5043
5227
  const hint = $("rd-chat-hint");
5044
5228
  if (hint) hint.textContent = active
5045
- ? t("运行中:消息随下一步送达(插不进正在跑的这一步)")
5229
+ ? t("运行中:新消息会排队,本轮回答完后依次送达")
5046
5230
  : t("已结束:发送后将自动开新一轮接着做");
5047
5231
  flow.scrollTop = flow.scrollHeight;
5048
5232
  }
@@ -5050,12 +5234,28 @@ async function renderChat(run, active) {
5050
5234
  function drawChatAtts() {
5051
5235
  const box = $("rd-chat-att-list");
5052
5236
  if (!box) return;
5237
+ // 点名字开预览(待提交区原文走 /api/attachments/<id>),× 才是移除
5053
5238
  box.innerHTML = chatAtts.map((a, i) =>
5054
- '<span class="att-chip2">' + esc(a.name) + '<b onclick="chatRemoveAtt(' + i + ')" title="' +
5055
- esc(t("移除")) + '">×</b></span>').join("");
5239
+ '<span class="att-chip2 att-view" data-att-id="' + esc(a.id || "") +
5240
+ '" data-att-name="' + esc(a.name) + '" title="' + esc(t("点击查看")) + '">' + esc(a.name) +
5241
+ '<b onclick="chatRemoveAtt(' + i + ')" title="' + esc(t("移除")) + '">×' +
5242
+ '</b></span>').join("");
5056
5243
  }
5057
5244
  window.chatRemoveAtt = function (i) { chatAtts.splice(i, 1); drawChatAtts(); };
5058
5245
 
5246
+ /* 对话附件点击预览:已落盘的走 run 工作目录 /api/runs/<id>/file(rel 为
5247
+ * _attachments/ 相对路径),待提交的走 /api/attachments/<id>;
5248
+ * 弹窗与成品预览同款——图片直接看图、文本看码、二进制给下载。 */
5249
+ window.chatAttPopup = function (rel) {
5250
+ if (!rel || !chatRunId) return;
5251
+ return _fpPreviewUrl("/api/runs/" + encodeURIComponent(chatRunId) +
5252
+ "/file?name=" + encodeURIComponent(rel), rel);
5253
+ };
5254
+ window.chatPendingPopup = function (id, name) {
5255
+ if (!id) return;
5256
+ return _fpPreviewUrl("/api/attachments/" + encodeURIComponent(id), name || "attachment");
5257
+ };
5258
+
5059
5259
  async function chatUploadFiles(files) {
5060
5260
  for (const f of files) {
5061
5261
  try {
@@ -5083,24 +5283,33 @@ window.chatSend = async function () {
5083
5283
  btn.disabled = true;
5084
5284
  try {
5085
5285
  if (active) {
5286
+ // 运行中:消息进信箱排队(时间线上带「待送达」标记),本轮跑完自动续轮送达
5086
5287
  await api("/api/runs/" + encodeURIComponent(chatRunId) + "/messages", {
5087
5288
  method: "POST",
5088
5289
  body: JSON.stringify({ text, attachments: chatAtts.map((a) => a.id) }),
5089
5290
  });
5090
- toast(t("已入箱:随下一步送达"));
5291
+ toast(t("已排队:随下一步一起送达"));
5091
5292
  } else {
5092
5293
  const r = await api("/api/runs/" + encodeURIComponent(chatRunId) + "/chat", {
5093
5294
  method: "POST",
5094
5295
  body: JSON.stringify({ text, attachments: chatAtts.map((a) => a.id) }),
5095
5296
  });
5096
5297
  toast(t("已开新一轮,接着做…"));
5097
- if (r && r.run_id) { chatSig = ""; jumpToRun(r.run_id); return; }
5298
+ if (r && r.run_id) {
5299
+ if (S.detailTaskKey) { S.taskSig = ""; renderTaskDetail(); }
5300
+ else jumpToRun(r.run_id);
5301
+ }
5098
5302
  }
5303
+ // 发送成功一律清空输入框(只清输入,时间线里的历史回复不动);
5304
+ // 此前 /chat 分支提前 return 跳过了清空,发出去的字一直留在框里
5099
5305
  ta.value = "";
5306
+ ta.style.height = "";
5100
5307
  chatAtts = []; drawChatAtts();
5101
5308
  chatSig = ""; // 强制重画时间线
5102
- const d = await api("/api/runs/" + encodeURIComponent(chatRunId));
5103
- if (d.run) renderChat(d.run, d.run.status === "running" || d.run.status === "queued");
5309
+ if (active) {
5310
+ const d = await api("/api/runs/" + encodeURIComponent(chatRunId));
5311
+ if (d.run) renderChat(d.run, d.run.status === "running" || d.run.status === "queued");
5312
+ }
5104
5313
  } catch (e) { toast(t("发送失败:") + e.message, true); }
5105
5314
  finally { btn.disabled = false; }
5106
5315
  };
@@ -5115,6 +5324,12 @@ function bindChat() {
5115
5324
  e.target.value = "";
5116
5325
  });
5117
5326
  send.addEventListener("click", window.chatSend);
5327
+ // 自动伸高:跟内容走、有上限(160px 后内部滚动);清空由 chatSend 归零。
5328
+ // 高度只由用户输入驱动,轮询重画不碰它——框框不再忽大忽小
5329
+ ta.addEventListener("input", () => {
5330
+ ta.style.height = "auto";
5331
+ ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
5332
+ });
5118
5333
  ta.addEventListener("keydown", (e) => {
5119
5334
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); window.chatSend(); }
5120
5335
  });
@@ -5128,6 +5343,19 @@ function bindChat() {
5128
5343
  return f && !f.name ? new File([f], "paste-" + Date.now() + ".png", { type: f.type }) : f;
5129
5344
  }).filter(Boolean));
5130
5345
  });
5346
+ // 附件胶囊点击预览:委托绑在静态容器上——时间线轮询整块重建 innerHTML、
5347
+ // 输入条胶囊增删重画,都不丢监听。时间线胶囊走已落盘文件,输入条胶囊
5348
+ // (× 移除按钮除外)走待提交区
5349
+ $("rd-chat-flow").addEventListener("click", (e) => {
5350
+ const chip = e.target.closest(".att-open");
5351
+ if (chip) window.chatAttPopup(chip.dataset.att);
5352
+ });
5353
+ const attBox = $("rd-chat-att-list");
5354
+ if (attBox) attBox.addEventListener("click", (e) => {
5355
+ if (e.target.closest("b")) return; // × 自己的 onclick 负责移除
5356
+ const chip = e.target.closest("[data-att-id]");
5357
+ if (chip) window.chatPendingPopup(chip.dataset.attId, chip.dataset.attName);
5358
+ });
5131
5359
  }
5132
5360
 
5133
5361
  /* ---------------------------------------------------------- 外观:皮肤 + 明暗(换肤) */
@@ -5136,6 +5364,7 @@ function bindChat() {
5136
5364
  * 皮肤改色只需要动 style.css,预览与真实界面不会各自漂移。 */
5137
5365
  const SKINS = [
5138
5366
  { id: "ocean", name: "深海", desc: "藏青底色 + 天蓝强调,夜间长时间盯任务更沉静(默认)" },
5367
+ { id: "hermes", name: "墨金", desc: "暖墨底色 + 金色发丝线,Hermes 式古典优雅" },
5139
5368
  { id: "classic", name: "经典", desc: "黑白灰 + 蓝色强调,ChatGPT 式清爽配色" },
5140
5369
  { id: "forest", name: "森野", desc: "墨绿底色 + 青翠强调,偏自然的护眼配色" },
5141
5370
  { id: "amber", name: "暖阳", desc: "暖棕底色 + 琥珀强调,纸感暖调" },
@@ -5554,20 +5783,20 @@ function bindSelById(id) {
5554
5783
  function bindRepaint() { S.bindSig = null; renderBindings(); }
5555
5784
 
5556
5785
  /* 一键推荐绑定:给每个链为空的 CLI 按协议适配规则预填一条主模型。
5557
- * 推荐顺序:显式协议原生匹配 > auto 且 wire_caps 命中;默认模型停用的厂商
5558
- * 降权(当前键位是坏的,除非只剩它,否则不当首选——失效链重推荐实测);
5559
- * 同分按 priority / 数组序稳定。只预填草稿态(dirty),逐条看清后各自保存
5786
+ * 推荐顺序:显式协议原生匹配 > auto 且 wire_caps 命中;同分按 priority /
5787
+ * 数组序稳定。默认模型停用的厂商直接不推荐——它当前键位就是坏的,绑上去
5788
+ * 解析照样失败;没有合适的就什么都不绑(2026-09-17 用户拍板,不再保留
5789
+ * 「只剩它也上」的兜底)。只预填草稿态(dirty),逐条看清后各自保存
5560
5790
  * 或「全部保存」,健康链绝不被覆盖。 */
5561
5791
  function recommendFor(c) {
5562
- const allow = bindAllowedProtocols(c.orch_kind);
5563
- const provs = (S.providers || []).filter((p) => chainProvUsable(p, allow));
5792
+ // 目录页「默认模型」下拉用 orch_kind || id 判协议(仅管理条目没有 orch_kind),
5793
+ // 推荐口径与下拉过滤保持一致;绑定页条目恒有 orch_kind,不受影响
5794
+ const allow = bindAllowedProtocols(c.orch_kind || c.id);
5795
+ const badDefault = (p) => (p.models || []).some((m) => m.name === p.model &&
5796
+ (m.hidden || m.enabled === false));
5797
+ const provs = (S.providers || []).filter((p) => !badDefault(p) && chainProvUsable(p, allow));
5564
5798
  if (!provs.length) return null;
5565
- const score = (p) => {
5566
- const native = allow.indexOf(p.protocol) >= 0;
5567
- const badDefault = (p.models || []).some((m) => m.name === p.model &&
5568
- (m.hidden || m.enabled === false)) ? 500 : 0;
5569
- return (native ? 0 : 1) * 1000 + badDefault + (p.priority || 0);
5570
- };
5799
+ const score = (p) => (allow.indexOf(p.protocol) >= 0 ? 0 : 1) * 1000 + (p.priority || 0);
5571
5800
  provs.sort((a, b) => score(a) - score(b));
5572
5801
  const p = provs[0];
5573
5802
  const models = (p.models || []).filter((m) => !m.hidden && m.enabled !== false);
@@ -5605,48 +5834,38 @@ function chainDeadReasons(c, chain) {
5605
5834
  return dead;
5606
5835
  }
5607
5836
 
5837
+ /* 单条绑定链的推荐修复动作:需要改返回新链,不动返回 null。
5838
+ * 空链 → 预填推荐;整条死透(或只有链首且已死)→ 重推荐;链首死但链内还有
5839
+ * 活的备选 → 新链首插最前(原降级序保留,推荐项已在链内则升首去重)。 */
5840
+ function bindRepairAction(c, st) {
5841
+ const rec = recommendFor(c);
5842
+ if (!st.chain.length) return rec ? [rec] : null;
5843
+ const dead = chainDeadReasons(c, st.chain);
5844
+ const allDead = dead.every((d) => d);
5845
+ const headDead = dead[0] !== ""; // 空 p = CLI 默认凭据,是有意配置不算死
5846
+ if (allDead) return rec ? [rec] : null;
5847
+ if (headDead && rec && chainKey(st.chain) !== chainKey([rec]))
5848
+ return [rec].concat(st.chain.filter((x) => !(x.p === rec.p && x.m === rec.m)));
5849
+ return null;
5850
+ }
5851
+
5608
5852
  function autoBindAll() {
5609
5853
  const targets = (S.catalog || []).filter((c) => c.installed && c.orch_kind);
5610
5854
  let filled = 0, skipped = 0, noProv = 0, refilled = 0;
5611
5855
  for (const c of targets) {
5612
5856
  const st = bindSelById(c.id);
5613
- if (!st.chain.length) {
5614
- const rec = recommendFor(c);
5615
- if (!rec) { noProv++; continue; }
5616
- st.chain = [rec];
5617
- st.dirty = true;
5618
- filled++;
5857
+ const act = bindRepairAction(c, st);
5858
+ if (!act) {
5859
+ // 没动它:分清「健康链无需推荐」和「没有可推荐的」两种落空
5860
+ if (!st.chain.length) { noProv++; continue; }
5861
+ const dead = chainDeadReasons(c, st.chain);
5862
+ if (dead.every((d) => d)) noProv++; else skipped++;
5619
5863
  continue;
5620
5864
  }
5621
- // 链非空:只处理死链——整条链全部死透(或只有链首且已死)才重推荐,
5622
- // 部分存活的长链保留(还降级得动,不擅自覆盖用户手工配的结构)
5623
- const dead = chainDeadReasons(c, st.chain);
5624
- const aliveN = dead.filter((d) => !d).length;
5625
- const allDead = aliveN === 0;
5626
- const headDead = dead[0] !== ""; // 空 p = CLI 默认凭据,是有意配置不算死
5627
- const rec = recommendFor(c);
5628
- if (allDead && rec) {
5629
- st.chain = [rec];
5630
- st.dirty = true;
5631
- refilled++;
5632
- } else if (allDead) {
5633
- noProv++;
5634
- } else if (headDead && rec && chainKey(st.chain) !== chainKey([rec])) {
5635
- // 链首死但链内还有活的备选:只把推荐的新链首插到最前面(原降级序保留)
5636
- const hasRec = st.chain.some((x) => x.p === rec.p && x.m === rec.m);
5637
- if (!hasRec) {
5638
- st.chain = [rec].concat(st.chain);
5639
- st.dirty = true;
5640
- refilled++;
5641
- } else {
5642
- // 推荐项已在链内:把它升到链首
5643
- st.chain = [rec].concat(st.chain.filter((x) => x !== rec));
5644
- st.dirty = true;
5645
- refilled++;
5646
- }
5647
- } else {
5648
- skipped++;
5649
- }
5865
+ const wasEmpty = !st.chain.length;
5866
+ st.chain = act;
5867
+ st.dirty = true;
5868
+ if (wasEmpty) filled++; else refilled++;
5650
5869
  }
5651
5870
  bindRepaint();
5652
5871
  if (filled && refilled) {
@@ -5664,6 +5883,56 @@ function autoBindAll() {
5664
5883
  }
5665
5884
  }
5666
5885
 
5886
+ /* 厂商/模型停用·启用·删除后自动补一次推荐绑定(2026-09-17 用户拍板):
5887
+ * 绑定链——空链预填、死链重推荐、死链首插新首,与「一键推荐绑定」同规则,
5888
+ * 但直接落盘(自动场景没有人工确认环节);目录页——空默认模型直填。
5889
+ * 没有合适的推荐就保持原样,什么都不绑。绑定页上用户手改中的草稿(dirty)
5890
+ * 不碰;一处都没改成静默返回,不打扰停用/启用的操作反馈。 */
5891
+ let _autoRebindRunning = false, _autoRebindAgain = false;
5892
+ async function autoRebindSoon() {
5893
+ if (_autoRebindRunning) { _autoRebindAgain = true; return; }
5894
+ _autoRebindRunning = true;
5895
+ try {
5896
+ await poll(); // 拿停用/启用落盘后的最新 providers / bindings / catalog
5897
+ let fixed = 0;
5898
+ for (const c of (S.catalog || []).filter((x) => x.installed && x.orch_kind)) {
5899
+ const st = bindSelById(c.id);
5900
+ if (st.dirty) continue; // 用户手改中,不覆盖草稿
5901
+ const act = bindRepairAction(c, st);
5902
+ if (!act) continue;
5903
+ const b = (S.bindings || {})[c.id] || {};
5904
+ try {
5905
+ await api("/api/models/binding", { method: "POST", body: JSON.stringify({
5906
+ agent_id: c.id, provider_id: act[0].p,
5907
+ chain: act.map((x) => ({ provider_id: x.p, model: x.m })),
5908
+ difficulty_routing: !!b.difficulty_routing }) });
5909
+ st.chain = act; st.dirty = false; st.key = chainKey(act);
5910
+ fixed++;
5911
+ } catch (e) { /* 单条失败不打断,等下次变更再补 */ }
5912
+ }
5913
+ for (const c of (S.catalog || []).filter((x) =>
5914
+ x.installed && x.config_writable && !fmtModel(x.model))) {
5915
+ const rec = recommendFor(c);
5916
+ if (!rec) continue;
5917
+ try {
5918
+ const r = await api("/api/catalog/" + encodeURIComponent(c.id) + "/model",
5919
+ { method: "POST", body: JSON.stringify({ model: rec.m }) });
5920
+ c.model = r.model || rec.m;
5921
+ fixed++;
5922
+ } catch (e) { /* 同上 */ }
5923
+ }
5924
+ if (fixed) {
5925
+ S.catSig = null; S.bindSig = null;
5926
+ render();
5927
+ toast(t("厂商/模型变动,已自动重绑 %1 处。").replace("%1", fixed));
5928
+ }
5929
+ } catch (e) { /* 自动补绑失败静默:不打断用户的停用/启用操作 */ }
5930
+ finally {
5931
+ _autoRebindRunning = false;
5932
+ if (_autoRebindAgain) { _autoRebindAgain = false; autoRebindSoon(); }
5933
+ }
5934
+ }
5935
+
5667
5936
  async function saveAllBindings() {
5668
5937
  const targets = (S.catalog || []).filter((c) => c.installed && c.orch_kind);
5669
5938
  const dirty = targets.filter((c) => (S.bindSel[c.id] || {}).dirty);
@@ -5698,6 +5967,8 @@ function bindModelBox(c, provId) {
5698
5967
  : ""));
5699
5968
  return '<span class="ochip' + (i === 0 ? " primary" : "") + '">' +
5700
5969
  "<b>" + (i === 0 ? t("主") : t("备")) + "</b>" + esc(pname) + " · " + esc(c2.m) +
5970
+ (modelHasImage(c2.p, c2.m)
5971
+ ? ' <span class="tag ok" title="' + esc(t("支持图片输入")) + '">' + t("图") + "</span>" : "") +
5701
5972
  (dead ? ' <span class="hint warn">⚠ ' + esc(t("协议不匹配,解析时跳过")) + "</span>" : badge) +
5702
5973
  (i > 0 ? '<button class="mini" data-m="' + esc(c2.m) + '" data-p="' + esc(c2.p) +
5703
5974
  '" title="' + t("设为主模型") + '" onclick="bindPromote(\'' + esc(c.id) + '\', this)">' +
@@ -5707,7 +5978,7 @@ function bindModelBox(c, provId) {
5707
5978
  "</span>";
5708
5979
  }).join("")
5709
5980
  : '<span class="hint">' + t("未设置") + (provId
5710
- ? t("(按供应商/难度自动解析——供应商协议不匹配或被停用时解析为空,回落 CLI 本机默认)")
5981
+ ? t("(按供应商/难度自动解析——供应商协议不匹配或被停用时解析为空,相关步骤将判失败)")
5711
5982
  : t("(用 CLI 默认模型——不会注入任何供应商凭据)")) + "</span>";
5712
5983
  return '<div class="field"><label>' + t("运行时模型链(跨厂商,最多 ") + MAX_ORCH_MODELS + t(" 条)") + "</label>" +
5713
5984
  '<div class="orch-row">' + chips +
@@ -5750,6 +6021,8 @@ function bindPanel(c) {
5750
6021
  return '<label class="oitem' + (dead ? " dim" : "") + '"><input type="checkbox" value="' + esc(m) + '" data-p="' + esc(g.id) + '"' +
5751
6022
  (has ? " checked" : "") + (dead ? " disabled" : "") +
5752
6023
  " onchange=\"bindPick('" + esc(c.id) + "', this, this.checked)\">" + esc(m) +
6024
+ (modelHasImage(g.id, m)
6025
+ ? ' <span class="tag ok" title="' + esc(t("支持图片输入")) + '">' + t("图") + "</span>" : "") +
5753
6026
  (dead ? ' <span class="hint warn">' + esc(t("无可用 wire,解析时跳过")) + "</span>" : "") +
5754
6027
  "</label>";
5755
6028
  }).join("") +
@@ -5895,6 +6168,45 @@ async function saveModel(id) {
5895
6168
  poll();
5896
6169
  }
5897
6170
 
6171
+ /* 一键绑定推荐模型(智能体目录页):给「默认模型」还空着的已安装智能体按
6172
+ * 协议适配规则(复用绑定页 recommendFor 评分)挑一家厂商的启用模型并直接
6173
+ * 写入该 CLI 自身配置(写入侧先自动 .bak 备份)。已配置的不动——目录页的
6174
+ * 默认模型会写进 CLI 全局配置,手工设置过的值(含 CLI 订阅自带的默认)不
6175
+ * 该被一键覆盖;要换模型逐卡下拉改就行。 */
6176
+ async function catAutoBindAll() {
6177
+ const targets = (S.catalog || []).filter((c) => c.installed && c.config_writable);
6178
+ if (!targets.length) { toast(t("没有支持写入默认模型的已安装智能体。"), true); return; }
6179
+ const empty = targets.filter((c) => !fmtModel(c.model));
6180
+ if (!empty.length) { toast(t("所有已安装智能体都已配置默认模型,无需绑定。")); return; }
6181
+ let done = 0, failed = 0, noProv = 0, firstErr = "";
6182
+ for (const c of empty) {
6183
+ const rec = recommendFor(c);
6184
+ if (!rec) { noProv++; continue; }
6185
+ try {
6186
+ const r = await api("/api/catalog/" + encodeURIComponent(c.id) + "/model",
6187
+ { method: "POST", body: JSON.stringify({ model: rec.m }) });
6188
+ c.model = r.model || rec.m;
6189
+ done++;
6190
+ } catch (e) {
6191
+ failed++;
6192
+ if (!firstErr) firstErr = c.name + ":" + e.message;
6193
+ }
6194
+ }
6195
+ S.catSig = null;
6196
+ renderCatalog();
6197
+ if (done && failed) {
6198
+ toast(t("已为 %1 个智能体写入推荐模型,%2 个失败。").replace("%1", done).replace("%2", failed) +
6199
+ (firstErr ? " " + firstErr : ""), true);
6200
+ } else if (done) {
6201
+ toast(t("已为 %1 个智能体写入推荐模型(原配置已自动备份 .bak)。").replace("%1", done));
6202
+ } else if (failed) {
6203
+ toast(t("推荐模型写入失败:") + firstErr, true);
6204
+ } else {
6205
+ toast(t("没有可推荐的:先到「模型接入」页导入与 CLI 协议匹配的供应商。"), true);
6206
+ }
6207
+ poll();
6208
+ }
6209
+
5898
6210
  /* 一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
5899
6211
  用的模型就是目录页「默认模型」已写入该 CLI 配置文件的那份;密钥由服务端按编排同款规则注入 */
5900
6212
  async function openAgent(id) {
@@ -6957,9 +7269,24 @@ async function loadSelfupdate(force) {
6957
7269
  SU = await api("/api/selfupdate" + (force ? "?force=1" : ""));
6958
7270
  } catch (e) { SU = null; }
6959
7271
  renderSu();
7272
+ maybeWhatsnew();
6960
7273
  return SU;
6961
7274
  }
6962
7275
 
7276
+ /* 升级重启后的一次性「本次更新内容」:localStorage 记住已展示的版本,
7277
+ * 版本变化且有本地 changelog 小节时弹窗(首次使用只记账不弹)。 */
7278
+ function maybeWhatsnew() {
7279
+ if (!SU || !SU.current) return;
7280
+ const prev = localStorage.getItem("su.myver");
7281
+ localStorage.setItem("su.myver", SU.current);
7282
+ if (prev && prev !== SU.current && SU.whatsnew) {
7283
+ openModal(t("本次更新内容"),
7284
+ "<p class=\"hint\">" + t("已更新到") + " <b>v" + esc(SU.current) + "</b></p>" +
7285
+ "<pre class=\"su-notes\">" + esc(SU.whatsnew) + "</pre>",
7286
+ "<button class=\"small\" onclick=\"closeModal()\">" + t("知道了") + "</button>");
7287
+ }
7288
+ }
7289
+
6963
7290
  function renderSu() {
6964
7291
  const info = $("su-info");
6965
7292
  if (!info) return;
@@ -6970,6 +7297,10 @@ function renderSu() {
6970
7297
  if (SU.has_update) {
6971
7298
  html += "<p class=\"hint\"><b>" + t("发现新版本") + " v" + SU.latest +
6972
7299
  t(" ") + "<a href=\"#\" onclick=\"event.preventDefault();suApply()\">" + t("立即升级") + "</a></b></p>";
7300
+ if (SU.notes) {
7301
+ html += "<div class=\"hint\"><b>" + t("新版本更新内容") + t(":") + "</b>" +
7302
+ "<pre class=\"su-notes\">" + esc(SU.notes) + "</pre></div>";
7303
+ }
6973
7304
  } else if (SU.mode === "npm" && !SU.note) {
6974
7305
  html += "<p class=\"hint\">" + t("已是最新版。") + "</p>";
6975
7306
  }
@@ -8028,6 +8359,7 @@ document.addEventListener("DOMContentLoaded", () => {
8028
8359
  $("btn-reload-catalog").addEventListener("click", async () => {
8029
8360
  await api("/api/catalog/reload", { method: "POST" }); poll(); refreshSessionAgents();
8030
8361
  });
8362
+ $("btn-catalog-autobind").addEventListener("click", catAutoBindAll);
8031
8363
  $("btn-import").addEventListener("click", openImportDialog);
8032
8364
  $("btn-add-provider").addEventListener("click", openAddProviderDialog);
8033
8365
  $("prov-search").addEventListener("input", () => {