// 后台进程(bg proc,DESIGN §8.12)前端:工具卡活化 + 完成 toast + 停止按钮。 // 数据源 GET /v1/procs(用户级,纯文件系统读取)。轮询式:有 running proc 时每 5s // 一拉,否则只靠触发点刷新(登录/选 task/run 收尾/[Background] 工具结果)—— // 不走 SSE:proc 完成时刻往往没有活跃 run,SSE 通道根本不在;轮询成本忽略不计。 // // 展示沿用工具卡体验(用户反馈:别另起顶部条):[Background] 的工具结果卡本身 // 变活卡 —— .running spinner + summary 跳秒 + 停止按钮,终态定格 exit/耗时。 // 历史重渲(刷新页面)同样活化,updateCards 按最新拉取校正状态。 // toast 用户级:切到别的任务也能收到完成提示,点击跳转对应任务。 import { api } from "./api.js"; import { state } from "./state.js"; import { $ } from "./dom.js"; import { escapeHtml } from "./format.js"; import { selectTask, syncBgprocLock } from "./chat.js"; const POLL_MS = 5000; let _pollTimer = null; let _tickTimer = null; let _known = new Map(); // proc_id -> 上次见到的 status(跨拉取比对出 running→终态) let _byId = new Map(); // proc_id -> 最新 proc 对象(kill 需要 task_id / 卡片更新用) let _fetchedAt = 0; // 上次拉取时刻(秒数本地推进的基准) export async function refreshProcs() { if (!state.token) return; let data; try { data = await api("GET", "/v1/procs"); } catch (e) { return; } // 轮询失败静默 const procs = data.procs || []; procs.forEach((p) => { const prev = _known.get(p.proc_id); if (prev === "running" && p.status !== "running") notifyDone(p); _known.set(p.proc_id, p.status); _byId.set(p.proc_id, p); }); _fetchedAt = Date.now(); updateCards(); syncBgprocLock(); // 对话锁:本 task 有 running proc → composer 锁定;结束的那次拉取解锁 if (procs.some((p) => p.status === "running")) startPolling(); else stopPolling(); } // 本 task 是否有 running 后台进程(chat.js 对话锁 / Enter 拦截用) export function hasRunningProc(taskId) { if (!taskId) return false; for (const p of _byId.values()) { if (p.task_id === taskId && p.status === "running") return true; } return false; } // 终止该 task 全部 running 后台进程(composer 主按钮"停止"入口;通常只有 1 个) export async function killTaskProcs(taskId) { const running = [..._byId.values()].filter( (p) => p.task_id === taskId && p.status === "running" ); if (!running.length) return; if (!confirm("确定终止后台进程?已产生的输出会保留,进程本身无法恢复。")) return; for (const p of running) { try { await api("POST", `/v1/tasks/${p.task_id}/procs/${p.proc_id}/kill`); } catch (e) { /* kill 幂等,失败下轮轮询自会校正 */ } } refreshProcs(); } function startPolling() { if (!_pollTimer) _pollTimer = setInterval(refreshProcs, POLL_MS); if (!_tickTimer) _tickTimer = setInterval(tickElapsed, 1000); } function stopPolling() { if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } if (_tickTimer) { clearInterval(_tickTimer); _tickTimer = null; } } // ───── 工具卡活化(live 与历史重渲两个渲染点都调)───── // det = [Background] 工具结果的
。从结果文本抽 proc_id, // 打上 data-proc-id + 状态 span + 停止按钮;真实状态由 updateCards 按拉取数据校正 // (历史卡刚渲染时状态未知,先不转 spinner,refreshProcs 回来再定)。 export function decorateBgprocCard(det, txt) { if (!det || !txt || !txt.startsWith("[Background]")) return; const m = txt.match(/proc_id=(\w+)/); if (!m) return; det.classList.add("bgproc"); det.dataset.procId = m[1]; const sum = det.querySelector("summary"); if (!sum) return; const st = document.createElement("span"); st.className = "tool-elapsed bp-state"; sum.appendChild(st); const btn = document.createElement("button"); btn.type = "button"; btn.className = "small bp-kill"; btn.textContent = "停止"; btn.style.display = "none"; // 状态未知先藏,updateCards 见 running 再显 btn.onclick = (e) => { e.preventDefault(); // 别触发 details 开合 e.stopPropagation(); killProc(det.dataset.procId, btn); }; sum.appendChild(btn); const p = _byId.get(m[1]); if (p) updateCards(); // 已有该 proc 的状态(live 场景轮询先到)→ 立即定态 } async function killProc(procId, btn) { const p = _byId.get(procId); if (!p) return; if (!confirm("确定终止这个后台进程?已产生的输出会保留,进程本身无法恢复。")) return; btn.disabled = true; try { await api("POST", `/v1/tasks/${p.task_id}/procs/${procId}/kill`); } catch (e) { /* kill 幂等,失败下轮轮询自会校正 */ } refreshProcs(); } // 页面上所有 bgproc 卡按 _byId 最新状态校正(spinner / 跳秒基准 / 终态定格) function updateCards() { document.querySelectorAll(".tool-call.bgproc[data-proc-id]").forEach((det) => { const p = _byId.get(det.dataset.procId); const st = det.querySelector(".bp-state"); const btn = det.querySelector(".bp-kill"); if (!p || !st) return; if (p.status === "running") { det.classList.add("running"); st.dataset.base = p.elapsed_s; st.textContent = ` · 后台运行中 ${fmtElapsed(p.elapsed_s)}`; if (btn) btn.style.display = ""; } else { det.classList.remove("running"); delete st.dataset.base; const label = p.killed ? "已终止" : p.status === "finished" ? (p.exit_code === 0 ? "已完成" : `失败(exit ${p.exit_code})`) : "已中断"; st.textContent = ` · 后台${label} · 耗时 ${fmtElapsed(p.elapsed_s)}`; if (btn) btn.remove(); } }); } // 秒数本地推进(与工具卡跳秒同体验):elapsed = 拉取时基准 + 本地流逝 function tickElapsed() { const dt = Math.floor((Date.now() - _fetchedAt) / 1000); document.querySelectorAll(".tool-call.bgproc.running .bp-state").forEach((st) => { const base = parseInt(st.dataset.base, 10); if (!isNaN(base)) st.textContent = ` · 后台运行中 ${fmtElapsed(base + dt)}`; }); } function fmtElapsed(secs) { secs = Math.max(0, secs | 0); if (secs < 60) return secs + "s"; if (secs < 3600) return Math.floor(secs / 60) + "m" + String(secs % 60).padStart(2, "0") + "s"; return Math.floor(secs / 3600) + "h" + String(Math.floor((secs % 3600) / 60)).padStart(2, "0") + "m"; } // ───── 完成 toast(右下角,10s 自动消失;非当前 task 点击可跳转)───── function notifyDone(p) { let wrap = $("proc-toasts"); if (!wrap) { wrap = document.createElement("div"); wrap.id = "proc-toasts"; document.body.appendChild(wrap); } const ok = p.exit_code === 0; const label = p.killed ? "已终止" : (ok ? "已完成" : `失败(exit ${p.exit_code})`); const jump = p.task_id && p.task_id !== state.taskId; const t = document.createElement("div"); t.className = "proc-toast" + (ok || p.killed ? "" : " fail"); t.innerHTML = `
后台任务${label} · 耗时 ${fmtElapsed(p.elapsed_s || 0)}${jump ? " · 点击查看" : " · 可继续对话"}
${escapeHtml(p.command)}
`; t.onclick = () => { t.remove(); if (jump) selectTask(p.task_id); }; wrap.appendChild(t); setTimeout(() => t.remove(), 10000); }