181 lines
7.3 KiB
JavaScript
181 lines
7.3 KiB
JavaScript
// 后台进程(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] 工具结果的 <details class="tool-call">。从结果文本抽 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 = `
|
|
<div>后台任务${label} · 耗时 ${fmtElapsed(p.elapsed_s || 0)}${jump ? " · 点击查看" : " · 可继续对话"}</div>
|
|
<div class="pt-cmd">${escapeHtml(p.command)}</div>`;
|
|
t.onclick = () => {
|
|
t.remove();
|
|
if (jump) selectTask(p.task_id);
|
|
};
|
|
wrap.appendChild(t);
|
|
setTimeout(() => t.remove(), 10000);
|
|
}
|