codebee 0.1.95 → 0.1.97

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
package/app/ui/app.js CHANGED
@@ -840,8 +840,12 @@ function recommendTaskType(goal) {
840
840
  // 专属词在前(简历/PPT/标书的目标里常带技术词,放 code 之后会被截胡,
841
841
  // 「优化我的简历,投后端岗」实测命中 code——2026-10-07 巡检):
842
842
  ["resume", /(简历|求职|应聘)/i],
843
- ["presentation", /(演示文稿|ppt|幻灯片|答辩)/i],
843
+ ["presentation", /(演示文稿|ppt|幻灯片|答辩|演示场合)/i],
844
844
  ["bid_doc", /(标书|投标|招标应答)/i],
845
+ // 缺陷复盘同为专属词(「复盘这些bug」会被 code 的 bug 词截胡):要求复盘
846
+ // 与 bug/缺陷 共现(或命中漏测),纯 debug(「分析这个bug」)仍归 code。
847
+ ["defect_retro",
848
+ /(缺陷复盘|漏测|(bug|缺陷)[^,。;]{0,10}复盘|复盘[^,。;]{0,10}(bug|缺陷))/i],
845
849
  ["code", /(代码|bug|报错|修复|重构|接口|数据库|前端|后端|python|javascript|typescript|java|go\b|git)/i],
846
850
  ["translation", /(翻译|译成|translate|translation|中译英|英译中)/i],
847
851
  ["email", /(邮件|email|e-mail|回信|邀约信|商务函)/i],
@@ -849,11 +853,18 @@ function recommendTaskType(goal) {
849
853
  ["video_script", /(短视频|口播|分镜|抖音|b站|视频号|视频脚本)/i],
850
854
  ["speech", /(演讲稿|发言稿|致辞|演讲)/i],
851
855
  ["tech_proposal", /(技术方案|架构方案|选型方案|实施方案)/i],
852
- ["research", /(调研报告|竞品调研|市场调研|深度研究)/i],
853
- ["serial_novel", /(连载|续写.*章|网文|长篇小说)/i],
856
+ ["research", /(调研|深度研究)/i],
857
+ // goal_hint 引导词对齐(2026-10-08 第二轮巡检):serial_novel 例句
858
+ // 「都市女频/可签约平台」、article 提示「头条/知乎」——照提示输入
859
+ // 此前不弹类型建议(同 video_script 补词第 3 例缝隙)。
860
+ ["serial_novel", /(连载|续写.*章|网文|长篇小说|女频|男频|签约平台)/i],
854
861
  ["novel", /(小说|故事|短篇|章节)/i],
855
- ["article", /(公众号|自媒体|文章|博客|小红书)/i],
862
+ ["article", /(公众号|自媒体|文章|博客|小红书|头条|知乎)/i],
856
863
  ["doc", /(文档|操作手册|说明书)/i],
864
+ // 宽词放末位兜底(「电影排行榜文章」由前面的 article 先接住);词形对齐
865
+ // rank_scan 的 goal_hint(「默认分析总榜热门题材」)。至此 direct 之外
866
+ // 17 型推荐规则全覆盖(2026-10-08 巡检补 rank_scan/defect_retro 两型)。
867
+ ["rank_scan", /(扫榜|热门题材|榜单分析|排行榜)/i],
857
868
  ];
858
869
  const hit = rules.find((x) => x[1].test(s));
859
870
  return hit && flowById(hit[0]) ? hit[0] : "";
@@ -3117,9 +3128,13 @@ async function createTask() {
3117
3128
  if (suggested) {
3118
3129
  const flow = flowById(suggested) || {};
3119
3130
  resetSubmit();
3131
+ // 快档目标(rank_scan/defect_retro)没有评审门禁,卖点如实换成各自的
3132
+ // 专用数据链路/产出模板;review/code 目标维持门禁口径。
3133
+ const tail = flow.engine === "direct"
3134
+ ? t("这个需求更适合「{0}」流程,能使用对应的专用数据链路与产出模板。是否切换后执行?")
3135
+ : t("这个需求更适合「{0}」流程,能使用对应的规划与质量门禁。是否切换后执行?");
3120
3136
  const yes = await uiConfirm(
3121
- t("这个需求更适合「{0}」流程,能使用对应的规划与质量门禁。是否切换后执行?")
3122
- .replace("{0}", t(flow.name || suggested)),
3137
+ tail.replace("{0}", t(flow.name || suggested)),
3123
3138
  { title: t("推荐任务类型"), ok: t("切换类型") });
3124
3139
  S.typeSuggestionDone = true;
3125
3140
  if (yes) pickType(suggested);
@@ -3847,6 +3862,7 @@ async function renameTask(id) {
3847
3862
  async function retryTask(id) {
3848
3863
  try {
3849
3864
  const r = await api("/api/tasks/" + encodeURIComponent(id) + "/retry", { method: "POST" });
3865
+ chatJumpLatest(); // 续跑副本开新一轮:时间线要跳到最新,别停在原地或历史中间
3850
3866
  jumpToRun(r.run_id);
3851
3867
  } catch (e) { toast(t("重试失败:") + e.message, true); return; }
3852
3868
  poll();
@@ -3873,6 +3889,7 @@ async function continueSerial(id) {
3873
3889
  r = await api("/api/tasks/" + encodeURIComponent(id) + "/continue",
3874
3890
  { method: "POST", body: JSON.stringify({ chapters: ch }) });
3875
3891
  } catch (e) { toast(t("创建续写任务失败:") + e.message, true); return; }
3892
+ chatJumpLatest(); // 续写批次开新一轮:对话时间线跳到最新
3876
3893
  jumpToRun(r.run_id);
3877
3894
  poll();
3878
3895
  }
@@ -4548,6 +4565,7 @@ window.sideOpenRun = function (id, n) {
4548
4565
  * 按时间顺序列出该任务全部 run 的全部步骤,run 之间加分隔条。 */
4549
4566
  window.sideOpenTask = function (key) {
4550
4567
  _taskStepRenderToken++;
4568
+ chatJumpLatest(); // 首开任务详情:对话时间线落最新(默认落蜂巢时切到对话页签也带到底)
4551
4569
  $("rd-steps").innerHTML = '<div class="hint" role="status">' + esc(t("正在加载任务详情…")) + "</div>";
4552
4570
  S.lastRun = null;
4553
4571
  S.detailTaskKey = key;
@@ -4678,12 +4696,127 @@ function renderTaskDetail() {
4678
4696
  setResumeNowBtn(null); // 退避与否要看 run 数据,先收起防上一个任务残留
4679
4697
  const isTask = ((S.state || {}).tasks || []).some((t2) => t2.id === key);
4680
4698
  if (isTask) {
4699
+ renderProjectMemoryControls(key);
4700
+ renderCheckpointControls(latestSummary.id || "", true);
4701
+ refreshCheckpointRestoreAvailability(latestSummary);
4702
+ if (latestSummary.id && S._checkpointRun !== latestSummary.id) {
4703
+ S._checkpointRun = latestSummary.id;
4704
+ loadRunCheckpoints(latestSummary.id);
4705
+ }
4681
4706
  loadTaskRuns(key, latestSummary);
4682
4707
  return;
4683
4708
  }
4684
4709
  drawTaskDetail(key, ((S.state || {}).runs || []).filter((r) => (r.task_id || r.id) === key));
4685
4710
  }
4686
4711
 
4712
+ function renderCheckpointControls(runId, visible) {
4713
+ const box = $("rd-checkpoints");
4714
+ if (!box) return;
4715
+ box.classList.toggle("hidden", !visible || !runId);
4716
+ if (runId && box.dataset.runId !== runId) {
4717
+ box.dataset.runId = runId;
4718
+ box.dataset.hasRestorable = "false";
4719
+ const restore = $("rd-checkpoints-restore");
4720
+ if (restore) restore.classList.add("hidden");
4721
+ box.classList.add("needs-load");
4722
+ }
4723
+ }
4724
+
4725
+ function refreshCheckpointRestoreAvailability(run) {
4726
+ const box = $("rd-checkpoints");
4727
+ const button = $("rd-checkpoints-restore");
4728
+ if (!box || !button) return;
4729
+ const terminal = !!run && ["done", "failed", "cancelled", "timeout"].includes(run.status);
4730
+ button.classList.toggle("hidden", !terminal || box.dataset.hasRestorable !== "true");
4731
+ }
4732
+
4733
+ function renderProjectMemoryControls(taskId) {
4734
+ const box = $("rd-project-memory");
4735
+ if (!box) return;
4736
+ const task = ((S.state || {}).tasks || []).find((item) => item.id === taskId);
4737
+ box.classList.toggle("hidden", !task || !task.workdir);
4738
+ if (task && task.workdir && S._projectMemoryTask !== taskId) {
4739
+ S._projectMemoryTask = taskId;
4740
+ loadProjectMemory(taskId);
4741
+ }
4742
+ }
4743
+
4744
+ window.loadProjectMemory = async function (taskId) {
4745
+ taskId = taskId || S.detailTaskKey || ((S.state || {}).tasks || []).find((item) => item.id === S.lastRunTask?.id)?.id;
4746
+ if (!taskId) return;
4747
+ const requestSeq = S._projectMemorySeq = (S._projectMemorySeq || 0) + 1;
4748
+ const box = $("rd-project-memory-body");
4749
+ if (!box) return;
4750
+ box.textContent = t("正在加载项目记忆…");
4751
+ try {
4752
+ const data = await api("/api/tasks/" + encodeURIComponent(taskId) + "/memory");
4753
+ if (requestSeq !== S._projectMemorySeq) return;
4754
+ if (S.detailTaskKey && S.detailTaskKey !== taskId) return;
4755
+ const entries = data.entries || [];
4756
+ if (!entries.length) { box.innerHTML = '<span class="hint">' + esc(t("暂无项目记忆")) + "</span>"; return; }
4757
+ box.innerHTML = entries.map((entry) => {
4758
+ const canApprove = entry.status === "pending" && !entry.expired && entry.verified;
4759
+ const canRestore = entry.status === "archived" && !entry.expired && entry.verified;
4760
+ const actions = (canApprove ? '<button type="button" class="ghost small" onclick="decideProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',\'approve\',' + Number(entry.version) + ')">' + esc(t("批准")) + "</button><button type=\"button\" class=\"ghost small\" onclick=\"decideProjectMemory('" + jsq(taskId) + "','" + jsq(entry.id) + "','reject'," + Number(entry.version) + ')\">' + esc(t("拒绝")) + "</button>" : "") +
4761
+ (entry.status === "approved" ? '<button type="button" class="ghost small" onclick="decideProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',\'archive\',' + Number(entry.version) + ')">' + esc(t("归档")) + "</button>" : "") +
4762
+ (canRestore ? '<button type="button" class="ghost small" onclick="decideProjectMemory(\'' + jsq(taskId) + '\',\'' + jsq(entry.id) + '\',\'restore\',' + Number(entry.version) + ')">' + esc(t("恢复")) + "</button>" : "");
4763
+ return '<article class="rd-memory-row"><div><strong>' + esc(entry.title || t("项目记忆")) + '</strong><span class="chip">' + esc(entry.status || "") + (entry.expired ? " · " + esc(t("已过期")) : "") + '</span></div><pre>' + esc(entry.content || "") + '</pre><div class="rd-memory-actions">' + actions + "</div></article>";
4764
+ }).join("");
4765
+ } catch (error) { box.innerHTML = '<span class="hint">' + esc(t("加载项目记忆失败:")) + esc(error.message) + "</span>"; }
4766
+ };
4767
+
4768
+ window.decideProjectMemory = async function (taskId, id, action, version) {
4769
+ try {
4770
+ await api("/api/tasks/" + encodeURIComponent(taskId) + "/memory", {
4771
+ method: "POST", headers: Object.assign({ "Content-Type": "application/json" }, authHeaders()),
4772
+ body: JSON.stringify({ id, action, expected_version: version }),
4773
+ });
4774
+ await loadProjectMemory(taskId);
4775
+ } catch (error) { toast(t("项目记忆操作失败:") + error.message, true); }
4776
+ };
4777
+
4778
+ window.loadRunCheckpoints = async function (requestedRunId) {
4779
+ const runId = requestedRunId || S.detailRunId || ((S.state || {}).task_latest || {})[S.detailTaskKey]?.id;
4780
+ if (!runId) return;
4781
+ const requestSeq = S._checkpointRequestSeq = (S._checkpointRequestSeq || 0) + 1;
4782
+ const body = $("rd-checkpoints-body");
4783
+ const box = $("rd-checkpoints");
4784
+ box.classList.remove("hidden");
4785
+ body.textContent = t("正在读取文件检查点…");
4786
+ try {
4787
+ const data = await api("/api/runs/" + encodeURIComponent(runId) + "/checkpoints");
4788
+ if (requestSeq !== S._checkpointRequestSeq) return;
4789
+ if (S.detailRunId && S.detailRunId !== runId) return;
4790
+ if (S.detailTaskKey) {
4791
+ const current = ((S.state || {}).task_latest || {})[S.detailTaskKey];
4792
+ if (current && current.id !== runId) return;
4793
+ }
4794
+ const files = data.files && data.files.files || [];
4795
+ const restore = $("rd-checkpoints-restore");
4796
+ const latest = S.detailTaskKey && ((S.state || {}).task_latest || {})[S.detailTaskKey];
4797
+ const run = (S.lastRun && S.lastRun.id === runId) ? S.lastRun : latest;
4798
+ box.dataset.hasRestorable = files.some((file) => file.restorable) ? "true" : "false";
4799
+ refreshCheckpointRestoreAvailability(run);
4800
+ body.innerHTML = (data.files && data.files.warning ? '<p class="hint">' + esc(data.files.warning) + "</p>" : "") +
4801
+ (files.length ? files.map((file) => '<article class="rd-checkpoint-row"><strong>' + esc(file.path) + '</strong><span class="chip">' + esc(file.status) + (file.restorable ? " · " + esc(t("可恢复")) : "") + '</span><pre>' + esc(file.diff || "") + "</pre></article>").join("") : '<span class="hint">' + esc(t("此运行没有文件快照")) + "</span>");
4802
+ box.dataset.runId = runId;
4803
+ box.classList.remove("needs-load");
4804
+ } catch (error) { body.innerHTML = '<span class="hint">' + esc(t("检查点读取失败:")) + esc(error.message) + "</span>"; }
4805
+ };
4806
+
4807
+ window.restoreRunCheckpoints = async function () {
4808
+ const runId = $("rd-checkpoints")?.dataset.runId;
4809
+ if (!runId || !await uiConfirm(t("恢复会覆盖检查点记录的文件,shell、MCP 和外部服务副作用不保证可回滚。确定继续?"), { ok: t("恢复文件"), danger: true })) return;
4810
+ try {
4811
+ const result = await api("/api/runs/" + encodeURIComponent(runId) + "/checkpoints/restore", {
4812
+ method: "POST", headers: Object.assign({ "Content-Type": "application/json" }, authHeaders()),
4813
+ body: JSON.stringify({ confirm: true }),
4814
+ });
4815
+ toast(t("已恢复 ") + (result.restored || 0) + t(" 个文件") + ((result.conflicts || []).length ? ";" + t("冲突 ") + result.conflicts.join(", ") : ""));
4816
+ await loadRunCheckpoints();
4817
+ } catch (error) { toast(t("恢复失败:") + error.message, true); }
4818
+ };
4819
+
4687
4820
  function loadTaskRuns(key, latestSummary) {
4688
4821
  const cached = _taskRunsCache.get(key);
4689
4822
  if (cached) {
@@ -5101,6 +5234,7 @@ window.cancelAllRuns = cancelAllRuns;
5101
5234
 
5102
5235
  async function openRun(id, pinTab) {
5103
5236
  _taskStepRenderToken++;
5237
+ chatJumpLatest(); // 首开运行详情:对话时间线落最新(历史长时别停在顶部)
5104
5238
  S.detailRunId = id;
5105
5239
  S.detailTaskKey = null;
5106
5240
  S.runDetailSig = ""; // 新目标首帧必画;也兜住「任务态改过 DOM 后回到本运行」的串台
@@ -5185,6 +5319,10 @@ function rdTabAvail() {
5185
5319
 
5186
5320
  function applyRdTabs() {
5187
5321
  const avail = rdTabAvail();
5322
+ // 「切进对话页签」以 pane 入口前的真实显隐为准:rdChatNavGo/setRdTab 等
5323
+ // 入口都是先改 S.rdTab 再进来,函数内抓 S.rdTab 旧值抓不到过渡。
5324
+ const _chatPaneIn = document.querySelector('#run-detail .rd-pane[data-pane="chat"]');
5325
+ const chatWasHidden = !_chatPaneIn || _chatPaneIn.classList.contains("hidden");
5188
5326
  if (!S.rdTab || !avail[S.rdTab]) {
5189
5327
  S.rdTab = ["chat", "hive", "steps", "result", "preview", "git", "bible", "bookmeta"]
5190
5328
  .find((k) => avail[k]) || "steps";
@@ -5209,6 +5347,17 @@ function applyRdTabs() {
5209
5347
  }
5210
5348
  // 对话页签不放日志抽屉:会盖住贴底输入条(手动切来/自动选卡都覆盖)
5211
5349
  if (S.rdTab === "chat" && !$("rd-log").classList.contains("hidden")) window.rdLogClose();
5350
+ // 切进对话页签且带着跳最新标记(继续任务/首开详情置的):把时间线带到底。
5351
+ // 内容已与当前 run 对齐(drawChatFlow 盖的 run 戳)才就地落底并消费——内容
5352
+ // 还是上一轮的旧帧(续跑换 run、/timeline 在途)则保留标记,等 drawChatFlow
5353
+ // 重建完新内容再落底,避免停在旧帧尾、新一轮气泡压在折叠线下面。
5354
+ if (S.rdTab === "chat" && chatWasHidden && chatForceBottom) {
5355
+ const fl = $("rd-chat-flow");
5356
+ if (fl && fl.dataset.runId && fl.dataset.runId === chatRunId) {
5357
+ chatScrollBottomNow(fl);
5358
+ chatForceBottom = false;
5359
+ }
5360
+ }
5212
5361
  renderChatNav();
5213
5362
  }
5214
5363
 
@@ -5467,6 +5616,18 @@ async function renderRunDetail() {
5467
5616
  $("btn-share").classList.toggle("hidden", active); // 分享页:结束后可生成自包含 HTML
5468
5617
  $("btn-talk").classList.toggle("hidden", !(run.task_id && !chatEngineIsDirect(run)));
5469
5618
  const rcTask = ((S.state || {}).tasks || []).find((x) => x.id === run.task_id);
5619
+ if (rcTask) {
5620
+ renderProjectMemoryControls(rcTask.id);
5621
+ } else {
5622
+ const memoryBox = $("rd-project-memory");
5623
+ if (memoryBox) memoryBox.classList.add("hidden");
5624
+ }
5625
+ renderCheckpointControls(run.id, !!rcTask);
5626
+ refreshCheckpointRestoreAvailability(run);
5627
+ if (rcTask && S._checkpointRun !== run.id) {
5628
+ S._checkpointRun = run.id;
5629
+ loadRunCheckpoints(run.id);
5630
+ }
5470
5631
  // 签名守卫(模型页/检查器同款):数据没变不重绘。详情区是全页最重的
5471
5632
  // 渲染面,此前 SSE 推送+轮询每 8s 无差别整块重写,是「页面一闪一闪、
5472
5633
  // 发布表单填一半被清空」的总根源(2026-09-30 用户实案)。运行中 ETA
@@ -5980,6 +6141,7 @@ function pbBlock(task, platform) {
5980
6141
  }
5981
6142
  const busy = st === "busy";
5982
6143
  let btns = "";
6144
+ let pendHtml = ""; // 批量选择 chips(bookReady 分支里填充,extra 段落拼接)
5983
6145
  if (st === "none" || st === "error" || st === "waiting_login") {
5984
6146
  btns += '<button class="ghost" onclick="pbConnect(\'' + platform + '\')">' +
5985
6147
  (st === "error" ? t("重连") : t("连接平台")) + "</button>";
@@ -6006,11 +6168,62 @@ function pbBlock(task, platform) {
6006
6168
  if (run && run.status === "running") {
6007
6169
  btns += '<span class="pb-book">' + esc(t("自动发布中 ") + run.done + "/" + run.total) + "</span>";
6008
6170
  } else if (au.pending > 0) {
6009
- btns += ' <button class="ghost" ' + (busy || !au.guard_ok ? "disabled" : "") +
6010
- ' title="' + esc(au.guard_ok ? t("按章号顺序逐章填稿(人工模式每点一次填一章,浏览器里提交后再点发下一章)") : au.guard_reason || "") + '"' +
6171
+ // 门禁不再禁用按钮:点了走 pbApiWithQualityOverride 的复审/强制对话,
6172
+ // 死按钮只会让用户无路可走(2026-10-08 实案:guard false 全灰点不了)
6173
+ btns += ' <button class="ghost" ' + (busy ? "disabled" : "") +
6174
+ ' title="' + esc(au.guard_ok ? t("按章号顺序逐章填稿(人工模式每点一次填一章,浏览器里提交后再点发下一章)") : t("护栏提示:") + (au.guard_reason || "")) + '"' +
6011
6175
  ' onclick="pbPublishAll(\'' + esc(task.id) + "', '" + platform + '\')">' +
6012
6176
  t("发布全部待发") + t("(") + au.pending + t(")") + "</button>";
6013
6177
  }
6178
+ // 批量选择:待发章号 chips 按卷分组(卷计划来自服务端 items[].volume),
6179
+ // 「发布所选」人工模式 / 「直发所选」逐章自动提交(护栏照常生效)。
6180
+ // 分卷由服务端按卷计划自动选/建(volumes.py),界面上不用管卷。
6181
+ const its = (au.items && au.items.length ? au.items : [])
6182
+ .map((x) => ({ n: Number(x.chapter_no), v: x.volume || "" }))
6183
+ .filter((x) => x.n > 0);
6184
+ if (its.length && !run) {
6185
+ const key = task.id + ":" + platform;
6186
+ const sig = its.map((x) => x.n).join(",");
6187
+ S.pbSel = S.pbSel || {};
6188
+ if (!S.pbSel[key] || S.pbSel[key].sig !== sig)
6189
+ S.pbSel[key] = { sig, sel: new Set(its.map((x) => x.n)) };
6190
+ const sel = S.pbSel[key].sel;
6191
+ // 连续段按卷名分组;无卷计划的整块平铺不显示卷头
6192
+ const groups = [];
6193
+ for (const x of its) {
6194
+ const g = groups[groups.length - 1];
6195
+ if (g && g.v === x.v) g.ns.push(x.n);
6196
+ else groups.push({ v: x.v, ns: [x.n] });
6197
+ }
6198
+ let chips = "";
6199
+ for (const g of groups) {
6200
+ if (groups.length > 1 || g.v)
6201
+ chips += '<div class="pb-vol">' + esc(g.v || t("未分卷")) + "</div>";
6202
+ for (const n of g.ns)
6203
+ chips += '<label class="pb-chip' + (sel.has(n) ? " on" : "") + '">' +
6204
+ '<input type="checkbox" ' + (sel.has(n) ? "checked" : "") +
6205
+ ' onchange="pbSelToggle(\'' + esc(task.id) + "','" + platform + "'," + n + ',this.checked)">' +
6206
+ n + "</label>";
6207
+ }
6208
+ pendHtml += '<div class="pb-pend" data-key="' + esc(key) + '">' +
6209
+ '<div class="pb-pend-head">' +
6210
+ '<span>' + esc(t("待发 ")) + its.length + esc(t(" 章(按卷计划自动分卷)")) + "</span>" +
6211
+ '<a href="javascript:void(0)" onclick="pbSelAll(\'' + esc(task.id) + "','" + platform + "',true)" + '">' + esc(t("全选")) + "</a>" +
6212
+ '<a href="javascript:void(0)" onclick="pbSelAll(\'' + esc(task.id) + "','" + platform + "',false)" + '">' + esc(t("清空")) + "</a>" +
6213
+ "</div>" +
6214
+ '<div class="pb-pend-chips">' + chips + "</div>" +
6215
+ '<div class="pb-pend-btns">' +
6216
+ '<button class="ghost" ' + (busy || !sel.size ? "disabled" : "") +
6217
+ ' title="' + esc(au.guard_ok ? "" : t("护栏提示:") + (au.guard_reason || "")) + '"' +
6218
+ ' onclick="pbPublishSelected(\'' + esc(task.id) + "','" + platform + '\',false)">' +
6219
+ t("发布所选") + t("(") + sel.size + t(")") + "</button>" +
6220
+ '<button class="ghost" ' + (busy || !sel.size ? "disabled" : "") +
6221
+ ' title="' + esc(t("所选章节逐章自动提交(无人值守),护栏每日上限/连败退避照常生效") +
6222
+ (au.guard_ok ? "" : ";" + t("护栏提示:") + (au.guard_reason || ""))) + '"' +
6223
+ ' onclick="pbPublishSelected(\'' + esc(task.id) + "','" + platform + '\',true)">' +
6224
+ t("直发所选") + t("(") + sel.size + t(")") + "</button>" +
6225
+ "</div></div>";
6226
+ }
6014
6227
  if (au.pending > 0 && !au.guard_ok) {
6015
6228
  btns += '<div class="pb-err">' + esc(au.guard_reason || t("护栏拦截")) + "</div>";
6016
6229
  }
@@ -6040,6 +6253,7 @@ function pbBlock(task, platform) {
6040
6253
  esc(t("作品已在平台建好(建书流程没走通或手工建的)?补登记后直接发章,不会在平台新建")) + '">' + t("登记已有作品") + "</button>";
6041
6254
  }
6042
6255
  let extra = "";
6256
+ extra += pendHtml;
6043
6257
  if (st === "error" && !bookReady && ps.last_action === "create_book") {
6044
6258
  extra += '<div class="pb-hint">' + esc(t("若作品其实已在平台建好,点「登记已有作品」补登记,勿重复创建")) + "</div>";
6045
6259
  }
@@ -6287,8 +6501,71 @@ window.pbConfirmChapter = async function (taskId, platform, chapterNo) {
6287
6501
  S._pbSig = ""; pbKick();
6288
6502
  };
6289
6503
 
6290
- window.pbPublishAll = async function (taskId, platform) {
6291
- const au = (((S.pubAuto && S.pubAuto.books) || [])
6504
+ function pbSelCountSync(key) {
6505
+ // 就地对账:按钮计数/禁用态与全部 chip 勾选态向状态层看齐——重绘链一旦
6506
+ // 断掉(poll 失败/详情上下文切换),勾选与计数也不许脱节(2026-10-08 实案
6507
+ // 「勾了 18 计数 48」即重绘未跟上)。
6508
+ const box = document.querySelector('.pb-pend[data-key="' +
6509
+ String(key).replace(/"/g, "&quot;") + '"]');
6510
+ const ent = (S.pbSel || {})[key];
6511
+ if (!box || !ent) return;
6512
+ for (const b of box.querySelectorAll("button")) {
6513
+ const t = b.textContent || "";
6514
+ if (/(\d+)$/.test(t)) b.textContent = t.replace(/(\d+)$/, "(" + ent.sel.size + ")");
6515
+ b.disabled = ent.sel.size === 0;
6516
+ }
6517
+ for (const c of box.querySelectorAll(".pb-chip")) {
6518
+ const inp = c.querySelector("input");
6519
+ const num = Number((c.textContent || "").trim());
6520
+ if (!inp || !num) continue;
6521
+ const on = ent.sel.has(num);
6522
+ if (inp.checked !== on) inp.checked = on;
6523
+ c.classList.toggle("on", on);
6524
+ }
6525
+ }
6526
+
6527
+ window.pbSelToggle = function (taskId, platform, no, on) {
6528
+ const key = taskId + ":" + platform;
6529
+ const ent = (S.pbSel || {})[key];
6530
+ if (!ent) return;
6531
+ if (on) ent.sel.add(Number(no)); else ent.sel.delete(Number(no));
6532
+ pbSelCountSync(key); // 先就地同步,再触发整块重绘
6533
+ S._pbSig = ""; pbKick();
6534
+ };
6535
+
6536
+ window.pbSelAll = function (taskId, platform, all) {
6537
+ const key = taskId + ":" + platform;
6538
+ const ent = (S.pbSel || {})[key];
6539
+ if (!ent) return;
6540
+ if (all) for (const n of ent.sig.split(",")) ent.sel.add(Number(n));
6541
+ else ent.sel.clear();
6542
+ pbSelCountSync(key);
6543
+ S._pbSig = ""; pbKick();
6544
+ };
6545
+
6546
+ window.pbPublishSelected = async function (taskId, platform, autoSubmit) {
6547
+ const key = taskId + ":" + platform;
6548
+ const ent = (S.pbSel || {})[key];
6549
+ const chapters = ent ? [...ent.sel].sort((a, b) => a - b) : [];
6550
+ if (!chapters.length) return;
6551
+ const msg = autoSubmit
6552
+ ? t("将对所选 {0} 章(第 {1} 章—第 {2} 章)逐章自动填稿并提交发布,无需人工确认;护栏(每日上限/连续失败暂停)生效。确定直发?")
6553
+ : t("将按章号顺序逐章填所选 {0} 章(第 {1} 章—第 {2} 章)。人工模式每轮填一章并停在表单页,由你在浏览器里确认提交。分卷会按卷计划自动选/建。继续?");
6554
+ if (!(await uiConfirm(t(msg, chapters.length, chapters[0], chapters[chapters.length - 1]),
6555
+ { ok: autoSubmit ? t("开始直发") : t("开始发布") }))) return;
6556
+ try {
6557
+ const r = await pbApiWithQualityOverride(
6558
+ "/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
6559
+ { platform, chapters, auto_submit: !!autoSubmit },
6560
+ "批量发布前的质量门禁未通过", taskId);
6561
+ if (!(r && r.gate_reviewed))
6562
+ toast(autoSubmit ? t("直发已启动,进度见发布台")
6563
+ : t("正在填第一章稿——填好后请在浏览器窗口里确认提交,再重新校准"));
6564
+ } catch (e) { toast(t("自动发布失败:") + e.message, true); }
6565
+ S._pbSig = ""; pbKick();
6566
+ };
6567
+
6568
+ window.pbPublishAll = async function (taskId, platform) { const au = (((S.pubAuto && S.pubAuto.books) || [])
6292
6569
  .find((b) => b.platform === platform)) || {};
6293
6570
  if (!au.pending) return;
6294
6571
  if (!(await uiConfirm(t("将从最靠前的待发章节开始填稿(共 {0} 章待发)。本轮只填一章并停在表单页,由你在浏览器里确认提交;提交后点击“我已在平台提交”,再选择下一章。护栏(每日上限/连续失败暂停)生效。继续?", au.pending),
@@ -8228,12 +8505,200 @@ function hiveThinkLine(lines) {
8228
8505
  return "";
8229
8506
  }
8230
8507
 
8508
+ /* —— 3D 场景(WebGL 蜂巢塔)控制层:引擎在 hive3d.js,这里只管模式切换/
8509
+ * localStorage 持久化/WebGL 不可用降级,以及把 renderHive 的泳道数据翻译给场景。 —— */
8510
+ let hiveScene = null; // Hive3D 场景实例(懒创建)
8511
+ let hiveSceneDead = false; // WebGL 不可用/上下文丢失——本次页面周期内不再尝试
8512
+ let hiveMode = "3d"; // 当前视图模式(orch.hiveView)
8513
+ let hiveRunId = ""; // 最近一次 renderHive 的 run.id(尾巴/思考缓存键前缀)
8514
+ const hiveLiveMeta = {}; // rel -> { started_at }(3D 芯片秒表用)
8515
+ let hiveDialogPoll = null;
8516
+ let hiveDialogKey = "";
8517
+
8518
+ function setHiveSceneButtons(is3d) {
8519
+ document.querySelectorAll("[data-hive-view]").forEach((button) => {
8520
+ const active = button.dataset.hiveView === (is3d ? "3d" : "2d");
8521
+ button.classList.toggle("active", active);
8522
+ button.setAttribute("aria-pressed", active ? "true" : "false");
8523
+ });
8524
+ }
8525
+
8526
+ function ensureHiveScene() {
8527
+ if (hiveScene || hiveSceneDead) return hiveScene;
8528
+ const canvas = $("rd-hive-gl"), overlay = $("rd-hive-overlay");
8529
+ if (!canvas || !overlay || !window.Hive3D) { hiveSceneDead = true; return null; }
8530
+ hiveScene = window.Hive3D.create({
8531
+ canvas, overlay,
8532
+ onCellActivate(rid, rel, cell) { openHiveMonitorLog(rid, rel, cell); },
8533
+ /* 悬停气泡与运行中芯片的实时内容:尾巴/思考来自 hiveTick 的缓存,秒表现算 */
8534
+ cellRefresh(rel) {
8535
+ const key = hiveRunId + "|" + rel;
8536
+ const meta = hiveLiveMeta[rel] || {};
8537
+ return {
8538
+ tail: hiveTails[key] || "",
8539
+ think: hiveThink[key] || "",
8540
+ elapsed: meta.started_at ? hiveElapsed(meta.started_at) : "",
8541
+ };
8542
+ },
8543
+ onFatal() { // 上下文丢失等不可恢复故障:退回 2D 并禁用 3D 入口
8544
+ hiveSceneDead = true;
8545
+ hiveScene = null;
8546
+ setHiveSceneMode("2d", { silent: true });
8547
+ toast(t("3D 场景不可用,已切换 2D 列表"), true);
8548
+ },
8549
+ });
8550
+ if (!hiveScene) hiveSceneDead = true;
8551
+ return hiveScene;
8552
+ }
8553
+
8554
+ async function openHiveMonitorLog(runId, rel, cell) {
8555
+ if (!rel) { toast(t("该步骤无日志"), true); return; }
8556
+ const dialog = $("hive-log-dialog"), title = $("hive-log-title"), meta = $("hive-log-meta"), pre = $("hive-log-text"), error = $("hive-log-error");
8557
+ if (!dialog || !title || !meta || !pre) { hiveOpenLog(runId, rel); return; }
8558
+ if (hiveDialogPoll) { clearInterval(hiveDialogPoll); hiveDialogPoll = null; }
8559
+ hiveDialogKey = runId + "|" + rel;
8560
+ title.textContent = cell && cell.role ? t(cell.role) : rdLogStepLabel(runId, rel);
8561
+ const status = cell && cell.status ? t({ running: "运行中", done: "完成", queued: "排队中", failed: "失败", timeout: "超时", cancelled: "已取消" }[cell.status] || cell.status) : "";
8562
+ meta.textContent = [status, cell && cell.displayAgent, cell && (cell.displayElapsed || cell.started_at)].filter(Boolean).join(" · ");
8563
+ pre.textContent = t("加载日志中…");
8564
+ if (error) error.textContent = "";
8565
+ if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", "");
8566
+ const load = async () => {
8567
+ try {
8568
+ const r = await api("/api/runs/" + encodeURIComponent(runId) + "/log?step=" + encodeURIComponent(rel) + "&pretty=1");
8569
+ if (hiveDialogKey !== runId + "|" + rel) return;
8570
+ pre.textContent = r.log || (r.step_status === "running" ? t("(等待输出…)") : t("(无输出)"));
8571
+ pre.scrollTop = pre.scrollHeight;
8572
+ if (r.step_status !== "running" && hiveDialogPoll) { clearInterval(hiveDialogPoll); hiveDialogPoll = null; }
8573
+ } catch (e) {
8574
+ if (error) error.textContent = t("日志读取失败:") + (e && e.message ? e.message : "");
8575
+ }
8576
+ };
8577
+ await load();
8578
+ if (cell && cell.status === "running") hiveDialogPoll = setInterval(load, 2500);
8579
+ }
8580
+
8581
+ function closeHiveMonitorLog() {
8582
+ hiveDialogKey = "";
8583
+ if (hiveDialogPoll) { clearInterval(hiveDialogPoll); hiveDialogPoll = null; }
8584
+ const dialog = $("hive-log-dialog");
8585
+ if (dialog && dialog.open) dialog.close();
8586
+ }
8587
+
8588
+ function setHiveSceneMode(mode, opts) {
8589
+ const viewport = $("rd-hive-viewport");
8590
+ if (!viewport) return;
8591
+ let is3d = mode !== "2d";
8592
+ if (is3d) {
8593
+ const sc = ensureHiveScene();
8594
+ if (!sc) {
8595
+ is3d = false;
8596
+ if (!(opts && opts.silent)) toast(t("当前环境不支持 WebGL,已切换 2D 列表"), true);
8597
+ }
8598
+ }
8599
+ hiveMode = is3d ? "3d" : "2d";
8600
+ if (hiveScene) hiveScene.setActive(is3d);
8601
+ viewport.classList.toggle("hive-mode-3d", is3d);
8602
+ viewport.classList.toggle("hive-mode-2d", !is3d);
8603
+ const tools = document.querySelector(".hive-scene-tools");
8604
+ if (tools) tools.classList.toggle("hive-2d", !is3d);
8605
+ setHiveSceneButtons(is3d);
8606
+ try { localStorage.setItem("orch.hiveView", hiveMode); } catch (e) { /* 隐私模式等 */ }
8607
+ }
8608
+
8609
+ function setupHiveSceneControls() {
8610
+ const viewport = $("rd-hive-viewport");
8611
+ if (!viewport || viewport.dataset.controlsReady) return;
8612
+ viewport.dataset.controlsReady = "true";
8613
+ const hiveDialog = $("hive-log-dialog");
8614
+ const hiveClose = $("hive-log-close");
8615
+ if (hiveClose) hiveClose.addEventListener("click", closeHiveMonitorLog);
8616
+ if (hiveDialog) hiveDialog.addEventListener("close", () => {
8617
+ hiveDialogKey = "";
8618
+ if (hiveDialogPoll) { clearInterval(hiveDialogPoll); hiveDialogPoll = null; }
8619
+ });
8620
+ document.querySelectorAll("[data-hive-view]").forEach((button) => {
8621
+ button.addEventListener("click", () => setHiveSceneMode(button.dataset.hiveView));
8622
+ });
8623
+ document.querySelectorAll("[data-hive-scene-action]").forEach((button) => {
8624
+ button.addEventListener("click", () => {
8625
+ if (hiveMode !== "3d" || !hiveScene) return;
8626
+ if (button.dataset.hiveSceneAction === "reset") hiveScene.resetView();
8627
+ else hiveScene.zoomAt(button.dataset.hiveSceneAction === "zoom-in" ? 1 / 1.18 : 1.18);
8628
+ });
8629
+ });
8630
+ let saved = "3d";
8631
+ try { saved = localStorage.getItem("orch.hiveView") || "3d"; } catch (e) { /* ignore */ }
8632
+ setHiveSceneMode(saved === "2d" ? "2d" : "3d", { silent: true });
8633
+ }
8634
+
8635
+ /* renderHive → 场景数据翻译:泳道=蜂巢塔的一层,格子按状态映射高度/颜色。
8636
+ * 气泡卡 HTML 在这里用 esc() 拼好(场景端 innerHTML 直用,自己不转义);
8637
+ * 运行中格子的尾巴/思考行留空,由场景每 2Hz 经 cellRefresh 回填。 */
8638
+ function hiveSceneSync(run, lanes, byStage) {
8639
+ hiveRunId = run.id;
8640
+ for (const k of Object.keys(hiveLiveMeta)) delete hiveLiveMeta[k];
8641
+ const model = { runId: run.id, lanes: [] };
8642
+ lanes.forEach((stage, i) => {
8643
+ const list = byStage[stage];
8644
+ const hasRun = list.some((s) => s.status === "running");
8645
+ const settled = list.filter((s) => !["running", "queued"].includes(s.status)).length;
8646
+ model.lanes.push({
8647
+ name: stage, count: list.length, settled, active: hasRun,
8648
+ /* 工位悬停气泡(阶段级):进度 + 最近落定步骤 */
8649
+ tip:
8650
+ '<b class="hg-tip-role">' + esc(stage) + "</b>" +
8651
+ '<i class="hg-tip-who">' + esc(settled + " / " + list.length) +
8652
+ (hasRun ? " · " + esc(t("进行中")) : "") + "</i>" +
8653
+ '<p class="hg-tip-tail">' +
8654
+ esc(list.filter((s) => !["running", "queued"].includes(s.status)).slice(-1).map((s) => s.role || "").join("")) +
8655
+ "</p>" +
8656
+ '<u class="hg-tip-meta">' + esc(t("点击工位看该阶段最新日志")) + "</u>",
8657
+ cells: list.slice(-8).map((s) => {
8658
+ const st = s.status === "queued" ? "queued"
8659
+ : s.status === "running" ? "running"
8660
+ : s.status === "failed" ? "failed"
8661
+ : s.status === "cancelled" ? "cancelled"
8662
+ : s.status === "timeout" ? "timeout" : "done";
8663
+ const who = s.agent_label || s.agent || "";
8664
+ const mdl = String(s.model || "").trim();
8665
+ const whoShow = who + (mdl ? " · " + mdl : "");
8666
+ const rel = s.log || "";
8667
+ if (st === "running") hiveLiveMeta[rel] = { started_at: s.started_at || "" };
8668
+ const concl = String(s.summary || "").replace(/\s+/g, " ").trim();
8669
+ const elapsed = s.duration_s != null ? s.duration_s + "s" : hiveElapsed(s.started_at);
8670
+ const stWord = { running: t("运行中"), done: t("完成"), failed: t("失败"),
8671
+ timeout: t("超时"), cancelled: t("已取消"), queued: t("排队中") }[st] || s.status;
8672
+ const metaBits = [stWord, elapsed, rel ? t("点击格子看日志") : ""].filter(Boolean).join(" · ");
8673
+ const tip =
8674
+ '<b class="hg-tip-role">' + esc(s.role || "") + "</b>" +
8675
+ (whoShow ? '<i class="hg-tip-who">' + esc(whoShow) + "</i>" : "") +
8676
+ '<p class="hg-tip-tail">' + (st === "running" ? "" : esc(concl)) + "</p>" +
8677
+ '<em class="hg-tip-think"></em>' +
8678
+ '<u class="hg-tip-meta">' + esc(metaBits) + "</u>";
8679
+ return {
8680
+ rel, role: s.role || "", status: st, tip,
8681
+ displayTail: st === "running" ? "" : concl,
8682
+ displayStatus: stWord,
8683
+ displayElapsed: elapsed,
8684
+ displayAgent: whoShow,
8685
+ };
8686
+ }),
8687
+ });
8688
+ });
8689
+ const sc = ensureHiveScene();
8690
+ if (!sc) return;
8691
+ sc.sync(model); // 2D 模式也同步:切回 3D 时数据即 ready(画布未激活零 GPU 开销)
8692
+ sc.setActive(hiveMode === "3d");
8693
+ }
8694
+
8231
8695
  window.renderHive = function (run) {
8232
8696
  const box = $("rd-hive");
8233
8697
  if (!box) return;
8234
8698
  const steps = (run && run.steps) || [];
8235
- if (!run || !steps.length) { box.classList.add("hidden"); stopHiveTick(); return; }
8699
+ if (!run || !steps.length) { box.classList.add("hidden"); stopHiveTick(); if (hiveScene) hiveScene.setActive(false); return; }
8236
8700
  box.classList.remove("hidden");
8701
+ setupHiveSceneControls();
8237
8702
  // 按步骤首次出现顺序分泳道(流水线天然有序)
8238
8703
  const lanes = [], byStage = {};
8239
8704
  steps.forEach((s) => {
@@ -8359,6 +8824,7 @@ window.renderHive = function (run) {
8359
8824
  if (cellsBox._hiveEnterT) clearTimeout(cellsBox._hiveEnterT);
8360
8825
  cellsBox._hiveEnterT = setTimeout(() => cellsBox.classList.remove("hive-enter"), 1400);
8361
8826
  }
8827
+ hiveSceneSync(run, lanes, byStage); // WebGL 蜂巢塔吃同一份泳道数据(内部自管激活)
8362
8828
  const live = running.length && (run.status === "running" || run.status === "queued");
8363
8829
  if (hiveClock) { clearInterval(hiveClock); hiveClock = null; }
8364
8830
  if (live) {
@@ -8444,6 +8910,7 @@ async function resumeNow() {
8444
8910
  } catch (e) { toast(t("操作失败:") + (e && e.message ? e.message : t("网络异常")), true); return; }
8445
8911
  if (r && r.ok) {
8446
8912
  toast(t("已跳过等待,马上继续"));
8913
+ chatJumpLatest(); // 续跑副本接着跑:新步骤落时间线尾部,跳过去看最新
8447
8914
  refreshState(); // 后端 bump 也会推 SSE,这里主动刷一轮让按钮立刻消失
8448
8915
  } else {
8449
8916
  toast((r && r.error) || t("操作失败"), true);
@@ -8720,7 +9187,28 @@ let chatSig = "";
8720
9187
  let chatLiveSig = ""; // 时间线实时增量签名(思考/正文长度变才重建 DOM)
8721
9188
  let chatLiveTimer = null; // 运行中快轮询句柄(1.2s,见 scheduleChatLive)
8722
9189
  let chatLiveRunId = null;
8723
- let chatForceBottom = false; // 发送消息后强制贴底一次(不管当时滚到哪,都要看到自己刚发的内容与回复)
9190
+ let chatForceBottom = false; // 强制贴底一次(不管当时滚到哪,都要看到最新内容);发送消息、继续任务、打开详情共用
9191
+
9192
+ /* 瞬时落底:.rd-chat-flow 带 scroll-behavior:smooth,直接赋值 scrollTop 会变
9193
+ * 成跨全时间的平滑滚动动画(长对话要滑近一秒,测试也会量到半途)——跳最新
9194
+ * 这类大跨度移动一律压成瞬时。 */
9195
+ function chatScrollBottomNow(fl) {
9196
+ if (!fl) return;
9197
+ const prev = fl.style.scrollBehavior;
9198
+ fl.style.scrollBehavior = "auto";
9199
+ fl.scrollTop = fl.scrollHeight;
9200
+ fl.style.scrollBehavior = prev;
9201
+ }
9202
+
9203
+ /* 让对话时间线跳到最新:置强制贴底标记(drawChatFlow 下一次重建时消费),
9204
+ * 并就现有内容先即时贴底,视觉零等待。「继续任务/立即续跑/继续连载」点完
9205
+ * 新一轮内容落在时间线尾部,用户要看到的是最新进展,不是停在原地或历史中间;
9206
+ * 首次打开详情同理。页签当前不可见时标记保留(drawChatFlow 不消费),
9207
+ * 切进对话页签时由 applyRdTabs 带到底。 */
9208
+ function chatJumpLatest() {
9209
+ chatForceBottom = true;
9210
+ chatScrollBottomNow($("rd-chat-flow"));
9211
+ }
8724
9212
 
8725
9213
  function chatEngineIsDirect(run) {
8726
9214
  const st = S.state || {};
@@ -8870,6 +9358,7 @@ function drawChatFlow(run, data, active) {
8870
9358
  ":" + (it.consumed ? 1 : 0), "") + "|" + ((data && data.result) ? "r" : "");
8871
9359
  if (liveSig === chatLiveSig && flow.childElementCount) return;
8872
9360
  chatLiveSig = liveSig;
9361
+ flow.dataset.runId = run.id; // run 戳:applyRdTabs 判「内容是否已对齐当前对话」用
8873
9362
  // 时间显示统一收敛到 HH:MM(日期在 meta 条里,全量戳塞气泡就是噪音);
8874
9363
  // 附件兼容 字符串路径 / {name|path} 对象 两种形态(对象直接拼会成 [object Object])
8875
9364
  const chatTime = (v) => { const s = String(v || "");
@@ -8961,10 +9450,13 @@ function drawChatFlow(run, data, active) {
8961
9450
  ? t("运行中:新消息会排队,本轮回答完后依次送达")
8962
9451
  : t("已结束:发送后将自动开新一轮接着做");
8963
9452
  // 贴底跟随:用户滚到底部附近才自动滚到最新输出,回看历史不打扰;
8964
- // 刚发送过消息则强制贴底一次(用户要看到自己发的消息与正在来的回复)
8965
- const stick = chatForceBottom ||
9453
+ // 带强制贴底标记(发送/继续任务/首开详情置的)则无条件跳最新。
9454
+ // 页签当前不可见(display:none 时 scrollHeight 恒 0)贴底无处落:
9455
+ // 标记保留给 applyRdTabs,切进对话页签那一刻再带到底。
9456
+ const flowVisible = flow.offsetParent !== null;
9457
+ const stick = (chatForceBottom && flowVisible) ||
8966
9458
  flow.scrollHeight - flow.scrollTop - flow.clientHeight < 80;
8967
- chatForceBottom = false;
9459
+ if (chatForceBottom && flowVisible) chatForceBottom = false;
8968
9460
  // 思考过程面板(.ct-body 自带滚动条)逐帧整体重建会丢滚动位置——重绘前
8969
9461
  // 记住「贴底 or 用户上滑到哪」,重绘后恢复:贴底则一直跟最新(2026-09-22
8970
9462
  // 用户诉求),上滑回看历史思维链则保持位置不被打扰。
@@ -8974,7 +9466,7 @@ function drawChatFlow(run, data, active) {
8974
9466
  : true;
8975
9467
  const thinkTop = prevThink ? prevThink.scrollTop : 0;
8976
9468
  flow.innerHTML = html || '<div class="hint">' + esc(t("还没有对话内容")) + "</div>";
8977
- if (stick) flow.scrollTop = flow.scrollHeight;
9469
+ if (stick) chatScrollBottomNow(flow);
8978
9470
  const newThink = flow.querySelector('[data-think-live="1"] .ct-body');
8979
9471
  if (newThink) {
8980
9472
  if (thinkPin) newThink.scrollTop = newThink.scrollHeight;
@@ -9532,9 +10024,7 @@ window.chatSend = async function () {
9532
10024
  ta.style.height = "";
9533
10025
  chatAtts = []; drawChatAtts();
9534
10026
  chatSig = ""; // 强制重画时间线
9535
- chatForceBottom = true; // 发完滚到最新:看到自己刚发的消息与正在来的回复
9536
- const fl = $("rd-chat-flow");
9537
- if (fl) fl.scrollTop = fl.scrollHeight; // 重画前先即时贴底,视觉零跳动
10027
+ chatJumpLatest(); // 发完滚到最新:看到自己刚发的消息与正在来的回复
9538
10028
  if (active) {
9539
10029
  const d = await api("/api/runs/" + encodeURIComponent(chatRunId));
9540
10030
  if (d.run) renderChat(d.run, d.run.status === "running" || d.run.status === "queued");
@@ -9549,6 +10039,7 @@ function bindChat() {
9549
10039
  if (!btn || !file || !send || !ta) return;
9550
10040
  const focusBtn = $("rd-chat-focus");
9551
10041
  if (focusBtn) focusBtn.addEventListener("click", () => {
10042
+ chatJumpLatest(); // 继续对话:先看到最新一轮再开口
9552
10043
  S.rdTab = "chat";
9553
10044
  S.rdTabPin = true;
9554
10045
  applyRdTabs();
@@ -9697,6 +10188,7 @@ const SKINS = [
9697
10188
  { id: "ocean", name: "深海", desc: "藏青底色 + 天蓝强调,夜间长时间盯任务更沉静(默认)" },
9698
10189
  { id: "hermes", name: "墨金", desc: "暖墨底色 + 金色发丝线,Hermes 式古典优雅" },
9699
10190
  { id: "classic", name: "经典", desc: "黑白灰 + 蓝色强调,ChatGPT 式清爽配色" },
10191
+ { id: "paper", name: "简白", desc: "纯白画布 + 浅灰分区 + 克制蓝强调,Codex 式极简明亮" },
9700
10192
  { id: "forest", name: "森野", desc: "墨绿底色 + 青翠强调,偏自然的护眼配色" },
9701
10193
  { id: "amber", name: "暖阳", desc: "暖棕底色 + 琥珀强调,纸感暖调" },
9702
10194
  { id: "violet", name: "霓虹", desc: "暗紫底色 + 品红强调,霓虹感强" },
@@ -13090,10 +13582,25 @@ async function loadEvalBench() {
13090
13582
  const ae = $("eb-auto-enabled"), ad = $("eb-auto-days");
13091
13583
  if (ae && S.settings) ae.checked = S.settings.bench_auto_enabled === true;
13092
13584
  if (ad && S.settings) ad.value = S.settings.bench_auto_days || 7;
13585
+ const fbe = $("eb-feedback-enabled");
13586
+ if (fbe && S.settings) fbe.checked = S.settings.bench_feedback_enabled !== false;
13093
13587
  renderEvalBench();
13094
13588
  if (S.evalbench && S.evalbench.running) EB_TIMER = setInterval(pollEvalBench, 2500);
13095
13589
  }
13096
13590
 
13591
+ async function saveBenchFeedback() {
13592
+ const msg = $("eb-feedback-msg");
13593
+ try {
13594
+ const r = await api("/api/settings", { method: "POST", body: JSON.stringify({
13595
+ bench_feedback_enabled: !$("eb-feedback-enabled") || $("eb-feedback-enabled").checked }) });
13596
+ S.settings = r.settings;
13597
+ if (msg) { msg.textContent = t("已保存"); }
13598
+ pollEvalBench();
13599
+ } catch (e) {
13600
+ if (msg) { msg.textContent = e.message; }
13601
+ }
13602
+ }
13603
+
13097
13604
  async function saveBenchAuto() {
13098
13605
  const msg = $("eb-auto-msg");
13099
13606
  try {
@@ -13161,7 +13668,8 @@ function renderEvalBench() {
13161
13668
  : t("裁判未就绪:先在「编排设置」配置编排者供应商");
13162
13669
  const pr = eb.progress;
13163
13670
  $("eb-progress").textContent = eb.running && pr
13164
- ? t("评测中 ") + (pr.done || 0) + "/" + (pr.total || 0) + (pr.current ? t(" · ") + pr.current : "")
13671
+ ? t("评测中 ") + (pr.done || 0) + "/" + (pr.total || 0) + (pr.current ? t(" · ") + pr.current : "") +
13672
+ (pr.cancel ? t(" · 停止中…当前样题完成后即停") : "")
13165
13673
  : "";
13166
13674
  board.innerHTML = (eb.leaderboard || []).map((r) => {
13167
13675
  const dims = Object.keys(r.scores || {}).length
@@ -13194,6 +13702,26 @@ function renderEvalBench() {
13194
13702
  }
13195
13703
  const btn = $("eb-run-btn");
13196
13704
  if (btn) btn.disabled = !!eb.running;
13705
+ const stopBtn = $("eb-stop-btn");
13706
+ if (stopBtn) {
13707
+ const stopping = !!(eb.running && eb.progress && eb.progress.cancel);
13708
+ stopBtn.textContent = stopping ? t("强制停止") : t("停止");
13709
+ stopBtn.disabled = !eb.running; // 空闲置灰:第二次点击=强制脱离的入口只在停止中开放
13710
+ }
13711
+ const fbBox = $("eb-feedback-status");
13712
+ if (fbBox) {
13713
+ const fb = eb.feedback || {};
13714
+ if (!fb.enabled) {
13715
+ fbBox.textContent = t("反哺已关闭:评测只出榜单,不影响选路。");
13716
+ } else {
13717
+ const sig = Object.values(fb.signals || {});
13718
+ const down = sig.filter((x) => x.below).length;
13719
+ const up = sig.length - down;
13720
+ fbBox.textContent = sig.length
13721
+ ? t("反哺生效:") + up + t(" 个模型加权 · ") + down + t(" 个降权(据 14 天内实测分)")
13722
+ : t("反哺生效中:暂无 14 天内实测分,跑一轮评测后开始影响排序。");
13723
+ }
13724
+ }
13197
13725
  }
13198
13726
 
13199
13727
  /* 逐题对比:行=样题,列=候选模型(按榜单名次),格=最新一次得分 */
@@ -13241,7 +13769,21 @@ async function evalbenchRun() {
13241
13769
  }
13242
13770
 
13243
13771
  async function evalbenchCancel() {
13244
- try { await api("/api/evalbench/cancel", { method: "POST" }); } catch (e) { /* 空闲时 200 ok=false */ }
13772
+ // 两段式:第一击优雅停(等当前样题做完——生成调用最坏要等几分钟);
13773
+ // 服务端已打停止标记后再点=强制脱离(立即回到空闲,后台线程自行收尾)。
13774
+ const msg = $("eb-msg");
13775
+ const already = !!(S.evalbench && S.evalbench.progress && S.evalbench.progress.cancel);
13776
+ try {
13777
+ const r = await api("/api/evalbench/cancel",
13778
+ { method: "POST", body: JSON.stringify({ force: already }) });
13779
+ if (r && r.ok && r.force) {
13780
+ if (msg) { msg.className = "msg ok"; msg.textContent = t("已强制脱离:界面回到空闲,后台当前调用跑完即自行退出"); }
13781
+ } else if (r && r.ok) {
13782
+ if (msg) { msg.className = "msg"; msg.textContent = t("已请求停止:当前样题完成后即停(生成调用最长可能等几分钟);等不及再点一次「强制停止」"); }
13783
+ }
13784
+ } catch (e) {
13785
+ if (msg) { msg.className = "msg err"; msg.textContent = e.message; }
13786
+ }
13245
13787
  pollEvalBench();
13246
13788
  }
13247
13789