codebee 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/ui/app.js CHANGED
@@ -297,6 +297,93 @@ function urlAuth(u) {
297
297
  }
298
298
 
299
299
  /* ---------------------------------------------------------- 工具 */
300
+ let _requestBusyCount = 0;
301
+ let _lastActionButton = null;
302
+ let _lastActionAt = 0;
303
+ const _requestBusyButtons = new WeakMap();
304
+
305
+ /* 记录发起请求的按钮。写请求统一在 api() 里挂忙碌态,避免每个业务入口各写一套;
306
+ * 确认框的按钮不覆盖原始操作按钮,但会续期,使“确认后执行”仍反馈在原按钮上。 */
307
+ document.addEventListener("click", (e) => {
308
+ const btn = e.target && e.target.closest ? e.target.closest("button") : null;
309
+ if (!btn) return;
310
+ if (btn.classList.contains("request-busy")) {
311
+ e.preventDefault();
312
+ e.stopImmediatePropagation();
313
+ return;
314
+ }
315
+ _lastActionAt = performance.now();
316
+ if (!btn.closest("#ask")) _lastActionButton = btn;
317
+ }, true);
318
+
319
+ function requestBusyStart(opts) {
320
+ const method = String((opts && opts.method) || "GET").toUpperCase();
321
+ if (opts && opts.busy === false) return null;
322
+ if ((method === "GET" || method === "HEAD") && !(opts && opts.busy)) return null;
323
+
324
+ _requestBusyCount++;
325
+ let bar = $("request-progress");
326
+ if (!bar && document.body) {
327
+ bar = document.createElement("div");
328
+ bar.id = "request-progress";
329
+ bar.className = "request-progress";
330
+ bar.setAttribute("role", "status");
331
+ bar.setAttribute("aria-label", t("操作处理中"));
332
+ bar.setAttribute("aria-hidden", "true");
333
+ document.body.appendChild(bar);
334
+ }
335
+ if (bar) {
336
+ bar.classList.add("active");
337
+ bar.setAttribute("aria-hidden", "false");
338
+ }
339
+
340
+ let btn = opts && opts.busyElement;
341
+ if (typeof btn === "string") btn = document.querySelector(btn);
342
+ if (!btn && performance.now() - _lastActionAt < 2000) btn = _lastActionButton;
343
+ if (!btn || !document.documentElement.contains(btn)) btn = null;
344
+ if (btn) {
345
+ let state = _requestBusyButtons.get(btn);
346
+ if (!state) {
347
+ state = {
348
+ count: 0,
349
+ ariaBusy: btn.getAttribute("aria-busy"),
350
+ ariaDisabled: btn.getAttribute("aria-disabled"),
351
+ };
352
+ _requestBusyButtons.set(btn, state);
353
+ }
354
+ state.count++;
355
+ btn.classList.add("request-busy");
356
+ btn.setAttribute("aria-busy", "true");
357
+ btn.setAttribute("aria-disabled", "true");
358
+ }
359
+ return { button: btn };
360
+ }
361
+
362
+ function requestBusyEnd(token) {
363
+ if (!token) return;
364
+ _requestBusyCount = Math.max(0, _requestBusyCount - 1);
365
+ const btn = token.button;
366
+ const state = btn && _requestBusyButtons.get(btn);
367
+ if (state) {
368
+ state.count--;
369
+ if (state.count <= 0) {
370
+ btn.classList.remove("request-busy");
371
+ if (state.ariaBusy === null) btn.removeAttribute("aria-busy");
372
+ else btn.setAttribute("aria-busy", state.ariaBusy);
373
+ if (state.ariaDisabled === null) btn.removeAttribute("aria-disabled");
374
+ else btn.setAttribute("aria-disabled", state.ariaDisabled);
375
+ _requestBusyButtons.delete(btn);
376
+ }
377
+ }
378
+ if (_requestBusyCount === 0) {
379
+ const bar = $("request-progress");
380
+ if (bar) {
381
+ bar.classList.remove("active");
382
+ bar.setAttribute("aria-hidden", "true");
383
+ }
384
+ }
385
+ }
386
+
300
387
  async function api(path, opts) {
301
388
  opts = opts || {};
302
389
  // 可选超时:长请求(如外部插件下载)传 opts.timeout,网络卡死时也能
@@ -306,21 +393,30 @@ async function api(path, opts) {
306
393
  ctrl = new AbortController();
307
394
  var timer = setTimeout(() => ctrl.abort(), opts.timeout);
308
395
  }
309
- let res;
396
+ const busyToken = requestBusyStart(opts);
397
+ const fetchOpts = Object.assign({}, opts);
398
+ delete fetchOpts.timeout;
399
+ delete fetchOpts.busy;
400
+ delete fetchOpts.busyElement;
310
401
  try {
311
- res = await fetch(path, Object.assign({ headers: authHeaders() }, opts, ctrl ? { signal: ctrl.signal } : {}));
312
- } catch (e) {
313
- if (ctrl && e.name === "AbortError") throw new Error(t("请求超时,请重试或检查网络"));
314
- throw e;
402
+ let res;
403
+ try {
404
+ res = await fetch(path, Object.assign({ headers: authHeaders() }, fetchOpts,
405
+ ctrl ? { signal: ctrl.signal } : {}));
406
+ } catch (e) {
407
+ if (ctrl && e.name === "AbortError") throw new Error(t("请求超时,请重试或检查网络"));
408
+ throw e;
409
+ }
410
+ if (res.status === 401) { showTokenGate(t("令牌不正确或已更换,请重新输入")); throw new Error(t("需要访问令牌")); }
411
+ let data = null;
412
+ try { data = await res.json(); } catch (e) { /* ignore */ }
413
+ if (res.status === 423 && data && data.control) setControl(data.control);
414
+ if (!res.ok) throw new Error((data && data.error) || ("HTTP " + res.status));
415
+ return data;
315
416
  } finally {
316
417
  if (timer) clearTimeout(timer);
418
+ requestBusyEnd(busyToken);
317
419
  }
318
- if (res.status === 401) { showTokenGate(t("令牌不正确或已更换,请重新输入")); throw new Error(t("需要访问令牌")); }
319
- let data = null;
320
- try { data = await res.json(); } catch (e) { /* ignore */ }
321
- if (res.status === 423 && data && data.control) setControl(data.control);
322
- if (!res.ok) throw new Error((data && data.error) || ("HTTP " + res.status));
323
- return data;
324
420
  }
