codebee 0.1.69 → 0.1.70

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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
package/app/ui/app.js CHANGED
@@ -12,6 +12,389 @@ document.title = codebeeDocumentTitle((localStorage.getItem("orch.lang") || "zh"
12
12
  const $ = (id) => document.getElementById(id);
13
13
  const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", mainPage: "", /* 主栏正显示的全局页("" = 新建任务表单);左栏是任务树还是设置导航看 body.settings-mode */ detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {}, lastPrefs: null, prefsApplied: false, sideUsage: null, sideUsageAt: 0, ovUsage: null, ovDays: undefined };
14
14
 
15
+ /* ---------------------------------------------------------- 内置浏览器工作区 */
16
+ const BROWSER_STORAGE_KEY = "orch.browser.workspace.v1";
17
+ const BROWSER_HOME = "__codebee_home__";
18
+ const BROWSER_SEARCH = "https://www.google.com/search?igu=1&q=";
19
+ const BROWSER_FRAME_BLOCKED_HOSTS = new Set(["github.com", "www.github.com", "x.com", "twitter.com", "www.twitter.com", "linkedin.com", "www.linkedin.com"]);
20
+ const BROWSER_TASK_STORAGE_KEY = "orch.browser.task.v1";
21
+ let browserState = browserLoadState();
22
+ let browserTaskId = "";
23
+ let browserPublishAt = 0;
24
+ let browserFrameWatch = 0;
25
+
26
+ try { browserTaskId = localStorage.getItem(BROWSER_TASK_STORAGE_KEY) || ""; } catch (e) { browserTaskId = ""; }
27
+
28
+ function browserLoadState() {
29
+ try {
30
+ const raw = JSON.parse(localStorage.getItem(BROWSER_STORAGE_KEY) || "{}");
31
+ const tabs = Array.isArray(raw.tabs) && raw.tabs.length ? raw.tabs : [{ id: "home", url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }];
32
+ const kept = tabs.slice(0, 20).map(browserNormalizeTab);
33
+ return { tabs: kept, active: Math.max(0, Math.min(Number(raw.active) || 0, kept.length - 1)) };
34
+ } catch (e) {
35
+ return { tabs: [{ id: "home", url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }], active: 0 };
36
+ }
37
+ }
38
+
39
+ function browserNormalizeTab(tab, i) {
40
+ const history = Array.isArray(tab.history) && tab.history.length ? tab.history.slice(-20) : [tab.url || BROWSER_HOME];
41
+ const index = Math.max(0, Math.min(Number(tab.index) || history.length - 1, history.length - 1));
42
+ return { id: String(tab.id || ("tab-" + i)), url: history[index], title: String(tab.title || "CodeBee 浏览器").slice(0, 80), history, index };
43
+ }
44
+
45
+ function browserSaveState() {
46
+ try {
47
+ browserState.tabs = browserState.tabs.slice(0, 20).map(browserNormalizeTab);
48
+ browserState.active = Math.max(0, Math.min(browserState.active, browserState.tabs.length - 1));
49
+ localStorage.setItem(BROWSER_STORAGE_KEY, JSON.stringify(browserState));
50
+ } catch (e) { /* private browsing/storage quota: browser remains usable in memory */ }
51
+ const count = $("browser-history-count");
52
+ if (count) count.textContent = t("{0} 个标签页", browserState.tabs.length);
53
+ }
54
+
55
+ function browserTaskList() {
56
+ return ((S.state || {}).tasks || []).filter((task) => task && task.id);
57
+ }
58
+
59
+ function browserTaskForId(id) {
60
+ return browserTaskList().find((task) => task.id === id) || null;
61
+ }
62
+
63
+ function browserCurrentTask() {
64
+ const candidates = [browserTaskId, S.detailTaskKey,
65
+ S.lastRun && S.lastRun.task_id, S.lastRunTask && S.lastRunTask.id];
66
+ for (const id of candidates) {
67
+ const task = id && browserTaskForId(id);
68
+ if (task) return task;
69
+ }
70
+ return browserTaskList()[0] || null;
71
+ }
72
+
73
+ function browserLatestRun(task) {
74
+ if (!task) return null;
75
+ if (S.lastRun && S.lastRun.task_id === task.id) return S.lastRun;
76
+ const runs = ((S.state || {}).runs || []).filter((run) => run && run.task_id === task.id);
77
+ return runs[0] || ((S.state || {}).task_latest || {})[task.id] || null;
78
+ }
79
+
80
+ function browserStatusLabel(status) {
81
+ const map = { running: "运行中", queued: "排队中", done: "已完成", failed: "失败",
82
+ cancelled: "已取消", timeout: "超时", waiting: "等待中" };
83
+ return t(map[status] || status || "未开始");
84
+ }
85
+
86
+ function browserRenderHomeTask(task, run, status, verifyPass) {
87
+ const title = $("browser-home-task-title"), meta = $("browser-home-task-meta");
88
+ const runLine = $("browser-home-run");
89
+ if (!title || !meta || !runLine) return;
90
+ const buttons = {};
91
+ document.querySelectorAll("[data-browser-home-action]").forEach((button) => {
92
+ buttons[button.dataset.browserHomeAction] = button;
93
+ });
94
+ if (!task) {
95
+ title.textContent = t("还没有任务");
96
+ meta.textContent = t("先新建任务,CodeBee 会在这里接管预览、验证与发布动作。");
97
+ runLine.textContent = t("等待任务进入工作台");
98
+ Object.values(buttons).forEach((button) => { button.disabled = true; });
99
+ return;
100
+ }
101
+ const type = task.type || task.flow || "";
102
+ title.textContent = task.title || task.goal || t("未命名任务");
103
+ meta.textContent = [browserStatusLabel(status), type].filter(Boolean).join(" · ");
104
+ const verifyText = verifyPass === true ? t("验证通过") :
105
+ (verifyPass === false ? t("验证未通过") : t("等待验证"));
106
+ runLine.textContent = run && run.id
107
+ ? t("运行 #{0} · {1}", String(run.id).slice(0, 8), verifyText)
108
+ : t("还没有运行记录");
109
+ if (buttons.preview) buttons.preview.disabled = !run || !run.id;
110
+ if (buttons.verify) buttons.verify.disabled = !run || !run.id;
111
+ if (buttons.retry) buttons.retry.disabled = status === "running" || status === "queued";
112
+ if (buttons.publish) buttons.publish.disabled = !/(serial|novel|小说|连载)/i.test(type);
113
+ }
114
+
115
+ function browserRenderTaskOptions() {
116
+ const select = $("browser-task-select");
117
+ if (!select) return null;
118
+ const tasks = browserTaskList();
119
+ const current = browserCurrentTask();
120
+ if (current && !browserTaskId) browserTaskId = current.id;
121
+ if (!tasks.length) {
122
+ select.innerHTML = '<option value="">' + esc(t("暂无任务")) + "</option>";
123
+ select.disabled = true;
124
+ return null;
125
+ }
126
+ select.disabled = false;
127
+ select.innerHTML = tasks.map((task) => '<option value="' + esc(task.id) + '">' +
128
+ esc(task.title || task.goal || t("未命名任务")) + "</option>").join("");
129
+ const chosen = browserTaskForId(browserTaskId) || current || tasks[0];
130
+ browserTaskId = chosen.id;
131
+ select.value = browserTaskId;
132
+ try { localStorage.setItem(BROWSER_TASK_STORAGE_KEY, browserTaskId); } catch (e) { /* memory only */ }
133
+ return chosen;
134
+ }
135
+
136
+ function browserRenderTaskContext() {
137
+ const task = browserRenderTaskOptions();
138
+ const card = $("browser-task-card"), verify = $("browser-verify-card"), publish = $("browser-publish-card");
139
+ const actions = $("browser-context-actions");
140
+ if (!card || !verify || !publish) return task;
141
+ const run = browserLatestRun(task);
142
+ if (!task) {
143
+ card.innerHTML = '<div class="browser-task-empty">' + esc(t("还没有任务;先在左侧新建一个任务")) + "</div>";
144
+ verify.innerHTML = '<span>' + esc(t("任务运行后,这里会显示验证证据")) + "</span>";
145
+ publish.innerHTML = '<span>' + esc(t("连载任务完成后,可从这里进入发布工作台")) + "</span>";
146
+ if (actions) actions.querySelectorAll("button").forEach((button) => { button.disabled = true; });
147
+ browserRenderHomeTask(null, null, "", null);
148
+ return null;
149
+ }
150
+ if (actions) actions.querySelectorAll("button").forEach((button) => { button.disabled = false; });
151
+ const latest = ((S.state || {}).task_latest || {})[task.id] || {};
152
+ const status = run && run.status || task.status || latest.status || "";
153
+ const previewAction = $("browser-action-preview"), detailAction = $("browser-action-detail"), retryAction = $("browser-action-retry");
154
+ if (previewAction) previewAction.disabled = !run || !run.id;
155
+ if (detailAction) detailAction.disabled = !run || !run.id;
156
+ if (retryAction) retryAction.disabled = status === "running" || status === "queued";
157
+ const type = task.type || task.flow || "";
158
+ card.innerHTML = '<div class="browser-task-title">' + esc(task.title || t("未命名任务")) + "</div>" +
159
+ '<div class="browser-task-meta"><span class="browser-task-status">' + esc(browserStatusLabel(status)) +
160
+ "</span>" + (type ? '<span>' + esc(type) + "</span>" : "") +
161
+ (run && run.id ? '<span>#' + esc(String(run.id).slice(0, 8)) + "</span>" : "") + "</div>" +
162
+ (task.goal ? '<div class="browser-task-goal">' + esc(task.goal) + "</div>" : "");
163
+ const verdict = (run && run.verdict) || latest.verdict || {};
164
+ const verifyPass = verdict.verify_pass != null ? verdict.verify_pass :
165
+ (run && run.verify_pass != null ? run.verify_pass : null);
166
+ const verifyClass = verifyPass === true ? "ok" : (verifyPass === false ? "bad" : "");
167
+ const verifyIcon = verifyPass === true ? "i-check" : (verifyPass === false ? "i-x" : "i-gauge");
168
+ const verifyText = verifyPass === true ? "验证通过" : (verifyPass === false ? "验证未通过" : "等待验证");
169
+ const command = task.verify_command || "";
170
+ verify.innerHTML = '<div class="browser-verify-line ' + verifyClass + '"><svg class="ico" aria-hidden="true"><use href="#' + verifyIcon + '"></use></svg><span>' + esc(t(verifyText)) + "</span></div>" +
171
+ (command ? '<div class="browser-verify-command" title="' + esc(command) + '">' + esc(command) + "</div>" : '<span>' + esc(t("未配置验证命令")) + "</span>");
172
+ const platforms = ["fanqie", "qimao"], ps = (S.pubState && S.pubState.platforms) || {};
173
+ publish.innerHTML = '<div class="browser-publish-platforms">' + platforms.map((platform) => {
174
+ const state = ps[platform] || {}, label = platform === "fanqie" ? "番茄" : "七猫";
175
+ const statusText = state.status === "connected" ? "已连接" : (state.status === "busy" ? "操作中" : "未连接");
176
+ return '<div class="browser-publish-platform"><b>' + esc(t(label)) + '</b><span>' + esc(t(statusText)) + "</span></div>";
177
+ }).join("") + "</div>";
178
+ browserRenderHomeTask(task, run, status, verifyPass);
179
+ return task;
180
+ }
181
+
182
+ function browserOpenTaskRun(task, tab) {
183
+ const run = browserLatestRun(task);
184
+ if (!task || !run || !run.id) { toast(t("当前任务还没有运行记录"), true); return; }
185
+ showDetailInMain();
186
+ openRun(run.id, tab || null);
187
+ }
188
+
189
+ function browserOpenTaskPreview(task) {
190
+ const run = browserLatestRun(task);
191
+ if (!run || !run.id) { toast(t("当前任务还没有可预览的成果"), true); return; }
192
+ api("/api/runs/" + encodeURIComponent(run.id) + "/preview").then((data) => {
193
+ if (!data || !data.ok || !data.base) throw new Error(t("当前任务没有网页成品"));
194
+ browserNavigate(data.base + data.entry);
195
+ }).catch((e) => toast(e.message || String(e), true));
196
+ }
197
+
198
+ async function browserLoadPublishState(task) {
199
+ if (!task || Date.now() - browserPublishAt < 5000) return;
200
+ browserPublishAt = Date.now();
201
+ try {
202
+ const state = await api("/api/publish");
203
+ S.pubState = state;
204
+ if (task.id) {
205
+ try { S.pubTaskInfo = await api("/api/publish/task/" + encodeURIComponent(task.id) + "/history"); } catch (e) { /* optional */ }
206
+ }
207
+ browserRenderTaskContext();
208
+ } catch (e) { /* publishing is optional and may not be installed */ }
209
+ }
210
+
211
+ function normalizeBrowserUrl(value) {
212
+ const raw = String(value || "").trim();
213
+ if (!raw) return BROWSER_HOME;
214
+ if (raw === BROWSER_HOME || raw.indexOf("/preview/") === 0 || raw.indexOf(location.origin + "/preview/") === 0) return raw;
215
+ if (/^(javascript|data|vbscript|file):/i.test(raw)) return "";
216
+ if (/^https?:\/\//i.test(raw)) return raw;
217
+ if (/^[\w.-]+\.[a-z]{2,}(\/.*)?$/i.test(raw)) return "https://" + raw;
218
+ return BROWSER_SEARCH + encodeURIComponent(raw);
219
+ }
220
+
221
+ function browserActiveTab() { return browserState.tabs[browserState.active] || browserState.tabs[0]; }
222
+
223
+ function browserTitle(url) {
224
+ if (url === BROWSER_HOME) return "CodeBee 浏览器";
225
+ try { return new URL(url, location.origin).hostname.replace(/^www\./, "") || "浏览器"; } catch (e) { return "浏览器"; }
226
+ }
227
+
228
+ // Google sends X-Frame-Options on its normal entry/search URLs. The documented
229
+ // `igu=1` variant keeps the same Google UI while allowing an embedded workspace
230
+ // to render it; other sites still use the system-browser fallback when they
231
+ // opt out of framing.
232
+ function browserEmbedUrl(url) {
233
+ try {
234
+ const parsed = new URL(url, location.origin);
235
+ const host = (parsed.hostname || "").toLowerCase();
236
+ if (host === "google.com" || host.endsWith(".google.com")) parsed.searchParams.set("igu", "1");
237
+ return parsed.toString();
238
+ } catch (e) { return url; }
239
+ }
240
+
241
+ // CodeBee's query token is a local application credential. It must never be
242
+ // attached to Google, GitHub, publishing platforms, or any other third party.
243
+ function browserUrlAuth(url) {
244
+ try {
245
+ const parsed = new URL(url, location.origin);
246
+ return parsed.origin === location.origin ? urlAuth(url) : url;
247
+ } catch (e) { return url; }
248
+ }
249
+
250
+ function browserRenderHint(url) {
251
+ const hint = $("browser-hint");
252
+ if (!hint) return;
253
+ if (url === BROWSER_HOME) {
254
+ hint.textContent = t("外部网站可能禁止嵌入;可用右上角系统浏览器打开。");
255
+ return;
256
+ }
257
+ try {
258
+ const host = (new URL(url, location.origin).hostname || "").toLowerCase();
259
+ if (host === "google.com" || host.endsWith(".google.com")) {
260
+ hint.textContent = t("Google 可在本机系统浏览器访问;嵌入工作区会被 Google 拒绝,请用右上角系统浏览器打开。");
261
+ } else if (BROWSER_FRAME_BLOCKED_HOSTS.has(host)) {
262
+ hint.textContent = t("此网站禁止嵌入页面;请点击右上角系统浏览器打开。");
263
+ } else {
264
+ hint.textContent = t("外部网站可能禁止嵌入;可用右上角系统浏览器打开。");
265
+ }
266
+ } catch (e) {
267
+ hint.textContent = t("外部网站可能禁止嵌入;可用右上角系统浏览器打开。");
268
+ }
269
+ }
270
+
271
+ function browserSetFrameError(show, message) {
272
+ const frame = $("browser-frame"), error = $("browser-frame-error"), text = $("browser-frame-error-text");
273
+ if (!frame || !error) return;
274
+ error.classList.toggle("hidden", !show);
275
+ frame.classList.toggle("hidden", show);
276
+ if (text && message) text.textContent = message;
277
+ }
278
+
279
+ function browserWatchFrame(url) {
280
+ window.clearTimeout(browserFrameWatch);
281
+ browserSetFrameError(false);
282
+ const frame = $("browser-frame");
283
+ if (!frame || url === BROWSER_HOME) return;
284
+ browserFrameWatch = window.setTimeout(() => {
285
+ let host = "";
286
+ try { host = (new URL(url, location.origin).hostname || "").toLowerCase(); } catch (e) { /* invalid URL was already normalized */ }
287
+ if (host === "google.com" || host.endsWith(".google.com")) {
288
+ browserSetFrameError(true, t("Google 可以在本机系统浏览器访问,但会拒绝嵌入 CodeBee 工作区;这不是本地网络问题。"));
289
+ return;
290
+ }
291
+ try {
292
+ // Cross-origin pages throw here when they loaded successfully. A failed
293
+ // frame stays at about:blank, which is the useful signal for Google/XFO.
294
+ const docUrl = frame.contentDocument && frame.contentDocument.URL;
295
+ if (docUrl === "about:blank") browserSetFrameError(true);
296
+ } catch (e) { /* loaded cross-origin content; keep it visible */ }
297
+ }, 2400);
298
+ }
299
+
300
+ function browserDrawTabs() {
301
+ const tabs = $("browser-tabs");
302
+ if (!tabs) return;
303
+ tabs.querySelectorAll(".browser-tab").forEach((x) => x.remove());
304
+ const add = $("browser-new-tab");
305
+ browserState.tabs.forEach((tab, i) => {
306
+ const b = document.createElement("button");
307
+ b.type = "button"; b.className = "browser-tab" + (i === browserState.active ? " active" : "");
308
+ b.dataset.browserTab = tab.id; b.setAttribute("role", "tab"); b.setAttribute("aria-selected", i === browserState.active ? "true" : "false");
309
+ b.innerHTML = '<svg class="ico" aria-hidden="true"><use href="#i-browser"></use></svg><span>' + esc(tab.title || browserTitle(tab.url)) + '</span><span class="browser-tab-close" aria-label="关闭标签页">×</span>';
310
+ tabs.insertBefore(b, add);
311
+ });
312
+ }
313
+
314
+ function browserDraw() {
315
+ const tab = browserActiveTab();
316
+ if (!tab) return;
317
+ browserRenderTaskContext();
318
+ browserDrawTabs();
319
+ const address = $("browser-address"), frame = $("browser-frame"), empty = $("browser-empty");
320
+ if (address) address.value = tab.url === BROWSER_HOME ? "" : tab.url;
321
+ const canBack = tab.index > 0, canForward = tab.index < tab.history.length - 1;
322
+ ["browser-back", "browser-forward"].forEach((id, i) => { const b = $(id); if (b) b.disabled = i ? !canForward : !canBack; });
323
+ if (tab.url === BROWSER_HOME) {
324
+ window.clearTimeout(browserFrameWatch);
325
+ browserSetFrameError(false);
326
+ if (frame) { frame.classList.add("hidden"); frame.removeAttribute("src"); }
327
+ if (empty) empty.classList.remove("hidden");
328
+ } else {
329
+ if (empty) empty.classList.add("hidden");
330
+ if (frame) {
331
+ frame.classList.remove("hidden");
332
+ frame.src = browserUrlAuth(browserEmbedUrl(tab.url));
333
+ browserWatchFrame(tab.url);
334
+ }
335
+ }
336
+ browserRenderHint(tab.url);
337
+ browserSaveState();
338
+ }
339
+
340
+ function browserNavigate(value, replace) {
341
+ const url = normalizeBrowserUrl(value);
342
+ if (!url) { toast(t("不允许打开此类地址"), true); return false; }
343
+ const tab = browserActiveTab();
344
+ if (replace) tab.history[tab.index] = url;
345
+ else { tab.history = tab.history.slice(0, tab.index + 1).concat(url).slice(-20); tab.index = tab.history.length - 1; }
346
+ tab.url = url; tab.title = browserTitle(url); browserDraw(); return true;
347
+ }
348
+
349
+ function browserOpenPreview() {
350
+ browserOpenTaskPreview(browserCurrentTask());
351
+ }
352
+
353
+ function browserBind() {
354
+ if (!$("browser-frame")) return;
355
+ $("browser-nav-form").addEventListener("submit", (e) => { e.preventDefault(); browserNavigate($("browser-address").value); });
356
+ $("browser-back").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.index > 0) { t0.index--; t0.url = t0.history[t0.index]; browserDraw(); } });
357
+ $("browser-forward").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.index < t0.history.length - 1) { t0.index++; t0.url = t0.history[t0.index]; browserDraw(); } });
358
+ $("browser-reload").addEventListener("click", () => { const f = $("browser-frame"); if (f && !f.classList.contains("hidden")) f.src = f.src; });
359
+ $("browser-home").addEventListener("click", () => browserNavigate(BROWSER_HOME));
360
+ $("browser-new-tab").addEventListener("click", () => { browserState.tabs.push({ id: "tab-" + Date.now(), url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }); browserState.active = browserState.tabs.length - 1; browserDraw(); });
361
+ $("browser-open-external").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.url !== BROWSER_HOME) window.open(browserUrlAuth(t0.url), "_blank", "noopener"); });
362
+ $("browser-frame-error-open").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.url !== BROWSER_HOME) window.open(browserUrlAuth(t0.url), "_blank", "noopener"); });
363
+ $("browser-empty").addEventListener("click", (e) => {
364
+ const button = e.target.closest("[data-browser-home-action]");
365
+ if (!button || button.disabled) return;
366
+ const task = browserCurrentTask(), action = button.dataset.browserHomeAction;
367
+ if (action === "preview") browserOpenTaskPreview(task);
368
+ else if (action === "verify") browserOpenTaskRun(task, "result");
369
+ else if (action === "publish") browserOpenTaskRun(task, "bookmeta");
370
+ else if (action === "retry") {
371
+ if (task) retryTask(task.id); else toast(t("请先选择任务"), true);
372
+ }
373
+ });
374
+ const taskSelect = $("browser-task-select");
375
+ if (taskSelect) taskSelect.addEventListener("change", () => {
376
+ browserTaskId = taskSelect.value || "";
377
+ try { localStorage.setItem(BROWSER_TASK_STORAGE_KEY, browserTaskId); } catch (e) { /* memory only */ }
378
+ browserRenderTaskContext();
379
+ browserLoadPublishState(browserCurrentTask());
380
+ });
381
+ $("browser-action-preview").addEventListener("click", () => browserOpenTaskPreview(browserCurrentTask()));
382
+ $("browser-action-detail").addEventListener("click", () => browserOpenTaskRun(browserCurrentTask()));
383
+ $("browser-action-retry").addEventListener("click", () => {
384
+ const task = browserCurrentTask();
385
+ if (task) retryTask(task.id); else toast(t("请先选择任务"), true);
386
+ });
387
+ $("browser-action-verify").addEventListener("click", () => browserOpenTaskRun(browserCurrentTask(), "result"));
388
+ $("browser-action-publish").addEventListener("click", () => {
389
+ const task = browserCurrentTask();
390
+ if (task) browserOpenTaskRun(task, "bookmeta"); else toast(t("请先选择任务"), true);
391
+ });
392
+ $("browser-shortcuts").addEventListener("click", (e) => { const b = e.target.closest("[data-browser-url], [data-browser-preview]"); if (!b) return; if (b.dataset.browserPreview) browserOpenPreview(); else browserNavigate(b.dataset.browserUrl); });
393
+ $("browser-tabs").addEventListener("click", (e) => { const close = e.target.closest(".browser-tab-close"), tab = e.target.closest(".browser-tab"); if (!tab) return; const i = browserState.tabs.findIndex((x) => x.id === tab.dataset.browserTab); if (close && browserState.tabs.length > 1) { browserState.tabs.splice(i, 1); browserState.active = Math.min(browserState.active, browserState.tabs.length - 1); } else if (!close && i >= 0) browserState.active = i; browserDraw(); });
394
+ browserDraw();
395
+ browserLoadPublishState(browserCurrentTask());
396
+ }
397
+
15
398
  /* ---------------------------------------------------------- 任务类型(流程) */
