codebee 0.1.10 → 0.1.11

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
@@ -271,6 +271,14 @@ function qsAuth() {
271
271
  return s ? "?" + s : "";
272
272
  }
273
273
 
274
+ /* 下载/预览等导航类 URL 同样带不了请求头:令牌拼进 query(已有 query 用 &,
275
+ * 没有用 ?;已带 token 的 URL 原样返回)。本机会话令牌为空则原样返回。 */
276
+ function urlAuth(u) {
277
+ const t = localStorage.getItem("orch.token");
278
+ if (!t || String(u).includes("token=")) return u;
279
+ return u + (String(u).includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t);
280
+ }
281
+
274
282
  /* ---------------------------------------------------------- 工具 */
275
283
  async function api(path, opts) {
276
284
  opts = opts || {};
@@ -395,6 +403,20 @@ function statusChip(st) {
395
403
  return '<span class="chip ' + esc(st) + '">' + (zh[st] || esc(st)) + "</span>";
396
404
  }
397
405
 
406
+ /* 运行状态文案(传 run 对象):退避窗口内的续跑副本写明「将于 HH:MM 自动
407
+ * 续跑」,别让 5 分钟等待看起来像卡死/资源排队(2026-09-18 重写任务误判案)。
408
+ * 到点后翻回「排队中」——页面轮询重渲染时 Date.now() 已过预定时刻。 */
409
+ function runStatusText(run) {
410
+ const st = String((run && run.status) || "");
411
+ if (st === "queued" && run && run.resume_enqueue_at) {
412
+ const at = Date.parse(String(run.resume_enqueue_at).replace(" ", "T"));
413
+ if (!isNaN(at) && Date.now() < at)
414
+ return t("将于 ") + String(run.resume_enqueue_at).slice(11, 16) + t(" 自动续跑");
415
+ }
416
+ return { queued: t("排队中"), running: t("运行中"), done: t("完成"),
417
+ failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") }[st] || st;
418
+ }
419
+
398
420
  /* 运行错误来源徽标:错误文案以「超时」打头(runner 统一格式)时标 TIMEOUT,
399
421
  * 和普通失败一眼区分;运行状态仍是 failed,不动状态机 */
400
422
  function errTag(err) {
@@ -1758,6 +1780,40 @@ function renderImplSelects() {
1758
1780
  }
1759
1781
  }
1760
1782
 
1783
+ /* 需求拷问采访卡(借鉴 grill-me 一次一问的折中:一卡多问、芯片点选):
1784
+ * 点选项把「问题+所选」追加进目标框,凑齐后用户补一句即可发送;「跳过」直接创建 */
1785
+ function renderClarify(questions, goal, resetSubmit) {
1786
+ const box = $("clarify-box");
1787
+ if (!box) { resetSubmit(); return; }
1788
+ box.classList.remove("hidden");
1789
+ box.innerHTML =
1790
+ '<div class="cl-head"><svg class="ico" aria-hidden="true"><use href="#i-chat"></use></svg>' +
1791
+ '<b>' + esc(t("先对齐几个点,再做更准")) + "</b>" +
1792
+ '<button type="button" class="ghost small" id="clarify-skip">' + esc(t("跳过,直接做")) + "</button></div>" +
1793
+ questions.map((it, i) =>
1794
+ '<div class="cl-q"><div class="cl-qtext">' + (i + 1) + ". " + esc(it.q) + "</div>" +
1795
+ '<div class="cl-opts">' + (it.options || []).map((o) =>
1796
+ '<button type="button" class="cl-opt" data-q="' + esc(it.q) + '" data-o="' + esc(o) + '">' +
1797
+ esc(o) + "</button>").join("") + "</div></div>").join("");
1798
+ box.querySelectorAll(".cl-opt").forEach((b) => b.addEventListener("click", () => {
1799
+ const ta = $("f-goal");
1800
+ if (!ta) return;
1801
+ const line = it0safe(b.dataset.q, b.dataset.o);
1802
+ ta.value = ta.value.trim() + (ta.value.includes(line) ? "" : (ta.value ? "\n" : "") + line);
1803
+ b.classList.add("picked");
1804
+ b.disabled = true;
1805
+ }));
1806
+ function it0safe(q, o) { return q + ":" + o; }
1807
+ const skip = $("clarify-skip");
1808
+ if (skip) skip.addEventListener("click", () => {
1809
+ box.classList.add("hidden");
1810
+ box.innerHTML = "";
1811
+ S.clarifyDone = true; // 跳过 = 本轮不再采访
1812
+ $("btn-create").click();
1813
+ });
1814
+ $("f-goal").focus();
1815
+ }
1816
+
1761
1817
  async function createTask() {
1762
1818
  if (S.creatingTask) return;
1763
1819
  S.creatingTask = true;
@@ -1790,6 +1846,22 @@ async function createTask() {
1790
1846
  resetSubmit();
1791
1847
  return;
1792
1848
  }
1849
+ // 需求拷问闸(借鉴 grill-me):goal 很短且没写背景时先问 1-3 个澄清问题;
1850
+ // 采访失败/无问题照常创建——是增强不是闸门
1851
+ if (payload.goal.length < 12 && !payload.context && !S.clarifyDone) {
1852
+ msg.textContent = t("目标有点简短,先问几个问题…");
1853
+ try {
1854
+ const cq = await api("/api/tasks/clarify", {
1855
+ method: "POST", body: JSON.stringify({ goal: payload.goal, type: payload.type }) });
1856
+ const qs = (cq && cq.questions) || [];
1857
+ if (qs.length) {
1858
+ S.clarifyDone = true; // 本轮已采访;再点发送直接创建
1859
+ renderClarify(qs, payload.goal, resetSubmit);
1860
+ return;
1861
+ }
1862
+ } catch (e) { /* 澄清失败照常创建 */ }
1863
+ }
1864
+ S.clarifyDone = false;
1793
1865
  if (!payload.workdir) delete payload.workdir; // 留空 → 服务端用「默认保存路径」(编排设置可改)
1794
1866
  else unhideSideDir(payload.workdir); // 在已移除的目录新建任务 → 自动恢复显示
1795
1867
  if (payload.mode === "manual") payload.implementer = $("f-impl").value;
@@ -2684,7 +2756,8 @@ function renderRunList() {
2684
2756
  ' title="' + (can ? t("勾选以批量删除") : t("运行中的记录不可删除,请先取消")) + '"' +
2685
2757
  ' onclick="event.stopPropagation()" onchange="toggleRunSel(\'' + esc(r.id) + '\', this.checked)">' +
2686
2758
  runKindTag(r.kind) +
2687
- '<span class="name">' + esc(r.title) + "</span>" + statusChip(r.status) +
2759
+ '<span class="name">' + esc(r.title) + "</span>" +
2760
+ '<span class="chip ' + esc(r.status || "") + '">' + esc(runStatusText(r)) + "</span>" +
2688
2761
  '<span class="time">' + esc(r.created_at) + "</span>" +
2689
2762
  '<button class="danger small" title="' + t("删除该记录") + '" onclick="event.stopPropagation(); deleteRun(\'' + esc(r.id) + '\')">' + t("删除") + '</button></div>' +
2690
2763
  '<div class="desc">' + esc(t(r.summary || r.error || (r.steps ? r.steps.length + t(" 个步骤") : ""))) + "</div></div>";
@@ -3073,10 +3146,9 @@ function renderTaskDetail() {
3073
3146
  setEditRetry(tk ? tk.id : "", tk ? tk.status : "", !!tk);
3074
3147
  const isTask = ((S.state || {}).tasks || []).some((t2) => t2.id === key);
3075
3148
  if (isTask) {
3076
- fetch("/api/tasks/" + encodeURIComponent(key) + "/runs")
3077
- .then((r) => r.json())
3149
+ api("/api/tasks/" + encodeURIComponent(key) + "/runs")
3078
3150
  .then((d) => { if (S.detailTaskKey === key) drawTaskDetail(key, d.runs || []); })
3079
- .catch((e) => { /* 拉取失败静默,等下一轮轮询重试 */ });
3151
+ .catch((e) => { /* 拉取失败静默,等下一轮轮询重试;401 已由 api() 弹令牌门 */ });
3080
3152
  return;
3081
3153
  }
3082
3154
  drawTaskDetail(key, ((S.state || {}).runs || []).filter((r) => (r.task_id || r.id) === key));
@@ -3146,13 +3218,12 @@ function drawTaskDetail(key, runs) {
3146
3218
  '<span class="stat">tokens <b>' + sum("tokens") + "</b></span>" +
3147
3219
  (latest.error ? '<span class="stat err">' + errTag(latest.error) + esc(latest.error.slice(0, 200)) + "</span>" : "");
3148
3220
  $("rd-plan").classList.add("hidden");
3149
- const statusTxt = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") };
3150
3221
  let html = "";
3151
3222
  ordered.forEach((r, i) => {
3152
3223
  const runNo = runs.length - i;
3153
3224
  html += '<div class="run-sep"><span class="rs-i">' + t("第 ") + runNo + "/" + runs.length + t(" 次运行") + '</span>' +
3154
3225
  '<span class="rs-t">' + esc(String(r.created_at || "").slice(5, 16)) + "</span>" +
3155
- '<span class="chip ' + esc(r.status || "") + '">' + (statusTxt[r.status] || r.status || "") + "</span>" +
3226
+ '<span class="chip ' + esc(r.status || "") + '">' + esc(runStatusText(r)) + "</span>" +
3156
3227
  (r.error ? '<span class="rs-err" title="' + esc(r.error.slice(0, 200)) + '">' + esc(r.error.slice(0, 60)) + "</span>" : "") +
3157
3228
  "</div>";
3158
3229
  html += (r.steps || []).slice().reverse().map((s) =>
@@ -3173,19 +3244,35 @@ function drawTaskDetail(key, runs) {
3173
3244
  if (currentLog && !stepsMatch(runs, currentLog)) window.rdLogClose();
3174
3245
  // 报告:最新一次 run 的(缓存,轮询重画不重复拉取)
3175
3246
  const drawReport = async () => {
3247
+ const hint = (msg) => {
3248
+ if (S.detailTaskKey !== key) return; // 拉取期间切了详情:别糊到别的任务上
3249
+ $("rd-report").innerHTML = '<div class="hint">' + esc(msg) + "</div>";
3250
+ };
3176
3251
  if (S.taskReport && S.taskReport.key === key && S.taskReport.runId === latest.id) {
3177
3252
  $("rd-report").innerHTML = S.taskReport.html; return;
3178
3253
  }
3179
3254
  if (latest.status === "done" || latest.report) {
3180
3255
  try {
3181
- const md = await fetch("/api/runs/" + encodeURIComponent(latest.id) + "/report").then((r) => r.text());
3256
+ const r = await fetch("/api/runs/" + encodeURIComponent(latest.id) + "/report",
3257
+ { headers: authHeaders() });
3258
+ if (!r.ok) throw new Error("HTTP " + r.status);
3259
+ const md = await r.text();
3260
+ if (!md.trim()) throw new Error("empty");
3182
3261
  const html2 = md2html(md);
3183
3262
  if (S.detailTaskKey !== key) return; // 拉取期间切了详情:过期报告不落缓存/不落盘
3184
3263
  S.taskReport = { key, runId: latest.id, html: html2 };
3185
3264
  $("rd-report").innerHTML = html2;
3186
- } catch (e) { /* 报告拉取失败静默,保留旧内容 */ }
3265
+ } catch (e) {
3266
+ // 拉取失败不再静默留白(手机端「报告空空」的来源之一):给可见提示,
3267
+ // 不落缓存,下一轮轮询签名变化时自动重试
3268
+ hint(t("报告读取失败(") + ((e || {}).message || t("网络异常")) + t("),稍后自动重试"));
3269
+ }
3270
+ } else if (latest.status === "running" || latest.status === "queued") {
3271
+ hint(t("本次运行进行中:报告结束后在这里生成,实时进度看「步骤」页签"));
3187
3272
  } else {
3188
- $("rd-report").innerHTML = '<div class="hint">' + t("运行结束后生成") + '</div>';
3273
+ const st = { failed: "失败", cancelled: "已取消", timeout: "超时" }[latest.status] || latest.status || "";
3274
+ hint(t("本次运行没有生成报告") + (st ? t("(状态:") + st + t(")") : "") +
3275
+ t(";各步骤日志在「步骤」页签"));
3189
3276
  }
3190
3277
  };
3191
3278
  drawReport();
@@ -3481,7 +3568,7 @@ async function renderRunDetail() {
3481
3568
  $("rd-title").textContent = run.title;
3482
3569
  const chip = $("rd-status");
3483
3570
  chip.className = "chip " + run.status;
3484
- chip.textContent = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[run.status] || run.status;
3571
+ chip.textContent = runStatusText(run);
3485
3572
  const active = run.status === "queued" || run.status === "running";
3486
3573
  $("btn-cancel").classList.toggle("hidden", !active);
3487
3574
  S.cancelTargetRunId = active ? run.id : null;
@@ -3528,13 +3615,29 @@ async function renderRunDetail() {
3528
3615
  applyStepFocus();
3529
3616
  // 报告
3530
3617
  if (run.status === "done" || run.report) {
3531
- const md = await fetch("/api/runs/" + encodeURIComponent(id) + "/report").then((r) => r.text());
3532
- // 报告拉取期间切了详情:过期报告/成品不落盘(A 的报告写进 B 详情的报告区)
3533
- if (S.detailRunId !== id || S.detailTaskKey) return;
3534
- $("rd-report").innerHTML = md2html(md);
3618
+ try {
3619
+ const r = await fetch("/api/runs/" + encodeURIComponent(id) + "/report",
3620
+ { headers: authHeaders() });
3621
+ if (!r.ok) throw new Error("HTTP " + r.status);
3622
+ const md = await r.text();
3623
+ if (!md.trim()) throw new Error("empty");
3624
+ // 报告拉取期间切了详情:过期报告/成品不落盘(A 的报告写进 B 详情的报告区)
3625
+ if (S.detailRunId !== id || S.detailTaskKey) return;
3626
+ $("rd-report").innerHTML = md2html(md);
3627
+ } catch (e) {
3628
+ // 拉取失败不再静默留白:给可见提示(下轮轮询重画会重试)
3629
+ if (S.detailRunId === id && !S.detailTaskKey)
3630
+ $("rd-report").innerHTML = '<div class="hint">' +
3631
+ esc(t("报告读取失败(") + ((e || {}).message || t("网络异常")) + t("),稍后自动重试")) + "</div>";
3632
+ }
3535
3633
  loadArtifacts(id);
3536
3634
  } else {
3537
- $("rd-report").innerHTML = '<div class="hint">' + t("运行结束后生成") + '</div>';
3635
+ const st = { failed: "失败", cancelled: "已取消", timeout: "超时" }[run.status] || run.status || "";
3636
+ $("rd-report").innerHTML = '<div class="hint">' +
3637
+ (run.status === "running" || run.status === "queued"
3638
+ ? esc(t("本次运行进行中:报告结束后在这里生成,实时进度看「步骤」页签"))
3639
+ : esc(t("本次运行没有生成报告") + (st ? t("(状态:") + st + t(")") : "") + t(";各步骤日志在「步骤」页签"))) +
3640
+ "</div>";
3538
3641
  }
3539
3642
  // 成品文件双入口同源:主栏「成果」分区 + 检查器成品 TAB(列表上下文),
3540
3643
  // 详情上下文检查器让位后主栏是唯一可见面
@@ -3746,6 +3849,7 @@ function renderBookMetaPanel(task) {
3746
3849
  bmStatusChip(st) + '<span class="flex1"></span>' + action + "</div>";
3747
3850
  return '<div class="bm-card st-' + esc(st || "new") + '">' + head + body +
3748
3851
  (entry.source && st === "done" ? '<div class="bm-src">' + esc(t("来源:") + entry.source) + "</div>" : "") +
3852
+ pbBlock(task, p.id) +
3749
3853
  "</div>";
3750
3854
  }).join("") + "</div>";
3751
3855
  box.classList.remove("hidden");
@@ -3758,6 +3862,7 @@ function renderBookMetaPanel(task) {
3758
3862
  badge.textContent = anyRun ? "●" : (n ? n + "/" + BOOKMETA_PLATFORMS.length : "");
3759
3863
  badge.className = "rd-badge" + (badge.textContent ? (anyRun ? " live" : "") : " hidden");
3760
3864
  }
3865
+ pbSyncState(task); // 发布状态独立于 SSE(/api/publish),节流拉取+变化重渲染
3761
3866
  }
3762
3867
 
3763
3868
  window.bmGen = async function (taskId, platform) {
@@ -3774,6 +3879,202 @@ window.bmCopyBtn = function (btn) {
3774
3879
  toast(t("已复制:") + (btn.dataset.label || ""));
3775
3880
  };
3776
3881
 
3882
+ /* ---------------- 一键发布(bookmeta 分区内每平台卡的发布行) ----------------
3883
+ * 状态独立于 SSE(/api/publish 轮询,5s 节流;waiting_login/busy 靠主轮询周期
3884
+ * 自然刷新)。Phase 1 纪律:所有提交动作 auto_submit=false——表单填好后停,
3885
+ * 提交权留给用户在浏览器窗口里人工确认(建书/发章皆是)。 */
3886
+ const PB_ST = {
3887
+ none: ["未连接", "pb-st-none"], waiting_login: ["等扫码…", "pb-st-wait"],
3888
+ connected: ["已连接", "pb-st-ok"], busy: ["操作中…", "pb-st-busy"],
3889
+ error: ["出错", "pb-st-err"],
3890
+ };
3891
+
3892
+ function pbBlock(task, platform) {
3893
+ const ps = (S.pubState && S.pubState.platforms && S.pubState.platforms[platform]) || {};
3894
+ const st = ps.status || "none";
3895
+ const [label, cls] = PB_ST[st] || PB_ST.none;
3896
+ const books = (S.pubTaskInfo && S.pubTaskInfo.books) || {};
3897
+ const book = books[platform];
3898
+ const hist = (S.pubTaskInfo && S.pubTaskInfo.history) || [];
3899
+ const nCh = hist.filter((r) => r.platform === platform && r.action === "upload_chapter" && r.ok).length;
3900
+ const busy = st === "busy";
3901
+ let btns = "";
3902
+ if (st === "none" || st === "error" || st === "waiting_login") {
3903
+ btns += '<button class="ghost" onclick="pbConnect(\'' + platform + '\')">' +
3904
+ (st === "error" ? t("重连") : t("连接平台")) + "</button>";
3905
+ } else if (st === "connected") {
3906
+ btns += '<button class="ghost" onclick="pbDisconnect(\'' + platform + '\')" title="' + esc(t("关掉该平台的浏览器窗口(登录态保留)")) + '">' + t("断开") + "</button>";
3907
+ }
3908
+ btns += '<button class="ghost pb-tool" onclick="pbProbe(\'' + platform + '\')" title="' +
3909
+ esc(t("dump 平台表单结构(校准自动填表用)")) + '">' + t("探测") + "</button>";
3910
+ if (book) {
3911
+ btns += '<span class="pb-book" title="' + esc(t("已在此平台创建的作品")) + '">' +
3912
+ esc(t("已建书:") + (book.title || "")) + "</span>";
3913
+ btns += ' <button class="primary" ' + (busy ? "disabled" : "") +
3914
+ ' onclick="pbUploadChapter(\'' + esc(task.id) + "', '" + platform + '\')">' +
3915
+ t("发一章") + (nCh ? "(已发 " + nCh + ")" : "") + "</button>";
3916
+ // 批量发布(publish/auto.py):待发清单 + 护栏 + 进度都来自 /pending 视图
3917
+ const au = (((S.pubAuto && S.pubAuto.books) || [])
3918
+ .find((b) => b.platform === platform)) || {};
3919
+ const run = (S.pubAuto && S.pubAuto.running &&
3920
+ S.pubAuto.running.platform === platform) ? S.pubAuto.running : null;
3921
+ if (run && run.status === "running") {
3922
+ btns += '<span class="pb-book">' + esc(t("自动发布中 ") + run.done + "/" + run.total) + "</span>";
3923
+ } else if (au.pending > 0) {
3924
+ btns += ' <button class="ghost" ' + (busy || !au.guard_ok ? "disabled" : "") +
3925
+ ' title="' + esc(au.guard_ok ? t("按章号顺序逐章填稿(人工模式每点一次填一章,浏览器里提交后再点发下一章)") : au.guard_reason || "") + '"' +
3926
+ ' onclick="pbPublishAll(\'' + esc(task.id) + "', '" + platform + '\')">' +
3927
+ t("发布全部待发") + "(" + au.pending + ")</button>";
3928
+ }
3929
+ if (au.pending > 0 && !au.guard_ok) {
3930
+ btns += '<div class="pb-err">' + esc(au.guard_reason || t("护栏拦截")) + "</div>";
3931
+ }
3932
+ if (run && run.status === "manual_pause") {
3933
+ // 人工模式一轮只填一章:等用户在浏览器提交后再发起(连发会导航离开
3934
+ // 未提交的编辑器丢稿)。message 是下一步指引,不是错误。
3935
+ btns += '<div class="pb-hint">' + esc(run.message || t("已填好一章,请在浏览器确认提交")) + "</div>";
3936
+ } else if (run && run.status !== "running") {
3937
+ const doneLine = run.status === "done"
3938
+ ? t("自动发布完成:") + run.done + "/" + run.total + (run.message ? "。" + run.message : "")
3939
+ : t("自动发布中断:") + (run.error || "");
3940
+ btns += '<div class="' + (run.status === "done" ? "pb-hint" : "pb-err") + '">' +
3941
+ esc(doneLine + "(" + (run.at || "") + ")") + "</div>";
3942
+ }
3943
+ } else {
3944
+ btns += '<button class="primary" ' + (busy ? "disabled" : "") +
3945
+ ' onclick="pbCreateBook(\'' + esc(task.id) + "', '" + platform + '\')">' + t("创建作品") + "</button>";
3946
+ }
3947
+ return '<div class="bm-pub">' +
3948
+ '<span class="pb-badge ' + cls + '">' + esc(t(label)) + "</span>" +
3949
+ btns +
3950
+ (st === "error" && ps.error ? '<div class="pb-err">' + esc(ps.error) + "</div>" : "") +
3951
+ '<div class="pb-chapters hidden" id="pb-ch-' + platform + '"></div>' +
3952
+ "</div>";
3953
+ }
3954
+
3955
+ let _pbTimer = 0;
3956
+ function pbSyncState(task) {
3957
+ clearTimeout(_pbTimer);
3958
+ _pbTimer = setTimeout(async () => {
3959
+ const now = Date.now();
3960
+ if (now - (S.pubFetchAt || 0) < 5000) return; // 节流:主轮询不每次都打 /api/publish
3961
+ S.pubFetchAt = now;
3962
+ let sig = "";
3963
+ try {
3964
+ const v = await api("/api/publish");
3965
+ S.pubState = v;
3966
+ sig += JSON.stringify(v.platforms || {});
3967
+ } catch (e) { return; }
3968
+ try {
3969
+ const ti = await api("/api/publish/task/" + encodeURIComponent(task.id) + "/history");
3970
+ S.pubTaskInfo = ti;
3971
+ sig += "#" + JSON.stringify(ti.books || {}) + "#" + (ti.history || []).length;
3972
+ } catch (e) { /* 任务级失败不阻塞平台状态 */ }
3973
+ try {
3974
+ // 批量发布视图(待发数/护栏/进度)——进行中时靠本节流轮询自然刷新
3975
+ const au = await api("/api/publish/task/" + encodeURIComponent(task.id) + "/pending");
3976
+ S.pubAuto = au;
3977
+ sig += "@" + JSON.stringify(au.books || {}) + "@" + JSON.stringify(au.running || {});
3978
+ } catch (e) { /* 视图缺失(老服务)不阻塞 */ }
3979
+ if (sig !== (S._pbSig || "") && !$("rd-bookmeta").classList.contains("hidden")) {
3980
+ S._pbSig = sig;
3981
+ renderBookMetaPanel(task);
3982
+ } else S._pbSig = sig;
3983
+ }, 120);
3984
+ }
3985
+
3986
+ window.pbConnect = async function (platform) {
3987
+ try {
3988
+ await api("/api/publish/" + platform + "/connect", { method: "POST", body: "{}" });
3989
+ toast(t("已打开浏览器——请在窗口里登录平台,登录后这里自动变为「已连接」"));
3990
+ } catch (e) { toast(t("连接失败:") + e.message, true); }
3991
+ S._pbSig = ""; pbKick();
3992
+ };
3993
+
3994
+ window.pbDisconnect = async function (platform) {
3995
+ try { await api("/api/publish/" + platform + "/disconnect", { method: "POST", body: "{}" }); }
3996
+ catch (e) { toast(t("断开失败:") + e.message, true); }
3997
+ S._pbSig = ""; pbKick();
3998
+ };
3999
+
4000
+ window.pbProbe = async function (platform) {
4001
+ try {
4002
+ await api("/api/publish/" + platform + "/probe", { method: "POST", body: "{}" });
4003
+ toast(t("已开始探测,结果记录在发布台账(data/publish)里"));
4004
+ } catch (e) { toast(t("探测失败:") + e.message, true); }
4005
+ S._pbSig = ""; pbKick();
4006
+ };
4007
+
4008
+ window.pbCreateBook = async function (taskId, platform) {
4009
+ if (!confirm(t("将用生成的作品信息在平台自动填建书表单。填好后会停在最后一步,由你在浏览器里人工点提交。继续?"))) return;
4010
+ try {
4011
+ await api("/api/publish/task/" + encodeURIComponent(taskId) + "/create-book",
4012
+ { method: "POST", body: JSON.stringify({ platform }) });
4013
+ toast(t("正在自动填写建书表单…(完成后请在浏览器里确认提交)"));
4014
+ } catch (e) { toast(t("建书失败:") + e.message, true); }
4015
+ S._pbSig = ""; pbKick();
4016
+ };
4017
+
4018
+ window.pbUploadChapter = async function (taskId, platform) {
4019
+ const box = $("pb-ch-" + platform);
4020
+ if (!box) return;
4021
+ if (!box.classList.contains("hidden")) { box.classList.add("hidden"); return; }
4022
+ box.classList.remove("hidden");
4023
+ box.innerHTML = '<div class="pb-loading">' + esc(t("正在列出章节文件…")) + "</div>";
4024
+ let wd = "";
4025
+ try { const tk = ((S.state || {}).tasks || []).find((x) => x.id === taskId); wd = (tk && tk.workdir) || ""; } catch (e) { /* 兜底空 */ }
4026
+ if (!wd) { box.innerHTML = '<div class="pb-err">' + esc(t("找不到任务工作目录")) + "</div>"; return; }
4027
+ let files = [];
4028
+ try {
4029
+ const r = await api("/api/dir/scan?path=" + encodeURIComponent(wd));
4030
+ files = (r.files || []).filter((f) => /\.md$/i.test(f.name || "") &&
4031
+ !/^作品信息/.test((f.name || "").split("/").pop()));
4032
+ } catch (e) {
4033
+ box.innerHTML = '<div class="pb-err">' + esc(t("列文件失败:") + e.message) + "</div>";
4034
+ return;
4035
+ }
4036
+ if (!files.length) { box.innerHTML = '<div class="pb-err">' + esc(t("工作目录里没有 .md 章稿")) + "</div>"; return; }
4037
+ files.sort((a, b) => (a.name > b.name ? 1 : -1));
4038
+ box.innerHTML = '<div class="pb-hint">' + esc(t("选一章发送(表单填好后停,人工确认提交);已成功发过的章会被台账拦下:")) + "</div>" +
4039
+ files.map((f) =>
4040
+ '<button class="ghost pb-ch-item" onclick="pbSendChapter(\'' + esc(taskId) + "', '" + platform +
4041
+ '\',\'' + esc(f.name) + '\')" title="' + esc(f.name) + '">' + esc(f.name.split("/").pop()) + "</button>"
4042
+ ).join("");
4043
+ };
4044
+
4045
+ window.pbSendChapter = async function (taskId, platform, relName) {
4046
+ if (!confirm(t("将把《" + relName + "》填进平台章节编辑器,填好后由你人工提交。继续?"))) return;
4047
+ try {
4048
+ await api("/api/publish/task/" + encodeURIComponent(taskId) + "/chapter",
4049
+ { method: "POST", body: JSON.stringify({ platform, file: relName }) });
4050
+ toast(t("正在填写章节…(完成后请在浏览器里确认提交)"));
4051
+ const box = $("pb-ch-" + platform);
4052
+ if (box) box.classList.add("hidden");
4053
+ } catch (e) { toast(t("发章失败:") + e.message, true); }
4054
+ S._pbSig = ""; pbKick();
4055
+ };
4056
+
4057
+ window.pbPublishAll = async function (taskId, platform) {
4058
+ const au = (((S.pubAuto && S.pubAuto.books) || [])
4059
+ .find((b) => b.platform === platform)) || {};
4060
+ if (!au.pending) return;
4061
+ if (!confirm(t("将从最靠前的待发章节开始填稿(共 " + au.pending +
4062
+ " 章待发)。本轮只填一章并停在表单页,由你在浏览器里确认提交;提交后再点一次即发下一章。护栏(每日上限/连续失败暂停)生效。继续?"))) return;
4063
+ try {
4064
+ await api("/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
4065
+ { method: "POST", body: JSON.stringify({ platform }) });
4066
+ toast(t("正在填第一章稿——填好后请在浏览器窗口里确认提交"));
4067
+ } catch (e) { toast(t("自动发布失败:") + e.message, true); }
4068
+ S._pbSig = ""; pbKick();
4069
+ };
4070
+
4071
+ function pbKick() {
4072
+ const key = detailSideTaskKey();
4073
+ if (!key) return;
4074
+ const tk = ((S.state || {}).tasks || []).find((x) => x.id === key);
4075
+ if (tk) { S.pubFetchAt = 0; pbSyncState(tk); }
4076
+ }
4077
+
3777
4078
  /* ---------------- 详情页任务级 side 数据(检查器让位后的主栏自给) ----------------
3778
4079
  * 检查器收进列表上下文后,任务累计统计与 git 实时状态由详情页自己轮询
3779
4080
  * /api/tasks/<id>/side(KB 级、2s 节流,与检查器同款)。拉不到(无主运行、
@@ -4413,8 +4714,8 @@ function drawInspector() {
4413
4714
  const artBox = $("insp-artifacts");
4414
4715
  if (artBox) artBox.innerHTML = arts.length
4415
4716
  ? arts.map((f) =>
4416
- '<a class="file-chip" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4417
- encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
4717
+ '<a class="file-chip" href="' + urlAuth("/api/runs/" + encodeURIComponent(runId) + "/file?name=" +
4718
+ encodeURIComponent(f.name)) + '" target="_blank" rel="noopener" title="' +
4418
4719
  esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(runId) +
4419
4720
  '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4420
4721
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
@@ -4585,6 +4886,7 @@ function _fpExt(name) { return (String(name).split(".").pop() || "").toLowerCase
4585
4886
  * 上下文 → 额外给「编辑」,改完保存回工作目录(POST /api/dir/save)。 */
4586
4887
  async function _fpPreviewUrl(url, name, size, opts) {
4587
4888
  opts = opts || {};
4889
+ url = urlAuth(url); // 预览 fetch 与下载链接都走这个 URL:远程会话在此统一补令牌
4588
4890
  const ext = _fpExt(name);
4589
4891
  _fpSaveCtx = opts.save || null;
4590
4892
  _fpRawText = ""; _fpDirty = false; _fpCopyText = null;
@@ -4780,8 +5082,8 @@ function artifactsChips(runId, files) {
4780
5082
  return files.map((f) => {
4781
5083
  const isTxt = FP_TXT.has(_fpExt(f.name)); // 文本/代码都有弹窗预览(按代码格式展示)
4782
5084
  return '<span class="fc-row">' +
4783
- '<a class="file-chip artifact-file-open" href="/api/runs/' + encodeURIComponent(runId) + "/file?name=" +
4784
- encodeURIComponent(f.name) + '" target="_blank" rel="noopener" ' +
5085
+ '<a class="file-chip artifact-file-open" href="' + urlAuth("/api/runs/" + encodeURIComponent(runId) + "/file?name=" +
5086
+ encodeURIComponent(f.name)) + '" target="_blank" rel="noopener" ' +
4785
5087
  'title="' + esc(f.name + " · " + fmtSize(f.size)) + '" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
4786
5088
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
4787
5089
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>" +
@@ -5579,8 +5881,8 @@ function chatResultHTML(run, res) {
5579
5881
  if (res.duration_s != null) meta.push(chatDurTxt(res.duration_s));
5580
5882
  const files = res.files || [];
5581
5883
  const chips = files.map((f) =>
5582
- '<a class="file-chip chat-file-open" href="/api/runs/' + encodeURIComponent(run.id) + "/file?name=" +
5583
- encodeURIComponent(f.name) + '" target="_blank" rel="noopener" title="' +
5884
+ '<a class="file-chip chat-file-open" href="' + urlAuth("/api/runs/" + encodeURIComponent(run.id) + "/file?name=" +
5885
+ encodeURIComponent(f.name)) + '" target="_blank" rel="noopener" title="' +
5584
5886
  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) + '">' +
5585
5887
  '<i class="fx">' + esc(_fpExt(f.name).slice(0, 4) || "file") + "</i>" +
5586
5888
  '<span class="p">' + esc(f.name) + "</span><i>" + fmtSize(f.size) + "</i></a>").join("");
@@ -5639,8 +5941,7 @@ function renderRunOutcome(run, runs) {
5639
5941
  const status = String(run.status || "");
5640
5942
  const active = status === "running" || status === "queued";
5641
5943
  const bad = status === "failed" || status === "cancelled" || status === "timeout";
5642
- const label = active ? (status === "queued" ? t("排队中") : t("运行中"))
5643
- : (status === "done" ? t("完成") : status === "failed" ? t("失败") : status === "cancelled" ? t("已取消") : status || t("未知状态"));
5944
+ const label = runStatusText(run);
5644
5945
  const icon = status === "done" ? "#i-check" : bad ? "#i-x" : "#i-gauge";
5645
5946
  const duration = runDurationSeconds(run);
5646
5947
  const latest = steps.slice().reverse().find((s) => s.agent_label || s.agent || s.role);
@@ -5682,9 +5983,7 @@ function renderDetailOverview(run, runs, task) {
5682
5983
  const status = String(run.status || "");
5683
5984
  const active = status === "running" || status === "queued";
5684
5985
  const bad = status === "failed" || status === "cancelled" || status === "timeout";
5685
- const statusText = active ? (status === "queued" ? t("排队中") : t("运行中"))
5686
- : status === "done" ? t("完成") : status === "failed" ? t("失败")
5687
- : status === "cancelled" ? t("已取消") : status || t("未知状态");
5986
+ const statusText = runStatusText(run);
5688
5987
  const last = steps.slice().reverse().find((s) => s.summary || s.output || s.note) || steps[steps.length - 1];
5689
5988
  const lastText = last ? String(last.summary || last.output || last.note || "").replace(/\s+/g, " ").trim().slice(0, 240) : "";
5690
5989
  const executor = run.executor || run.implementer || (last && (last.agent_label || last.agent)) || "";
package/app/ui/i18n.js CHANGED
@@ -682,6 +682,9 @@
682
682
  "推送当前分支并用 gh 创建 PR(目标分支自动取 main/master)": "Push current branch and create a PR with gh (base auto-detected: main/master)",
683
683
  "把当前分支推送到远程并用 gh 创建 PR?(目标分支自动取 main/master)": "Push current branch and create a PR with gh? (base auto-detected: main/master)",
684
684
  "把当前分支推送到远程并用 gh 创建 PR?(目标分支自动取 main/master;提交信息框内容将作为 PR 标题)": "Push current branch and create a PR with gh? (base auto-detected: main/master; the commit message box will be used as the PR title)",
685
+ "目标有点简短,先问几个问题…": "That's a bit brief — a few questions first…",
686
+ "先对齐几个点,再做更准": "Quick alignment before we build",
687
+ "跳过,直接做": "Skip, just do it",
685
688
  "PR 已创建:": "PR created: ",
686
689
  "PR 已创建": "PR created",
687
690
  "背景信息、约束、相关文件(可空)": "Background, constraints, related files (optional)",
@@ -1033,7 +1036,7 @@
1033
1036
  "当前配置不生效:请检查供应商密钥、启停状态与模型选择。": "Current config is not active: check the API key, enabled state, and model selection.",
1034
1037
  "最大并发任务数(多任务同时跑、互不打扰)": "Max concurrent tasks (run in parallel, fully isolated)",
1035
1038
  "1 · 串行": "1 · serial",
1036
- "1 = 串行排队;建议 2-4。每个任务在独立线程执行,运行数据按任务隔离。": "1 = serial queue; 2-4 recommended. Each task runs in its own thread; data is isolated per task.",
1039
+ "1 = 串行排队;任务多可开 6-12 并行。同一任务仍单飞(防双烧评审),跨任务互不打扰;排队超 2 分钟会自动补队自愈。": "1 = serial queue; open 6-12 when running many tasks. Same task stays single-flight (no double review); tasks don't block each other; anything stuck in queue over 2 minutes self-heals automatically.",
1037
1040
  "默认保存路径(新建任务未指定工作目录时使用;支持 ~)": "Default save path (used when a new task doesn't specify one; ~ supported)",
1038
1041
  "保存时把「旧默认路径下」的现有任务目录迁移到新路径(运行中的跳过;手动指定的目录不受影响)": "When saving, migrate existing task directories under the old default path to the new one (skip running tasks; manually specified paths are untouched)",
1039
1042
  "不沿用(全新开始)": "Don't resume (fresh start)",
package/app/ui/index.html CHANGED
@@ -290,6 +290,8 @@
290
290
  <div id="cmp-quick" class="cmp-quick" role="group" aria-label="常用类型" data-i18n-aria="常用类型"></div>
291
291
  <!-- 开跑前成本预估(refreshEstimate 填充;无同类样本时保持隐藏) -->
292
292
  <p class="hint cmp-estimate hidden" id="cmp-estimate"></p>
293
+ <!-- 需求拷问采访卡(renderClarify 渲染;goal 模糊时出现,跳过=直接创建) -->
294
+ <div id="clarify-box" class="clarify-box hidden"></div>
293
295
  <div class="field hidden" id="row-resume-session">
294
296
  <label data-i18n="选择会话">选择会话</label>
295
297
  <select id="f-resume-session"><option data-i18n="(加载中…)">(加载中…)</option></select>
@@ -324,7 +326,7 @@
324
326
  <div id="bible-create-field" class="field hidden">
325
327
  <label><svg class="ico" aria-hidden="true"><use href="#i-book"></use></svg><span data-i18n="初始故事圣经">初始故事圣经</span><span class="field-optional" data-i18n="可选">可选</span></label>
326
328
  <textarea id="f-bible" rows="5" placeholder="人物、世界观、主线冲突、伏笔和不能违背的设定;会在开跑前写入 story-bible.md" data-i18n-ph="人物、世界观、主线冲突、伏笔和不能违背的设定;会在开跑前写入 story-bible.md"></textarea>
327
- <p class="hint" data-i18n="连载任务会在每章起草与评审时自动注入;工作目录已有 story-bible.md 时请留空,避免覆盖已有设定。">连载任务会在每章起草与评审时自动注入;工作目录已有 story-bible.md 时请留空,避免覆盖已有设定。</p>
329
+ <p class="hint" data-i18n="连载任务会在每章起草与评审时自动注入;工作目录已有 story-bible.md 时请留空,避免覆盖已有设定。">连载任务会在每章起草与评审时自动注入;工作目录已有 story-bible.md 时请留空,避免覆盖已有设定。另可放 plot-modules.md 剧情模块库(拆文沉淀的桥段/爽点素材,每章自动注入)。</p>
328
330
  </div>
329
331
  <div class="grid-3">
330
332
  <div class="field"><label data-i18n="稿件文件(合并稿)">稿件文件(合并稿)</label><input id="f-manuscript" value="manuscript.md" placeholder="工作目录内的文件名" data-i18n-ph="工作目录内的文件名"></div>
@@ -675,10 +677,10 @@
675
677
  <h3 data-i18n="运行设置" class="sec-title">运行设置</h3>
676
678
  <div class="field"><label data-i18n="最大并发任务数(多任务同时跑、互不打扰)">最大并发任务数(多任务同时跑、互不打扰)</label>
677
679
  <div class="input-row">
678
- <input id="set-workers" type="number" min="1" max="6" value="3">
680
+ <input id="set-workers" type="number" min="1" max="12" value="6">
679
681
  <button data-i18n="保存" class="ghost small" onclick="saveSettings()">保存</button>
680
682
  </div>
681
- <p data-i18n="1 = 串行排队;建议 2-4。每个任务在独立线程执行,运行数据按任务隔离。" class="hint">1 = 串行排队;建议 2-4。每个任务在独立线程执行,运行数据按任务隔离。</p>
683
+ <p data-i18n="1 = 串行排队;任务多可开 6-12 并行。同一任务仍单飞(防双烧评审),跨任务互不打扰;排队超 2 分钟会自动补队自愈。" class="hint">1 = 串行排队;任务多可开 6-12 并行。同一任务仍单飞(防双烧评审),跨任务互不打扰;排队超 2 分钟会自动补队自愈。</p>
682
684
  </div>
683
685
  <div class="field"><label data-i18n="默认保存路径(新建任务未指定工作目录时使用;支持 ~)">默认保存路径(新建任务未指定工作目录时使用;支持 ~)</label>
684
686
  <div class="input-row">
package/app/ui/style.css CHANGED
@@ -5049,3 +5049,67 @@ body.welcome-open { overflow: hidden; }
5049
5049
  font-size: 10.5px; color: var(--muted); opacity: .9;
5050
5050
  border: 1px solid var(--border); border-radius: 999px; padding: 0 7px; line-height: 16px;
5051
5051
  }
5052
+
5053
+ /* ---------------- 一键发布:平台卡内的发布行(追加于文件尾,防并行重排) ---------------- */
5054
+ .bm-pub {
5055
+ margin: 0 18px 14px; padding: 10px 12px 8px; border-top: 1px dashed var(--border);
5056
+ display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
5057
+ }
5058
+ .pb-badge {
5059
+ font-size: 11px; line-height: 18px; padding: 0 9px; border-radius: 999px;
5060
+ border: 1px solid var(--border); color: var(--muted); white-space: nowrap;
5061
+ }
5062
+ .pb-badge.pb-st-ok { color: #1d7a3e; border-color: color-mix(in srgb, #1d7a3e 35%, transparent); }
5063
+ .pb-badge.pb-st-wait { color: #9a6b00; border-color: color-mix(in srgb, #9a6b00 35%, transparent); }
5064
+ .pb-badge.pb-st-busy { color: #1f5fbf; border-color: color-mix(in srgb, #1f5fbf 35%, transparent); }
5065
+ .pb-badge.pb-st-err { color: #c23b3b; border-color: color-mix(in srgb, #c23b3b 40%, transparent); }
5066
+ .pb-book { font-size: 12px; color: var(--muted); max-width: 220px;
5067
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5068
+ .pb-err { width: 100%; font-size: 12px; color: #c23b3b; word-break: break-all; }
5069
+ .pb-chapters { width: 100%; display: flex; flex-direction: column; gap: 4px;
5070
+ margin-top: 4px; max-height: 240px; overflow: auto; }
5071
+ .pb-hint { font-size: 11.5px; color: var(--muted); }
5072
+ .pb-ch-item { text-align: left; justify-content: flex-start; font-size: 12.5px; }
5073
+
5074
+ /* 发布行按钮规格对齐(2026-09-18 二次校准):几何探针实测卡片头部按钮
5075
+ * (重新生成)= 12.5px / 9px 圆角 / 400 字重 / 5px 12px——发布行按钮完全
5076
+ * 同规格,不再用药丸形与加重字重;主操作仅以 accent 描边区分层级。
5077
+ * 只作用于 .bm-pub 内,不动全局 button.primary/ghost。 */
5078
+ .bm-pub { gap: 6px 8px; }
5079
+ .bm-pub button {
5080
+ font-size: 12.5px; line-height: 1; padding: 5px 12px; border-radius: 9px; font-weight: 400;
5081
+ }
5082
+ .bm-pub button .ico { width: 13px; height: 13px; }
5083
+ .bm-pub button.primary {
5084
+ background: none;
5085
+ border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
5086
+ color: var(--accent); font-weight: 500; padding: 4px 13px;
5087
+ }
5088
+ .bm-pub button.primary:hover {
5089
+ filter: none; background: color-mix(in srgb, var(--accent) 12%, transparent);
5090
+ }
5091
+ .bm-pub button.primary:disabled { background: none; }
5092
+ .bm-pub button.pb-tool { /* 探测等校准工具:最弱一档 */
5093
+ color: var(--muted); border-color: transparent; background: none; padding: 5px 9px;
5094
+ }
5095
+ .bm-pub button.pb-tool:hover { color: var(--accent); background: var(--panel2); }
5096
+ .bm-pub .pb-ch-item { padding: 5px 11px; } /* 章稿列表项同规格 */
5097
+
5098
+ /* 需求拷问采访卡(借鉴 grill-me):goal 模糊时的澄清问题+选项芯片 */
5099
+ .clarify-box {
5100
+ margin-top: 10px; padding: 12px 14px; border: 1px dashed var(--border-strong);
5101
+ border-radius: 10px; background: var(--panel2); font-size: 13px;
5102
+ }
5103
+ .clarify-box.hidden { display: none; }
5104
+ .cl-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
5105
+ .cl-head .ico { width: 15px; height: 15px; color: var(--accent); }
5106
+ .cl-head b { flex: 1; }
5107
+ .cl-q { margin-top: 8px; }
5108
+ .cl-qtext { font-weight: 600; margin-bottom: 6px; }
5109
+ .cl-opts { display: flex; flex-wrap: wrap; gap: 6px; }
5110
+ .cl-opt {
5111
+ border: 1px solid var(--border); background: transparent; color: inherit;
5112
+ border-radius: 999px; padding: 4px 12px; font-size: 12.5px; cursor: pointer; opacity: .9;
5113
+ }
5114
+ .cl-opt:hover { border-color: var(--accent); color: var(--accent); opacity: 1; }
5115
+ .cl-opt.picked { border-color: var(--accent); color: var(--accent); opacity: .75; cursor: default; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {