codebee 0.1.96 → 0.1.98

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
@@ -4696,12 +4696,127 @@ function renderTaskDetail() {
4696
4696
  setResumeNowBtn(null); // 退避与否要看 run 数据,先收起防上一个任务残留
4697
4697
  const isTask = ((S.state || {}).tasks || []).some((t2) => t2.id === key);
4698
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
+ }
4699
4706
  loadTaskRuns(key, latestSummary);
4700
4707
  return;
4701
4708
  }
4702
4709
  drawTaskDetail(key, ((S.state || {}).runs || []).filter((r) => (r.task_id || r.id) === key));
4703
4710
  }
4704
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
+
4705
4820
  function loadTaskRuns(key, latestSummary) {
4706
4821
  const cached = _taskRunsCache.get(key);
4707
4822
  if (cached) {
@@ -5501,6 +5616,18 @@ async function renderRunDetail() {
5501
5616
  $("btn-share").classList.toggle("hidden", active); // 分享页:结束后可生成自包含 HTML
5502
5617
  $("btn-talk").classList.toggle("hidden", !(run.task_id && !chatEngineIsDirect(run)));
5503
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
+ }
5504
5631
  // 签名守卫(模型页/检查器同款):数据没变不重绘。详情区是全页最重的
5505
5632
  // 渲染面,此前 SSE 推送+轮询每 8s 无差别整块重写,是「页面一闪一闪、
5506
5633
  // 发布表单填一半被清空」的总根源(2026-09-30 用户实案)。运行中 ETA
@@ -6041,42 +6168,58 @@ function pbBlock(task, platform) {
6041
6168
  if (run && run.status === "running") {
6042
6169
  btns += '<span class="pb-book">' + esc(t("自动发布中 ") + run.done + "/" + run.total) + "</span>";
6043
6170
  } else if (au.pending > 0) {
6044
- btns += ' <button class="ghost" ' + (busy || !au.guard_ok ? "disabled" : "") +
6045
- ' 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 || "")) + '"' +
6046
6175
  ' onclick="pbPublishAll(\'' + esc(task.id) + "', '" + platform + '\')">' +
6047
6176
  t("发布全部待发") + t("(") + au.pending + t(")") + "</button>";
6048
6177
  }
6049
- // 批量选择:待发章号 chips(默认全选),「发布所选」人工模式 / 「直发所选」
6050
- // 逐章自动提交(护栏每日上限/连败退避照常生效)。分卷由服务端按卷计划
6051
- // 自动选/建(volumes.py),界面上不用管卷。
6052
- const items = (au.items && au.items.length ? au.items : [])
6053
- .map((x) => Number(x.chapter_no)).filter((n) => n > 0);
6054
- if (items.length && !run) {
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) {
6055
6185
  const key = task.id + ":" + platform;
6056
- const sig = items.join(",");
6186
+ const sig = its.map((x) => x.n).join(",");
6057
6187
  S.pbSel = S.pbSel || {};
6058
6188
  if (!S.pbSel[key] || S.pbSel[key].sig !== sig)
6059
- S.pbSel[key] = { sig, sel: new Set(items) };
6189
+ S.pbSel[key] = { sig, sel: new Set(its.map((x) => x.n)) };
6060
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
+ }
6061
6198
  let chips = "";
6062
- for (const n of items)
6063
- chips += '<label class="pb-chip' + (sel.has(n) ? " on" : "") + '">' +
6064
- '<input type="checkbox" ' + (sel.has(n) ? "checked" : "") +
6065
- ' onchange="pbSelToggle(\'' + esc(task.id) + "','" + platform + "'," + n + ',this.checked)">' +
6066
- n + "</label>";
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
+ }
6067
6208
  pendHtml += '<div class="pb-pend" data-key="' + esc(key) + '">' +
6068
6209
  '<div class="pb-pend-head">' +
6069
- '<span>' + esc(t("待发 ")) + items.length + esc(t(" 章(按卷计划自动分卷)")) + "</span>" +
6210
+ '<span>' + esc(t("待发 ")) + its.length + esc(t(" 章(按卷计划自动分卷)")) + "</span>" +
6070
6211
  '<a href="javascript:void(0)" onclick="pbSelAll(\'' + esc(task.id) + "','" + platform + "',true)" + '">' + esc(t("全选")) + "</a>" +
6071
6212
  '<a href="javascript:void(0)" onclick="pbSelAll(\'' + esc(task.id) + "','" + platform + "',false)" + '">' + esc(t("清空")) + "</a>" +
6072
6213
  "</div>" +
6073
6214
  '<div class="pb-pend-chips">' + chips + "</div>" +
6074
6215
  '<div class="pb-pend-btns">' +
6075
- '<button class="ghost" ' + (busy || !au.guard_ok || !sel.size ? "disabled" : "") +
6216
+ '<button class="ghost" ' + (busy || !sel.size ? "disabled" : "") +
6217
+ ' title="' + esc(au.guard_ok ? "" : t("护栏提示:") + (au.guard_reason || "")) + '"' +
6076
6218
  ' onclick="pbPublishSelected(\'' + esc(task.id) + "','" + platform + '\',false)">' +
6077
6219
  t("发布所选") + t("(") + sel.size + t(")") + "</button>" +
6078
- '<button class="ghost" ' + (busy || !au.guard_ok || !sel.size ? "disabled" : "") +
6079
- ' title="' + esc(t("所选章节逐章自动提交(无人值守),护栏每日上限/连败退避照常生效")) + '"' +
6220
+ '<button class="ghost" ' + (busy || !sel.size ? "disabled" : "") +
6221
+ ' title="' + esc(t("所选章节逐章自动提交(无人值守),护栏每日上限/连败退避照常生效") +
6222
+ (au.guard_ok ? "" : ";" + t("护栏提示:") + (au.guard_reason || ""))) + '"' +
6080
6223
  ' onclick="pbPublishSelected(\'' + esc(task.id) + "','" + platform + '\',true)">' +
6081
6224
  t("直发所选") + t("(") + sel.size + t(")") + "</button>" +
6082
6225
  "</div></div>";
@@ -6358,12 +6501,36 @@ window.pbConfirmChapter = async function (taskId, platform, chapterNo) {
6358
6501
  S._pbSig = ""; pbKick();
6359
6502
  };
6360
6503
 
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
+
6361
6527
  window.pbSelToggle = function (taskId, platform, no, on) {
6362
6528
  const key = taskId + ":" + platform;
6363
6529
  const ent = (S.pbSel || {})[key];
6364
6530
  if (!ent) return;
6365
6531
  if (on) ent.sel.add(Number(no)); else ent.sel.delete(Number(no));
6366
- S._pbSig = ""; pbKick(); // 重画刷新按钮计数(勾选态存 S.pbSel 不丢)
6532
+ pbSelCountSync(key); // 先就地同步,再触发整块重绘
6533
+ S._pbSig = ""; pbKick();
6367
6534
  };
6368
6535
 
6369
6536
  window.pbSelAll = function (taskId, platform, all) {
@@ -6372,6 +6539,7 @@ window.pbSelAll = function (taskId, platform, all) {
6372
6539
  if (!ent) return;
6373
6540
  if (all) for (const n of ent.sig.split(",")) ent.sel.add(Number(n));
6374
6541
  else ent.sel.clear();
6542
+ pbSelCountSync(key);
6375
6543
  S._pbSig = ""; pbKick();
6376
6544
  };
6377
6545
 
@@ -8337,26 +8505,17 @@ function hiveThinkLine(lines) {
8337
8505
  return "";
8338
8506
  }
8339
8507
 
8340
- const hiveSceneTransform = { x: 0, y: 0, zoom: 1 };
8341
-
8342
- function updateHiveSceneTransform() {
8343
- const scene = $("rd-hive-cells");
8344
- if (!scene) return;
8345
- scene.style.setProperty("--scene-x", hiveSceneTransform.x + "px");
8346
- scene.style.setProperty("--scene-y", hiveSceneTransform.y + "px");
8347
- scene.style.setProperty("--scene-zoom", String(hiveSceneTransform.zoom));
8348
- }
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 = "";
8349
8517
 
8350
- function setHiveSceneMode(mode) {
8351
- const scene = $("rd-hive-cells");
8352
- const viewport = $("rd-hive-viewport");
8353
- const tools = document.querySelector(".hive-scene-tools");
8354
- if (!scene || !viewport) return;
8355
- const is3d = mode !== "2d";
8356
- scene.classList.toggle("hive-3d", is3d);
8357
- scene.classList.toggle("hive-2d", !is3d);
8358
- viewport.classList.toggle("hive-2d", !is3d);
8359
- if (tools) tools.classList.toggle("hive-2d", !is3d);
8518
+ function setHiveSceneButtons(is3d) {
8360
8519
  document.querySelectorAll("[data-hive-view]").forEach((button) => {
8361
8520
  const active = button.dataset.hiveView === (is3d ? "3d" : "2d");
8362
8521
  button.classList.toggle("active", active);
@@ -8364,66 +8523,180 @@ function setHiveSceneMode(mode) {
8364
8523
  });
8365
8524
  }
8366
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
+
8367
8609
  function setupHiveSceneControls() {
8368
8610
  const viewport = $("rd-hive-viewport");
8369
- const scene = $("rd-hive-cells");
8370
- if (!viewport || !scene || viewport.dataset.controlsReady) return;
8611
+ if (!viewport || viewport.dataset.controlsReady) return;
8371
8612
  viewport.dataset.controlsReady = "true";
8372
- updateHiveSceneTransform();
8373
-
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
+ });
8374
8620
  document.querySelectorAll("[data-hive-view]").forEach((button) => {
8375
8621
  button.addEventListener("click", () => setHiveSceneMode(button.dataset.hiveView));
8376
8622
  });
8377
8623
  document.querySelectorAll("[data-hive-scene-action]").forEach((button) => {
8378
8624
  button.addEventListener("click", () => {
8379
- if (button.dataset.hiveSceneAction === "reset") {
8380
- hiveSceneTransform.x = 0;
8381
- hiveSceneTransform.y = 0;
8382
- hiveSceneTransform.zoom = 1;
8383
- } else {
8384
- hiveSceneTransform.zoom = Math.max(.65, Math.min(1.55,
8385
- hiveSceneTransform.zoom + (button.dataset.hiveSceneAction === "zoom-in" ? .1 : -.1)));
8386
- }
8387
- updateHiveSceneTransform();
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);
8388
8628
  });
8389
8629
  });
8390
-
8391
- let drag = null;
8392
- viewport.addEventListener("pointerdown", (event) => {
8393
- if (event.button !== 0 || event.target.closest(".hive-cell, button, a, input, textarea")) return;
8394
- drag = { id: event.pointerId, x: event.clientX, y: event.clientY,
8395
- originX: hiveSceneTransform.x, originY: hiveSceneTransform.y };
8396
- viewport.classList.add("is-dragging");
8397
- viewport.setPointerCapture(event.pointerId);
8398
- });
8399
- viewport.addEventListener("pointermove", (event) => {
8400
- if (!drag || drag.id !== event.pointerId) return;
8401
- hiveSceneTransform.x = drag.originX + event.clientX - drag.x;
8402
- hiveSceneTransform.y = drag.originY + event.clientY - drag.y;
8403
- updateHiveSceneTransform();
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
+ });
8404
8688
  });
8405
- const endDrag = (event) => {
8406
- if (!drag || (event && drag.id !== event.pointerId)) return;
8407
- drag = null;
8408
- viewport.classList.remove("is-dragging");
8409
- };
8410
- viewport.addEventListener("pointerup", endDrag);
8411
- viewport.addEventListener("pointercancel", endDrag);
8412
- viewport.addEventListener("lostpointercapture", endDrag);
8413
- viewport.addEventListener("wheel", (event) => {
8414
- if (!scene.classList.contains("hive-3d")) return;
8415
- event.preventDefault();
8416
- hiveSceneTransform.zoom = Math.max(.65, Math.min(1.55,
8417
- hiveSceneTransform.zoom * (event.deltaY < 0 ? 1.08 : .92)));
8418
- updateHiveSceneTransform();
8419
- }, { passive: false });
8689
+ const sc = ensureHiveScene();
8690
+ if (!sc) return;
8691
+ sc.sync(model); // 2D 模式也同步:切回 3D 时数据即 ready(画布未激活零 GPU 开销)
8692
+ sc.setActive(hiveMode === "3d");
8420
8693
  }
8421
8694
 
8422
8695
  window.renderHive = function (run) {
8423
8696
  const box = $("rd-hive");
8424
8697
  if (!box) return;
8425
8698
  const steps = (run && run.steps) || [];
8426
- 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; }
8427
8700
  box.classList.remove("hidden");
8428
8701
  setupHiveSceneControls();
8429
8702
  // 按步骤首次出现顺序分泳道(流水线天然有序)
@@ -8551,6 +8824,7 @@ window.renderHive = function (run) {
8551
8824
  if (cellsBox._hiveEnterT) clearTimeout(cellsBox._hiveEnterT);
8552
8825
  cellsBox._hiveEnterT = setTimeout(() => cellsBox.classList.remove("hive-enter"), 1400);
8553
8826
  }
8827
+ hiveSceneSync(run, lanes, byStage); // WebGL 蜂巢塔吃同一份泳道数据(内部自管激活)
8554
8828
  const live = running.length && (run.status === "running" || run.status === "queued");
8555
8829
  if (hiveClock) { clearInterval(hiveClock); hiveClock = null; }
8556
8830
  if (live) {