codebee 0.1.96 → 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.
package/app/ui/board.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /* CodeBee 任务驾驶舱(/board.html):4s 轮询 /api/board(KB 级聚合端点),
2
- 不碰 /api/state 全量,也不占 SSE。 */
2
+ 不碰 /api/state 全量,也不占 SSE。下钻抽屉按需拉 /api/board/run 与
3
+ /api/runs/<id>/log,不参与 4s 轮询。 */
3
4
  "use strict";
4
5
 
5
6
  const $ = (s) => document.querySelector(s);
@@ -45,6 +46,7 @@ function esc(s) {
45
46
  return String(s == null ? "" : s).replace(/[&<>"']/g,
46
47
  (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
47
48
  }
49
+ function hmms(s) { return String(s || "").slice(11, 16); }
48
50
 
49
51
  /* ---------------------------------------------------------------- 令牌门 */
50
52
  function token() { return localStorage.getItem("orch.token") || ""; }
@@ -64,24 +66,67 @@ function submitGate() {
64
66
  poll();
65
67
  }
66
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
+
67
104
  /* ---------------------------------------------------------------- 拉取 */
68
105
  let connLost = false;
69
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
+
70
114
  async function poll() {
71
115
  let data;
72
116
  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();
117
+ data = await api("/api/board");
77
118
  if (connLost) { connLost = false; $("#hd-conn").classList.add("hidden"); }
78
119
  } catch (e) {
120
+ if (e && e.message === "401") return;
79
121
  if (!connLost) { connLost = true; $("#hd-conn").classList.remove("hidden"); }
80
122
  $("#hd-updated").textContent = t("重试连接中");
81
123
  return;
82
124
  }
125
+ LAST = data;
83
126
  render(data);
127
+ if (DRAWER.runId) syncDrawer();
84
128
  }
129
+ let LAST = null;
85
130
 
86
131
  /* ---------------------------------------------------------------- 渲染 */
87
132
  const cardEls = new Map(); // task_id -> 卡元素(复用防闪烁)
@@ -94,121 +139,129 @@ function render(d) {
94
139
  const pool = d.pool || {};
95
140
  const health = Array.isArray(d.health) ? d.health : [];
96
141
 
97
- // KPI
142
+ // 指标条:一格一个数,副位放该指标自己的第二维度(不再重复标签)
98
143
  const done = Number(d.today_done) || 0;
99
144
  const failed = Number(d.today_failed) || 0;
100
145
  const finished = done + failed;
101
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
+
102
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
+
103
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
+
104
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
+
105
168
  $("#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
- }
169
+ $("#k-use-sub").textContent = fmtCost(usage.cost_usd) + " · " + (Number(usage.calls) || 0) + " " + t("次调用");
122
170
 
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;
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 个绿灯点阵是噪音)
128
185
  const downs = health.filter((h) => h.status === "down" && !h.silenced);
129
186
  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
- }
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") : "";
141
194
 
142
- // 执行池 / 概览侧栏
195
+ // 执行池
143
196
  const alive = Math.max(0, Number(pool.alive) || 0);
144
197
  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 + "%";
198
+ $("#pool-meter-fill").style.width = (target ? Math.min(100, alive / target * 100) : 0) + "%";
147
199
  $("#pool-alive").textContent = alive;
148
200
  $("#pool-target").textContent = target;
149
- $("#overview-queued").textContent = queued.length;
150
- $("#overview-success").textContent = finished ? Math.round(done / finished * 100) + "%" : "—";
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 || "—";
151
207
 
152
208
  renderWall(d);
153
- renderTrend(d.trend || []);
209
+ renderTrend(d.trend || [], String(d.now || "").slice(0, 10));
154
210
  renderModels(d.models || []);
155
211
  renderRecent(recent);
156
212
  }
