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/board.js CHANGED
@@ -1,305 +1,509 @@
1
- /* CodeBee 任务驾驶舱(/board.html):4s 轮询 /api/board(KB 级聚合端点),
2
- 不碰 /api/state 全量,也不占 SSE。 */
3
- "use strict";
4
-
5
- const $ = (s) => document.querySelector(s);
6
- const t = (...a) => (window.t ? window.t(...a) : a[0]); // i18n.js 先于本脚本加载;透传占位符参数({0}/{1})
7
- const POLL_MS = 4000;
8
-
9
- // URL ?lang=en|zh 可覆盖语言(i18n.js 已按 localStorage 自动 apply 过一次,
10
- // 这里写回偏好并重刷静态文案;动态文案走 t() 每次渲染实时取)
11
- try {
12
- const ql = new URLSearchParams(location.search).get("lang");
13
- if ((ql === "en" || ql === "zh") && window.getLang && window.getLang() !== ql) {
14
- window.setLang(ql);
15
- if (window.applyI18n) window.applyI18n();
16
- }
17
- } catch (e) { /* ignore */ }
18
-
19
- /* ---------------------------------------------------------------- 工具 */
20
- function fmtTokens(n) {
21
- n = Number(n) || 0;
22
- if (n >= 1e8) return (n / 1e8).toFixed(2) + t("亿");
23
- if (n >= 1e4) return (n / 1e4).toFixed(1) + t("万");
24
- if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
25
- return String(n);
26
- }
27
- function fmtCost(n) {
28
- n = Number(n) || 0;
29
- if (n >= 100) return "$" + n.toFixed(0);
30
- if (n >= 1) return "$" + n.toFixed(2);
31
- return "$" + n.toFixed(3);
32
- }
33
- function fmtDur(sec) {
34
- sec = Math.max(0, Math.floor(Number(sec) || 0));
35
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
36
- if (h) return h + "h" + String(m).padStart(2, "0") + "m";
37
- return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
38
- }
39
- function parseTs(s) {
40
- // 后端时间格式 "YYYY-MM-DD HH:MM:SS";补 T 让各浏览器 Date 都能解析
41
- const t = Date.parse(String(s || "").replace(" ", "T"));
42
- return isNaN(t) ? 0 : t;
43
- }
44
- function esc(s) {
45
- return String(s == null ? "" : s).replace(/[&<>"']/g,
46
- (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
47
- }
48
-
49
- /* ---------------------------------------------------------------- 令牌门 */
50
- function token() { return localStorage.getItem("orch.token") || ""; }
51
-
52
- function showGate(msg) {
53
- $("#gate").classList.remove("hidden");
54
- $("#gate-err").textContent = msg || "";
55
- setTimeout(() => $("#gate-token").focus(), 50);
56
- }
57
- $("#gate-ok").addEventListener("click", submitGate);
58
- $("#gate-token").addEventListener("keydown", (e) => { if (e.key === "Enter") submitGate(); });
59
- function submitGate() {
60
- const v = $("#gate-token").value.trim();
61
- if (!v) return;
62
- localStorage.setItem("orch.token", v);
63
- $("#gate").classList.add("hidden");
64
- poll();
65
- }
66
-
67
- /* ---------------------------------------------------------------- 拉取 */
68
- let connLost = false;
69
-
70
- async function poll() {
71
- let data;
72
- try {
73
- const r = await fetch("/api/board", { headers: { "X-CodeBee-Token": token() } });
74
- if (r.status === 401) { showGate(t("令牌不正确或已变更")); return; }
75
- if (!r.ok) throw new Error("HTTP " + r.status);
76
- data = await r.json();
77
- if (connLost) { connLost = false; $("#hd-conn").classList.add("hidden"); }
78
- } catch (e) {
79
- if (!connLost) { connLost = true; $("#hd-conn").classList.remove("hidden"); }
80
- $("#hd-updated").textContent = t("重试连接中");
81
- return;
82
- }
83
- render(data);
84
- }
85
-
86
- /* ---------------------------------------------------------------- 渲染 */
87
- const cardEls = new Map(); // task_id -> 卡元素(复用防闪烁)
88
-
89
- function render(d) {
90
- const running = Array.isArray(d.running) ? d.running : [];
91
- const queued = Array.isArray(d.queued) ? d.queued : [];
92
- const recent = Array.isArray(d.recent) ? d.recent : [];
93
- const usage = d.usage_today || {};
94
- const pool = d.pool || {};
95
- const health = Array.isArray(d.health) ? d.health : [];
96
-
97
- // KPI
98
- const done = Number(d.today_done) || 0;
99
- const failed = Number(d.today_failed) || 0;
100
- const finished = done + failed;
101
- $("#k-running").textContent = running.length;
102
- $("#k-queued").textContent = queued.length;
103
- $("#k-done").textContent = done;
104
- $("#k-failed").textContent = failed;
105
- $("#k-tokens").textContent = fmtTokens(usage.tokens);
106
- $("#k-cost").textContent = fmtCost(usage.cost_usd);
107
-
108
- // 总态势 Hero:把“有没有活”变成第一眼可读的状态,而不是只重复 KPI。
109
- const heroActive = running.length > 0;
110
- $("#hero-live-count").textContent = running.length;
111
- $("#orb-number").textContent = running.length;
112
- $("#hero-state").textContent = heroActive ? t("蜂群运行中") : t("蜂群待命");
113
- if (heroActive) {
114
- let copy = t("当前有 {0} 个任务正在并行执行", running.length);
115
- if (queued.length) copy += " · " + t("有 {0} 个任务等待调度", queued.length);
116
- $("#hero-copy").textContent = copy;
117
- } else if (queued.length) {
118
- $("#hero-copy").textContent = t("有 {0} 个任务等待调度", queued.length);
119
- } else {
120
- $("#hero-copy").textContent = t("没有正在运行的任务,新任务在主界面创建后会实时出现在这里");
121
- }
122
-
123
- // 顶栏
124
- $("#hd-pool").textContent = t("蜂巢") + " " + (pool.alive || 0) + "/" + (pool.target || 0)
125
- + " · " + t("对话") + " " + (pool.chat_alive || 0) + "/" + (pool.chat_pool || 0);
126
- const syncTime = String(d.now || "").slice(11, 19) || new Date().toLocaleTimeString([], { hour12: false });
127
- $("#hd-updated").textContent = t("已同步") + " · " + syncTime;
128
- const downs = health.filter((h) => h.status === "down" && !h.silenced);
129
- const hp = $("#hd-health");
130
- if (downs.length) {
131
- hp.classList.remove("hidden");
132
- hp.textContent = "⚠ " + downs.map((h) => h.provider).join("、") + " " + t("异常");
133
- hp.title = downs.map((h) => (h.provider + " " + (h.model || "")).trim()).join("\n");
134
- $("#hero-signal").textContent = t("有 {0} 个服务异常", downs.length);
135
- $("#overview").classList.add("has-alert");
136
- } else {
137
- hp.classList.add("hidden");
138
- $("#hero-signal").textContent = t("系统状态良好");
139
- $("#overview").classList.remove("has-alert");
140
- }
141
-
142
- // 执行池 / 概览侧栏
143
- const alive = Math.max(0, Number(pool.alive) || 0);
144
- const target = Math.max(0, Number(pool.target) || 0);
145
- const fill = target ? Math.min(100, alive / target * 100) : 0;
146
- $("#pool-meter-fill").style.width = fill + "%";
147
- $("#pool-alive").textContent = alive;
148
- $("#pool-target").textContent = target;
149
- $("#overview-queued").textContent = queued.length;
150
- $("#overview-success").textContent = finished ? Math.round(done / finished * 100) + "%" : "—";
151
-
152
- renderWall(d);
153
- renderTrend(d.trend || []);
154
- renderModels(d.models || []);
155
- renderRecent(recent);
156
- }
157
-
158
- function renderWall(d) {
159
- const wrap = $("#wall-cards");
160
- const empty = $("#wall-empty");
161
- const running = Array.isArray(d.running) ? d.running : [];
162
- const queued = Array.isArray(d.queued) ? d.queued : [];
163
- const has = running.length > 0;
164
- $("#wall-count").textContent = running.length + " ACTIVE";
165
- $("#queue-count").textContent = queued.length;
166
- empty.classList.toggle("hidden", has);
167
- wrap.classList.toggle("hidden", !has);
168
-
169
- const seen = new Set();
170
- for (const c of running) {
171
- seen.add(c.task_id);
172
- let el = cardEls.get(c.task_id);
173
- if (!el) {
174
- el = document.createElement("div");
175
- el.className = "card";
176
- el.innerHTML =
177
- '<div class="card-top"><span class="c-type"></span><span class="c-title"></span>' +
178
- '<span class="c-elapsed" data-started=""></span></div>' +
179
- '<div class="c-meta"><span class="b b-model"></span><span class="b b-prov"></span>' +
180
- '<span class="b b-agent"></span><span class="c-run-tok"></span></div>' +
181
- '<div class="c-steps"></div>' +
182
- '<div class="c-stepinfo"><span class="no"></span><span class="ag"></span><span class="sm"></span></div>' +
183
- '<div class="c-live"><span class="c-live-tag"></span><pre class="c-live-text"></pre></div>' +
184
- '<div class="c-pct"></div>';
185
- wrap.appendChild(el);
186
- cardEls.set(c.task_id, el);
187
- }
188
- el.querySelector(".c-type").textContent = c.type_name || c.type || t("任务");
189
- el.querySelector(".c-title").textContent = c.title || t("(未命名)");
190
- const el2 = el.querySelector(".c-elapsed");
191
- el2.dataset.started = c.started_at || "";
192
- el2.textContent = fmtDur((Date.now() - parseTs(c.started_at)) / 1000);
193
-
194
- // 模型 / 厂商 / 执行智能体徽章 + 本次消耗
195
- const cur0 = c.current || {};
196
- const bModel = el.querySelector(".b-model");
197
- bModel.textContent = cur0.model || t("模型待定");
198
- bModel.classList.toggle("dim", !cur0.model);
199
- const bProv = el.querySelector(".b-prov");
200
- bProv.textContent = cur0.provider || "";
201
- bProv.classList.toggle("hidden", !cur0.provider);
202
- const bAgent = el.querySelector(".b-agent");
203
- bAgent.textContent = cur0.agent || "";
204
- bAgent.classList.toggle("hidden", !cur0.agent);
205
- const rt = el.querySelector(".c-run-tok");
206
- const cost = Number(c.run_cost_usd) || 0;
207
- rt.textContent = (c.run_tokens ? fmtTokens(c.run_tokens) + " tok" : "") +
208
- (cost > 0 ? (c.run_tokens ? " · " : "") + "$" + cost.toFixed(cost >= 1 ? 2 : 3) : "");
209
- rt.classList.toggle("hidden", !c.run_tokens && !(cost > 0));
210
-
211
- // 分段进度条
212
- const seg = el.querySelector(".c-steps");
213
- const total = Math.max(c.steps_total || 0, 1);
214
- if (seg.childElementCount !== total) {
215
- seg.innerHTML = "";
216
- for (let i = 0; i < total; i++) seg.appendChild(document.createElement("i"));
217
- }
218
- [...seg.children].forEach((b, i) => {
219
- b.className = i < (c.steps_done || 0) ? "d" : (i === (c.steps_done || 0) ? "r" : "");
220
- });
221
-
222
- const cur = c.current || {};
223
- el.querySelector(".no").textContent = cur.n ? ("#" + cur.n) : "—";
224
- el.querySelector(".ag").textContent = cur.agent || "";
225
- el.querySelector(".sm").textContent = cur.summary || t("执行中");
226
-
227
- // 实时流窗口:思考/输出尾部多行(live_label 是 code,前端翻译)
228
- const live = el.querySelector(".c-live");
229
- const lt = cur.live_text || "";
230
- live.classList.toggle("hidden", !lt);
231
- if (lt) {
232
- const tagMap = { output: t("输出"), thinking: t("思考") };
233
- el.querySelector(".c-live-tag").textContent = tagMap[cur.live_label] || t("实时");
234
- el.querySelector(".c-live-text").textContent = lt;
235
- }
236
-
237
- // 有预估时长则给完成度百分比
238
- const pct = el.querySelector(".c-pct");
239
- if (c.estimate_s && parseTs(c.started_at)) {
240
- const p = Math.min(99, Math.round(((Date.now() - parseTs(c.started_at)) / 1000) / c.estimate_s * 100));
241
- pct.textContent = t("预估 {0} · 进度≈{1}%", fmtDur(c.estimate_s), p);
242
- } else {
243
- pct.textContent = "";
244
- }
245
- }
246
- for (const [tid, el] of cardEls) {
247
- if (!seen.has(tid)) { el.remove(); cardEls.delete(tid); }
248
- }
249
-
250
- // 排队条
251
- const qr = $("#queue-row");
252
- qr.classList.toggle("hidden", !queued.length);
253
- $("#queue-chips").innerHTML = queued.map((q) =>
254
- '<span class="chip" title="' + esc(q.title) + '">' + esc(q.title) + "</span>").join("");
255
- }
256
-
257
- function renderTrend(trend) {
258
- const box = $("#trend");
259
- const max = Math.max(1, ...trend.map((t) => t.tokens || 0));
260
- box.innerHTML = trend.map((t) => {
261
- const h = Math.round((t.tokens || 0) / max * 100);
262
- const label = String(t.day || "").slice(5); // MM-DD
263
- return '<div class="bar' + (t.tokens ? "" : " zero") + '" title="' + esc(t.day) + " · " +
264
- fmtTokens(t.tokens) + '"><i style="height:' + Math.max(h, 3) + '%"></i><b>' + esc(label) + "</b></div>";
265
- }).join("");
266
- }
267
-
268
- function renderModels(models) {
269
- const box = $("#models");
270
- if (!models.length) { box.innerHTML = '<div class="empty">' + t("暂无调用记录") + "</div>"; return; }
271
- const max = Math.max(1, ...models.map((m) => m.tokens || 0));
272
- box.innerHTML = models.map((m) =>
273
- '<div class="model-row"><span class="nm" title="' + esc(m.model) + '">' + esc(m.model) + "</span>" +
274
- '<span class="tk"><i style="width:' + Math.max(4, Math.round((m.tokens || 0) / max * 100)) + '%"></i></span>' +
275
- '<span class="vl">' + fmtTokens(m.tokens) + "</span></div>").join("");
276
- }
277
-
278
- function renderRecent(recent) {
279
- const box = $("#recent");
280
- $("#recent-count").textContent = recent.length + " " + t("条事件");
281
- if (!recent.length) { box.innerHTML = '<div class="empty">' + t("还没有已完成的运行") + "</div>"; return; }
282
- const dot = { done: "done", failed: "failed", cancelled: "cancelled" };
283
- box.innerHTML = recent.map((r) => {
284
- const cls = dot[r.status] || "cancelled";
285
- const t = String(r.ended_at || "").slice(11, 16);
286
- return '<div class="r-row"><span class="r-dot ' + cls + '"></span>' +
287
- '<span class="r-t' + (r.status === "failed" ? " failed" : "") + '" title="' +
288
- esc(r.title + (r.error ? " · " + r.error : "")) + '">' + esc(r.title) + "</span>" +
289
- '<span class="r-d">' + (r.duration_s != null ? fmtDur(r.duration_s) : "—") + " · " + esc(t) + "</span></div>";
290
- }).join("");
291
- }
292
-
293
- /* ---------------------------------------------------------------- 时钟与耗时每秒跳 */
294
- setInterval(() => {
295
- const n = new Date();
296
- const p = (x) => String(x).padStart(2, "0");
297
- $("#hd-clock").textContent = p(n.getHours()) + ":" + p(n.getMinutes()) + ":" + p(n.getSeconds());
298
- document.querySelectorAll(".c-elapsed[data-started]").forEach((el) => {
299
- const st = parseTs(el.dataset.started);
300
- if (st) el.textContent = fmtDur((Date.now() - st) / 1000);
301
- });
302
- }, 1000);
303
-
304
- poll();
305
- setInterval(poll, POLL_MS);
1
+ /* CodeBee 任务驾驶舱(/board.html):4s 轮询 /api/board(KB 级聚合端点),
2
+ 不碰 /api/state 全量,也不占 SSE。下钻抽屉按需拉 /api/board/run 与
3
+ /api/runs/<id>/log,不参与 4s 轮询。 */
4
+ "use strict";
5
+
6
+ const $ = (s) => document.querySelector(s);
7
+ const t = (...a) => (window.t ? window.t(...a) : a[0]); // i18n.js 先于本脚本加载;透传占位符参数({0}/{1})
8
+ const POLL_MS = 4000;
9
+
10
+ // URL ?lang=en|zh 可覆盖语言(i18n.js 已按 localStorage 自动 apply 过一次,
11
+ // 这里写回偏好并重刷静态文案;动态文案走 t() 每次渲染实时取)
12
+ try {
13
+ const ql = new URLSearchParams(location.search).get("lang");
14
+ if ((ql === "en" || ql === "zh") && window.getLang && window.getLang() !== ql) {
15
+ window.setLang(ql);
16
+ if (window.applyI18n) window.applyI18n();
17
+ }
18
+ } catch (e) { /* ignore */ }
19
+
20
+ /* ---------------------------------------------------------------- 工具 */
21
+ function fmtTokens(n) {
22
+ n = Number(n) || 0;
23
+ if (n >= 1e8) return (n / 1e8).toFixed(2) + t("亿");
24
+ if (n >= 1e4) return (n / 1e4).toFixed(1) + t("万");
25
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
26
+ return String(n);
27
+ }
28
+ function fmtCost(n) {
29
+ n = Number(n) || 0;
30
+ if (n >= 100) return "$" + n.toFixed(0);
31
+ if (n >= 1) return "$" + n.toFixed(2);
32
+ return "$" + n.toFixed(3);
33
+ }
34
+ function fmtDur(sec) {
35
+ sec = Math.max(0, Math.floor(Number(sec) || 0));
36
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
37
+ if (h) return h + "h" + String(m).padStart(2, "0") + "m";
38
+ return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
39
+ }
40
+ function parseTs(s) {
41
+ // 后端时间格式 "YYYY-MM-DD HH:MM:SS";补 T 让各浏览器 Date 都能解析
42
+ const t = Date.parse(String(s || "").replace(" ", "T"));
43
+ return isNaN(t) ? 0 : t;
44
+ }
45
+ function esc(s) {
46
+ return String(s == null ? "" : s).replace(/[&<>"']/g,
47
+ (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
48
+ }
49
+ function hmms(s) { return String(s || "").slice(11, 16); }
50
+
51
+ /* ---------------------------------------------------------------- 令牌门 */
52
+ function token() { return localStorage.getItem("orch.token") || ""; }
53
+
54
+ function showGate(msg) {
55
+ $("#gate").classList.remove("hidden");
56
+ $("#gate-err").textContent = msg || "";
57
+ setTimeout(() => $("#gate-token").focus(), 50);
58
+ }
59
+ $("#gate-ok").addEventListener("click", submitGate);
60
+ $("#gate-token").addEventListener("keydown", (e) => { if (e.key === "Enter") submitGate(); });
61
+ function submitGate() {
62
+ const v = $("#gate-token").value.trim();
63
+ if (!v) return;
64
+ localStorage.setItem("orch.token", v);
65
+ $("#gate").classList.add("hidden");
66
+ poll();
67
+ }
68
+
69
+ /* ---------------------------------------------------------------- 换肤
70
+ 大屏独立记忆:orch.boardSkin / orch.boardTheme 优先,缺省时继承主界面的
71
+ orch.skin / orch.theme(第一次打开跟主界面一致),再缺省落 ocean/dark。
72
+ 只写 html 的 data-skin/data-theme,值集与主应用 style.css 同一套。 */
73
+ const SKINS = [["ocean", "深海"], ["classic", "经典"], ["forest", "森林"],
74
+ ["amber", "暖阳"], ["violet", "霓虹"], ["contrast", "高对比"]];
75
+ const SKIN_IDS = new Set(SKINS.map((s) => s[0]));
76
+ const _q = new URLSearchParams(location.search);
77
+
78
+ function skin() {
79
+ const v = _q.get("skin") || localStorage.getItem("orch.boardSkin") || localStorage.getItem("orch.skin") || "ocean";
80
+ return SKIN_IDS.has(v) ? v : "ocean";
81
+ }
82
+ function mode() {
83
+ const v = _q.get("mode") || localStorage.getItem("orch.boardTheme") || localStorage.getItem("orch.theme") || "dark";
84
+ return v === "light" ? "light" : "dark";
85
+ }
86
+ function applySkin() {
87
+ const r = document.documentElement;
88
+ r.dataset.skin = skin();
89
+ r.dataset.theme = mode();
90
+ $("#sw-skin-n").textContent = t((SKINS.find((s) => s[0] === skin()) || SKINS[0])[1]);
91
+ $("#sw-mode-n").textContent = t(mode() === "dark" ? "夜间" : "日间");
92
+ }
93
+ $("#sw-skin").addEventListener("click", () => {
94
+ const i = SKINS.findIndex((s) => s[0] === skin());
95
+ localStorage.setItem("orch.boardSkin", SKINS[(i + 1) % SKINS.length][0]);
96
+ applySkin();
97
+ });
98
+ $("#sw-mode").addEventListener("click", () => {
99
+ localStorage.setItem("orch.boardTheme", mode() === "dark" ? "light" : "dark");
100
+ applySkin();
101
+ });
102
+ applySkin();
103
+
104
+ /* ---------------------------------------------------------------- 拉取 */
105
+ let connLost = false;
106
+
107
+ async function api(path) {
108
+ const r = await fetch(path, { headers: { "X-CodeBee-Token": token() } });
109
+ if (r.status === 401) { showGate(t("令牌不正确或已变更")); throw new Error("401"); }
110
+ if (!r.ok) throw new Error("HTTP " + r.status);
111
+ return r.json();
112
+ }
113
+
114
+ async function poll() {
115
+ let data;
116
+ try {
117
+ data = await api("/api/board");
118
+ if (connLost) { connLost = false; $("#hd-conn").classList.add("hidden"); }
119
+ } catch (e) {
120
+ if (e && e.message === "401") return;
121
+ if (!connLost) { connLost = true; $("#hd-conn").classList.remove("hidden"); }
122
+ $("#hd-updated").textContent = t("重试连接中");
123
+ return;
124
+ }
125
+ LAST = data;
126
+ render(data);
127
+ if (DRAWER.runId) syncDrawer();
128
+ }
129
+ let LAST = null;
130
+
131
+ /* ---------------------------------------------------------------- 渲染 */
132
+ const cardEls = new Map(); // task_id -> 卡元素(复用防闪烁)
133
+
134
+ function render(d) {
135
+ const running = Array.isArray(d.running) ? d.running : [];
136
+ const queued = Array.isArray(d.queued) ? d.queued : [];
137
+ const recent = Array.isArray(d.recent) ? d.recent : [];
138
+ const usage = d.usage_today || {};
139
+ const pool = d.pool || {};
140
+ const health = Array.isArray(d.health) ? d.health : [];
141
+
142
+ // 指标条:一格一个数,副位放该指标自己的第二维度(不再重复标签)
143
+ const done = Number(d.today_done) || 0;
144
+ const failed = Number(d.today_failed) || 0;
145
+ const finished = done + failed;
146
+ $("#k-running").textContent = running.length;
147
+ const dSum = running.reduce((a, c) => a + (Number(c.steps_done) || 0), 0);
148
+ const tSum = running.reduce((a, c) => a + (Number(c.steps_total) || 0), 0);
149
+ $("#k-run-sub").textContent = running.length ? t("步 {0}/{1}", dSum, tSum) : t("无活动");
150
+
151
+ $("#k-queued").textContent = queued.length;
152
+ $(".k-queue").classList.toggle("zero", !queued.length);
153
+ $("#k-queue-sub").textContent = queued.length
154
+ ? t("最长等待 {0}", fmtDur((Date.now() - parseTs(queued[0].created_at)) / 1000))
155
+ : t("队首空闲");
156
+
157
+ $("#k-done").textContent = done;
158
+ $("#k-done-sub").textContent = finished ? t("成功率 {0}%", Math.round(done / finished * 100)) : t("尚无结课运行");
159
+ $("#k-ratio-fill").style.width = (finished ? done / finished * 100 : 0) + "%";
160
+
161
+ $("#k-failed").textContent = failed;
162
+ $(".k-fail").classList.toggle("zero", !failed);
163
+ const firstFail = recent.find((r) => r.status === "failed");
164
+ $("#k-fail-sub").textContent = failed
165
+ ? (firstFail ? t("最近 {0}", hmms(firstFail.ended_at)) : t("需要关注"))
166
+ : t("今日零失败");
167
+
168
+ $("#k-tokens").textContent = fmtTokens(usage.tokens);
169
+ $("#k-use-sub").textContent = fmtCost(usage.cost_usd) + " · " + (Number(usage.calls) || 0) + " " + t("次调用");
170
+
171
+ // 顶栏态势:整屏只在这里说一次「有没有活」
172
+ const st = $("#hd-state");
173
+ st.classList.toggle("live", running.length > 0);
174
+ $("#hd-state-text").textContent = running.length
175
+ ? t("蜂群运行中 · {0} 个任务并行", running.length)
176
+ : (queued.length ? t("蜂群待命 · {0} 个任务排队", queued.length) : t("蜂群待命"));
177
+ $("#hd-pool-alive").textContent = pool.alive || 0;
178
+ $("#hd-pool-target").textContent = "/" + (pool.target || 0);
179
+ $("#hd-chat-alive").textContent = pool.chat_alive || 0;
180
+ $("#hd-chat-pool").textContent = "/" + (pool.chat_pool || 0);
181
+ $("#hd-updated").textContent = t("已同步") + " · " +
182
+ (String(d.now || "").slice(11, 19) || new Date().toLocaleTimeString([], { hour12: false }));
183
+
184
+ // 服务健康:常态一行计数,异常才点名(7 个绿灯点阵是噪音)
185
+ const downs = health.filter((h) => h.status === "down" && !h.silenced);
186
+ const hp = $("#hd-health");
187
+ hp.classList.toggle("hidden", !health.length);
188
+ hp.classList.toggle("bad", downs.length > 0);
189
+ $("#hd-health-text").textContent = downs.length
190
+ ? t("{0} 个服务异常", downs.length)
191
+ : t("服务 {0}/{1} 正常", health.length, health.length);
192
+ hp.title = downs.length
193
+ ? downs.map((h) => (h.provider + " " + (h.model || "")).trim()).join("\n") : "";
194
+
195
+ // 执行池
196
+ const alive = Math.max(0, Number(pool.alive) || 0);
197
+ const target = Math.max(0, Number(pool.target) || 0);
198
+ $("#pool-meter-fill").style.width = (target ? Math.min(100, alive / target * 100) : 0) + "%";
199
+ $("#pool-alive").textContent = alive;
200
+ $("#pool-target").textContent = target;
201
+ $("#chat-alive").textContent = Math.max(0, Number(pool.chat_alive) || 0);
202
+ $("#chat-pool").textContent = Math.max(0, Number(pool.chat_pool) || 0);
203
+ $("#chat-meter-fill").style.width = (pool.chat_pool
204
+ ? Math.min(100, (pool.chat_alive || 0) / pool.chat_pool * 100) : 0) + "%";
205
+ $("#pool-available").textContent = Math.max(0, Number(pool.available) || 0);
206
+ $("#pool-mode").textContent = pool.mode || "—";
207
+
208
+ renderWall(d);
209
+ renderTrend(d.trend || [], String(d.now || "").slice(0, 10));
210
+ renderModels(d.models || []);
211
+ renderRecent(recent);
212
+ }
213
+
214
+ /* 卡片骨架建一次,之后只改字段——4s 重绘不能把终端滚动和动画打断 */
215
+ function buildCard() {
216
+ const el = document.createElement("article");
217
+ el.className = "card";
218
+ el.innerHTML =
219
+ '<div class="c-head"><span class="c-type"></span><span class="c-title"></span>' +
220
+ '<span class="c-elapsed" data-started=""></span></div>' +
221
+ '<div class="c-main"><p class="c-goal"></p><div class="c-steps"></div>' +
222
+ '<div class="c-step-line"><span class="no"></span><span class="ag"></span><span class="sm"></span></div>' +
223
+ '<div class="c-badges"><span class="b b-model"></span><span class="b b-prov"></span></div></div>' +
224
+ '<div class="c-foot"><span class="c-est"><span class="est-track"><i></i></span><em></em></span>' +
225
+ '<span class="c-tok"></span></div>' +
226
+ '<div class="c-live"><div class="c-live-head"><span class="tag"></span><span class="caret"></span></div>' +
227
+ '<div class="c-live-body"><pre class="c-live-text"></pre></div></div>';
228
+ el.addEventListener("click", () => openDrawer(el.dataset.run || "", el));
229
+ return el;
230
+ }
231
+
232
+ function renderWall(d) {
233
+ const wrap = $("#wall-cards");
234
+ const empty = $("#wall-empty");
235
+ const running = Array.isArray(d.running) ? d.running : [];
236
+ const queued = Array.isArray(d.queued) ? d.queued : [];
237
+ const has = running.length > 0;
238
+
239
+ $("#wall-count").textContent = running.length + " ACTIVE";
240
+ $("#queue-count").textContent = queued.length;
241
+ // toggle 第二参是 force(true=加类),传反会让空态面板在运行时常驻
242
+ empty.classList.toggle("hidden", has);
243
+ // className 整写会抹掉刚 toggle 的 hidden,所以密度与 hidden 一起算
244
+ wrap.className = "wall-grid " +
245
+ (running.length <= 1 ? "d-solo" : running.length <= 3 ? "d-few" : "d-many") +
246
+ (has ? "" : " hidden");
247
+ renderIdle(d);
248
+
249
+ const seen = new Set();
250
+ for (const c of running) {
251
+ seen.add(c.task_id);
252
+ let el = cardEls.get(c.task_id);
253
+ if (!el) { el = buildCard(); wrap.appendChild(el); cardEls.set(c.task_id, el); }
254
+ el.dataset.task = c.task_id;
255
+ el.dataset.run = c.run_id || "";
256
+
257
+ el.querySelector(".c-type").textContent = c.type_name || c.type || t("任务");
258
+ el.querySelector(".c-title").textContent = c.title || t("(未命名)");
259
+ el.querySelector(".c-goal").textContent = c.goal || "";
260
+ const el2 = el.querySelector(".c-elapsed");
261
+ el2.dataset.started = c.started_at || "";
262
+ el2.textContent = fmtDur((Date.now() - parseTs(c.started_at)) / 1000);
263
+
264
+ const cur = c.current || {};
265
+ const seg = el.querySelector(".c-steps");
266
+ const total = Math.max(c.steps_total || 0, 1);
267
+ if (seg.childElementCount !== total) {
268
+ seg.innerHTML = "";
269
+ for (let i = 0; i < total; i++) seg.appendChild(document.createElement("i"));
270
+ }
271
+ [...seg.children].forEach((b, i) => {
272
+ b.className = i < (c.steps_done || 0) ? "d" : (i === (c.steps_done || 0) ? "r" : "");
273
+ });
274
+
275
+ el.querySelector(".c-step-line .no").textContent = cur.n ? ("#" + cur.n) : "—";
276
+ el.querySelector(".c-step-line .ag").textContent = cur.agent || "";
277
+ el.querySelector(".c-step-line .sm").textContent = cur.summary || cur.tail || t("执行中");
278
+
279
+ const bModel = el.querySelector(".b-model");
280
+ bModel.textContent = cur.model || t("模型待定");
281
+ bModel.classList.toggle("dim", !cur.model);
282
+ const bProv = el.querySelector(".b-prov");
283
+ bProv.textContent = cur.provider || "";
284
+ bProv.classList.toggle("hidden", !cur.provider);
285
+
286
+ const cost = Number(c.run_cost_usd) || 0;
287
+ const rt = el.querySelector(".c-tok");
288
+ rt.textContent = (c.run_tokens ? fmtTokens(c.run_tokens) + " tok" : "") +
289
+ (cost > 0 ? (c.run_tokens ? " · " : "") + fmtCost(cost) : "");
290
+ rt.classList.toggle("hidden", !c.run_tokens && !(cost > 0));
291
+
292
+ const est = el.querySelector(".c-est");
293
+ if (c.estimate_s && parseTs(c.started_at)) {
294
+ const p = Math.min(99, Math.round(((Date.now() - parseTs(c.started_at)) / 1000) / c.estimate_s * 100));
295
+ est.classList.remove("hidden");
296
+ est.querySelector(".est-track i").style.width = p + "%";
297
+ est.querySelector("em").textContent = t("预估 {0} · 进度≈{1}%", fmtDur(c.estimate_s), p);
298
+ } else {
299
+ est.classList.add("hidden");
300
+ }
301
+
302
+ // 终端框常驻:没有流也留占位,否则卡内多出来的高度无处安放,卡片会塌成一条
303
+ const live = el.querySelector(".c-live");
304
+ const lt = cur.live_text || cur.tail || "";
305
+ const tagMap = { output: t("输出"), thinking: t("思考") };
306
+ live.querySelector(".tag").textContent = tagMap[cur.live_label] || (lt ? t("实时") : "");
307
+ const txt = live.querySelector(".c-live-text");
308
+ txt.textContent = lt || t("等待实时输出…");
309
+ txt.classList.toggle("dim", !lt);
310
+ live.querySelector(".caret").classList.toggle("hidden", !lt);
311
+ if (DRAWER.taskId === c.task_id) el.classList.add("sel");
312
+ }
313
+ for (const [tid, el] of cardEls) {
314
+ if (!seen.has(tid)) { el.remove(); cardEls.delete(tid); }
315
+ }
316
+
317
+ const qr = $("#queue-row");
318
+ qr.classList.toggle("hidden", !queued.length);
319
+ $("#queue-chips").innerHTML = queued.map((q) => {
320
+ const wait = q.created_at ? fmtDur((Date.now() - parseTs(q.created_at)) / 1000) : "";
321
+ return '<span class="chip" title="' + esc(q.title) + '">' + esc(q.title) +
322
+ (wait ? "<em>" + esc(wait) + "</em>" : "") + "</span>";
323
+ }).join("");
324
+ }
325
+
326
+ /* 空闲时面板不放轨道动画装饰,改画今日运行表——大屏最该回答「今天跑过什么」 */
327
+ function renderIdle(d) {
328
+ const today = String(d.today || "");
329
+ const rows = (Array.isArray(d.recent) ? d.recent : []).filter((r) =>
330
+ String(r.ended_at || "").startsWith(today));
331
+ $("#idle-count").textContent = rows.length;
332
+ const ST = { done: t("完成"), failed: t("失败"), cancelled: t("取消") };
333
+ $("#idle-rows").innerHTML = rows.length ? rows.map((r) =>
334
+ '<div class="h-row"><span class="h-st ' + esc(r.status) + '">' + esc(ST[r.status] || r.status) + "</span>" +
335
+ '<span class="h-t" title="' + esc(r.title) + '">' + esc(r.title) + "</span>" +
336
+ '<span class="h-meta">' + (r.duration_s != null ? fmtDur(r.duration_s) : "—") + "</span>" +
337
+ '<span class="h-time">' + esc(hmms(r.ended_at)) + "</span>" +
338
+ (r.error ? '<span class="h-e" title="' + esc(r.error) + '">' + esc(r.error) + "</span>" : "") +
339
+ "</div>").join("") : '<div class="empty">' + t("今天还没有已结束的") + "</div>";
340
+ }
341
+
342
+ function renderTrend(trend, today) {
343
+ const box = $("#trend");
344
+ const max = Math.max(1, ...trend.map((t) => t.tokens || 0));
345
+ const sum = trend.reduce((a, t) => a + (t.tokens || 0), 0);
346
+ $("#trend-total").textContent = fmtTokens(sum) + " / " + t("7日");
347
+ box.innerHTML = trend.map((t) => {
348
+ const h = Math.round((t.tokens || 0) / max * 100);
349
+ const isToday = today && String(t.day || "") === today;
350
+ return '<div class="bar' + (t.tokens ? " on" : "") + (isToday ? " today" : "") +
351
+ '" title="' + esc(t.day) + " · " + fmtTokens(t.tokens) + '"><i style="height:' +
352
+ Math.max(h, 3) + '%"></i><b>' + esc(String(t.day || "").slice(5)) + "</b></div>";
353
+ }).join("");
354
+ }
355
+
356
+ function renderModels(models) {
357
+ const box = $("#models");
358
+ if (!models.length) { box.innerHTML = '<div class="empty">' + t("暂无调用记录") + "</div>"; return; }
359
+ const max = Math.max(1, ...models.map((m) => m.tokens || 0));
360
+ const sum = models.reduce((a, m) => a + (m.tokens || 0), 0) || 1;
361
+ box.innerHTML = models.map((m) =>
362
+ '<div class="model-row"><div class="l"><div class="nm" title="' + esc(m.model) + '">' + esc(m.model) + "</div>" +
363
+ '<div class="tk"><i style="width:' + Math.max(3, (m.tokens || 0) / max * 100) + '%"></i></div></div>' +
364
+ '<div class="vl">' + fmtTokens(m.tokens) + "<em>" + Math.round((m.tokens || 0) / sum * 100) + "%</em></div></div>"
365
+ ).join("");
366
+ }
367
+
368
+ function renderRecent(recent) {
369
+ const box = $("#recent");
370
+ $("#recent-count").textContent = recent.length + " " + t("条");
371
+ if (!recent.length) { box.innerHTML = '<div class="empty">' + t("还没有已完成的运行") + "</div>"; return; }
372
+ box.innerHTML = recent.map((r) => {
373
+ const cls = r.status === "done" ? "done" : r.status === "failed" ? "failed" : "cancelled";
374
+ return '<div class="r-row"><span class="r-dot ' + cls + '"></span>' +
375
+ '<span class="r-t" title="' + esc(r.title + (r.error ? " · " + r.error : "")) + '">' + esc(r.title) + "</span>" +
376
+ '<span class="r-meta">' + (r.duration_s != null ? fmtDur(r.duration_s) : "—") + " · " + esc(hmms(r.ended_at)) + "</span>" +
377
+ (r.error ? '<span class="r-err">' + esc(r.error) + "</span>" : "") + "</div>";
378
+ }).join("");
379
+ }
380
+
381
+ /* ---------------------------------------------------------------- 下钻抽屉
382
+ 步骤链:/api/board/run?run_id=(点开才拉,不进 4s 轮询)
383
+ 单步日志:现成的 /api/runs/<id>/log?step=<rel>&pretty=1
384
+ 运行中的步每 2.5s 续拉,结束即停,避免盯着不动的日志以为坏了。 */
385
+ const DRAWER = { taskId: "", runId: "", sel: -1, steps: [], timer: 0 };
386
+
387
+ async function openDrawer(runId, cardEl) {
388
+ if (!runId) return;
389
+ DRAWER.runId = runId;
390
+ DRAWER.taskId = cardEl ? cardEl.dataset.task || "" : "";
391
+ DRAWER.sel = -1;
392
+ document.querySelectorAll(".card.sel").forEach((el) => el.classList.remove("sel"));
393
+ if (cardEl) cardEl.classList.add("sel");
394
+ $("#scrim").classList.add("on");
395
+ $("#drawer").classList.add("on");
396
+ $("#drawer").setAttribute("aria-hidden", "false");
397
+
398
+ const c = ((LAST || {}).running || []).find((x) => x.run_id === runId) || {};
399
+ $("#dw-type").textContent = c.type_name || t("任务");
400
+ $("#dw-title").textContent = c.title || runId;
401
+ $("#dw-goal").textContent = c.goal || "";
402
+ DRAWER.taskId = c.task_id || DRAWER.taskId;
403
+ $("#dw-meta").innerHTML = [
404
+ [t("开始"), String(c.started_at || "").slice(11, 19) || "—"],
405
+ [t("已运行"), c.started_at ? fmtDur((Date.now() - parseTs(c.started_at)) / 1000) : "—"],
406
+ [t("进度"), (c.steps_done || 0) + "/" + (c.steps_total || 0)],
407
+ [t("预估"), c.estimate_s ? fmtDur(c.estimate_s) : t("无")],
408
+ [t("执行体"), (c.current || {}).agent || "—"],
409
+ [t("模型"), (c.current || {}).model || "—"],
410
+ [t("厂商"), (c.current || {}).provider || "—"],
411
+ [t("消耗"), fmtTokens(c.run_tokens) + " · " + fmtCost(c.run_cost_usd)],
412
+ ].map(([k, v]) => "<div><span>" + esc(k) + "</span><b>" + esc(v) + "</b></div>").join("");
413
+
414
+ $("#dw-steps").innerHTML = '<div class="empty">' + t("加载中") + "…</div>";
415
+ $("#dw-log-body").textContent = "";
416
+ await syncDrawer();
417
+ clearInterval(DRAWER.timer);
418
+ DRAWER.timer = setInterval(() => {
419
+ if (!DRAWER.runId) return clearInterval(DRAWER.timer);
420
+ syncDrawer();
421
+ }, 2500);
422
+ }
423
+
424
+ const STEP_ST = () => ({ done: t("完成"), running: t("运行中"), failed: t("失败"),
425
+ cancelled: t("取消"), pending: t("等待") });
426
+
427
+ async function syncDrawer() {
428
+ if (!DRAWER.runId) return;
429
+ let d;
430
+ try { d = await api("/api/board/run?run_id=" + encodeURIComponent(DRAWER.runId)); }
431
+ catch (e) { $("#dw-steps").innerHTML = '<div class="empty">' + t("读取失败") + "</div>"; return; }
432
+ const steps = Array.isArray(d.steps) ? d.steps : [];
433
+ DRAWER.steps = steps;
434
+ if (!steps.length) { $("#dw-steps").innerHTML = '<div class="empty">' + t("暂无步骤") + "</div>"; return; }
435
+ // 未手选时自动跟到运行中的那一步(没有运行步就停到最后一步)
436
+ if (DRAWER.sel < 0) {
437
+ const live = steps.findIndex((s) => s.status === "running");
438
+ DRAWER.sel = live >= 0 ? live : steps.length - 1;
439
+ }
440
+ DRAWER.sel = Math.min(DRAWER.sel, steps.length - 1);
441
+ const ST = STEP_ST();
442
+ $("#dw-steps").innerHTML = steps.map((s, i) =>
443
+ '<div class="s-row' + (i === DRAWER.sel ? " act" : "") + '" data-s="' + i + '">' +
444
+ '<span class="s-i">' + (s.n != null ? s.n : i + 1) + "</span>" +
445
+ '<span class="s-n">' + esc(s.role || s.agent || t("步骤")) +
446
+ (s.status === "running" ? '<i class="s-m"><i></i></i>' : "") + "</span>" +
447
+ '<span class="s-a">' + esc(s.agent_label || s.agent || "—") + "</span>" +
448
+ '<span class="s-d">' + (s.duration_s != null ? fmtDur(s.duration_s) : "—") + "</span>" +
449
+ '<span class="s-s ' + esc(s.status || "") + '">' + esc(ST[s.status] || s.status || "—") + "</span></div>"
450
+ ).join("");
451
+ renderStepLog(steps[DRAWER.sel]);
452
+ }
453
+
454
+ async function renderStepLog(s) {
455
+ if (!s) return;
456
+ $("#dw-log-tag").textContent = "#" + (s.n != null ? s.n : "") + " " + (s.role || t("步骤"));
457
+ const ST = STEP_ST();
458
+ $("#dw-log-sub").textContent = ST[s.status] || s.status || "";
459
+ const box = $("#dw-log-body");
460
+ if (!s.log) { box.textContent = s.summary || s.error || t("该步没有日志文件"); return; }
461
+ const stick = box.scrollTop + box.clientHeight >= box.scrollHeight - 24;
462
+ try {
463
+ const d = await api("/api/runs/" + encodeURIComponent(DRAWER.runId) + "/log?step=" +
464
+ encodeURIComponent(s.log) + "&pretty=1&tail=20000");
465
+ box.textContent = d.log || t("(日志为空)");
466
+ if (s.status === "running" || stick) box.scrollTop = box.scrollHeight;
467
+ } catch (e) {
468
+ box.textContent = t("日志读取失败");
469
+ }
470
+ }
471
+
472
+ function closeDrawer() {
473
+ clearInterval(DRAWER.timer);
474
+ DRAWER.runId = ""; DRAWER.taskId = ""; DRAWER.sel = -1; DRAWER.steps = [];
475
+ $("#scrim").classList.remove("on");
476
+ $("#drawer").classList.remove("on");
477
+ $("#drawer").setAttribute("aria-hidden", "true");
478
+ document.querySelectorAll(".card.sel").forEach((el) => el.classList.remove("sel"));
479
+ }
480
+
481
+ $("#dw-close").addEventListener("click", closeDrawer);
482
+ $("#scrim").addEventListener("click", closeDrawer);
483
+ $("#dw-steps").addEventListener("click", (e) => {
484
+ const row = e.target.closest(".s-row");
485
+ if (!row) return;
486
+ DRAWER.sel = Number(row.dataset.s);
487
+ document.querySelectorAll("#dw-steps .s-row").forEach((el) =>
488
+ el.classList.toggle("act", el === row));
489
+ renderStepLog(DRAWER.steps[DRAWER.sel]);
490
+ });
491
+ document.addEventListener("keydown", (e) => {
492
+ if (e.key === "Escape") closeDrawer();
493
+ if (e.key === "T" || e.key === "t") $("#sw-mode").click();
494
+ if (e.key === "S" || e.key === "s") $("#sw-skin").click();
495
+ });
496
+
497
+ /* ---------------------------------------------------------------- 时钟与耗时每秒跳 */
498
+ setInterval(() => {
499
+ const n = new Date();
500
+ const p = (x) => String(x).padStart(2, "0");
501
+ $("#hd-clock").textContent = p(n.getHours()) + ":" + p(n.getMinutes()) + ":" + p(n.getSeconds());
502
+ document.querySelectorAll(".c-elapsed[data-started]").forEach((el) => {
503
+ const st = parseTs(el.dataset.started);
504
+ if (st) el.textContent = fmtDur((Date.now() - st) / 1000);
505
+ });
506
+ }, 1000);
507
+
508
+ poll();
509
+ setInterval(poll, POLL_MS);