zcbot/web/static/js/software_jobs.js

145 lines
6.1 KiB
JavaScript

// 专业软件任务中心:用户级轮询、跨对话状态、取消与终态通知。
import { api } from "./api.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { escapeHtml } from "./format.js";
import { selectTask } from "./chat.js";
import { dialogConfirm, message } from "./dialog.js";
const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]);
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
const POLL_ACTIVE_MS = 4000;
const POLL_IDLE_MS = 30000;
let timer = null;
let known = new Map();
let jobs = [];
let expanded = false;
let initialized = false;
const statusLabel = {
queued: "等待计算节点", offered: "正在分配节点", dispatched: "节点已接收",
running: "正在执行", disconnected: "节点连接中断", cancelling: "正在停止",
succeeded: "已完成", failed: "失败", cancelled: "已取消",
};
const stageLabel = {
accepted: "节点已接收", waiting_input: "正在下载输入文件", ready_to_run: "准备软件环境",
origin_running: "Origin 正在生成图表", uploading_outputs: "正在上传结果",
cancel_requested: "停止请求已发送", terminal: "任务已结束",
};
export function initSoftwareJobs() {
if (initialized || !$("software-job-center")) return;
initialized = true;
$("software-job-toggle").onclick = () => { expanded = !expanded; render(); };
document.addEventListener("click", (event) => {
const center = $("software-job-center");
if (expanded && center && !center.contains(event.target)) { expanded = false; render(); }
});
refreshSoftwareJobs();
}
export async function refreshSoftwareJobs() {
if (!state.token) return;
try {
const data = await api("GET", "/v1/software-jobs?limit=50");
const next = data.results || [];
next.forEach((job) => {
const previous = known.get(job.job_id);
if (previous && ACTIVE.has(previous) && TERMINAL.has(job.status)) notifyTerminal(job);
known.set(job.job_id, job.status);
});
jobs = next;
render();
} catch (_) { /* 后台刷新失败静默,下轮恢复 */ }
schedule();
}
function schedule() {
if (timer) clearTimeout(timer);
const delay = jobs.some((job) => ACTIVE.has(job.status)) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
timer = setTimeout(refreshSoftwareJobs, document.hidden ? Math.max(delay, 30000) : delay);
}
document.addEventListener("visibilitychange", () => {
if (!document.hidden && state.token) refreshSoftwareJobs(); else schedule();
});
function render() {
const center = $("software-job-center");
const panel = $("software-job-panel");
const active = jobs.filter((job) => ACTIVE.has(job.status));
const recent = jobs.slice(0, 10);
center.classList.toggle("show", active.length > 0 || recent.length > 0);
center.classList.toggle("expanded", expanded);
$("software-job-count").textContent = active.length ? `${active.length} 运行中` : `${jobs.length}`;
panel.hidden = !expanded;
if (!expanded) return;
panel.innerHTML = recent.length
? recent.map(jobCard).join("")
: '<div class="sj-empty">暂无专业软件任务</div>';
panel.querySelectorAll("[data-job-action]").forEach((button) => {
button.onclick = (event) => handleAction(event, button);
});
}
function jobCard(job) {
const summary = job.request_summary || {};
const input = job.input || {};
const active = ACTIVE.has(job.status);
const progress = Math.max(0, Math.min(100, Number(job.progress || 0)));
const detail = stageLabel[job.stage] || statusLabel[job.status] || "正在处理";
const error = job.error && (job.error.detail || job.error.code);
return `<article class="sj-card ${escapeHtml(job.status)}" data-job-id="${escapeHtml(job.job_id)}">
<div class="sj-title"><strong>${escapeHtml(summary.display_name || job.capability)}</strong>
<span>${escapeHtml(statusLabel[job.status] || job.status)}</span></div>
<div class="sj-sub">${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</div>
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
<div class="sj-meta">${escapeHtml(job.task_name || "未命名对话")}${input.filename ? ` · ${escapeHtml(input.filename)}` : ""}</div>
<div class="sj-actions">
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">打开对话</button>
${job.status === "succeeded" ? `<button class="small primary" data-job-action="analyze" data-task-id="${escapeHtml(job.task_id)}">分析结果</button>` : ""}
${active && job.status !== "cancelling" ? '<button class="small danger" data-job-action="cancel">停止</button>' : ""}
</div>
</article>`;
}
async function handleAction(event, button) {
event.stopPropagation();
const card = button.closest("[data-job-id]");
const jobId = card.dataset.jobId;
const action = button.dataset.jobAction;
if (action === "cancel") {
if (!await dialogConfirm({
title: "停止专业软件任务",
message: "确定停止这个任务?已经产生但尚未发布的中间输出可能不会保留。",
okText: "停止", danger: true,
})) return;
button.disabled = true;
try { await api("POST", `/v1/software-jobs/${jobId}/cancel`); }
catch (error) { message(error.message || "停止失败", "error"); }
refreshSoftwareJobs();
return;
}
const taskId = button.dataset.taskId;
if (taskId) await selectTask(taskId);
expanded = false;
render();
if (action === "analyze") {
setTimeout(() => {
const input = $("chat-input");
if (!input) return;
input.value = `请分析专业软件任务 ${jobId} 的结果,结合输出图表和输入数据总结主要结论。`;
input.focus();
input.dispatchEvent(new Event("input", { bubbles: true }));
}, 400);
}
}
function notifyTerminal(job) {
const ok = job.status === "succeeded";
const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败");
const summary = job.request_summary || {};
message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000);
expanded = true;
}