325
421
 
326
422
  /* 轻提示:3.5s 自动消失 */
@@ -426,13 +522,13 @@ function jsq(s) {
426
522
  }
427
523
 
428
524
  function statusChip(st) {
429
- const zh = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") };
525
+ const zh = { queued: t("正在启动"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") };
430
526
  return '<span class="chip ' + esc(st) + '">' + (zh[st] || esc(st)) + "</span>";
431
527
  }
432
528
 
433
529
  /* 运行状态文案(传 run 对象):退避窗口内的续跑副本写明「将于 HH:MM 自动
434
530
  * 续跑」,别让 5 分钟等待看起来像卡死/资源排队(2026-09-18 重写任务误判案)。
435
- * 到点后翻回「排队中」——页面轮询重渲染时 Date.now() 已过预定时刻。 */
531
+ * 到点后翻回「正在启动」——页面轮询重渲染时 Date.now() 已过预定时刻。 */
436
532
  function runStatusText(run) {
437
533
  const st = String((run && run.status) || "");
438
534
  if (st === "queued" && run && run.resume_enqueue_at) {
@@ -440,7 +536,7 @@ function runStatusText(run) {
440
536
  if (!isNaN(at) && Date.now() < at)
441
537
  return t("将于 {0} 自动续跑", String(run.resume_enqueue_at).slice(11, 16));
442
538
  }
443
- return { queued: t("排队中"), running: t("运行中"), done: t("完成"),
539
+ return { queued: t("正在启动"), running: t("运行中"), done: t("完成"),
444
540
  failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") }[st] || st;
445
541
  }
446
542
 
@@ -632,7 +728,7 @@ async function healthOp(op) {
632
728
  async function healthDisableModel(pid, model) {
633
729
  if (!pid || !model) { closeModal(); return; }
634
730
  const yes = await uiConfirm(
635
- t("确认禁用模型 {0}?链降级将自动跳过它,其余模型不受影响;可在 CLI 绑定页重新启用。").replace("{0}", pid + " · " + model),
731
+ t("确认禁用模型 {0}?链降级将自动跳过它,其余模型不受影响;可在模型调度页重新启用。").replace("{0}", pid + " · " + model),
636
732
  { title: t("禁用模型"), danger: true, ok: t("禁用") });
637
733
  if (!yes) return;
638
734
  try {
@@ -657,7 +753,7 @@ async function healthDisableModel(pid, model) {
657
753
  async function healthDisableProvider(pid) {
658
754
  if (!pid) { closeModal(); return; }
659
755
  const yes = await uiConfirm(
660
- t("确认禁用该厂商?禁用后链降级自动跳过它,恢复后可在 CLI 绑定页重新启用。"),
756
+ t("确认禁用该厂商?禁用后链降级自动跳过它,恢复后可在模型调度页重新启用。"),
661
757
  { title: t("禁用厂商"), danger: true, ok: t("禁用") });
662
758
  if (!yes) return;
663
759
  try {
@@ -783,7 +879,7 @@ async function ctrlClick() {
783
879
  function startCtrlHeartbeat() {
784
880
  setInterval(() => {
785
881
  if (S.control && S.control.mine) {
786
- api("/api/control/heartbeat", { method: "POST", body: "{}" })
882
+ api("/api/control/heartbeat", { method: "POST", body: "{}", busy: false })
787
883
  .then((d) => setControl(d.control)).catch(() => {});
788
884
  }
789
885
  }, 15000);
@@ -855,7 +951,7 @@ function renderBindings() {
855
951
  }
856
952
  bbox.innerHTML = targets.map((c) => {
857
953
  const b = (S.bindings || {})[c.id] || {};
858
- const opts = '<option value="">' + t("不绑定(用 CLI 自身的凭据与配置)") + '</option>' + bindable.map((p) => {
954
+ const opts = '<option value="">' + t("自动推荐(推荐)") + '</option>' + bindable.map((p) => {
859
955
  const adaptedOnly = p.protocol !== "anthropic" && p.protocol !== "openai";
860
956
  return '<option value="' + esc(p.id) + '"' + (b.provider_id === p.id ? " selected" : "") + ">" +
861
957
  esc(p.name) + t("(") + esc(protoLabel(p)) + (adaptedOnly ? t(" · 已适配") : "") +
@@ -879,13 +975,13 @@ function renderBindings() {
879
975
  return '<div class="card"><div class="head"><span class="name">' + esc(c.name) + "</span>" +
880
976
  '<span class="tag">' + esc(c.orch_kind) + "</span></div>" +
881
977
  '<div class="field"><label>' + t("供应商") + '</label><select id="bindprov-' + esc(c.id) + '">' + opts + "</select></div>" +
882
- '<p class="hint">' + t("绑定后编排调用会注入该供应商的 API key 与地址;不绑定则只按下方模型链传 -m 参数。") + '</p>' +
978
+ '<p class="hint">' + t("默认不需要绑定:系统会按任务类型、难度、成本与健康状态自动推荐可用模型;只有需要固定厂商、模型或降级顺序时才在这里绑定。绑定后编排调用会注入该供应商的 API key 与地址;完全未指定时保持自动调度,没有兼容可用供应商才使用 CLI 自身配置。") + '</p>' +
883
979
  bindModelBox(c, b.provider_id) + offWarn + protoWarn + chainWarn +
884
980
  '<div class="ops"><label class="toggle"><input type="checkbox" id="binddiff-' + esc(c.id) + '"' +
885
981
  (b.difficulty_routing ? " checked" : "") + '>' + t(" 按难度自动选模型(简单/困难)") + '</label>' +
886
982
  '<button class="ghost small" onclick="saveBinding(\'' + esc(c.id) + '\')">' + t("保存") + '</button></div></div>';
887
983
  }).join("") +
888
- (!targets.length ? '<p class="hint">' + t("还没有已安装且可编排的 CLI——先到「智能体管理」页安装并启用。") + '</p>' : "") +
984
+ (!targets.length ? '<p class="hint">' + t("还没有已安装且可编排的 CLI——先到「本机智能体」页安装并启用。") + '</p>' : "") +
889
985
  (nonBindable ? '<p class="hint">' + t("另有 ") + nonBindable +
890
986
  t(" 个供应商(google 等协议)仅登记,不支持注入 CLI,未出现在上面的下拉中。") + '</p>' : "");
891
987
  }
@@ -974,7 +1070,7 @@ async function batchProvOp(op) {
974
1070
  const tips = {
975
1071
  enable: t("启用所选 ") + ids.length + t(" 个供应商?"),
976
1072
  disable: t("停用所选 ") + ids.length + t(" 个供应商?\n停用后其绑定会回落为 CLI 默认;配置与模型列表都保留,可随时再启用。"),
977
- delete: t("删除所选 ") + ids.length + t(" 个供应商?\n相关 CLI 绑定会自动解绑,此操作不可撤销。"),
1073
+ delete: t("删除所选 ") + ids.length + t(" 个供应商?\n相关显式模型调度会自动解除,此操作不可撤销。"),
978
1074
  };
979
1075
  if (!await uiConfirm(tips[op] || (t("执行「") + op + t("」?")))) return;
980
1076
  try {
@@ -1687,7 +1783,7 @@ async function openImportDialog() {
1687
1783
  '<button class="ghost" onclick="closeModal()">' + t("取消") + '</button>');
1688
1784
  let data;
1689
1785
  try {
1690
- data = await api("/api/models/sources");
1786
+ data = await api("/api/models/sources", { busy: true });
1691
1787
  } catch (e) {
1692
1788
  $("modal-body").innerHTML = '<div class="msg bad">' + t("扫描失败:") + esc(e.message) + "</div>";
1693
1789
  return;
@@ -1780,11 +1876,15 @@ async function doImport() {
1780
1876
 
1781
1877
  async function saveBinding(id) {
1782
1878
  const st = bindSelById(id);
1879
+ const provSel = $("bindprov-" + id);
1783
1880
  // 主供应商跟链首走:模型链是唯一真源,链首非空时供应商下拉必须与之一致
1784
1881
  // (下拉是旧状态时把旧值发上去,后端「显式指定」就会盖回停用的旧供应商)。
1785
- const headP = st.chain.length ? st.chain[0].p : "";
1786
- const provSel = $("bindprov-" + id);
1787
- if (provSel && provSel.value !== (headP || "")) provSel.value = headP || "";
1882
+ // 空链时保留下拉选择:这是“只锁定供应商,模型按该供应商默认/难度选”的
1883
+ // 合法显式覆盖,不能重置成自动模式。
1884
+ const headP = st.chain.length ? st.chain[0].p : (provSel ? provSel.value : "");
1885
+ if (st.chain.length && provSel && provSel.value !== (headP || "")) {
1886
+ provSel.value = headP || "";
1887
+ }
1788
1888
  try {
1789
1889
  await api("/api/models/binding", { method: "POST", body: JSON.stringify({
1790
1890
  agent_id: id, provider_id: provSel ? provSel.value : "",
@@ -1926,7 +2026,8 @@ async function createTask() {
1926
2026
  msg.textContent = t("目标有点简短,先问几个问题…");
1927
2027
  try {
1928
2028
  const cq = await api("/api/tasks/clarify", {
1929
- method: "POST", body: JSON.stringify({ goal: payload.goal, type: payload.type }) });
2029
+ method: "POST", timeout: 75000,
2030
+ body: JSON.stringify({ goal: payload.goal, type: payload.type }) });
1930
2031
  const qs = (cq && cq.questions) || [];
1931
2032
  if (qs.length) {
1932
2033
  S.clarifyDone = true; // 本轮已采访;再点发送直接创建
@@ -1984,7 +2085,8 @@ async function createTask() {
1984
2085
  if (critics.length) payload.critics = critics;
1985
2086
  }
1986
2087
  try {
1987
- const r = await api("/api/tasks", { method: "POST", body: JSON.stringify(payload) });
2088
+ const r = await api("/api/tasks", { method: "POST", timeout: 30000,
2089
+ body: JSON.stringify(payload) });
1988
2090
  msg.textContent = t("已创建,跳转运行页…");
1989
2091
  // 先把新任务刷进 state 再跳:chatEngineIsDirect 靠 S.state.tasks 判引擎,
1990
2092
  // 不刷的话对话页签不会就绪,自动选卡落不到「对话」
@@ -2580,7 +2682,7 @@ async function retryTask(id) {
2580
2682
  async function continueSerial(id) {
2581
2683
  let info;
2582
2684
  try {
2583
- info = await api("/api/tasks/" + encodeURIComponent(id) + "/continue-info");
2685
+ info = await api("/api/tasks/" + encodeURIComponent(id) + "/continue-info", { busy: true });
2584
2686
  } catch (e) { toast(t("无法续写:") + e.message, true); return; }
2585
2687
  if (!info || !info.can) {
2586
2688
  toast(t("无法续写:") + ((info && info.reason) || t("当前状态不支持")), true);
@@ -3260,7 +3362,7 @@ function drawTaskDetail(key, runs) {
3260
3362
  // 取消收尾把僵尸步骤落成「已取消」时要立即重画,不等条数变化;
3261
3363
  // 作品信息状态入签名:后台一键生成 running→done 要立刻反映到成果区面板
3262
3364
  const bmTask = ((S.state || {}).tasks || []).find((x) => x.id === key);
3263
- // 自动续跑退避相位入签名:预定入队时刻过了之后 chip 要从「将于 HH:MM」翻回「排队中」
3365
+ // 自动续跑退避相位入签名:预定启动时刻过了之后 chip 翻为「正在启动」
3264
3366
  const lr0 = runs[0] || {};
3265
3367
  const resumePending = (lr0.resume_enqueue_at &&
3266
3368
  Date.now() < Date.parse(String(lr0.resume_enqueue_at).replace(" ", "T"))) ? 1 : 0;
@@ -3274,8 +3376,8 @@ function drawTaskDetail(key, runs) {
3274
3376
  const ordered = runs.slice(); // 详情按最近运行优先,方便排查
3275
3377
  const totalSteps = runs.reduce((a, r) => a + (r.steps || []).length, 0);
3276
3378
  const active = runs.some((r) => r.status === "running" || r.status === "queued");
3277
- // 活跃态细分真实状态:排队里还分「等并发」和「自动续跑退避(预定 HH:MM 入队)」,
3278
- // 后者在 chip 上写明下一轮何时起跑,别让 5 分钟退避窗口看起来像卡死(Z.ai 误伤案)
3379
+ // 活跃态细分真实状态:queued 仅用于自动续跑退避或创建到起跑的瞬时状态;
3380
+ // 前者在 chip 上写明下一轮何时起跑,避免退避窗口看起来像卡死。
3279
3381
  const activeRun0 = runs.find((r) => r.status === "running" || r.status === "queued");
3280
3382
  const st = activeRun0 ? activeRun0.status : latest.status;
3281
3383
  const resumeIn = (st === "queued" && resumePending)
@@ -3285,7 +3387,7 @@ function drawTaskDetail(key, runs) {
3285
3387
  chip.className = "chip " + st;
3286
3388
  chip.textContent = resumeIn
3287
3389
  ? t("将于 ") + resumeIn + t(" 自动续跑(第 ") + (Number(latest.auto_resumes) || 0) + t(" 次)")
3288
- : ({ queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st);
3390
+ : ({ queued: t("正在启动"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st);
3289
3391
  const bpt = $("btn-pause");
3290
3392
  if (bpt) bpt.classList.add("hidden");
3291
3393
  $("btn-delete").classList.add("hidden");
@@ -6057,12 +6159,17 @@ function chatResultHTML(run, res) {
6057
6159
  if (res.turns) meta.push(res.turns + " " + t("轮对话"));
6058
6160
  if (res.duration_s != null) meta.push(chatDurTxt(res.duration_s));
6059
6161
  const files = res.files || [];
6060
- const chips = files.map((f) =>
6061
- '<a class="file-chip chat-file-open" href="' + urlAuth("/api/runs/" + encodeURIComponent(run.id) + "/file?name=" +
6062
- encodeURIComponent(f.name)) + '" target="_blank" rel="noopener" title="' +
6063
- esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(run.id) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
6064
- '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
6065
- '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>").join("");
6162
+ const chips = files.map((f) => {
6163
+ const fUrl = urlAuth("/api/runs/" + encodeURIComponent(run.id) + "/file?name=" + encodeURIComponent(f.name));
6164
+ return '<div class="file-chip has-actions">' +
6165
+ '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
6166
+ '<span class="p" title="' + esc(f.name + " · " + fmtSize(f.size)) + '">' + esc(f.name) + "</span>" +
6167
+ '<span class="fsz">' + fmtSize(f.size) + "</span>" +
6168
+ '<span class="fbtns">' +
6169
+ '<button type="button" class="chip-btn" title="' + esc(t("预览")) + '" onclick="artPopup(\'' + esc(run.id) + "', '" + esc(f.name) + "', " + (Number(f.size) || 0) + ')"><svg class="ico" aria-hidden="true"><use href="#i-file-text"/></svg></button>' +
6170
+ '<a class="chip-btn" href="' + fUrl + '" download="' + esc(f.name) + '" title="' + esc(t("下载")) + '"><svg class="ico" aria-hidden="true"><use href="#i-arrow-left"/></svg></a>' +
6171
+ "</span></div>";
6172
+ }).join("");
6066
6173
  return '<div class="chat-row">' +
6067
6174
  '<span class="chat-avatar" aria-hidden="true"><svg class="ico"><use href="' + icon + '"></use></svg></span>' +
6068
6175
  '<div class="chat-result' + (ok ? "" : bad ? " bad" : " off") + '">' +
@@ -6367,6 +6474,7 @@ function bindVoiceInput() {
6367
6474
  if (e.results[i].isFinal) text += e.results[i][0].transcript;
6368
6475
  }
6369
6476
  if (text) {
6477
+ text = voiceFixup(text);
6370
6478
  ta.value = (ta.value ? ta.value.replace(/\s+$/, "") + " " : "") + text;
6371
6479
  ta.dispatchEvent(new Event("input", { bubbles: true }));
6372
6480
  }
@@ -6378,6 +6486,24 @@ function bindVoiceInput() {
6378
6486
  });
6379
6487
  }
6380
6488
 
6489
+ /* 语音识别结果纠偏(借鉴 lexicon 个人词典):设置里维护的专有名词表
6490
+ * (localStorage orch.voice_terms,一行一个「错误→正确」或直接「术语」),
6491
+ * 识别结果做逐词替换。人名/书名/项目名是识别高频错位,词表是最小可用解。 */
6492
+ function voiceFixup(text) {
6493
+ let terms = [];
6494
+ try {
6495
+ const raw = localStorage.getItem("orch.voice_terms") || "";
6496
+ terms = raw.split("\n").map((l) => l.trim()).filter(Boolean);
6497
+ } catch (e) {}
6498
+ for (const t of terms) {
6499
+ const m = t.split("→");
6500
+ if (m.length === 2 && m[0].trim() && m[1].trim()) {
6501
+ text = text.split(m[0].trim()).join(m[1].trim());
6502
+ }
6503
+ }
6504
+ return text;
6505
+ }
6506
+
6381
6507
  /* ---------------------------------------------------------- 外观:皮肤 + 明暗(换肤) */
6382
6508
  /* 调色板全部在 style.css(html[data-skin="X"],每套含夜间/日间两版变量);这里只放顺序
6383
6509
  * 与文案。卡片预览色块用 skinPalette 从 CSS 变量实时取值,不在 JS 里重复写色值——
@@ -6785,7 +6911,7 @@ function updateChip(c) {
6785
6911
  async function autoCheckUpdates() {
6786
6912
  if (Date.now() - (S.updateCheckAt || 0) < 5 * 60 * 1000) return;
6787
6913
  S.updateCheckAt = Date.now();
6788
- try { await api("/api/catalog/check-updates", { method: "POST" }); } catch (e) { /* 忽略 */ }
6914
+ try { await api("/api/catalog/check-updates", { method: "POST", busy: false }); } catch (e) { /* 忽略 */ }
6789
6915
  poll();
6790
6916
  }
6791
6917
 
@@ -6877,12 +7003,11 @@ function chainDeadReasons(c, chain) {
6877
7003
  return dead;
6878
7004
  }
6879
7005
 
6880
- /* 单条绑定链的推荐修复动作:需要改返回新链,不动返回 null。
6881
- * 空链 → 预填推荐;整条死透(或只有链首且已死)→ 重推荐;链首死但链内还有
6882
- * 活的备选 新链首插最前(原降级序保留,推荐项已在链内则升首去重)。 */
6883
- function bindRepairAction(c, st) {
7006
+ /* 单条显式绑定链的推荐修复动作:需要改返回新链,不动返回 null。
7007
+ * 空链代表默认自动调度,不能悄悄写成持久绑定;已有链失效时才推荐替代链。 */
7008
+ function bindRepairAction(c, st, includeEmpty) {
6884
7009
  const rec = recommendFor(c);
6885
- if (!st.chain.length) return rec ? [rec] : null;
7010
+ if (!st.chain.length) return includeEmpty && rec ? [rec] : null;
6886
7011
  const dead = chainDeadReasons(c, st.chain);
6887
7012
  const allDead = dead.every((d) => d);
6888
7013
  const headDead = dead[0] !== ""; // 空 p = CLI 默认凭据,是有意配置不算死
@@ -6897,7 +7022,7 @@ function autoBindAll() {
6897
7022
  let filled = 0, skipped = 0, noProv = 0, refilled = 0;
6898
7023
  for (const c of targets) {
6899
7024
  const st = bindSelById(c.id);
6900
- const act = bindRepairAction(c, st);
7025
+ const act = bindRepairAction(c, st, true);
6901
7026
  if (!act) {
6902
7027
  // 没动它:分清「健康链无需推荐」和「没有可推荐的」两种落空
6903
7028
  if (!st.chain.length) { noProv++; continue; }
@@ -6926,9 +7051,8 @@ function autoBindAll() {
6926
7051
  }
6927
7052
  }
6928
7053
 
6929
- /* 厂商/模型停用·启用·删除后自动补一次推荐绑定(2026-09-17 用户拍板):
6930
- * 绑定链——空链预填、死链重推荐、死链首插新首,与「一键推荐绑定」同规则,
6931
- * 但直接落盘(自动场景没有人工确认环节);目录页——空默认模型直填。
7054
+ /* 厂商/模型停用·启用·删除后自动修复显式绑定:
7055
+ * 空链保持自动调度,不写入绑定;已有链失效时才直接落盘替代链。
6932
7056
  * 没有合适的推荐就保持原样,什么都不绑。绑定页上用户手改中的草稿(dirty)
6933
7057
  * 不碰;一处都没改成静默返回,不打扰停用/启用的操作反馈。 */
6934
7058
  let _autoRebindRunning = false, _autoRebindAgain = false;
@@ -6941,11 +7065,11 @@ async function autoRebindSoon() {
6941
7065
  for (const c of (S.catalog || []).filter((x) => x.installed && x.orch_kind)) {
6942
7066
  const st = bindSelById(c.id);
6943
7067
  if (st.dirty) continue; // 用户手改中,不覆盖草稿
6944
- const act = bindRepairAction(c, st);
7068
+ const act = bindRepairAction(c, st, false);
6945
7069
  if (!act) continue;
6946
7070
  const b = (S.bindings || {})[c.id] || {};
6947
7071
  try {
6948
- await api("/api/models/binding", { method: "POST", body: JSON.stringify({
7072
+ await api("/api/models/binding", { method: "POST", busy: false, body: JSON.stringify({
6949
7073
  agent_id: c.id, provider_id: act[0].p,
6950
7074
  chain: act.map((x) => ({ provider_id: x.p, model: x.m })),
6951
7075
  difficulty_routing: !!b.difficulty_routing }) });
@@ -6953,17 +7077,6 @@ async function autoRebindSoon() {
6953
7077
  fixed++;
6954
7078
  } catch (e) { /* 单条失败不打断,等下次变更再补 */ }
6955
7079
  }
6956
- for (const c of (S.catalog || []).filter((x) =>
6957
- x.installed && x.config_writable && !fmtModel(x.model))) {
6958
- const rec = recommendFor(c);
6959
- if (!rec) continue;
6960
- try {
6961
- const r = await api("/api/catalog/" + encodeURIComponent(c.id) + "/model",
6962
- { method: "POST", body: JSON.stringify({ model: rec.m }) });
6963
- c.model = r.model || rec.m;
6964
- fixed++;
6965
- } catch (e) { /* 同上 */ }
6966
- }
6967
7080
  if (fixed) {
6968
7081
  S.catSig = null; S.bindSig = null;
6969
7082
  render();
@@ -7024,7 +7137,7 @@ function bindModelBox(c, provId) {
7024
7137
  }).join("")
7025
7138
  : '<span class="hint">' + t("未设置") + (provId
7026
7139
  ? t("(按供应商/难度自动解析——供应商协议不匹配或被停用时解析为空,相关步骤将判失败)")
7027
- : t("(用 CLI 默认模型——不会注入任何供应商凭据)")) + "</span>";
7140
+ : t("(自动推荐厂商与模型;没有兼容可用供应商时才使用 CLI 自身配置)")) + "</span>";
7028
7141
  return '<div class="field"><label>' + t("运行时模型链(跨厂商,最多 ") + MAX_ORCH_MODELS + t(" 条)") + "</label>" +
7029
7142
  '<div class="orch-row">' + chips +
7030
7143
  '<button class="ghost small" onclick="bindToggle(\'' + esc(c.id) + '\')">' +
@@ -7827,11 +7940,11 @@ function autoTplPace(tp) {
7827
7940
  });
7828
7941
  }
7829
7942
 
7830
- /* last_status → 徽章:queued=正常灰、error=红「拉起失败」、missed=黄「已错过」 */
7943
+ /* last_status → 徽章:started=正常灰、error=红「拉起失败」、missed=黄「已错过」 */
7831
7944
  function autoStatusTag(tsk) {
7832
7945
  if (tsk.last_status === "error") return '<span class="tag auto-tag-err">' + t("拉起失败") + "</span>";
7833
7946
  if (tsk.last_status === "missed") return '<span class="tag auto-tag-miss">' + t("已错过") + "</span>";
7834
- if (tsk.last_status === "queued") return '<span class="tag">' + t("正常") + "</span>";
7947
+ if (tsk.last_status === "started" || tsk.last_status === "queued") return '<span class="tag">' + t("正常") + "</span>";
7835
7948
  return "";
7836
7949
  }
7837
7950
 
@@ -8490,7 +8603,11 @@ function mkrDebounce() {
8490
8603
 
8491
8604
  /* 拉一页:reset=清空已累积列表(首进/搜索/换来源/刷新后)。 */
8492
8605
  async function mkrLoadPage(reset) {
8493
- if (S.mkrLoading) return;
8606
+ // 翻页保持单飞;搜索/换来源/刷新属于重置请求,可以抢占旧请求。
8607
+ // 旧响应回来时由 requestKey 丢弃,不能覆盖用户刚选的新条件。
8608
+ if (S.mkrLoading && !reset) return;
8609
+ const requestKey = (S.mkrRequestKey || 0) + 1;
8610
+ S.mkrRequestKey = requestKey;
8494
8611
  if (reset) { S.mkrAll = []; S.mkrOffset = 0; }
8495
8612
  const q = (($("mkr-search") || {}).value || "").trim();
8496
8613
  const srcSel = $("mkr-source");
@@ -8500,6 +8617,7 @@ async function mkrLoadPage(reset) {
8500
8617
  S.mkrLoading = true;
8501
8618
  let data = null;
8502
8619
  try { data = await api(url); } catch (e) { data = null; }
8620
+ if (requestKey !== S.mkrRequestKey) return;
8503
8621
  S.mkrLoading = false;
8504
8622
  if (!data) { S.marketRemote = null; S.mkrAll = null; renderMarketRemote(); return; }
8505
8623
  S.marketRemote = data;
@@ -8587,7 +8705,8 @@ async function mkrRefresh() {
8587
8705
  const old = btn.textContent;
8588
8706
  btn.textContent = t("拉取中…");
8589
8707
  try {
8590
- const r = await api("/api/market/remote/refresh", { method: "POST", body: "{}" });
8708
+ const r = await api("/api/market/remote/refresh", {
8709
+ method: "POST", timeout: 120000, body: "{}" });
8591
8710
  const bad = (r.refresh || []).filter((x) => !x.ok);
8592
8711
  if (bad.length) toast(t("部分来源拉取失败:") + bad.map((x) => x.error || x.id).join(t(";")), true);
8593
8712
  else toast(t("拉取成功"));
@@ -8878,7 +8997,7 @@ function suLastUpgradeRun() {
8878
8997
 
8879
8998
  async function loadSelfupdate(force) {
8880
8999
  try {
8881
- SU = await api("/api/selfupdate" + (force ? "?force=1" : ""));
9000
+ SU = await api("/api/selfupdate" + (force ? "?force=1" : ""), { busy: !!force });
8882
9001
  } catch (e) { SU = null; }
8883
9002
  renderSu();
8884
9003
  maybeWhatsnew();
@@ -8920,13 +9039,13 @@ function helpChapterBody(id) {
8920
9039
  if (id === "quickstart") {
8921
9040
  return '<div class="welcome-steps">' +
8922
9041
  '<div class="wstep"><b>1</b><span>' + t("添加模型供应商:设置 → 模型接入,填入 API Key") + '</span></div>' +
8923
- '<div class="wstep"><b>2</b><span>' + t("绑定 CLI 智能体:点「一键推荐绑定」自动配好") + '</span></div>' +
9042
+ '<div class="wstep"><b>2</b><span>' + t("启用本机智能体:运行时默认自动推荐模型") + '</span></div>' +
8924
9043
  '<div class="wstep"><b>3</b><span>' + t("新建任务:选工作目录、写目标,蜂群开工") + '</span></div>' +
8925
9044
  '</div>' +
8926
9045
  '<p class="help-note">' + t("任务跑起来后,详情页能看到步骤、蜂巢、成果文件与 Git 版本;「直接执行」类任务还能像聊天一样边跑边追加消息。") + '</p>' +
8927
9046
  '<div class="welcome-acts">' +
8928
9047
  '<button class="wl-btn primary" onclick="welcomeGo(\'models\')"><span>' + t("开始配置模型") + '</span><svg class="ico" aria-hidden="true"><use href="#i-chevron-r"></use></svg></button>' +
8929
- '<button class="wl-btn" onclick="welcomeGo(\'bindings\')"><span>' + t("绑定 CLI 智能体") + '</span><svg class="ico" aria-hidden="true"><use href="#i-chevron-r"></use></svg></button>' +
9048
+ '<button class="wl-btn" onclick="welcomeGo(\'bindings\')"><span>' + t("模型调度(可选)") + '</span><svg class="ico" aria-hidden="true"><use href="#i-chevron-r"></use></svg></button>' +
8930
9049
  '<button class="wl-btn ghost" onclick="welcomeClose()"><span>' + t("先跳过,直接体验") + '</span></button>' +
8931
9050
  '</div>';
8932
9051
  }
@@ -8935,8 +9054,8 @@ function helpChapterBody(id) {
8935
9054
  '<p class="help-p">' + t("「设置 → 模型接入 → 添加供应商」:填名称、API Key、接口地址(一般用默认)。保存后点「获取模型列表」自动拉取该厂商的模型;网关不提供列表接口时直接手工填模型名即可。同一厂商可配多把 Key,按顺序轮换,某把欠费自动切下一把。") + '</p>' +
8936
9055
  '<h3 class="help-h3">' + t("第二步:认识协议徽章") + '</h3>' +
8937
9056
  '<p class="help-p">' + t("新供应商默认自动识别协议,保存后在后台实测支持哪些调用形态,结果以徽章标在卡片上。带「· chat」表示该模型只适合站内直连对话;codex 这类只讲 responses 协议的 CLI 用不了它,绑定页会自动剔除,不用自己排查。") + '</p>' +
8938
- '<h3 class="help-h3">' + t("第三步:绑定 CLI 智能体") + '</h3>' +
8939
- '<p class="help-p">' + t("「设置CLI 绑定」给每个智能体配一条模型链,运行时按顺序故障转移。「一键推荐绑定」只填空缺,不覆盖手工配置;删除或停用厂商后链会自动补绑。") + '</p>' +
9057
+ '<h3 class="help-h3">' + t("第三步:启用本机智能体") + '</h3>' +
9058
+ '<p class="help-p">' + t("无需预先绑定模型。CodeBee 默认按任务类型、难度、成本和健康状态自动推荐;需要固定厂商、模型或降级顺序时,再到「设置 模型调度(可选)」指定。") + '</p>' +
8940
9059
  '<h3 class="help-h3">' + t("报错速查") + '</h3>' +
8941
9060
  '<ul class="help-list">' +
8942
9061
  '<li><b>401</b><span>' + t("Key 无效或过期——检查或更换 Key。") + '</span></li>' +
@@ -9001,7 +9120,7 @@ function helpChapterBody(id) {
9001
9120
  return '<div class="help-qa"><b>' + t("点「获取模型列表」报 404?") + '</b><p>' + t("部分网关不提供模型列表接口,属正常现象;只要能正常对话就不用管,模型名手工填即可。") + '</p></div>' +
9002
9121
  '<div class="help-qa"><b>' + t("报 503 / 无可用渠道?") + '</b><p>' + t("该模型当前没有可用渠道,最常见是余额耗尽。查一下余额或换个模型;同一厂商配了多把 Key 会自动轮换。") + '</p></div>' +
9003
9122
  '<div class="help-qa"><b>' + t("「冷却中」是什么意思?") + '</b><p>' + t("一把 Key 连续失败会被暂停 30 分钟,防止反复撞墙烧钱,到期自动恢复;也可以在密钥旁手动重置。") + '</p></div>' +
9004
- '<div class="help-qa"><b>' + t("任务一直显示「排队」?") + '</b><p>' + t("本地默认最多 6 个任务同时跑,排满就排队。排队时可以打开详情看前面还有几个;个别卡死的任务,看门狗会自动清理补队。") + '</p></div>' +
9123
+ '<div class="help-qa"><b>' + t("任务一直显示「排队」?") + '</b><p>' + t("新任务默认立即运行,不会排队;达到并发保护上限时本次会直接失败并提示稍后重试。只有自动续跑退避会显示预定时间;历史版本遗留的排队记录会由恢复机制立即接管或收口。") + '</p></div>' +
9005
9124
  '<div class="help-qa"><b>' + t("状态里写着「将于 HH:MM 自动续跑」?") + '</b><p>' + t("这一步失败了,正在退避等待自动重试,到点会接着跑,不需要手动干预;等不及也可以在详情页手动重试。") + '</p></div>' +
9006
9125
  '<div class="help-qa"><b>' + t("生成的文件在哪?") + '</b><p>' + t("写作类任务的章节、封面、报告都落在任务的运行目录,详情页「成果」页签可浏览和预览。代码类任务则在仓库的任务分支上改代码,详情页「版本」里审阅后再合并。") + '</p></div>' +
9007
9126
  '<div class="help-qa"><b>' + t("忘了令牌 / 手机打不开页面?") + '</b><p>' + t("服务启动日志里有带令牌的完整访问地址;远程设备必须用带令牌的 URL 打开(或在令牌门里输入一次),否则会一直要求授权。") + '</p></div>' +
@@ -9194,6 +9313,7 @@ async function savePetSkin(skin) {
9194
9313
  async function exportDiagBundle() {
9195
9314
  const b = $("diag-export");
9196
9315
  if (b) b.disabled = true;
9316
+ const busyToken = requestBusyStart({ method: "GET", busy: true, busyElement: b });
9197
9317
  try {
9198
9318
  const r = await fetch("/api/diagnostics/bundle", { headers: authHeaders() });
9199
9319
  if (!r.ok) throw new Error("HTTP " + r.status);
@@ -9209,6 +9329,7 @@ async function exportDiagBundle() {
9209
9329
  } catch (e) {
9210
9330
  toast(t("诊断包导出失败:") + e.message, true);
9211
9331
  } finally {
9332
+ requestBusyEnd(busyToken);
9212
9333
  if (b) b.disabled = false;
9213
9334
  }
9214
9335
  }
@@ -9216,7 +9337,7 @@ async function exportDiagBundle() {
9216
9337
  /* 一键反馈 Issue:拉脱敏摘要 → 预填 GitHub Issue 新建页(用户亲手提交,不自动回传) */
9217
9338
  async function reportIssue() {
9218
9339
  try {
9219
- const s = await api("/api/diagnostics/issue-summary");
9340
+ const s = await api("/api/diagnostics/issue-summary", { busy: true });
9220
9341
  const url = "https://github.com/Vercel-By-WXP/CodeBee/issues/new" +
9221
9342
  "?title=" + encodeURIComponent(s.title || "") +
9222
9343
  "&body=" + encodeURIComponent(s.body || "");
@@ -9304,7 +9425,7 @@ async function suStartupCheck() {
9304
9425
  }
9305
9426
 
9306
9427
  /* ---------------------------------------------------------- 页签 & 初始化 */
9307
- const TAB_TITLES = { tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", usage: "用量统计", agents: "智能体管理", models: "模型接入", bindings: "CLI 绑定", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
9428
+ const TAB_TITLES = { tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
9308
9429
  const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 设置导航里的子页(__phone 是弹框,不算)
9309
9430
 
9310
9431
  function tabTitle(name) {
@@ -10062,7 +10183,7 @@ function renderBackupPreview(pv) {
10062
10183
  rows.push('<span class="bad">' + esc(t("以下外部目录不在备份里,需自行拷贝:"))
10063
10184
  + esc(pv.external_workdirs.join("、")) + "</span>");
10064
10185
  if ((pv.busy_runs || []).length)
10065
- rows.push('<span class="bad">' + esc(t("有任务正在运行或排队,先等它们结束再导入")) + "</span>");
10186
+ rows.push('<span class="bad">' + esc(t("有任务正在运行或等待自动续跑,先等它们结束再导入")) + "</span>");
10066
10187
  el.innerHTML = rows.map((s) => "<div>" + s + "</div>").join("");
10067
10188
  if (!(pv.busy_runs || []).length) $("bi-apply").classList.remove("hidden");
10068
10189
  }
@@ -10746,7 +10867,7 @@ function cmdkOps() {
10746
10867
  { icon: "i-blocks", label: t("插件市场"), run: () => switchTab("market") },
10747
10868
  { icon: "i-book", label: t("经验库"), run: () => switchTab("skills") },
10748
10869
  { icon: "i-sigma", label: t("知识库"), run: () => switchTab("knowledge") },
10749
- { icon: "i-cpu", label: t("智能体管理"), run: () => switchTab("agents") },
10870
+ { icon: "i-cpu", label: t("本机智能体"), run: () => switchTab("agents") },
10750
10871
  { icon: "i-chart", label: t("用量统计"), run: () => switchTab("usage") },
10751
10872
  { icon: "i-gear", label: t("设置"), run: () => enterSettings() },
10752
10873
  { icon: "i-bee", label: t("帮助中心"), run: () => welcomeOpen() },