16
399
  async function loadFlows() {
17
400
  // 偏好记忆(借鉴 chinese-novelist-skill):并行拉最近一次的任务参数,
@@ -525,7 +908,7 @@ async function refreshEstimate() {
525
908
  : t("暂无同类历史,按流程基线估算"));
526
909
  const tokenPart = d.samples >= (d.min_samples_for_estimate || 10) && d.median_tokens != null
527
910
  ? " · ≈" + fmtTok(d.median_tokens) + " tokens" : "";
528
- const cost = (d.median_cost_usd || 0) > 0 ? " · ≈$" + Number(d.median_cost_usd).toFixed(2) : "";
911
+ const cost = (d.median_cost_usd || 0) > 0 ? " · ≈¥" + Number(d.median_cost_usd).toFixed(2) : "";
529
912
  el.textContent = t("预计完成约 {0},保守不超过 {1}", duration, p90) +
530
913
  tokenPart + cost + t("(") + source + t(")");
531
914
  el.classList.remove("hidden");
@@ -1100,6 +1483,10 @@ function applyState(d) {
1100
1483
  $("conn").className = "conn ok";
1101
1484
  restoreInspector(); // 刷新后恢复上次打开的检查器(只在已开时为空操作)
1102
1485
  ovMaybeRefresh(); // 停在概览页时:概览语与最近运行跟着这次状态一起刷新
1486
+ if (S.tab === "browser") {
1487
+ browserRenderTaskContext();
1488
+ browserLoadPublishState(browserCurrentTask());
1489
+ }
1103
1490
  }
1104
1491
 
1105
1492
  /* ---------------------------------------------------------- 供应商健康告警横幅 */
@@ -1261,6 +1648,7 @@ async function refreshPollLookups(force) {
1261
1648
  S.modelCatalog = models.catalog || [];
1262
1649
  S.sourceNames = models.source_names || S.sourceNames || {};
1263
1650
  S._modelsLoaded = true;
1651
+ refreshBenchScores(); // 评测实测分随模型目录一起保鲜(推荐选模用)
1264
1652
  renderDirectModelPicker();
1265
1653
  const provSig = JSON.stringify((S.providers || []).map((p) => [p.id, p.enabled, !!p.api_key]));
1266
1654
  if (provSig !== S.provSig) { S.provSig = provSig; loadOrchestrator(); }
@@ -1324,6 +1712,12 @@ function startSSE() {
1324
1712
  schedulePolling();
1325
1713
  poll();
1326
1714
  };
1715
+ // 命名事件:台账有新记账(event: usage)。不进 onmessage、不触发
1716
+ // applyState——只刷新左下角用量条,3s 节流在 sideUsageLoad 里。
1717
+ es.addEventListener("usage", () => {
1718
+ S._lastSseAt = Date.now();
1719
+ sideUsageLoad(true);
1720
+ });
1327
1721
  es.onmessage = (ev) => {
1328
1722
  S._lastSseAt = Date.now();
1329
1723
  try { applyState(JSON.parse(ev.data)); render(); } catch (e) { /* ignore */ }
@@ -1349,6 +1743,7 @@ document.addEventListener("visibilitychange", () => {
1349
1743
  } else if (!S.es) {
1350
1744
  startSSE();
1351
1745
  poll();
1746
+ sideUsageLoad(true); // 隐藏期落下的记账立刻补上(SSE 断开期间收不到 usage 事件)
1352
1747
  }
1353
1748
  });
1354
1749
 
@@ -1927,6 +2322,8 @@ function provModelRow(pid, m, group) {
1927
2322
  esc((tm.error || "").slice(0, 24)) + "</span>") : "";
1928
2323
  const price = (m.price_in != null && m.price_out != null)
1929
2324
  ? '<span class="hint">¥' + esc(m.price_in) + "/¥" + esc(m.price_out) + "</span>" : "";
2325
+ const priceBtn = '<span class="tag" title="' + t("手标单价(¥/百万 tokens),台账记账与花费预算按此折算") + '"' +
2326
+ ' onclick="editModelPrice(\'' + esc(pid) + '\', \'' + esc(m.name) + '\')">' + t("价") + "</span>";
1930
2327
  const imgcap = '<span class="tag imgcap' + (m.image_in ? " ok" : "") + '" title="' +
1931
2328
  (m.image_in ? t("支持图片输入,点击关闭")
1932
2329
  : t("纯文本模型,点击开启图片输入(内置智能体传图以此为准)")) + '"' +
@@ -1940,7 +2337,7 @@ function provModelRow(pid, m, group) {
1940
2337
  '<span class="drag" title="' + t("拖动调整优先级") + '"><svg class="ico" aria-hidden="true"><use href="#i-grip"></use></svg></span>' +
1941
2338
  '<span class="pprio">#' + m.priority + "</span>" +
1942
2339
  '<span class="pname" title="' + esc(m.name) + '">' + esc(m.name) + "</span>" +
1943
- price + imgcap + tmHtml +
2340
+ price + priceBtn + imgcap + tmHtml +
1944
2341
  '<span class="row-ops">' +
1945
2342
  '<button class="ghost small row-op" onclick="testModelBtn(\'' + esc(pid) + '\', \'' + esc(m.name) + '\')">' + t("测试") + '</button>' +
1946
2343
  '<button class="danger small row-op" title="' + t("从列表删除:刷新/重新导入不会再带回,可在分组底部恢复") + '"' +
@@ -2036,6 +2433,34 @@ function toggleModelImage(pid, name) {
2036
2433
  .catch((e) => toast(t("保存失败:") + e.message, true));
2037
2434
  }
2038
2435
 
2436
+ /* 手标模型单价(¥/百万 tokens):台账记账与花费预算按此折算;清空=回落价表 */
2437
+ function editModelPrice(pid, name) {
2438
+ const p = (S.providers || []).find((x) => x.id === pid);
2439
+ const m = p && (p.models || []).find((x) => x.name === name);
2440
+ const body = '<p class="hint">' +
2441
+ t("单价按 ¥/百万 tokens 填(输入价/输出价)。手标优先于导入价表;台账「费用」列与花费预算按它折算——不标则不记账。") + "</p>" +
2442
+ '<div class="grid-2">' +
2443
+ '<div class="field"><label>' + t("输入单价 ¥/M") + '</label><input id="mp-in" type="number" step="0.01" min="0" value="' +
2444
+ (m && m.price_in != null ? m.price_in : "") + '"></div>' +
2445
+ '<div class="field"><label>' + t("输出单价 ¥/M") + '</label><input id="mp-out" type="number" step="0.01" min="0" value="' +
2446
+ (m && m.price_out != null ? m.price_out : "") + '"></div></div>';
2447
+ openModal(t("标定单价:") + name, body,
2448
+ '<button class="ghost" onclick="closeModal()">' + t("取消") + '</button>' +
2449
+ '<button class="primary" onclick="saveModelPrice(\'' + esc(pid) + '\', \'' + esc(name) + '\')">' + t("保存") + '</button>');
2450
+ }
2451
+
2452
+ async function saveModelPrice(pid, name) {
2453
+ try {
2454
+ await api("/api/models/set-price", { method: "POST", body: JSON.stringify({
2455
+ provider_id: pid, name,
2456
+ price_in: ($("mp-in") || {}).value ?? "", price_out: ($("mp-out") || {}).value ?? "" }) });
2457
+ closeModal();
2458
+ S.modelsSig = null; S.providers = null;
2459
+ poll();
2460
+ toast(t("单价已标定"));
2461
+ } catch (e) { toast(t("保存失败:") + e.message, true); }
2462
+ }
2463
+
2039
2464
  /* 反查模型是否声明图片输入(绑定页 chip/列表只读徽标用) */
2040
2465
  function modelHasImage(pid, name) {
2041
2466
  const p = (S.providers || []).find((x) => x.id === pid);
@@ -3883,9 +4308,13 @@ function renderSideTasks() {
3883
4308
  /* 主视图打开详情面板的公共部分:留在任务树,主栏切到运行/任务详情 */
3884
4309
  function showDetailInMain() {
3885
4310
  S.tab = "runs";
4311
+ const main = document.querySelector("main");
4312
+ if (main) main.classList.remove("browser-page-active");
3886
4313
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-runs"));
3887
4314
  document.querySelectorAll(".set-item").forEach((b) => b.classList.remove("active"));
3888
4315
  document.body.classList.remove("settings-mode");
4316
+ syncRailState();
4317
+ syncInspBtn();
3889
4318
  renderPageCrumb();
3890
4319
  collapseDrawerIfMobile();
3891
4320
  syncInspectorVis(); // 从设置子页点进运行详情:任务上下文,检查器跟着回来
@@ -3955,6 +4384,7 @@ window.sideOpenTask = function (key) {
3955
4384
  rdTabReset();
3956
4385
  if (!S.histJump && typeof histPush === "function") histPush({ m: "main", tab: "run-detail" });
3957
4386
  showDetailInMain();
4387
+ syncInspBtn();
3958
4388
  $("run-detail").classList.remove("hidden");
3959
4389
  document.querySelector("#sub-runs .panel:first-child").classList.add("hidden");
3960
4390
  detailSideReset(); // 换详情目标:任务级 side 缓存作废,等首拉
@@ -4258,7 +4688,7 @@ function drawTaskDetail(key, runs, appendFrom) {
4258
4688
  $("rd-meta").innerHTML =
4259
4689
  '<span class="stat">' + t("运行 ") + '<b>' + runs.length + "/" + totalRuns + "</b>" + t(" 次") + "</span>" +
4260
4690
  '<span class="stat">' + t("步骤 ") + '<b>' + totalSteps + "/" + allSteps + "</b>" + t(" 步") + "</span>" +
4261
- '<span class="stat">' + t("成本 ") + '<b>$' + (aggregate ? aggregate.cost_usd : sum("cost_usd")).toFixed(3) + "</b></span>" +
4691
+ '<span class="stat">' + t("成本 ") + '<b>¥' + (aggregate ? aggregate.cost_usd : sum("cost_usd")).toFixed(3) + "</b></span>" +
4262
4692
  '<span class="stat">tokens <b>' + (aggregate ? aggregate.tokens : sum("tokens")) + "</b></span>" +
4263
4693
  (latest.error ? '<span class="stat err">' + errTag(latest.error) + esc(latest.error.slice(0, 200)) + "</span>" : "");
4264
4694
  $("rd-plan").classList.add("hidden");
@@ -4449,6 +4879,7 @@ async function openRun(id, pinTab) {
4449
4879
  rdTabReset(pinTab || null); // sideOpenRun 带步骤号时钉住步骤分区
4450
4880
  document.querySelector("#sub-runs .panel:first-child").classList.add("hidden");
4451
4881
  $("run-detail").classList.remove("hidden");
4882
+ syncInspBtn();
4452
4883
  detailSideReset(); // 换详情目标:任务级 side 缓存作废,等首拉
4453
4884
  syncInspectorVis(); // 详情已铺开:检查器让位(选中保留,返回列表自动滑回)
4454
4885
  renderRunDetail();
@@ -4464,6 +4895,7 @@ function closeRun() {
4464
4895
  stopHiveTick();
4465
4896
  detailSideReset();
4466
4897
  $("run-detail").classList.add("hidden");
4898
+ syncInspBtn();
4467
4899
  renderChatNav(); // 解除 main.chat-fill(对话为主的固定高度),恢复外层滚动
4468
4900
  if (document.body.classList.contains("settings-mode") || S.mainPage === "runs") {
4469
4901
  // 回运行列表:设置导航里点进来的,或主栏本就停在运行记录页(快捷导航/概览的入口)
@@ -4625,14 +5057,14 @@ function rdTabsSync(ctx) {
4625
5057
  S.rdTabSig = sig;
4626
5058
  const avail = rdTabAvail();
4627
5059
  const direct = chatAvail && chatEngineIsDirect(S.lastRun);
4628
- // 直连任务的主问题是「继续聊什么、上一轮回答是什么」——默认把对话
4629
- // 放在第一视线;编排/代码任务才默认落蜂巢,先看阶段与在岗步骤。
5060
+ // 所有任务的主问题都是「继续聊什么、各智能体输出了什么」——默认把
5061
+ // 对话放在第一视线;蜂巢/步骤/成果保留为侧边工作台入口。
4630
5062
  // 没跑到终态时成果分区是空的,避免打开详情先看到白板。
4631
5063
  const finishing = !c.running && ["done", "failed", "cancelled", "timeout"].includes(c.status);
4632
5064
  S.rdTab = (direct && avail.chat ? "chat" : null)
4633
5065
  // 跑出了能打开的网页成品:第一视线给「预览」——这正是用户做完一个
4634
5066
  // 前端任务最想看到的(借鉴对话式编程产品:跑完先看东西跑起来)。
4635
- // 直连任务的对话、待裁决的版本仍排在它前面:那两个是"要用户说话/动手"。
5067
+ // 对话与待裁决的版本仍排在它前面:那两个是"要用户说话/动手"。
4636
5068
  || (finishing && avail.preview ? "preview" : null)
4637
5069
  || (avail.hive ? "hive" : null)
4638
5070
  || (avail.chat ? "chat" : null)
@@ -5150,7 +5582,31 @@ function pbBlock(task, platform) {
5150
5582
  const books = (S.pubTaskInfo && S.pubTaskInfo.books) || {};
5151
5583
  const book = books[platform];
5152
5584
  const hist = (S.pubTaskInfo && S.pubTaskInfo.history) || [];
5153
- const nCh = hist.filter((r) => r.platform === platform && r.action === "upload_chapter" && r.ok).length;
5585
+ const pubCnt = (S.pubTaskInfo && S.pubTaskInfo.published) || {};
5586
+ // 已发口径:平台实况(点过校准)优先;否则本地台账去重章号数
5587
+ //(直接数记录会因重试虚高,见 2026-09-28 校准案)
5588
+ const localN = (typeof pubCnt[platform] === "number") ? pubCnt[platform]
5589
+ : new Set(hist.filter((r) => r.platform === platform && r.action === "upload_chapter"
5590
+ && r.ok && (r.chapter_no || 0) > 0).map((r) => r.chapter_no)).size;
5591
+ const hasRemote = book && (typeof book.remote_total === "number"
5592
+ || typeof book.remote_published === "number");
5593
+ const remoteN = hasRemote ? ((typeof book.remote_total === "number")
5594
+ ? book.remote_total : book.remote_published) : null;
5595
+ const nCh = hasRemote ? remoteN : localN;
5596
+ let chTitle = "";
5597
+ if (hasRemote) {
5598
+ const parts = [];
5599
+ parts.push(remoteN !== localN
5600
+ ? t("平台实况 ") + remoteN + t(" 章,本地台账 ") + localN + t(" 章,已按平台纠正")
5601
+ : t("已按平台实况校准:") + remoteN + t(" 章"));
5602
+ if (book.remote_published != null && book.remote_published !== remoteN)
5603
+ parts.push(t("其中已发布 ") + book.remote_published +
5604
+ (book.remote_review ? t(",审核中 ") + book.remote_review : ""));
5605
+ if (book.remote_synced_at) parts.push(t("校准于 ") + book.remote_synced_at);
5606
+ chTitle = parts.join(t(";"));
5607
+ } else {
5608
+ chTitle = t("本地台账口径,点「校准」按平台实况纠正");
5609
+ }
5154
5610
  const busy = st === "busy";
5155
5611
  let btns = "";
5156
5612
  if (st === "none" || st === "error" || st === "waiting_login") {
@@ -5165,8 +5621,12 @@ function pbBlock(task, platform) {
5165
5621
  btns += '<span class="pb-book" title="' + esc(t("已在此平台创建的作品")) + '">' +
5166
5622
  esc(t("已建书:") + (book.title || "")) + "</span>";
5167
5623
  btns += ' <button class="primary" ' + (busy ? "disabled" : "") +
5168
- ' onclick="pbUploadChapter(\'' + esc(task.id) + "', '" + platform + '\')">' +
5624
+ ' onclick="pbUploadChapter(\'' + esc(task.id) + "', '" + platform + '\')" title="' +
5625
+ esc(chTitle) + '">' +
5169
5626
  t("发一章") + (nCh ? t("(已发 ") + nCh + t(")") : "") + "</button>";
5627
+ btns += ' <button class="ghost pb-tool" ' + (busy ? "disabled" : "") +
5628
+ ' onclick="pbSyncPublished(\'' + esc(task.id) + "', '" + platform + '\')" title="' +
5629
+ esc(t("按平台实况纠正已发章数")) + '">' + t("校准") + "</button>";
5170
5630
  // 批量发布(publish/auto.py):待发清单 + 护栏 + 进度都来自 /pending 视图
5171
5631
  const au = (((S.pubAuto && S.pubAuto.books) || [])
5172
5632
  .find((b) => b.platform === platform)) || {};
@@ -5237,6 +5697,22 @@ function pbSyncState(task) {
5237
5697
  const ti = await api("/api/publish/task/" + encodeURIComponent(task.id) + "/history");
5238
5698
  S.pubTaskInfo = ti;
5239
5699
  sig += "#" + JSON.stringify(ti.books || {}) + "#" + (ti.history || []).length;
5700
+ // 已发章数自动校准:平台在线且登记在案、超 10 分钟没对过账就静默对一次
5701
+ //(服务端只 attach 在跑的浏览器,绝不因此弹新窗口;结果落 books.json,
5702
+ // 下一轮轮询带回来重渲染)。失败不打扰——旧数继续显示。
5703
+ const nowMs = Date.now();
5704
+ Object.keys(ti.books || {}).forEach((p) => {
5705
+ const bk = ti.books[p] || {};
5706
+ const ps2 = ((S.pubState && S.pubState.platforms) || {})[p] || {};
5707
+ const at = bk.remote_synced_at
5708
+ ? Date.parse(String(bk.remote_synced_at).replace(/-/g, "/")) || 0 : 0;
5709
+ if (ps2.status === "connected" && nowMs - at > 600000 &&
5710
+ nowMs - (S["_pbSyncAt_" + p] || 0) > 600000) {
5711
+ S["_pbSyncAt_" + p] = nowMs;
5712
+ api("/api/publish/task/" + encodeURIComponent(task.id) + "/sync-published",
5713
+ { method: "POST", body: JSON.stringify({ platform: p }) }).catch(() => {});
5714
+ }
5715
+ });
5240
5716
  } catch (e) { /* 任务级失败不阻塞平台状态 */ }
5241
5717
  try {
5242
5718
  // 批量发布视图(待发数/护栏/进度)——进行中时靠本节流轮询自然刷新
@@ -5251,6 +5727,18 @@ function pbSyncState(task) {
5251
5727
  }, 120);
5252
5728
  }
5253
5729
 
5730
+ window.pbSyncPublished = async function (taskId, platform) {
5731
+ // 手动校准:允许服务端 attach-or-launch(用户点了按钮,弹窗口是预期内)
5732
+ try {
5733
+ const r = await api("/api/publish/task/" + encodeURIComponent(taskId) +
5734
+ "/sync-published", { method: "POST",
5735
+ body: JSON.stringify({ platform: platform, manual: true }) });
5736
+ if (r.skipped && r.skipped[platform])
5737
+ toast(t("校准未执行:") + r.skipped[platform], true);
5738
+ else toast(t("正在按平台实况校准,几秒后自动更新"));
5739
+ } catch (e) { toast(t("校准失败:") + e.message, true); }
5740
+ };
5741
+
5254
5742
  window.pbConnect = async function (platform) {
5255
5743
  try {
5256
5744
  await api("/api/publish/" + platform + "/connect", { method: "POST", body: "{}" });
@@ -5823,11 +6311,27 @@ window.closeInspector = function () {
5823
6311
  function syncInspBtn() {
5824
6312
  const b = $("btn-insp");
5825
6313
  if (!b) return;
6314
+ const detailOpen = !!($("run-detail") && !$("run-detail").classList.contains("hidden"));
6315
+ const inTaskWorkspace = !document.body.classList.contains("settings-mode") && !S.mainPage && !detailOpen;
6316
+ b.classList.toggle("hidden", !inTaskWorkspace);
5826
6317
  const tip = document.body.classList.contains("inspector-open") ? t("收起任务详情") : t("打开任务详情");
5827
6318
  b.title = tip;
5828
6319
  b.setAttribute("aria-label", tip);
5829
6320
  }
5830
6321
 
6322
+ function syncRailState() {
6323
+ const buttons = Array.from(document.querySelectorAll(".side-rail .rail-btn"));
6324
+ buttons.forEach((b) => b.classList.remove("active"));
6325
+ if (!buttons.length) return;
6326
+ if (document.body.classList.contains("settings-mode")) {
6327
+ buttons[buttons.length - 1].classList.add("active");
6328
+ return;
6329
+ }
6330
+ const page = S.mainPage || S.tab;
6331
+ const index = page === "runs" ? 1 : page === "automation" ? 2 : page === "browser" ? 3 : 0;
6332
+ (buttons[index] || buttons[0]).classList.add("active");
6333
+ }
6334
+
5831
6335
  /* 手动开合检查器。没选中过任务时兜底拿最近一个跑过的(树序即最新在前),
5832
6336
  * 一个都没有才提示;选中过但已失效(被删)同样落到兜底。 */
5833
6337
  window.toggleInspector = function () {
@@ -5845,6 +6349,7 @@ window.toggleInspector = function () {
5845
6349
  function syncInspectorVis() {
5846
6350
  const insp = $("inspector");
5847
6351
  if (!insp) return;
6352
+ syncInspBtn();
5848
6353
  // 兜底只认「run 记录全部没了」(清理/删除):排空档不动已开着的检查器,
5849
6354
  // 否则自动续跑的 run 间隙(done→queued→running)会闪关
5850
6355
  const runsGone = S.state && S.inspKey && !((S.state.task_latest || {})[S.inspKey]);
@@ -6479,7 +6984,17 @@ window.fpSaveEdit = async function () {
6479
6984
 
6480
6985
  /* 成品文件弹窗预览 */
6481
6986
  window.artPopup = async function (runId, name, size) {
6482
- return _fpPreviewUrl("/api/runs/" + encodeURIComponent(runId) + "/file?name=" + encodeURIComponent(name), name, size);
6987
+ // 成果文本沿用目录文件弹窗的编辑器:先取得该 run 的真实工作目录,
6988
+ // 再把保存上下文交给同一套 mtime 冲突保护。二进制文件仍然只读预览。
6989
+ let save = null;
6990
+ if (FP_TXT.has(_fpExt(name))) {
6991
+ try {
6992
+ const d = await api("/api/runs/" + encodeURIComponent(runId) + "/files");
6993
+ if (d && d.workdir) save = { dir: d.workdir, name: String(name) };
6994
+ } catch (e) { /* 读取清单失败时保持只读预览 */ }
6995
+ }
6996
+ return _fpPreviewUrl("/api/runs/" + encodeURIComponent(runId) + "/file?name=" + encodeURIComponent(name), name, size,
6997
+ save ? { editable: true, save } : undefined);
6483
6998
  };
6484
6999
 
6485
7000
  /* 对话结果卡「运行展示」:HTML 成品在弹窗里真跑——复用 /preview 只读挂载
@@ -7701,11 +8216,11 @@ function bindDirector() {
7701
8216
  }
7702
8217
 
7703
8218
 
7704
- /* ---------------- 对话分区(直连任务默认视图) ----------------
7705
- * 直连 run 的蜂巢/版本/圣经基本是空的,真正的主角是时间线:用户说的话与 CLI
7706
- * 每轮输出混排成气泡。运行中发送 = 信箱(下一步送达);已结束发送 = /chat
7707
- * (后端自动起新一轮 run 接着聊)。轮次边界如实标注:无头 CLI 插不进正在跑的
7708
- * 进程,运行中递的话只在下一步生效。 */
8219
+ /* ---------------- 对话分区(所有任务默认视图) ----------------
8220
+ * 时间线把任务目标、用户消息与各智能体每轮输出混排成气泡;蜂巢/步骤/成果
8221
+ * 仍是同一详情工作区的辅助分区。运行中发送 = 信箱(下一步送达);已结束发送
8222
+ * = /chat(后端自动起新一轮 run 接着做)。轮次边界如实标注:无头 CLI 插不进
8223
+ * 正在跑的进程,运行中递的话只在下一步生效。 */
7709
8224
  let chatRunId = null;
7710
8225
  let chatAtts = [];
7711
8226
  let chatTimelineItems = []; // renderChat 每次重建时间线时刷新;复制/编辑重发按索引回查
@@ -7729,8 +8244,9 @@ function chatEngineIsDirect(run) {
7729
8244
  const st = S.state || {};
7730
8245
  const all = (st.tasks || []).concat(st.archived_tasks || []);
7731
8246
  const task = all.find((x) => x.id === (run && run.task_id));
7732
- return !!((task && task.engine === "direct") ||
7733
- (!task && run && (run.engine === "direct" || run.type === "direct")));
8247
+ // 对话工作区是所有有任务归属的运行的共同入口;管理型孤立运行
8248
+ // 没有可续跑的任务,不显示可发送的对话框。
8249
+ return !!(task && task.id === (run && run.task_id));
7734
8250
  }
7735
8251
 
7736
8252
  /* 思考过程块(2026-09-22 用户诉求「把思考过程打印出来」):内置智能体流式抓的
@@ -7827,7 +8343,7 @@ function chatBodyHTML(text) {
7827
8343
  async function renderChat(run, active) {
7828
8344
  const box = $("rd-chat");
7829
8345
  if (!box) return;
7830
- // 只对直连任务启用(其它流程有自己的蜂巢/步骤视图,不抢默认选卡)
8346
+ // 所有有任务归属的运行都启用对话时间线;蜂巢/步骤/成果仍按原页签保留
7831
8347
  if (!run || !chatEngineIsDirect(run)) { box.classList.add("hidden"); return; }
7832
8348
  box.classList.remove("hidden");
7833
8349
  const continueNote = $("rd-chat-continue-note");
@@ -7840,7 +8356,9 @@ async function renderChat(run, active) {
7840
8356
  if (task) rdPrefsFill(task);
7841
8357
  }
7842
8358
  const sig = run.id + "|" + run.status + "|" + (run.steps || []).length +
7843
- "|" + ((run.messages || []).length);
8359
+ "|" + ((run.messages || []).length) + "|" + (run.steps || []).map((s) =>
8360
+ [s.status, s.output, s.summary, s.thinking, s.stream, (s.activity || []).length].join("~")
8361
+ ).join("^");
7844
8362
  if (sig === chatSig) return; // 轮询重画去抖:内容没变不重建 DOM(保住输入焦点)
7845
8363
  const runForFetch = run.id; // 拉取期间可能切详情:过期响应不落盘(同 renderRunDetail 闸门)
7846
8364
  chatSig = sig;
@@ -7992,10 +8510,11 @@ function drawChatFlow(run, data, active) {
7992
8510
  * 措辞说「本轮」不说「任务」:追话每轮都落一张卡,卡头喊「任务完成」会被读成
7993
8511
  * 又建了一个新任务(2026-09-22 用户反馈:其实一直都在同一个任务里)。 */
7994
8512
  function chatResultHTML(run, res) {
7995
- const ok = res.status === "done";
8513
+ const partial = !!res.partial;
8514
+ const ok = res.status === "done" && !partial;
7996
8515
  const bad = res.status === "failed";
7997
- const icon = ok ? "#i-check" : "#i-x";
7998
- const label = ok ? t("本轮完成") : (bad ? t("本轮失败") : t("已取消"));
8516
+ const icon = ok ? "#i-check" : (partial ? "#i-gauge" : "#i-x");
8517
+ const label = partial ? t("本轮部分完成") : (ok ? t("本轮完成") : (bad ? t("本轮失败") : t("已取消")));
7999
8518
  const meta = [];
8000
8519
  if (res.executor) meta.push(esc(res.executor));
8001
8520
  if (res.turns) meta.push(res.turns + " " + t("轮对话"));
@@ -8021,11 +8540,11 @@ function chatResultHTML(run, res) {
8021
8540
  }).join("");
8022
8541
  return '<div class="chat-row">' +
8023
8542
  '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="' + icon + '"></use></svg></span>' +
8024
- '<div class="chat-result' + (ok ? "" : bad ? " bad" : " off") + '">' +
8543
+ '<div class="chat-result' + (partial ? " partial" : ok ? "" : bad ? " bad" : " off") + '">' +
8025
8544
  '<div class="cr-head"><svg class="ico" aria-hidden="true"><use href="' + icon + '"></use></svg>' +
8026
8545
  esc(label) + "</div>" +
8027
8546
  (meta.length ? '<div class="cr-meta">' + meta.join(" · ") + "</div>" : "") +
8028
- (bad && res.error ? '<div class="cr-err">' + esc(res.error) + "</div>" : "") +
8547
+ ((bad || partial) && res.error ? '<div class="cr-err">' + esc(res.error) + "</div>" : "") +
8029
8548
  '<div class="cr-files">' + (chips
8030
8549
  ? '<div class="cr-files-title">' + esc(t("产出文件")) + t("(") + files.length + t(")") + "</div>" +
8031
8550
  '<div class="file-chips">' + chips + "</div>"
@@ -9085,6 +9604,32 @@ function bindRepaint() { S.bindSig = null; renderBindings(); }
9085
9604
  * 解析照样失败;没有合适的就什么都不绑(2026-09-17 用户拍板,不再保留
9086
9605
  * 「只剩它也上」的兜底)。只预填草稿态(dirty),逐条看清后各自保存
9087
9606
  * 或「全部保存」,健康链绝不被覆盖。 */
9607
+ /* 评测台实测分(推荐选模用):与后端 dispatch 同款软信号——只在该供应商的
9608
+ * 启用模型里挑「榜上有名且分最高」的那个,无数据保持 priority 首个(现状)。 */
9609
+ async function refreshBenchScores() {
9610
+ const now = Date.now();
9611
+ if (S._benchAt && now - S._benchAt < 60000) return; // 评测数据变化慢,60s 节流
9612
+ S._benchAt = now;
9613
+ try {
9614
+ const eb = await api("/api/evalbench");
9615
+ const map = {};
9616
+ (eb.leaderboard || []).forEach((r) => {
9617
+ if (r.overall != null) map[r.provider_id + "\n" + r.model] = r.overall;
9618
+ });
9619
+ S.benchScores = map;
9620
+ } catch (e) { /* 评测数据不可得时推荐保持原行为 */ }
9621
+ }
9622
+
9623
+ function benchPick(pid, models) {
9624
+ const map = S.benchScores || {};
9625
+ let best = null, bestScore = -1;
9626
+ (models || []).forEach((m) => {
9627
+ const s = map[pid + "\n" + m.name];
9628
+ if (typeof s === "number" && s > bestScore) { best = m; bestScore = s; }
9629
+ });
9630
+ return best;
9631
+ }
9632
+
9088
9633
  function recommendFor(c) {
9089
9634
  // 目录页「默认模型」下拉用 orch_kind || id 判协议(仅管理条目没有 orch_kind),
9090
9635
  // 推荐口径与下拉过滤保持一致;绑定页条目恒有 orch_kind,不受影响
@@ -9098,7 +9643,10 @@ function recommendFor(c) {
9098
9643
  const p = provs[0];
9099
9644
  const models = (p.models || []).filter((m) => !m.hidden && m.enabled !== false);
9100
9645
  if (!models.length) return null;
9101
- return { p: p.id, m: models[0].name };
9646
+ // 供应商级选择规则保持不变(协议适配/优先级/冷却);实测分只在模型级做偏好,
9647
+ // 且不覆盖「默认模型停用+500 降权」等既有防线——见 0922 禁自动调度拍板
9648
+ const bench = benchPick(p.id, models);
9649
+ return { p: p.id, m: (bench || models[0]).name };
9102
9650
  }
9103
9651
 
9104
9652
  /* 供应商对某 CLI 是否「推荐可用」:启用、协议适配,且至少有一把不在冷却期的
@@ -9470,6 +10018,44 @@ async function catAutoBindAll() {
9470
10018
  poll();
9471
10019
  }
9472
10020
 
10021
+ /* 一键升级全部(智能体目录页):把所有「已安装且配了升级命令」的条目批量
10022
+ * 送进 mgmt 队列,服务端跳过「已是最新的」;每条复用单条升级的就地面板跟踪。
10023
+ * 批量起跑的日志默认收起(要看点「查看日志」),防止十几张卡同时摊开刷屏。 */
10024
+ async function catUpgradeAll() {
10025
+ const btn = $("btn-catalog-upgrade-all");
10026
+ if (btn) btn.disabled = true;
10027
+ try {
10028
+ const r = await api("/api/catalog/upgrade-all", { method: "POST" });
10029
+ S.mgmt = S.mgmt || {};
10030
+ for (const s of (r.started || [])) {
10031
+ const before = ((S.catalog || []).find((x) => x.id === s.id) || {}).version;
10032
+ S.mgmt[s.id] = { opLabel: t("升级"), status: "queued", versionBefore: before,
10033
+ log: "", showLog: false };
10034
+ pollMgmt(s.id, s.run_id);
10035
+ }
10036
+ S.catSig = null; renderCatalog();
10037
+ const n = (r.started || []).length, skip = (r.busy || []).length, bad = (r.failed || []).length;
10038
+ let msg;
10039
+ if (n) {
10040
+ msg = t("已开始升级 %1 个智能体").replace("%1", n);
10041
+ if (skip) msg += t(",%1 个忙于其他管理操作未动").replace("%1", skip);
10042
+ if (bad) msg += t(",%1 个任务创建失败").replace("%1", bad);
10043
+ } else if (skip) {
10044
+ msg = t("没有新的升级任务:%1 个条目正忙于其他管理操作。").replace("%1", skip);
10045
+ } else if (bad) {
10046
+ msg = t("升级任务创建失败:") + (r.failed || []).join("、");
10047
+ } else {
10048
+ msg = t("没有需要升级的智能体(都已最新,或未安装/未配置升级命令)。");
10049
+ }
10050
+ toast(msg, !n);
10051
+ } catch (e) {
10052
+ toast(t("失败:") + e.message, true);
10053
+ } finally {
10054
+ if (btn) btn.disabled = false;
10055
+ }
10056
+ poll();
10057
+ }
10058
+
9473
10059
  /* 一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
9474
10060
  用的模型就是目录页「默认模型」已写入该 CLI 配置文件的那份;密钥由服务端按编排同款规则注入 */
9475
10061
  async function openAgent(id) {
@@ -9649,6 +10235,8 @@ function openFlowsManager() {
9649
10235
  (f.builtin ? '<span class="tag ok">' + t("预置") + '</span>' : '<span class="tag">' + t("自定义") + '</span>') +
9650
10236
  (f.edited ? '<span class="tag">' + t("已改") + '</span>' : "") +
9651
10237
  '<button class="ghost small" onclick="flowForm(\'' + esc(f.id) + '\')">' + t("编辑") + '</button>' +
10238
+ '<button class="ghost small" onclick="flowExport(\'' + esc(f.id) + '\')">' + t("导出") + '</button>' +
10239
+ '<button class="ghost small" onclick="flowHistory(\'' + esc(f.id) + '\')">' + t("历史") + '</button>' +
9652
10240
  (f.builtin
9653
10241
  ? (f.edited ? '<button class="ghost small" onclick="flowReset(\'' + esc(f.id) + '\')">' + t("恢复默认") + '</button>' : "")
9654
10242
  : '<button class="danger small" onclick="deleteFlow(\'' + esc(f.id) + '\')">' + t("删除") + '</button>') +
@@ -9659,10 +10247,117 @@ function openFlowsManager() {
9659
10247
  const body = '<p class="hint">' + t("预置流程可直接编辑(阈值/轮数/维度/章节数/提示词),改动随时可「恢复默认」;") +
9660
10248
  t("自定义流程只需填名称与引擎,其余留空走默认。") + '</p>' +
9661
10249
  '<div class="list">' + rows + "</div>" +
9662
- '<button class="primary" style="margin-top:10px" onclick="flowForm()">' + t("+ 新建自定义流程") + '</button>';
10250
+ '<div class="input-row" style="margin-top:10px">' +
10251
+ '<button class="primary" onclick="flowForm()">' + t("+ 新建自定义流程") + '</button>' +
10252
+ '<button class="ghost" onclick="flowImport()">' + t("导入分享码") + '</button></div>';
9663
10253
  openModal(t("🧩 任务类型管理"), body, "");
9664
10254
  }
9665
10255
 
10256
+ /* 流程分享码:导出 = 拿码 + 尝试复制到剪贴板;导入 = 粘贴码 + 覆盖确认 */
10257
+ async function flowExport(fid) {
10258
+ let r;
10259
+ try { r = await api("/api/flows/export", { method: "POST", body: JSON.stringify({ id: fid }) }); }
10260
+ catch (e) { toast(t("导出失败:") + e.message, true); return; }
10261
+ const body = '<p class="hint">' +
10262
+ t("把这段分享码发给任何人,对方在「任务类型管理 → 导入分享码」粘贴即可一键安装:") + '</p>' +
10263
+ '<textarea id="fl-share-code" rows="6" readonly style="width:100%;font-family:var(--mono,monospace);font-size:12px;word-break:break-all">' +
10264
+ esc(r.code) + "</textarea>";
10265
+ openModal(t("📤 导出流程:") + (r.name || fid), body, "");
10266
+ const ta = $("fl-share-code");
10267
+ if (ta) { ta.focus(); ta.select(); }
10268
+ try { await navigator.clipboard.writeText(r.code); toast(t("分享码已复制到剪贴板")); } catch (e) { /* 无剪贴板权限时保持手选 */ }
10269
+ }
10270
+
10271
+ /* 版本历史(Langfuse prompt management 借鉴):当前版 + 历史版清单,每版带
10272
+ * 实测战绩;恢复前弹确认——被顶下来的当前版会进历史,错了还能翻回来。 */
10273
+ const FLOW_SOURCE_TXT = { manual: "手动修改", import: "分享码导入", restore: "恢复顶下" };
10274
+
10275
+ async function flowHistory(fid) {
10276
+ let versions;
10277
+ try { versions = (await api("/api/flows/versions?id=" + encodeURIComponent(fid))).versions || []; }
10278
+ catch (e) { toast(t("历史读取失败:") + e.message, true); return; }
10279
+ const statTxt = (st) => !st ? "" :
10280
+ '<span class="tag">' + t("任务 ") + st.runs +
10281
+ (st.pass_rate != null ? t(" 通过 ") + Math.round(st.pass_rate * 100) + "%" : "") +
10282
+ (st.avg_overall != null ? t(" 均分 ") + st.avg_overall : "") + "</span>";
10283
+ const rows = versions.map((v) => {
10284
+ const src = v.current ? '<span class="tag ok">' + t("当前") + "</span>"
10285
+ : '<span class="tag">' + t(FLOW_SOURCE_TXT[v.source] || v.source) + " " + esc(v.ts) + "</span>";
10286
+ const btn = v.current ? "" :
10287
+ '<button class="ghost small" onclick="flowRestore(\'' + esc(fid) + "', '" + esc(v.ts) + '\')">' + t("恢复") + "</button>";
10288
+ return '<div class="item"><div class="t"><span class="name">' + esc(v.name || fid) +
10289
+ "</span>" + src + '<span class="tag">' + esc(v.digest) + "</span>" + statTxt(v.stats) + btn +
10290
+ "</div></div>";
10291
+ }).join("");
10292
+ openModal(t("🕘 流程版本历史:") + fid,
10293
+ '<p class="hint">' +
10294
+ t("每次保存/导入覆盖前的版本自动留档(每流程最多 10 份)。战绩按任务钉住的流程指纹对账——改提示词前先看看现在这版跑得怎么样。") + "</p>" +
10295
+ '<div class="list">' + (rows || '<div class="hint">' + t("还没有历史版本。") + "</div>") + "</div>", "");
10296
+ }
10297
+
10298
+ async function flowRestore(fid, ts) {
10299
+ if (!await uiConfirm(t("把该流程恢复到 ") + ts + t(" 的版本?当前版本会先存进历史,随时可再翻回。"), { ok: t("恢复") })) return;
10300
+ try { await api("/api/flows/restore", { method: "POST", body: JSON.stringify({ id: fid, ts }) }); }
10301
+ catch (e) { toast(t("恢复失败:") + e.message, true); return; }
10302
+ invalidateFlowRubricDraft(fid);
10303
+ await loadFlows();
10304
+ openFlowsManager();
10305
+ toast(t("已恢复历史版本"));
10306
+ }
10307
+
10308
+ function flowImport() {
10309
+ const body = '<p class="hint">' +
10310
+ t("粘贴他人分享的流程码,先解析预览再确认导入。") + '</p>' +
10311
+ '<textarea id="fl-import-code" rows="6" placeholder="CBFLOW1.…" style="width:100%;font-family:var(--mono,monospace);font-size:12px;word-break:break-all"></textarea>' +
10312
+ '<div id="fl-import-view"></div>';
10313
+ openModal(t("📥 导入流程分享码"), body,
10314
+ '<button class="ghost" onclick="closeModal()">' + t("取消") + '</button>' +
10315
+ '<button class="primary" id="fl-import-btn" onclick="flowImportPreview()">' + t("解析预览") + '</button>');
10316
+ }
10317
+
10318
+ async function flowImportPreview() {
10319
+ const code = ($("fl-import-code") || {}).value || "";
10320
+ const view = $("fl-import-view"), btn = $("fl-import-btn");
10321
+ if (!code.trim()) { toast(t("请先粘贴分享码"), true); return; }
10322
+ let p;
10323
+ try { p = await api("/api/flows/preview", { method: "POST", body: JSON.stringify({ code }) }); }
10324
+ catch (e) { toast(t("解析失败:") + e.message, true); return; }
10325
+ if (!view) return;
10326
+ const conflict = p.exists
10327
+ ? (p.builtin
10328
+ ? t("⚠ 将覆盖预置流程「") + esc(p.existing_name) + t("」的自定义改动(可恢复默认回滚)")
10329
+ : t("⚠ 将覆盖现有自定义流程「") + esc(p.existing_name) + t("」"))
10330
+ : t("✓ 新流程,直接创建");
10331
+ view.innerHTML = '<div class="item"><div class="t"><span class="name">' + esc(p.name || p.id) +
10332
+ "</span>" + '<span class="tag">' + esc(p.id) + "</span>" +
10333
+ '<span class="tag">' + esc(t(p.engine === "code" ? "代码引擎"
10334
+ : p.engine === "direct" ? "直连引擎" : "评审引擎")) + "</span>" +
10335
+ (p.serial ? '<span class="tag">' + t("连载") + "</span>" : "") +
10336
+ (p.has_prompts ? '<span class="tag ok">' + t("含自定义提示词") + "</span>" : "") + "</div>" +
10337
+ '<div class="desc">' + (p.rubric || []).map((d) => esc(t(d))).join(" / ") +
10338
+ (p.threshold != null ? t(" 阈值:") + p.threshold : "") +
10339
+ (p.rounds != null ? t(" 轮数:") + p.rounds : "") + "</div>" +
10340
+ '<div class="desc">' + conflict + "</div></div>";
10341
+ btn.textContent = t("确认导入");
10342
+ btn.onclick = flowImportDo;
10343
+ S._importCode = code;
10344
+ }
10345
+
10346
+ async function flowImportDo() {
10347
+ const code = S._importCode || (($("fl-import-code") || {}).value || "");
10348
+ if (!code.trim()) { toast(t("请先粘贴分享码"), true); return; }
10349
+ let r;
10350
+ try { r = await api("/api/flows/import", { method: "POST", body: JSON.stringify({ code }) }); }
10351
+ catch (e) { toast(t("导入失败:") + e.message, true); return; }
10352
+ closeModal();
10353
+ invalidateFlowRubricDraft(r.id);
10354
+ await loadFlows();
10355
+ openFlowsManager();
10356
+ const how = r.status === "noop" ? t("(与现有内容一致)")
10357
+ : r.status === "updated" ? t("(已覆盖现有定义)") : "";
10358
+ toast(t("已导入流程「") + (r.name || r.id) + "」" + how);
10359
+ }
10360
+
9666
10361
  async function flowReset(fid) {
9667
10362
  if (!await uiConfirm(t("把「") + fid + t("」恢复为内置默认配置?"), { ok: t("恢复") })) return;
9668
10363
  try { await api("/api/flows/" + encodeURIComponent(fid) + "/reset", { method: "POST" }); }
@@ -10082,6 +10777,7 @@ function autoPrefTags(tsk) {
10082
10777
  out.push(t("对话模型") + t(":") + (p ? (p.name || p.id) : tsk.direct_provider_id) +
10083
10778
  "/" + (tsk.direct_model || t("随厂商推荐")));
10084
10779
  }
10780
+ out.push(tsk.fresh_chat ? t("每次新聊天") : t("延续上轮"));
10085
10781
  return out.map((s) => '<span class="tag">' + esc(s) + "</span>").join("");
10086
10782
  }
10087
10783
 
@@ -10095,6 +10791,7 @@ function autoCardHtml(tsk) {
10095
10791
  "</div>" +
10096
10792
  '<div class="auto-kind">' + esc(autoKindText(tsk)) +
10097
10793
  (flow ? '<span class="tag">' + t("流程:") + esc(t(flow.name)) + "</span>" : "") +
10794
+ (tsk.task_id ? '<span class="tag">' + t("会话复用") + "</span>" : "") +
10098
10795
  autoPrefTags(tsk) + "</div>" +
10099
10796
  '<div class="auto-prompt">' + esc(tsk.prompt) + "</div>" +
10100
10797
  '<div class="auto-meta">' +
@@ -10171,6 +10868,10 @@ async function autoForm(task, tpl) {
10171
10868
  '<div class="field"><label>' + t("思考程度") + "</label>" +
10172
10869
  autoPillHtml("au-thinking", AUTO_THINKINGS, thinking) + "</div>" +
10173
10870
  "</div>" +
10871
+ /* 会话延续:默认延续上一轮(巡检/盯梢类任务能感知上次进展);打开则每次全新 */
10872
+ '<div class="field"><label class="toggle"><input id="au-fresh" type="checkbox"' +
10873
+ (f.fresh_chat ? " checked" : "") + '> <span>' + t("每次运行时都开启新聊天") + "</span></label>" +
10874
+ '<p class="hint">' + t("默认关闭:到点运行延续同一任务上一轮的会话与成果(知道上次干到哪);打开则每次从零开始。") + "</p></div>" +
10174
10875
  '<div id="au-model-field" class="field hidden"><label>' + t("对话模型(可选)") + '</label><div class="grid-2">' +
10175
10876
  '<select id="au-direct-provider" aria-label="' + t("对话厂商") + '"></select>' +
10176
10877
  '<select id="au-direct-model-name" aria-label="' + t("对话模型") + '"></select>' +
@@ -10183,7 +10884,7 @@ async function autoForm(task, tpl) {
10183
10884
  '<div class="field au-fld hidden" data-k="weekday"><label>' + t("星期几") + '</label><select id="au-weekday">' + wdOpts + "</select></div>" +
10184
10885
  '<div class="field au-fld hidden" data-k="runat"><label>' + t("执行时间") + '</label><input id="au-runat" type="datetime-local" value="' + esc(runAt) + '"></div>' +
10185
10886
  "</div>" +
10186
- '<p class="hint">' + t("到点自动把执行内容作为一个新任务跑起来;错过的一次性任务不补跑。") + "</p>" +
10887
+ '<p class="hint">' + t("首次到点创建任务,后续到点重复触发同一任务;错过的一次性任务不补跑。") + "</p>" +
10187
10888
  "</div>";
10188
10889
  openModal(task ? t("编辑定时任务") : t("新建定时任务"), body, "");
10189
10890
  const foot = $("modal-foot");
@@ -10286,6 +10987,7 @@ async function saveAutoForm(id) {
10286
10987
  // 运行偏好:与 Composer 同名字段,后端 _apply_run_prefs 再归一一次
10287
10988
  payload.mode = $("au-mode") ? $("au-mode").value : "";
10288
10989
  payload.thinking = $("au-thinking") ? $("au-thinking").value : "";
10990
+ payload.fresh_chat = !!(($("au-fresh") || {}).checked);
10289
10991
  if (!$("au-model-field") || !$("au-model-field").classList.contains("hidden")) {
10290
10992
  payload.direct_provider_id = $("au-direct-provider") ? $("au-direct-provider").value : "";
10291
10993
  payload.direct_model = $("au-direct-model-name") ? $("au-direct-model-name").value : "";
@@ -10372,6 +11074,7 @@ async function loadZentao() {
10372
11074
  tg("zt-autoresolve", cfg.auto_resolve);
10373
11075
  tg("zt-automerge", cfg.auto_merge);
10374
11076
  tg("zt-poll", cfg.poll_enabled);
11077
+ tg("zt-holiday", cfg.skip_holidays);
10375
11078
  set("zt-interval", cfg.interval_minutes || 5);
10376
11079
  S.ztProfiles = JSON.parse(JSON.stringify(cfg.product_profiles || []));
10377
11080
  renderZentaoProfiles();
@@ -10791,6 +11494,7 @@ function renderZentaoStatus() {
10791
11494
  const parts = [];
10792
11495
  parts.push(cfg.poll_enabled
10793
11496
  ? (t("定时扫描已开启,每 ") + ztIntervalText(cfg.interval_minutes)
11497
+ + (cfg.skip_holidays ? t(",法定休假日自动顺延") : "")
10794
11498
  + (v.next_scan ? t(",下次 ") + esc(v.next_scan) : ""))
10795
11499
  : t("定时扫描未开启(仍可手动「立即扫描」)"));
10796
11500
  if (v.last_scan) parts.push(t("上次扫描 ") + esc(v.last_scan));
@@ -10810,30 +11514,66 @@ function zentaoBugUrl(c) {
10810
11514
  return base + "/bug-view-" + encodeURIComponent(c.bug_id) + ".html";
10811
11515
  }
10812
11516
 
11517
+ let ztArchivedOpen = false; // 已归档区默认收起(会话内记忆,不落 localStorage)
11518
+
11519
+ function zentaoToggleArchive() {
11520
+ ztArchivedOpen = !ztArchivedOpen;
11521
+ renderZentaoClaims();
11522
+ }
11523
+
11524
+ /* 归档 / 取消归档:后端改 claim 字段后整页重拉(记录少,不划算做乐观更新) */
11525
+ async function zentaoArchiveClaim(bugId, archived) {
11526
+ try {
11527
+ await api("/api/zentao/claims/archive", { method: "POST",
11528
+ body: JSON.stringify({ bug_id: bugId, archived: archived }) });
11529
+ await loadZentao();
11530
+ } catch (e) { toast((e && e.message) || String(e), true); }
11531
+ }
11532
+
11533
+ function zentaoClaimCard(c, archived) {
11534
+ const tasks = (c.tasks || []).map((tk) =>
11535
+ "<span>" + t("【") + (tk.side === "frontend" ? t("前端") : t("后端")) + t("】") +
11536
+ '<a href="#" onclick="zentaoOpenRun(\'' + esc(tk.run_id || "") + '\');return false;">' + esc(tk.task_id || "?") + "</a></span>").join("");
11537
+ const bugUrl = zentaoBugUrl(c);
11538
+ const name = "#" + esc(c.bug_id) + " " + esc(c.title || "");
11539
+ const headName = bugUrl
11540
+ ? '<a class="zt-bug-link" href="' + esc(bugUrl) + '" target="_blank" rel="noopener" title="' +
11541
+ esc(t("在禅道中打开 Bug 详情")) + '">' + name + "</a>"
11542
+ : name;
11543
+ const act = archived
11544
+ ? '<button class="ghost small" onclick="zentaoArchiveClaim(\'' + esc(c.bug_id) + '\',false)">' + t("取消归档") + "</button>"
11545
+ : '<button class="ghost small" title="' + esc(t("移入已归档区,不再出现在进行中列表")) + '" onclick="zentaoArchiveClaim(\'' + esc(c.bug_id) + '\',true)">' + t("归档") + "</button>";
11546
+ return '<div class="card' + (archived ? " zt-archived" : "") + '"><div class="head"><span class="name">' + headName + "</span>" +
11547
+ zentaoStateTag(c.state) + act + "</div>" +
11548
+ '<div class="auto-meta">' +
11549
+ (zentaoTriText(c) ? "<span>" + esc(zentaoTriText(c)) + "</span>" : "") +
11550
+ (tasks || "") +
11551
+ (c.claimed_at ? "<span>" + t("认领于 ") + esc(c.claimed_at) + "</span>" : "") +
11552
+ "</div>" +
11553
+ (archived && c.archive_reason ? '<div class="note">' + t("归档:") + esc(c.archive_reason) +
11554
+ (c.archived_at ? " · " + esc(c.archived_at) : "") + "</div>" : "") +
11555
+ (c.note ? '<div class="note">' + esc(c.note) + "</div>" : "") +
11556
+ "</div>";
11557
+ }
11558
+
10813
11559
  function renderZentaoClaims() {
10814
11560
  const box = $("zentao-claims");
10815
11561
  if (!box) return;
10816
11562
  const claims = (S.zentao && S.zentao.claims) || [];
10817
- box.innerHTML = claims.map((c) => {
10818
- const tasks = (c.tasks || []).map((tk) =>
10819
- "<span>" + t("【") + (tk.side === "frontend" ? t("前端") : t("后端")) + t("】") +
10820
- '<a href="#" onclick="zentaoOpenRun(\'' + esc(tk.run_id || "") + '\');return false;">' + esc(tk.task_id || "?") + "</a></span>").join("");
10821
- const bugUrl = zentaoBugUrl(c);
10822
- const name = "#" + esc(c.bug_id) + " " + esc(c.title || "");
10823
- const headName = bugUrl
10824
- ? '<a class="zt-bug-link" href="' + esc(bugUrl) + '" target="_blank" rel="noopener" title="' +
10825
- esc(t("在禅道中打开 Bug 详情")) + '">' + name + "</a>"
10826
- : name;
10827
- return '<div class="card"><div class="head"><span class="name">' + headName + "</span>" +
10828
- zentaoStateTag(c.state) + "</div>" +
10829
- '<div class="auto-meta">' +
10830
- (zentaoTriText(c) ? "<span>" + esc(zentaoTriText(c)) + "</span>" : "") +
10831
- (tasks || "") +
10832
- (c.claimed_at ? "<span>" + t("认领于 ") + esc(c.claimed_at) + "</span>" : "") +
10833
- "</div>" +
10834
- (c.note ? '<div class="note">' + esc(c.note) + "</div>" : "") +
10835
- "</div>";
10836
- }).join("") || '<div class="empty">' + t("还没有认领过 Bug——配置好连接与产品档案后点「立即扫描」。") + "</div>";
11563
+ const live = claims.filter((c) => !c.archived);
11564
+ const done = claims.filter((c) => c.archived);
11565
+ let html = live.map((c) => zentaoClaimCard(c, false)).join("");
11566
+ if (!claims.length)
11567
+ html = '<div class="empty">' + t("还没有认领过 Bug——配置好连接与产品档案后点「立即扫描」。") + "</div>";
11568
+ if (done.length) {
11569
+ html += '<button class="zt-arch-head' + (ztArchivedOpen ? " open" : "") +
11570
+ '" onclick="zentaoToggleArchive()">' +
11571
+ '<svg class="ico" aria-hidden="true"><use href="#i-chevron-r"></use></svg>' +
11572
+ t("已归档") + '<span class="tag">' + done.length + "</span></button>";
11573
+ if (ztArchivedOpen)
11574
+ html += '<div class="zt-arch-list">' + done.map((c) => zentaoClaimCard(c, true)).join("") + "</div>";
11575
+ }
11576
+ box.innerHTML = html;
10837
11577
  }
10838
11578
 
10839
11579
  function zentaoOpenRun(runId) {
@@ -10852,6 +11592,7 @@ function ztFormPayload() {
10852
11592
  auto_resolve: $("zt-autoresolve").checked,
10853
11593
  auto_merge: $("zt-automerge").checked,
10854
11594
  poll_enabled: $("zt-poll").checked,
11595
+ skip_holidays: $("zt-holiday").checked,
10855
11596
  interval_minutes: Math.max(5, parseInt($("zt-interval").value, 10) || 5),
10856
11597
  };
10857
11598
  const pw = ($("zt-password").value || "").trim();
@@ -10919,6 +11660,7 @@ async function scanZentao() {
10919
11660
  /* /api/market → {catalog, categories(闭集)};搜索/分类/状态全在本地过滤。
10920
11661
  * 外部目录(/api/market/remote)只读缓存不联网;联网拉取仅在用户点「拉取更新」。 */
10921
11662
  async function loadMarket() {
11663
+ loadPlugins();
10922
11664
  try { S.market = await api("/api/market"); }
10923
11665
  catch (e) { S.market = null; }
10924
11666
  if (mkActiveView() === "remote") {
@@ -10931,6 +11673,80 @@ async function loadMarket() {
10931
11673
  renderMarketRemote();
10932
11674
  }
10933
11675
 
11676
+ /* ---------------------------------------------------------- 本地插件(manifest) */
11677
+ async function loadPlugins() {
11678
+ try { S.plugins = await api("/api/plugins"); }
11679
+ catch (e) { S.plugins = null; }
11680
+ renderPlugins();
11681
+ }
11682
+
11683
+ function pluginCardHtml(p) {
11684
+ const title = p.display_name || p.name || p.id || "";
11685
+ const state = p.state || (p.installed ? (p.enabled ? "enabled" : "disabled") : "available");
11686
+ let ops = "";
11687
+ if (state === "blocked" || state === "broken") {
11688
+ ops = '<span class="tag warn">' + esc(t(state === "blocked" ? "不适配" : "损坏")) + "</span>";
11689
+ } else if (p.installed) {
11690
+ ops = '<label class="switch" title="' + esc(p.enabled ? t("已启用") : t("已停用")) + '">' +
11691
+ '<input type="checkbox"' + (p.enabled ? " checked" : "") +
11692
+ ' onchange="pluginToggle(\'' + esc(p.id) + '\', this.checked)"></label>' +
11693
+ '<button class="ghost small" onclick="pluginRemove(\'' + esc(p.id) + '\')">' + t("卸载") + "</button>";
11694
+ } else {
11695
+ ops = '<button class="primary small" onclick="pluginInstall(\'' + esc(p.id) + '\')">' + t("安装") + "</button>";
11696
+ }
11697
+ const caps = (p.capabilities || []).map((x) => '<span class="tag">' + esc(x) + "</span>").join("");
11698
+ const features = [
11699
+ p.skill_count ? t("技能 ") + p.skill_count : "",
11700
+ p.has_mcp ? "MCP" : "",
11701
+ p.version ? "v" + p.version : "",
11702
+ ].filter(Boolean).join(" · ");
11703
+ return '<div class="card mk-card plugin-card"><div class="head">' +
11704
+ '<span class="name">' + esc(t(title)) + "</span>" +
11705
+ '<span class="tag">' + esc(t(p.category || "Productivity")) + "</span></div>" +
11706
+ '<div class="note">' + esc(t(p.short_description || p.description || p.error || "")) + "</div>" +
11707
+ '<div class="mk-meta"><span>' + esc(features) + '</span><span>' + esc(p.author || "") + "</span></div>" +
11708
+ (caps ? '<div class="mk-meta">' + caps + "</div>" : "") +
11709
+ '<div class="ops">' + ops + "</div></div>";
11710
+ }
11711
+
11712
+ function renderPlugins() {
11713
+ const grid = $("plugin-grid");
11714
+ if (!grid) return;
11715
+ const data = S.plugins;
11716
+ if (!data) { grid.innerHTML = '<div class="empty">' + t("插件加载失败") + "</div>"; return; }
11717
+ const count = $("plugin-count");
11718
+ if (count) count.textContent = data.total || 0;
11719
+ grid.innerHTML = (data.plugins || []).map(pluginCardHtml).join("") ||
11720
+ '<div class="empty">' + t("还没有发现本地插件") + "</div>";
11721
+ }
11722
+
11723
+ async function pluginInstall(id) {
11724
+ try {
11725
+ const r = await api("/api/plugins/" + encodeURIComponent(id) + "/install", { method: "POST", body: "{}" });
11726
+ toast(r && r.already ? t("该插件已安装过") : t("插件安装成功"));
11727
+ } catch (e) { toast(e.message || t("安装失败"), true); return; }
11728
+ await loadPlugins();
11729
+ loadMarket();
11730
+ }
11731
+
11732
+ async function pluginToggle(id, enabled) {
11733
+ try {
11734
+ await api("/api/plugins/" + encodeURIComponent(id) + "/" + (enabled ? "enable" : "disable"), { method: "POST", body: "{}" });
11735
+ toast(enabled ? t("插件已启用") : t("插件已停用"));
11736
+ } catch (e) { toast(e.message || t("操作失败"), true); }
11737
+ loadPlugins();
11738
+ }
11739
+
11740
+ async function pluginRemove(id) {
11741
+ if (!await uiConfirm(t("卸载该插件?技能包和本地插件副本会被移除。"), { ok: t("卸载"), danger: true })) return;
11742
+ try {
11743
+ await api("/api/plugins/" + encodeURIComponent(id) + "/remove", { method: "POST", body: "{}" });
11744
+ toast(t("插件已卸载"));
11745
+ } catch (e) { toast(e.message || t("卸载失败"), true); return; }
11746
+ await loadPlugins();
11747
+ loadMarket();
11748
+ }
11749
+
10934
11750
  function mkCount(text) {
10935
11751
  const el = $("mk-count");
10936
11752
  if (el) el.textContent = text || "";
@@ -11262,6 +12078,17 @@ function mkrShowInstallPreview(p) {
11262
12078
  ? '<li class="mkpv-more">' + t("…等 ") + p.stripped_total + t(" 个") + "</li>" : "") + "</ul>"
11263
12079
  : "";
11264
12080
  const skills = (p.skills || []).map((s) => '<span class="tag">' + esc(s) + "</span>").join("");
12081
+ // 内容安检(装前扫描):高风险红字强提示 + 发现明细;注意级只留一行
12082
+ const riskFindings = (p.risk_findings || []).map((f) =>
12083
+ '<li>' + esc(t(f.category)) + ' · ' + esc(f.detail || "") + t("(行 ") + f.line + ")</li>").join("");
12084
+ const riskHtml = p.risk_label
12085
+ ? (p.risk_label.indexOf("高风险") >= 0
12086
+ ? '<div class="mkpv-sec" style="color:var(--err,#d33)"><b>' + esc(t(p.risk_label)) +
12087
+ ":</b>" + t("技能文本命中危险模式(可能诱导模型外传密钥/执行命令)。请逐条确认下方发现,不确定就别装。") +
12088
+ '<ul class="mkpv-strip">' + riskFindings + "</ul></div>"
12089
+ : '<div class="mkpv-sec">' + esc(t(p.risk_label)) + ":" + t("部分内容值得留意识别") +
12090
+ '<ul class="mkpv-strip">' + riskFindings + "</ul></div>")
12091
+ : "";
11265
12092
  openModal(t("安装前确认"),
11266
12093
  '<div class="mkpv">' +
11267
12094
  '<div class="mkpv-name">' + esc(p.title || p.id || "") + "</div>" +
@@ -11269,6 +12096,7 @@ function mkrShowInstallPreview(p) {
11269
12096
  (p.version ? "<span>v" + esc(p.version) + "</span>" : "") +
11270
12097
  "<span>" + esc(_mkrIntegrityText(p.integrity)) + "</span></div>" +
11271
12098
  (skills ? '<div class="mkpv-skills">' + t("包含技能:") + skills + "</div>" : "") +
12099
+ riskHtml +
11272
12100
  '<div class="mkpv-sum">' + t("共 ") + (p.files_total || 0) + t(" 个文件 · ") + esc(mkCharsText(p.total_chars || 0)) + "</div>" +
11273
12101
  (rows ? '<table class="mkpv-files"><thead><tr><th>' + t("文件") + "</th><th>" + t("体量") +
11274
12102
  "</th></tr></thead><tbody>" + rows + moreF + "</tbody></table>" : "") +
@@ -11276,7 +12104,9 @@ function mkrShowInstallPreview(p) {
11276
12104
  '<p class="hint">' + t("安装后运行任务时自动注入提示词;可到「经验库」启停或卸载。") + "</p>" +
11277
12105
  "</div>",
11278
12106
  '<button class="ghost" onclick="closeModal()">' + t("取消") + "</button>" +
11279
- '<button class="primary" id="mkpv-ok" onclick="mkrInstallConfirm(\'' + esc(p.token || "") + '\',\'' + esc(p.id || "") + '\')">' + t("确认安装") + "</button>");
12107
+ '<button class="' + (p.risk_label && p.risk_label.indexOf("高风险") >= 0 ? "danger" : "primary") +
12108
+ '" id="mkpv-ok" onclick="mkrInstallConfirm(\'' + esc(p.token || "") + '\',\'' + esc(p.id || "") + '\')">' +
12109
+ (p.risk_label && p.risk_label.indexOf("高风险") >= 0 ? t("我已确认风险,仍要安装") : t("确认安装")) + "</button>");
11280
12110
  }
11281
12111
 
11282
12112
  async function mkrInstallConfirm(token, id) {
@@ -11347,6 +12177,10 @@ async function loadSettings() {
11347
12177
  if (pet && S.settings) pet.checked = S.settings.pet_enabled !== false;
11348
12178
  const cs = $("set-claude-sync");
11349
12179
  if (cs && S.settings) cs.checked = S.settings.claude_config_sync !== false;
12180
+ const mcp = $("set-mcp-servers");
12181
+ if (mcp && S.settings) mcp.value = S.settings.mcp_servers || "";
12182
+ fillNotifyPush();
12183
+ fillBackupRemote();
11350
12184
  syncPetModeSeg();
11351
12185
  syncPetSkinSeg();
11352
12186
  const wd = $("set-workdir"), hint = $("set-workdir-hint");
@@ -11540,6 +12374,349 @@ async function saveSettings() {
11540
12374
  }
11541
12375
  }
11542
12376
 
12377
+ /* 推送通知(设置页):群 webhook + 个人推送通道(Bark/ntfy/Server酱/Telegram) */
12378
+ const NOTIFY_INPUTS = ["set-notify-webhook", "set-notify-bark-server", "set-notify-bark-key",
12379
+ "set-notify-ntfy", "set-notify-serverchan", "set-notify-tg-token", "set-notify-tg-chat"];
12380
+
12381
+ function fillNotifyPush() {
12382
+ if (!S.settings) return;
12383
+ const v = (k) => S.settings[k] || "";
12384
+ const set = (id, key) => { const el = $(id); if (el) el.value = v(key); };
12385
+ set("set-notify-webhook", "notify_webhook");
12386
+ set("set-notify-bark-server", "notify_bark_server");
12387
+ set("set-notify-bark-key", "notify_bark_key");
12388
+ set("set-notify-ntfy", "notify_ntfy_topic");
12389
+ set("set-notify-serverchan", "notify_serverchan_key");
12390
+ set("set-notify-tg-token", "notify_telegram_token");
12391
+ set("set-notify-tg-chat", "notify_telegram_chat_id");
12392
+ }
12393
+
12394
+ function notifyPushPatch() {
12395
+ const val = (id) => { const el = $(id); return el ? el.value.trim() : undefined; };
12396
+ const p = {};
12397
+ const pairs = [["set-notify-webhook", "notify_webhook"], ["set-notify-bark-server", "notify_bark_server"],
12398
+ ["set-notify-bark-key", "notify_bark_key"], ["set-notify-ntfy", "notify_ntfy_topic"],
12399
+ ["set-notify-serverchan", "notify_serverchan_key"], ["set-notify-tg-token", "notify_telegram_token"],
12400
+ ["set-notify-tg-chat", "notify_telegram_chat_id"]];
12401
+ pairs.forEach(([id, key]) => { const v = val(id); if (v !== undefined) p[key] = v; });
12402
+ return p;
12403
+ }
12404
+
12405
+ async function saveNotifyPush() {
12406
+ const msg = $("notify-test-msg");
12407
+ try {
12408
+ const r = await api("/api/settings", { method: "POST", body: JSON.stringify(notifyPushPatch()) });
12409
+ S.settings = r.settings;
12410
+ fillNotifyPush();
12411
+ if (msg) { msg.className = "msg ok"; msg.textContent = t("推送设置已保存"); }
12412
+ } catch (e) {
12413
+ if (msg) { msg.className = "msg err"; msg.textContent = e.message; }
12414
+ }
12415
+ }
12416
+
12417
+ async function saveMcpServers() {
12418
+ const el = $("set-mcp-servers");
12419
+ if (!el) return;
12420
+ try {
12421
+ const r = await api("/api/settings", { method: "POST", body: JSON.stringify({ mcp_servers: el.value }) });
12422
+ S.settings = r.settings;
12423
+ toast(t("MCP 配置已保存,下次对话生效"));
12424
+ } catch (e) { toast(t("保存失败:") + e.message, true); }
12425
+ }
12426
+
12427
+ /* MCP 一键模板(2026 社区最常用五款,npx 免安装):追加进现有清单,
12428
+ * name 冲突时覆盖同名条目;由用户自行点「保存 MCP 配置」落盘。 */
12429
+ const MCP_TEMPLATES = {
12430
+ fetch: () => ({ name: "fetch", command: "npx", args: ["-y", "mcp-server-fetch"],
12431
+ note: t("网页抓取:把 URL 内容取回为 markdown(内置智能体即可联网读网页)") }),
12432
+ memory: () => ({ name: "memory", command: "npx", args: ["-y", "@modelcontextprotocol/server-memory"],
12433
+ note: t("知识图谱记忆:跨对话的持久记忆(实体/关系存本机 JSONL)") }),
12434
+ filesystem: () => ({ name: "fs", command: "npx",
12435
+ args: ["-y", "@modelcontextprotocol/server-fs", (S.settings && S.settings.default_workdir_effective) || "."],
12436
+ note: t("文件访问:授权目录内的读/写/搜索(默认授权 CodeBee 保存路径,可改)") }),
12437
+ playwright: () => ({ name: "playwright", command: "npx",
12438
+ args: ["-y", "@playwright/mcp@latest"],
12439
+ note: t("浏览器自动化:开页面/点击/填表/截图(2026 社区使用量第一的 MCP 服务器)") }),
12440
+ context7: () => ({ name: "context7", command: "npx",
12441
+ args: ["-y", "@upstash/context7-mcp"],
12442
+ note: t("文档查询:取常用库的最新版文档(防 API 过时幻觉)") }),
12443
+ };
12444
+
12445
+ function mcpTemplate(kind) {
12446
+ const el = $("set-mcp-servers");
12447
+ if (!el) return;
12448
+ const mk = (MCP_TEMPLATES[kind] || (() => null))();
12449
+ if (!mk) return;
12450
+ let arr = [];
12451
+ try { arr = JSON.parse(el.value || "[]") || []; } catch (e) { arr = []; }
12452
+ if (!Array.isArray(arr)) arr = [];
12453
+ const i = arr.findIndex((x) => x && x.name === mk.name);
12454
+ const dupNote = i >= 0 ? t("(覆盖同名 ") + mk.name + ")" : "";
12455
+ if (i >= 0) arr[i] = { name: mk.name, command: mk.command, args: mk.args };
12456
+ else if (arr.length >= 4) { toast(t("最多 4 个 MCP 服务器,请先删减再套模板"), true); return; }
12457
+ else arr.push({ name: mk.name, command: mk.command, args: mk.args });
12458
+ el.value = JSON.stringify(arr, null, 2);
12459
+ toast(mk.note + dupNote + t("——记得点「保存 MCP 配置」"));
12460
+ }
12461
+
12462
+ async function testNotifyPush() {
12463
+ const msg = $("notify-test-msg");
12464
+ if (msg) { msg.className = "msg"; msg.textContent = t("发送中…"); }
12465
+ try {
12466
+ // 先落盘当前输入再测,避免「改了没保存测的是旧配置」的困惑
12467
+ await api("/api/settings", { method: "POST", body: JSON.stringify(notifyPushPatch()) });
12468
+ const r = await api("/api/notify/test", { method: "POST" });
12469
+ const names = Object.keys(r.channels || {});
12470
+ const bad = names.filter((k) => !r.channels[k]);
12471
+ if (msg) {
12472
+ msg.className = "msg " + (r.ok ? "ok" : "err");
12473
+ msg.textContent = r.ok
12474
+ ? (bad.length ? t("部分通道失败:") + bad.join(", ") : t("✓ 测试消息已发出(") + names.join(", ") + ")")
12475
+ : (t("全部通道发送失败:") + names.join(", "));
12476
+ }
12477
+ } catch (e) {
12478
+ if (msg) { msg.className = "msg err"; msg.textContent = e.message; }
12479
+ }
12480
+ }
12481
+
12482
+ /* ---------------------------------------------------------- 模型评测(评测基准台)
12483
+ * 固定样题 × 候选模型实跑 + 编排者裁判打分 → 能力榜;修 bug 题真跑代码做客观验证。 */
12484
+ let EB_TIMER = null;
12485
+
12486
+ async function loadEvalBench() {
12487
+ if (EB_TIMER) { clearInterval(EB_TIMER); EB_TIMER = null; }
12488
+ try { S.evalbench = await api("/api/evalbench"); } catch (e) { S.evalbench = null; }
12489
+ if (!S.models) { try { S.models = await api("/api/models"); } catch (e) { /* 页面其余部分自会提示 */ } }
12490
+ if (!S.settings) { try { S.settings = await api("/api/settings"); } catch (e) { /* 定时开关留默认 */ } }
12491
+ const ae = $("eb-auto-enabled"), ad = $("eb-auto-days");
12492
+ if (ae && S.settings) ae.checked = S.settings.bench_auto_enabled === true;
12493
+ if (ad && S.settings) ad.value = S.settings.bench_auto_days || 7;
12494
+ renderEvalBench();
12495
+ if (S.evalbench && S.evalbench.running) EB_TIMER = setInterval(pollEvalBench, 2500);
12496
+ }
12497
+
12498
+ async function saveBenchAuto() {
12499
+ const msg = $("eb-auto-msg");
12500
+ try {
12501
+ const r = await api("/api/settings", { method: "POST", body: JSON.stringify({
12502
+ bench_auto_enabled: !$("eb-auto-enabled") || $("eb-auto-enabled").checked,
12503
+ bench_auto_days: parseInt($("eb-auto-days").value, 10) || 7 }) });
12504
+ S.settings = r.settings;
12505
+ if (msg) { msg.textContent = t("已保存"); }
12506
+ } catch (e) {
12507
+ if (msg) { msg.textContent = e.message; }
12508
+ }
12509
+ }
12510
+
12511
+ function pollEvalBench() {
12512
+ api("/api/evalbench").then((r) => {
12513
+ S.evalbench = r; renderEvalBench();
12514
+ if (!r.running && EB_TIMER) { clearInterval(EB_TIMER); EB_TIMER = null; }
12515
+ }).catch(() => {});
12516
+ }
12517
+
12518
+ function ebCandidates() {
12519
+ const rows = [];
12520
+ (S.models && S.models.providers || []).forEach((p) => {
12521
+ if (p.enabled === false) return;
12522
+ (p.models || []).forEach((m) => {
12523
+ if (m.enabled === false || m.hidden) return;
12524
+ rows.push({ provider_id: p.id, provider_name: p.name || p.id, model: m.name });
12525
+ });
12526
+ });
12527
+ return rows;
12528
+ }
12529
+
12530
+ function renderEvalBench() {
12531
+ const eb = S.evalbench;
12532
+ if (!eb) return;
12533
+ const samplesBox = $("eb-samples"), candsBox = $("eb-cands"), board = $("eb-board");
12534
+ if (!samplesBox) return; // 子页不在 DOM(防御并行改版)
12535
+ if (!samplesBox.dataset.done) {
12536
+ samplesBox.innerHTML = eb.samples.map((s) =>
12537
+ '<div class="item"><div class="t"><span class="name">' + esc(t(s.name)) + "</span>" +
12538
+ (s.verify ? '<span class="tag ok">' + t("带客观验证") + "</span>" : "") +
12539
+ (s.builtin ? "" : '<span class="tag">' + t("自定义") + "</span>" +
12540
+ '<button class="ghost small" onclick="sampleExport(\'' + esc(s.id) + '\')">' + t("导出") + "</button>" +
12541
+ '<button class="danger small" onclick="sampleDelete(\'' + esc(s.id) + '\')">' + t("删除") + "</button>") +
12542
+ "</div>" +
12543
+ '<div class="desc">' + esc(t(s.requirement)) + t(" 维度:") +
12544
+ esc(s.dims.map((d) => t(d)).join(" / ")) + "</div></div>").join("");
12545
+ samplesBox.dataset.done = "1";
12546
+ }
12547
+ const prev = (eb.leaderboard || []).reduce((acc, r) => {
12548
+ acc[r.provider_id + "\n" + r.model] = true; return acc;
12549
+ }, {});
12550
+ candsBox.innerHTML = ebCandidates().map((c) => {
12551
+ const key = c.provider_id + "\n" + c.model;
12552
+ return '<label class="toggle" style="display:flex;align-items:center;gap:6px">' +
12553
+ '<input type="checkbox" class="eb-cand" data-pid="' + esc(c.provider_id) +
12554
+ '" data-model="' + esc(c.model) + '"' + (prev[key] ? " checked" : "") + "> " +
12555
+ '<span>' + esc(t(c.provider_name)) + " · " + esc(c.model) + "</span></label>";
12556
+ }).join("") || '<div class="hint">' + t("没有可用模型:先在「模型接入」配置启用了密钥的供应商。") + "</div>";
12557
+ const j = eb.judge || {};
12558
+ const jEl = $("eb-judge");
12559
+ if (jEl) jEl.textContent = j.ready
12560
+ ? t("裁判:") + (j.model || j.provider_id)
12561
+ : t("裁判未就绪:先在「编排设置」配置编排者供应商");
12562
+ const pr = eb.progress;
12563
+ $("eb-progress").textContent = eb.running && pr
12564
+ ? t("评测中 ") + (pr.done || 0) + "/" + (pr.total || 0) + (pr.current ? t(" · ") + pr.current : "")
12565
+ : "";
12566
+ board.innerHTML = (eb.leaderboard || []).map((r) => {
12567
+ const dims = Object.keys(r.scores || {}).length
12568
+ ? t(" ") + Object.keys(r.scores).map((d) => t(d) + " " + r.scores[d]).join(t(" ")) : "";
12569
+ const verify = r.verify_total
12570
+ ? t(" 客观验证 ") + r.verify_pass + "/" + r.verify_total : "";
12571
+ const marks = (r.same_family ? '<span class="tag">' + t("同族评审") + "</span>" : "") +
12572
+ (r.failed_n ? '<span class="tag">' + t("失败 ") + r.failed_n + "</span>" : "") +
12573
+ (!r.scored_n ? '<span class="tag">' + t("未得分") + "</span>" : "") +
12574
+ (r.value_per_yuan != null
12575
+ ? '<span class="tag ok" title="' + t("综合分 ÷ 评测花费(¥,按标定单价折算)") + '">' +
12576
+ t("性价比 ") + r.value_per_yuan + t(" 分/¥") + "</span>"
12577
+ : (r.cost_yuan != null ? '<span class="tag">' + t("花费 ¥") + r.cost_yuan + "</span>" : ""));
12578
+ return '<div class="item"><div class="t"><span class="name">#' + r.rank + " " +
12579
+ esc(r.model) + "</span>" +
12580
+ (r.overall != null ? '<span class="tag ok">' + t("综合 ") + r.overall + "</span>" : "") + marks +
12581
+ '<span class="tag">' + esc(t(r.provider_name)) + "</span></div>" +
12582
+ '<div class="desc">' + t("样题 ") + r.samples_n + verify + dims +
12583
+ (r.last_ts ? t(" · ") + r.last_ts : "") + "</div></div>";
12584
+ }).join("") || '<div class="hint">' + t("还没有评测结果:勾选候选模型后点「开始评测」。") + "</div>";
12585
+ renderEvalMatrix(eb);
12586
+ // 分数走势 sparkline(近 14 天全体已得分的按日均值;数据≥2 天才画)
12587
+ const trendBox = $("eb-trend");
12588
+ if (trendBox) {
12589
+ const series = (eb.trend || []).map((t) => t.overall);
12590
+ trendBox.innerHTML = (series.length > 1)
12591
+ ? '<span class="hint">' + t("分数走势(近 ") + series.length + t(" 天)") + "</span>" +
12592
+ kpiSparkSvg(series, series[series.length - 1] >= series[0] ? "up" : "down")
12593
+ : "";
12594
+ }
12595
+ const btn = $("eb-run-btn");
12596
+ if (btn) btn.disabled = !!eb.running;
12597
+ }
12598
+
12599
+ /* 逐题对比:行=样题,列=候选模型(按榜单名次),格=最新一次得分 */
12600
+ function renderEvalMatrix(eb) {
12601
+ const box = $("eb-matrix");
12602
+ if (!box) return;
12603
+ const mx = eb.matrix || { samples: [], cells: {} };
12604
+ const cols = (eb.leaderboard || [])
12605
+ .map((r) => r.provider_id + "|" + r.model);
12606
+ const sname = {};
12607
+ (eb.samples || []).forEach((s) => { sname[s.id] = s.name; });
12608
+ if (!mx.samples.length || !cols.length) { box.innerHTML = ""; return; }
12609
+ const cellHtml = (sid, mk) => {
12610
+ const c = (mx.cells[sid] || {})[mk];
12611
+ if (!c) return '<td style="color:var(--dim,#999);text-align:center">·</td>';
12612
+ if (!c.ok) return '<td style="text-align:center;color:var(--err,#d33)">✗</td>';
12613
+ if (!c.scored || c.overall == null)
12614
+ return '<td style="text-align:center;color:var(--dim,#999)">−</td>';
12615
+ const mark = c.verify_ok === false ? "⚠" : "";
12616
+ return '<td style="text-align:center">' + c.overall.toFixed(1) + mark + "</td>";
12617
+ };
12618
+ box.innerHTML = '<div style="overflow-x:auto"><table class="eb-mx" style="border-collapse:collapse;width:100%;font-size:12.5px">' +
12619
+ "<tr><th style=\"text-align:left;padding:4px 8px;color:var(--dim,#999)\">" + t("样题 \\ 模型") + "</th>" +
12620
+ cols.map((mk) => '<th style="padding:4px 8px;white-space:nowrap">' + esc(mk.split("|")[1]) + "</th>").join("") + "</tr>" +
12621
+ mx.samples.map((sid) =>
12622
+ "<tr><td style=\"padding:4px 8px;white-space:nowrap\">" + esc(t(sname[sid] || sid)) + "</td>" +
12623
+ cols.map((mk) => cellHtml(sid, mk)).join("") + "</tr>").join("") +
12624
+ "</table></div>" +
12625
+ '<p class="hint">' + t("⚠ = 该题客观验证未通过;✗ = 生成失败;− = 生成成功但未得分。") + "</p>";
12626
+ }
12627
+
12628
+ async function evalbenchRun() {
12629
+ const picks = Array.from(document.querySelectorAll(".eb-cand:checked")).map((el) => ({
12630
+ provider_id: el.dataset.pid, model: el.dataset.model }));
12631
+ const msg = $("eb-msg");
12632
+ if (!picks.length) { if (msg) { msg.className = "msg err"; msg.textContent = t("请先勾选至少一个候选模型"); } return; }
12633
+ try {
12634
+ await api("/api/evalbench/run", { method: "POST", body: JSON.stringify({ candidates: picks }) });
12635
+ if (msg) { msg.className = "msg ok"; msg.textContent = ""; }
12636
+ if (!EB_TIMER) EB_TIMER = setInterval(pollEvalBench, 2500);
12637
+ pollEvalBench();
12638
+ } catch (e) {
12639
+ if (msg) { msg.className = "msg err"; msg.textContent = e.message; }
12640
+ }
12641
+ }
12642
+
12643
+ async function evalbenchCancel() {
12644
+ try { await api("/api/evalbench/cancel", { method: "POST" }); } catch (e) { /* 空闲时 200 ok=false */ }
12645
+ pollEvalBench();
12646
+ }
12647
+
12648
+ /* 自定义样题:添加表单 / 删除 / 导出分享码 / 导入分享码 */
12649
+ function sampleAdd() {
12650
+ const body = '<div class="form">' +
12651
+ '<div class="field"><label>' + t("名称 ") + '<span class="req">*</span></label><input id="sm-name" maxlength="20"></div>' +
12652
+ '<div class="field"><label>' + t("评审维度(2-6 个,逗号分隔)") + '</label><input id="sm-dims" placeholder="' + t("例:正确性,可读性") + '"></div>' +
12653
+ '<div class="field"><label>' + t("题目要求(一句话,给裁判看)") + '</label><input id="sm-req" maxlength="120"></div>' +
12654
+ '<div class="field"><label>' + t("题目内容 ") + '<span class="req">*</span>' + t("(发给候选模型的完整题面,≤2000 字)") + '</label>' +
12655
+ '<textarea id="sm-prompt" rows="6" style="width:100%"></textarea></div></div>';
12656
+ openModal(t("+ 添加自定义样题"), body,
12657
+ '<button class="ghost" onclick="closeModal()">' + t("取消") + '</button>' +
12658
+ '<button class="primary" onclick="sampleAddDo()">' + t("保存") + '</button>');
12659
+ }
12660
+
12661
+ async function sampleAddDo() {
12662
+ const dims = (($("sm-dims") || {}).value || "").split(/[,,、\n]+/).map((s) => s.trim()).filter(Boolean);
12663
+ try {
12664
+ await api("/api/evalbench/sample-op", { method: "POST", body: JSON.stringify({
12665
+ op: "add", sample: { name: ($("sm-name") || {}).value, dims,
12666
+ requirement: ($("sm-req") || {}).value, prompt: ($("sm-prompt") || {}).value } }) });
12667
+ closeModal();
12668
+ S.evalbench = await api("/api/evalbench");
12669
+ const box = $("eb-samples");
12670
+ if (box) box.dataset.done = "";
12671
+ renderEvalBench();
12672
+ toast(t("样题已添加"));
12673
+ } catch (e) { toast(t("保存失败:") + e.message, true); }
12674
+ }
12675
+
12676
+ async function sampleDelete(sid) {
12677
+ if (!await uiConfirm(t("删除这道自定义样题?历史评测结果保留。"), { ok: t("删除"), danger: true })) return;
12678
+ try { await api("/api/evalbench/sample-op", { method: "POST", body: JSON.stringify({ op: "delete", sid }) }); }
12679
+ catch (e) { toast(t("删除失败:") + e.message, true); return; }
12680
+ S.evalbench = await api("/api/evalbench");
12681
+ const box = $("eb-samples");
12682
+ if (box) box.dataset.done = "";
12683
+ renderEvalBench();
12684
+ toast(t("已删除"));
12685
+ }
12686
+
12687
+ async function sampleExport(sid) {
12688
+ let r;
12689
+ try { r = await api("/api/evalbench/sample-export", { method: "POST", body: JSON.stringify({ id: sid }) }); }
12690
+ catch (e) { toast(t("导出失败:") + e.message, true); return; }
12691
+ const body = '<p class="hint">' + t("把这段样题分享码发给任何人,对方在「添加样题 → 导入」粘贴即可:") + '</p>' +
12692
+ '<textarea id="sm-share-code" rows="5" readonly style="width:100%;font-family:var(--mono,monospace);font-size:12px;word-break:break-all">' +
12693
+ esc(r.code) + "</textarea>";
12694
+ openModal(t("📤 导出样题"), body, "");
12695
+ try { await navigator.clipboard.writeText(r.code); toast(t("分享码已复制到剪贴板")); } catch (e) { /* 无剪贴板权限时保持手选 */ }
12696
+ }
12697
+
12698
+ function sampleImport() {
12699
+ const body = '<p class="hint">' + t("粘贴样题分享码(CBSAMP1. 开头),ID 冲突自动换号。") + '</p>' +
12700
+ '<textarea id="sm-import-code" rows="5" style="width:100%;font-family:var(--mono,monospace);font-size:12px;word-break:break-all"></textarea>';
12701
+ openModal(t("📥 导入样题分享码"), body,
12702
+ '<button class="ghost" onclick="closeModal()">' + t("取消") + '</button>' +
12703
+ '<button class="primary" onclick="sampleImportDo()">' + t("导入") + '</button>');
12704
+ }
12705
+
12706
+ async function sampleImportDo() {
12707
+ const code = ($("sm-import-code") || {}).value || "";
12708
+ if (!code.trim()) { toast(t("请先粘贴分享码"), true); return; }
12709
+ let r;
12710
+ try { r = await api("/api/evalbench/sample-import", { method: "POST", body: JSON.stringify({ code }) }); }
12711
+ catch (e) { toast(t("导入失败:") + e.message, true); return; }
12712
+ closeModal();
12713
+ S.evalbench = await api("/api/evalbench");
12714
+ const box = $("eb-samples");
12715
+ if (box) box.dataset.done = "";
12716
+ renderEvalBench();
12717
+ toast(t("已导入样题「") + (r.name || r.id) + "」");
12718
+ }
12719
+
11543
12720
  /* ---------------------------------------------------------- 关于与更新(selfupdate)
11544
12721
  * 后端 /api/selfupdate:mode=npm 才可自动升级;repo(git clone)提示 git pull。
11545
12722
  * 升级 = 建 mgmt run 跑 npm install -g @latest(日志实时落盘)→ 服务自动重启,
@@ -12095,7 +13272,7 @@ async function suStartupCheck() {
12095
13272
  }
12096
13273
 
12097
13274
  /* ---------------------------------------------------------- 页签 & 初始化 */
12098
- const TAB_TITLES = { overview: "概览", tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
13275
+ const TAB_TITLES = { overview: "概览", tasks: "任务", runs: "运行记录", automation: "自动化", browser: "浏览器", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
12099
13276
  const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 全部设置子页(__phone 是弹框,不算)
12100
13277
 
12101
13278
  function tabTitle(name) {
@@ -12998,20 +14175,32 @@ function ovMaybeRefresh() {
12998
14175
  /* ---------------------------------------------------------- 侧栏常驻:用量条 + 计数徽章 */
12999
14176
  /* 用量条的数字来自真实台账,口径写死为「本月」(自然月,从 1 号起)。
13000
14177
  * 刻意不做「用量 68%」那种进度条:CodeBee 没有套餐配额,没有分母可除,
13001
- * 编一个上限出来就是假数。这里陈列的是能直接去用量页核对的事实。 */
13002
- async function sideUsageLoad() {
13003
- if (S.sideUsage && S.sideUsageAt && Date.now() - S.sideUsageAt < 60000) return; // 1 分钟节流
14178
+ * 编一个上限出来就是假数。这里陈列的是能直接去用量页核对的事实。
14179
+ * 实时化:台账每落一条记录服务端就推一个 usage 事件(event: usage),
14180
+ * onusage 监听器据此带 force 拉台账;有在跑/排队 run 时 3s 快节奏,
14181
+ * 空闲回落 60s——数字不动的时候不烧请求。 */
14182
+ function sideUsageBusy() {
14183
+ const runs = (S.state && S.state.runs) || [];
14184
+ return runs.some((r) => r.status === "running" || r.status === "queued");
14185
+ }
14186
+
14187
+ async function sideUsageLoad(force) {
14188
+ if (S._suBusy) return; // 在飞保护:SSE 推送与轮询并发触发只算一次
14189
+ const gap = (force || sideUsageBusy()) ? 3000 : 60000;
14190
+ if (S.sideUsage && S.sideUsageAt && Date.now() - S.sideUsageAt < gap) return;
14191
+ S._suBusy = true;
13004
14192
  try {
13005
14193
  const u = await api("/api/usage?days=30");
13006
14194
  S.sideUsage = u || null;
13007
14195
  S.sideUsageAt = Date.now();
13008
14196
  } catch (e) { /* 拉不到就保留上次的值,不把侧栏清空 */ }
14197
+ finally { S._suBusy = false; }
13009
14198
  sideUsageRender();
13010
14199
  }
13011
14200
 
13012
14201
  function sideUsageRefresh() {
13013
14202
  sideUsageRender(); // 计数徽章靠 S.state,同步重画
13014
- sideUsageLoad(); // 台账按节流异步补
14203
+ sideUsageLoad(); // 台账按节奏异步补(忙 3s / 闲 60s,见 sideUsageLoad)
13015
14204
  }
13016
14205
 
13017
14206
  function sideUsageRender() {
@@ -13021,31 +14210,40 @@ function sideUsageRender() {
13021
14210
  const u = S.sideUsage;
13022
14211
  const tok = $("su-tok"), cost = $("su-cost"), spark = $("su-spark");
13023
14212
  if (!tok || !cost || !spark) return;
13024
- if (!u || !u.by_day || !u.by_day.length) {
13025
- tok.textContent = t("本月还没有调用");
13026
- cost.textContent = "";
13027
- spark.innerHTML = "";
13028
- return;
13029
- }
14213
+ let tokTxt = "", costTxt = "", sparkHtml = "", hasData = false;
13030
14214
  // 口径=自然月:days=31 只是取数窗口(任一自然月的已过天数都不超过它),
13031
14215
  // 展示前必须按当前年月再滤一遍——不滤的话月初那几天会把上个月的尾巴算进"本月",
13032
14216
  // 标签写着本月、数字却是滚动 30 天,就是假口径
13033
- const now = new Date();
13034
- const ym = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
13035
- const monthDays = u.by_day.filter((d) => String(d.day || "").slice(0, 7) === ym);
13036
- const sum = (k) => monthDays.reduce((s, d) => s + (Number(d[k]) || 0), 0);
13037
- const calls = sum("calls");
13038
- if (!calls) {
13039
- tok.textContent = t("本月还没有调用");
13040
- cost.textContent = "";
13041
- spark.innerHTML = "";
13042
- return;
14217
+ if (u && u.by_day && u.by_day.length) {
14218
+ const now = new Date();
14219
+ const ym = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
14220
+ const monthDays = u.by_day.filter((d) => String(d.day || "").slice(0, 7) === ym);
14221
+ const sum = (k) => monthDays.reduce((s, d) => s + (Number(d[k]) || 0), 0);
14222
+ const calls = sum("calls");
14223
+ if (calls) {
14224
+ hasData = true;
14225
+ tokTxt = fmtTok(sum("tokens")) + t(" tokens · ") + fmtTok(calls) + t(" 次");
14226
+ costTxt = fmtUsd(sum("cost_usd"));
14227
+ // 走势不受月界影响:近 14 天日序列,只是节奏参考,by_day 连续补零直接取尾
14228
+ const days = u.by_day.slice(-14).map((d) => d.tokens || 0);
14229
+ sparkHtml = days.length > 1 ? kpiSparkSvg(days, "accent") : "";
14230
+ }
14231
+ }
14232
+ if (!tokTxt) tokTxt = t("本月还没有调用");
14233
+ tok.textContent = tokTxt;
14234
+ cost.textContent = costTxt;
14235
+ spark.innerHTML = sparkHtml;
14236
+ // 数字真的变了才闪一下;占位符 → 首次真数据不闪(每次加载都闪就太闹了)
14237
+ const sig = tokTxt + "|" + costTxt;
14238
+ if (S._suHadData && S._suSig && S._suSig !== sig) {
14239
+ box.classList.remove("su-live");
14240
+ void box.offsetWidth; // 强制重排以重启动画
14241
+ box.classList.add("su-live");
14242
+ clearTimeout(S._suLiveT);
14243
+ S._suLiveT = setTimeout(() => box.classList.remove("su-live"), 1100);
13043
14244
  }
13044
- tok.textContent = fmtTok(sum("tokens")) + t(" tokens · ") + fmtTok(calls) + t(" 次");
13045
- cost.textContent = fmtUsd(sum("cost_usd"));
13046
- // 走势不受月界影响:近 14 天日序列,只是节奏参考,by_day 连续补零直接取尾
13047
- const days = u.by_day.slice(-14).map((d) => d.tokens || 0);
13048
- spark.innerHTML = days.length > 1 ? kpiSparkSvg(days, "accent") : "";
14245
+ S._suSig = sig;
14246
+ S._suHadData = hasData;
13049
14247
  }
13050
14248
 
13051
14249
  /* 计数徽章:挂在「运行记录」快捷导航项上,数当前在跑 + 排队的 run 数。
@@ -13167,9 +14365,14 @@ window.exportBackup = async function () {
13167
14365
  body: JSON.stringify({ target_dir: (($("bk-target") || {}).value || "").trim(),
13168
14366
  include_logs: $("bk-logs").checked }) });
13169
14367
  const ext = (r.external_workdirs || []);
14368
+ const remote = r.remote || {};
13170
14369
  $("bk-result").innerHTML = '<span class="ok">' + esc(t("已导出")) + t(":") + esc(r.path)
13171
14370
  + t("(") + esc(fmtSize(r.size || 0)) + t(",") + esc(t("任务")) + " " + (r.tasks || 0)
13172
14371
  + " · " + esc(t("运行记录")) + " " + (r.runs || 0) + t(")") + "</span>"
14372
+ + (remote && remote.skipped ? "" :
14373
+ (remote && remote.ok
14374
+ ? '<div><span class="ok">' + esc(t("☁ 已推送到远程备份端点")) + "</span></div>"
14375
+ : '<div><span class="bad">' + esc(t("☁ 远程推送失败:") + (remote.error || "")) + "</span></div>"))
13173
14376
  + (ext.length ? '<div><span class="bad">' + esc(t("以下外部目录不在备份里,需自行拷贝:"))
13174
14377
  + esc(ext.join("、")) + "</span></div>" : "")
13175
14378
  + '<button class="ghost small" onclick="copyText(this)" data-copy="' + esc(r.path) + '">'
@@ -13181,6 +14384,72 @@ window.exportBackup = async function () {
13181
14384
  }
13182
14385
  };
13183
14386
 
14387
+ /* 远程备份通道:配置存 settings,导出成功后由后端自动推送 */
14388
+ async function saveBackupRemote() {
14389
+ const msg = $("bk-remote-msg");
14390
+ try {
14391
+ const r = await api("/api/settings", { method: "POST", body: JSON.stringify({
14392
+ backup_remote_enabled: !$("bk-remote-enabled") || $("bk-remote-enabled").checked,
14393
+ backup_remote_url: ($("bk-remote-url") || {}).value || "",
14394
+ backup_remote_user: ($("bk-remote-user") || {}).value || "",
14395
+ backup_remote_pass: ($("bk-remote-pass") || {}).value || "" }) });
14396
+ S.settings = r.settings;
14397
+ if (msg) msg.textContent = t("已保存");
14398
+ } catch (e) { if (msg) msg.textContent = e.message; }
14399
+ }
14400
+
14401
+ async function testBackupRemote() {
14402
+ const msg = $("bk-remote-msg");
14403
+ if (msg) msg.textContent = t("测试中…");
14404
+ try {
14405
+ await api("/api/settings", { method: "POST", body: JSON.stringify({
14406
+ backup_remote_enabled: !$("bk-remote-enabled") || $("bk-remote-enabled").checked,
14407
+ backup_remote_url: ($("bk-remote-url") || {}).value || "",
14408
+ backup_remote_user: ($("bk-remote-user") || {}).value || "",
14409
+ backup_remote_pass: ($("bk-remote-pass") || {}).value || "" }) });
14410
+ const r = await api("/api/data/test-remote", { method: "POST", timeout: 90000 });
14411
+ if (msg) msg.className = "hint " + (r.ok ? "ok" : "err");
14412
+ if (msg) msg.textContent = r.ok ? t("✓ 远程端点连通,可上传") : (t("连接失败:") + (r.error || ""));
14413
+ } catch (e) { if (msg) msg.textContent = e.message; }
14414
+ }
14415
+
14416
+ function fillBackupRemote() {
14417
+ if (!S.settings) return;
14418
+ const set = (id, key) => { const el = $(id); if (el) el.value = S.settings[key] || ""; };
14419
+ const ck = $("bk-remote-enabled");
14420
+ if (ck) ck.checked = S.settings.backup_remote_enabled === true;
14421
+ set("bk-remote-url", "backup_remote_url");
14422
+ set("bk-remote-user", "backup_remote_user");
14423
+ set("bk-remote-pass", "backup_remote_pass");
14424
+ }
14425
+
14426
+ /* 远程备份拉取恢复:列端点上的备份包 → 点「拉取」→ 落 imports/ →
14427
+ * 自动回填路径并触发既有导入预览向导 */
14428
+ async function listRemoteBackups() {
14429
+ const msg = $("bi-remote-msg"), box = $("bi-remote-list");
14430
+ if (msg) msg.textContent = t("列举中…");
14431
+ let files;
14432
+ try { files = (await api("/api/data/remote-list", { method: "POST", timeout: 150000 })).files || []; }
14433
+ catch (e) { if (msg) msg.textContent = e.message; return; }
14434
+ if (msg) msg.textContent = files.length ? "" : t("端点上没有备份包");
14435
+ if (box) box.innerHTML = files.map((f) =>
14436
+ '<div class="item"><div class="t"><span class="name">' + esc(f) + "</span>" +
14437
+ '<button class="ghost small" onclick="pullRemoteBackup(\'' + esc(f) + '\')">' + t("拉取") + "</button></div></div>").join("");
14438
+ }
14439
+
14440
+ async function pullRemoteBackup(name) {
14441
+ const msg = $("bi-remote-msg");
14442
+ if (msg) msg.textContent = t("拉取中(包大时需数分钟)…");
14443
+ let p;
14444
+ try { p = (await api("/api/data/remote-pull", { method: "POST", timeout: 600000,
14445
+ body: JSON.stringify({ name }) })).path; }
14446
+ catch (e) { if (msg) msg.textContent = e.message; return; }
14447
+ if (msg) msg.textContent = t("已拉取到本地");
14448
+ const inp = $("bi-path");
14449
+ if (inp) inp.value = p;
14450
+ if (typeof inspectBackup === "function") inspectBackup(); // 直接进预览向导
14451
+ }
14452
+
13184
14453
  /* 通用「复制到剪贴板」:data-copy 属性携带文本(导出路径等) */
13185
14454
  window.copyText = async function (btn) {
13186
14455
  const text = btn.getAttribute("data-copy") || "";
@@ -13330,15 +14599,20 @@ function switchTab(name, shell) {
13330
14599
  S.mainPage = inMain ? name : ""; // 主栏停在哪个全局页;closeRun 靠它回到点开详情前那一页
13331
14600
  if (settingsNavSubs().has(name)) localStorage.setItem("orch.setTab", name);
13332
14601
  document.body.classList.toggle("settings-mode", !inMain);
14602
+ const main = document.querySelector("main");
14603
+ if (main) main.classList.toggle("browser-page-active", name === "browser");
14604
+ syncRailState();
13333
14605
  document.body.classList.remove("files-mode"); // 文件浏览页与两套导航都互斥,别叠在左栏
13334
14606
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
13335
14607
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", !inMain && b.dataset.sub === name));
13336
14608
  document.querySelectorAll(".qitem").forEach((b) => b.classList.toggle("active", inMain && b.dataset.page === name));
14609
+ document.querySelectorAll(".rail-btn[data-rail-page]").forEach((b) => b.classList.toggle("active", b.dataset.railPage === (inMain ? name : "tasks")));
13337
14610
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-" + name));
13338
14611
  renderPageCrumb(); // 页名 + 面包屑组名一起刷新(组名从左栏导航现读)
13339
14612
  if (name === "runs" && !S.detailRunId) closeRun();
13340
14613
  if (name === "agents") autoCheckUpdates(); // 进目录页自动查各 CLI 新版本
13341
14614
  if (name === "orch") { loadOrchestrator(); loadSettings(); } // 进编排设置页拉取配置
14615
+ if (name === "evalbench") loadEvalBench(); // 进模型评测页:榜单 + 候选清单
13342
14616
  if (name === "skills") loadSkills(); // 进经验库页拉取沉淀
13343
14617
  if (name === "knowledge") loadKnowledge(); // 进知识库页拉取条目
13344
14618
  if (name === "automation") { loadAutomation(); startAutoPoll(); } // 进自动化页:拉取 + 页面可见时每 8s 轮询
@@ -13350,6 +14624,7 @@ function switchTab(name, shell) {
13350
14624
  if (name === "data") loadDataPage(); // 进数据与备份页:清理配置/状态/可清理预估
13351
14625
  if (name === "usage") { syncUsageRange(); loadUsage(); } // 进用量页:对齐范围选中态并拉取
13352
14626
  if (name === "overview") { ovDays(); loadOverview(); } // 进概览页:对齐趋势范围并拉两期台账
14627
+ if (name === "browser") browserDraw();
13353
14628
  if (name === "appearance") renderAppearance(); // 进皮肤页:按当前皮肤/明暗重画卡片
13354
14629
  if (name === "appearance") renderCodeSettings(); // 代码设置行 + 双主题预览卡同步当前值
13355
14630
  if (name === "about") loadSelfupdate(false); // 进关于页:拉版本与更新状态
@@ -13366,7 +14641,11 @@ function exitSettings() {
13366
14641
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
13367
14642
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
13368
14643
  document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
14644
+ document.querySelectorAll(".rail-btn[data-rail-page]").forEach((b) => b.classList.toggle("active", b.dataset.railPage === "tasks"));
13369
14645
  document.body.classList.remove("settings-mode");
14646
+ const main = document.querySelector("main");
14647
+ if (main) main.classList.remove("browser-page-active");
14648
+ syncRailState();
13370
14649
  document.body.classList.remove("files-mode");
13371
14650
  cmpGreeting(); // 回到任务页:问候语刷新
13372
14651
  renderPageCrumb();
@@ -13462,6 +14741,10 @@ window.loadAutomation = loadAutomation;
13462
14741
  window.mkInstall = mkInstall;
13463
14742
  window.mkRemove = mkRemove;
13464
14743
  window.loadMarket = loadMarket;
14744
+ window.loadPlugins = loadPlugins;
14745
+ window.pluginInstall = pluginInstall;
14746
+ window.pluginToggle = pluginToggle;
14747
+ window.pluginRemove = pluginRemove;
13465
14748
  window.mkrInstall = mkrInstall;
13466
14749
  window.mkrRefresh = mkrRefresh;
13467
14750
  window.mkSetView = mkSetView;
@@ -13505,6 +14788,11 @@ document.addEventListener("DOMContentLoaded", () => {
13505
14788
  document.querySelectorAll("#usage-ranges [data-days]").forEach((b) =>
13506
14789
  b.addEventListener("click", () => setUsageDays(b.dataset.days)));
13507
14790
  $("btn-usage-refresh").addEventListener("click", loadUsage);
14791
+ const ubtn = $("btn-usage-export");
14792
+ if (ubtn) ubtn.addEventListener("click", () => {
14793
+ // 下载导航带令牌(urlAuth);范围跟随页面当前选择
14794
+ window.open(urlAuth("/api/usage/export?days=" + encodeURIComponent(usageRange())), "_blank");
14795
+ });
13508
14796
  document.querySelectorAll("#usage-heat-modes [data-heat]").forEach((b) =>
13509
14797
  b.addEventListener("click", () => setUsageHeatMode(b.dataset.heat)));
13510
14798
  $("btn-back").addEventListener("click", closeRun);
@@ -13757,6 +15045,8 @@ document.addEventListener("DOMContentLoaded", () => {
13757
15045
  $("btn-q-overview").addEventListener("click", () => switchTab("overview", "main"));
13758
15046
  $("btn-q-runs").addEventListener("click", () => switchTab("runs", "main"));
13759
15047
  $("btn-q-automation").addEventListener("click", () => switchTab("automation", "main"));
15048
+ $("btn-q-browser").addEventListener("click", () => switchTab("browser", "main"));
15049
+ browserBind();
13760
15050
  bindCtxMenus();
13761
15051
  bindInspector();
13762
15052
  if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
@@ -13859,6 +15149,7 @@ document.addEventListener("DOMContentLoaded", () => {
13859
15149
  $("btn-reload-catalog").addEventListener("click", async () => {
13860
15150
  await api("/api/catalog/reload", { method: "POST" }); poll(); refreshSessionAgents();
13861
15151
  });
15152
+ $("btn-catalog-upgrade-all").addEventListener("click", catUpgradeAll);
13862
15153
  $("btn-catalog-autobind").addEventListener("click", catAutoBindAll);
13863
15154
  $("btn-import").addEventListener("click", openImportDialog);
13864
15155
  $("btn-add-provider").addEventListener("click", openAddProviderDialog);
@@ -13971,6 +15262,7 @@ const CmdK = { tab: "all", q: "", sel: 0, flat: [] };
13971
15262
  function cmdkOps() {
13972
15263
  return [
13973
15264
  { icon: "i-tasks", label: t("新任务"), kbd: "N", run: () => $("btn-new-task").click() },
15265
+ { icon: "i-browser", label: t("浏览器"), run: () => switchTab("browser", "main") },
13974
15266
  { icon: "i-calendar-days", label: t("自动化"), run: () => switchTab("automation") },
13975
15267
  { icon: "i-blocks", label: t("插件市场"), run: () => switchTab("market") },
13976
15268
  { icon: "i-book", label: t("经验库"), run: () => switchTab("skills") },
@@ -14397,3 +15689,18 @@ async function beePyInstall() {
14397
15689
  } catch (e) { /* 轮询失败下一拍再试 */ }
14398
15690
  }, 3000);
14399
15691
  }
15692
+
15693
+ // CodeBee workspace switcher: the first item is intentionally informational
15694
+ // until multiple local workspaces are supported.
15695
+ function toggleBrandMenu(force) {
15696
+ const menu = $("brand-menu");
15697
+ const btn = $("btn-brand");
15698
+ if (!menu || !btn) return;
15699
+ const open = typeof force === "boolean" ? force : menu.classList.contains("hidden");
15700
+ menu.classList.toggle("hidden", !open);
15701
+ btn.setAttribute("aria-expanded", open ? "true" : "false");
15702
+ }
15703
+
15704
+ document.addEventListener("click", (e) => {
15705
+ if (!e.target.closest("#btn-brand, #brand-menu")) toggleBrandMenu(false);
15706
+ });