157
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
+
158
232
  function renderWall(d) {
159
233
  const wrap = $("#wall-cards");
160
234
  const empty = $("#wall-empty");
161
235
  const running = Array.isArray(d.running) ? d.running : [];
162
236
  const queued = Array.isArray(d.queued) ? d.queued : [];
163
237
  const has = running.length > 0;
238
+
164
239
  $("#wall-count").textContent = running.length + " ACTIVE";
165
240
  $("#queue-count").textContent = queued.length;
241
+ // toggle 第二参是 force(true=加类),传反会让空态面板在运行时常驻
166
242
  empty.classList.toggle("hidden", has);
167
- wrap.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);
168
248
 
169
249
  const seen = new Set();
170
250
  for (const c of running) {
171
251
  seen.add(c.task_id);
172
252
  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
- }
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
+
188
257
  el.querySelector(".c-type").textContent = c.type_name || c.type || t("任务");
189
258
  el.querySelector(".c-title").textContent = c.title || t("(未命名)");
259
+ el.querySelector(".c-goal").textContent = c.goal || "";
190
260
  const el2 = el.querySelector(".c-elapsed");
191
261
  el2.dataset.started = c.started_at || "";
192
262
  el2.textContent = fmtDur((Date.now() - parseTs(c.started_at)) / 1000);
193
263
 
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
- // 分段进度条
264
+ const cur = c.current || {};
212
265
  const seg = el.querySelector(".c-steps");
213
266
  const total = Math.max(c.steps_total || 0, 1);
214
267
  if (seg.childElementCount !== total) {
@@ -219,49 +272,84 @@ function renderWall(d) {
219
272
  b.className = i < (c.steps_done || 0) ? "d" : (i === (c.steps_done || 0) ? "r" : "");
220
273
  });
221
274
 
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("执行中");
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("执行中");
226
278
 
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
- }
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);
236
285
 
237
- // 有预估时长则给完成度百分比
238
- const pct = el.querySelector(".c-pct");
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");
239
293
  if (c.estimate_s && parseTs(c.started_at)) {
240
294
  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);
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);
242
298
  } else {
243
- pct.textContent = "";
299
+ est.classList.add("hidden");
244
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");
245
312
  }
246
313
  for (const [tid, el] of cardEls) {
247
314
  if (!seen.has(tid)) { el.remove(); cardEls.delete(tid); }
248
315
  }
249
316
 
250
- // 排队条
251
317
  const qr = $("#queue-row");
252
318
  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("");
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>";
255
340
  }
256
341
 
257
- function renderTrend(trend) {
342
+ function renderTrend(trend, today) {
258
343
  const box = $("#trend");
259
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日");
260
347
  box.innerHTML = trend.map((t) => {
261
348
  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>";
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>";
265
353
  }).join("");
266
354
  }
267
355
 
@@ -269,27 +357,143 @@ function renderModels(models) {
269
357
  const box = $("#models");
270
358
  if (!models.length) { box.innerHTML = '<div class="empty">' + t("暂无调用记录") + "</div>"; return; }
271
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;
272
361
  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("");
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("");
276
366
  }
277
367
 
278
368
  function renderRecent(recent) {
279
369
  const box = $("#recent");
280
- $("#recent-count").textContent = recent.length + " " + t("条事件");
370
+ $("#recent-count").textContent = recent.length + " " + t("条");
281
371
  if (!recent.length) { box.innerHTML = '<div class="empty">' + t("还没有已完成的运行") + "</div>"; return; }
282
- const dot = { done: "done", failed: "failed", cancelled: "cancelled" };
283
372
  box.innerHTML = recent.map((r) => {
284
- const cls = dot[r.status] || "cancelled";
285
- const t = String(r.ended_at || "").slice(11, 16);
373
+ const cls = r.status === "done" ? "done" : r.status === "failed" ? "failed" : "cancelled";
286
374
  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>";
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>";
290
378
  }).join("");
291
379
  }
292
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
+
293
497
  /* ---------------------------------------------------------------- 时钟与耗时每秒跳 */
294
498
  setInterval(() => {
295
499
  const n = new Date();