// 专业软件任务中心:右栏固定摘要、抽屉列表、游标分页和终态通知。
import { api } from "./api.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { escapeHtml } from "./format.js";
import {
openSoftwareJobResults,
refreshCurrentTaskAfterSoftwareJob,
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 PAGE_SIZE = 20;
const POLL_ACTIVE_MS = 4000;
const POLL_IDLE_MS = 30000;
let timer = null;
let known = new Map();
let jobs = [];
let nextCursor = null;
let expanded = false;
let loadingMore = false;
let initialized = false;
const statusLabel = {
queued: "等待计算节点", offered: "正在分配节点", dispatched: "节点已接收",
running: "正在执行", disconnected: "节点连接中断", cancelling: "正在停止",
succeeded: "已完成", failed: "失败", cancelled: "已取消",
};
const stageLabel = {
accepted: "节点已接收", downloading_inputs: "正在下载输入文件", ready_to_run: "准备软件环境",
software_running: "专业软件正在执行", uploading_outputs: "正在上传结果",
cancel_requested: "停止请求已发送", terminal: "任务已结束",
};
const icons = {
activity: '',
analyze: '',
cancel: '',
cancelled: '',
clock: '',
failed: '',
open: '',
copy: '',
pending: '',
running: '',
succeeded: '',
};
function statusIcon(status) {
if (status === "succeeded") return icons.succeeded;
if (status === "failed") return icons.failed;
if (status === "cancelled") return icons.cancelled;
if (status === "queued" || status === "dispatched") return icons.pending;
return icons.running;
}
export function initSoftwareJobs() {
if (initialized || !$("software-job-center")) return;
initialized = true;
$("software-job-toggle").onclick = openDrawer;
$("software-job-close").onclick = closeDrawer;
$("software-job-backdrop").onclick = closeDrawer;
$("software-job-list").addEventListener("scroll", onListScroll);
document.addEventListener("software-job-submitted", refreshSoftwareJobs);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && expanded) closeDrawer();
});
refreshSoftwareJobs();
}
function pagePath(cursor = null) {
const params = new URLSearchParams({ limit: String(PAGE_SIZE) });
if (cursor) {
params.set("before_created_at", cursor.created_at);
params.set("before_job_id", cursor.job_id);
}
return `/v1/software-jobs?${params}`;
}
export async function refreshSoftwareJobs() {
if (!state.token) return;
try {
const data = await api("GET", pagePath());
const next = data.results || [];
next.forEach((job) => {
const previous = known.get(job.job_id);
if (previous && ACTIVE.has(previous.status) && TERMINAL.has(job.status)) {
notifyTerminal(job);
} else if (
previous
&& previous.followup_status !== job.followup_status
&& ["running", "completed", "failed"].includes(job.followup_status)
) {
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
}
if (
job.followup_status === "running"
&& state.taskId === job.task_id
&& !state.liveRuns.has(job.task_id)
) {
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
}
known.set(job.job_id, {
status: job.status,
followup_status: job.followup_status,
});
});
const previousJobs = jobs;
const hadLoadedMore = previousJobs.length > PAGE_SIZE;
const firstPageIds = new Set(next.map((job) => job.job_id));
jobs = [...next, ...previousJobs.filter((job) => !firstPageIds.has(job.job_id))];
if (!hadLoadedMore) nextCursor = data.next_cursor;
render();
} catch (_) { /* 后台刷新失败静默,下轮恢复 */ }
schedule();
}
async function loadMore() {
if (loadingMore || !nextCursor) return;
loadingMore = true;
renderList();
try {
const data = await api("GET", pagePath(nextCursor));
const ids = new Set(jobs.map((job) => job.job_id));
jobs.push(...(data.results || []).filter((job) => !ids.has(job.job_id)));
nextCursor = data.next_cursor;
} catch (error) {
message(error.message || "加载专业软件任务失败", "error");
} finally {
loadingMore = false;
renderList();
}
}
function schedule() {
if (timer) clearTimeout(timer);
const delay = jobs.some((job) => (
ACTIVE.has(job.status) || ["pending", "running"].includes(job.followup_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 openDrawer() {
expanded = true;
render();
}
function closeDrawer() {
expanded = false;
render();
}
function render() {
const active = jobs.filter((job) => ACTIVE.has(job.status));
const latest = jobs[0];
const entryDot = $("software-job-entry-dot");
entryDot.className = "sj-entry-dot";
if (active.length) entryDot.classList.add("active");
else if (latest?.status === "succeeded") entryDot.classList.add("succeeded");
else if (latest?.status === "failed") entryDot.classList.add("failed");
$("software-job-count").textContent = active.length
? `${active.length} 个进行中 · ${statusLabel[active[0].status]}`
: (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无任务");
$("software-job-panel").classList.toggle("open", expanded);
$("software-job-panel").setAttribute("aria-hidden", String(!expanded));
$("software-job-backdrop").classList.toggle("open", expanded);
if (expanded) renderList();
}
function renderList() {
const list = $("software-job-list");
list.innerHTML = jobs.length
? jobs.map(jobCard).join("") + (nextCursor ? `
${loadingMore ? "正在加载…" : "继续向下滚动加载"}
` : "")
: '暂无专业软件任务
';
list.querySelectorAll("[data-job-action]").forEach((button) => {
button.onclick = (event) => handleAction(event, button);
});
}
function onListScroll(event) {
const list = event.currentTarget;
if (list.scrollHeight - list.scrollTop - list.clientHeight < 160) loadMore();
}
function jobRuntimeText(job) {
const runtime = job.execution_runtime || {};
const software = runtime.software === "OriginPro"
|| job.capability?.startsWith("origin.") ? "Origin" : (runtime.software || "软件");
const parts = [
runtime.node_version ? `Node ${runtime.node_version}` : "",
runtime.adapter_version ? `${software} Adapter ${runtime.adapter_version}` : "",
runtime.software_version ? `${software} ${runtime.software_version}` : "",
].filter(Boolean);
if (parts.length) return parts.join(" · ");
return ["queued", "offered"].includes(job.status)
? "执行版本待节点确认"
: "执行版本未记录";
}
function fmtDate(value) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
}
function elapsedText(job) {
const start = job.started_at || job.created_at;
const end = job.terminal_at;
if (!start || !end) return "";
const seconds = Math.max(0, Math.round((new Date(end) - new Date(start)) / 1000));
if (seconds < 60) return `${seconds} 秒`;
const minutes = Math.floor(seconds / 60);
return seconds % 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${minutes} 分钟`;
}
function followupLabel(job) {
if (job.completion_action === "analyze") {
if (job.followup_status === "pending") return "等待自动分析";
if (job.followup_status === "running") return "正在自动分析";
if (job.followup_status === "completed") return "已报告并分析";
if (job.followup_status === "failed") return "自动分析失败";
return "报告并分析";
}
return job.followup_status === "completed" ? "已报告结果" : "仅报告结果";
}
function jobActions(job, active) {
const task = escapeHtml(job.task_id);
if (active) return `
${job.status !== "cancelling" ? `` : ""}`;
if (job.status !== "succeeded") {
return ``;
}
const view = ``;
if (["pending", "running"].includes(job.followup_status) && job.completion_action === "analyze") {
const label = job.followup_status === "pending" ? "等待分析" : "分析中";
return `${view}`;
}
if (job.completion_action === "analyze" && job.followup_status === "completed") {
return `${view}`;
}
const label = job.followup_status === "failed" ? "重试分析" : "深入分析";
return `${view}`;
}
function jobCard(job) {
const summary = job.request_summary || {};
const inputs = Array.isArray(job.input) ? job.input : [];
const allInputNames = inputs.map((item) => item.filename).filter(Boolean);
const inputNames = allInputNames.slice(0, 2).join("、")
+ (allInputNames.length > 2 ? ` 等 ${allInputNames.length} 个文件` : "");
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);
const artifacts = Array.isArray(job.artifact_manifest)
? job.artifact_manifest.filter((item) => item && item.artifact_id) : [];
const formats = (summary.formats || []).filter(Boolean).join("、");
const elapsed = elapsedText(job);
const shortId = String(job.job_id || "").slice(0, 8);
return `
${escapeHtml(summary.display_name || job.capability)}
${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}
${escapeHtml(job.task_name || "未命名对话")}Job ${escapeHtml(shortId)}
${icons.activity}${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}
${active ? `
` : ""}
${inputNames ? `输入:${escapeHtml(inputNames)}` : ""}
${job.status === "succeeded" ? `输出:${artifacts.length} 个文件${formats ? ` · ${escapeHtml(formats)}` : ""}` : ""}
完成方式:${escapeHtml(followupLabel(job))}
${active ? `创建于 ${escapeHtml(fmtDate(job.created_at))}` : `${elapsed ? `耗时 ${escapeHtml(elapsed)} · ` : ""}${job.terminal_at ? `完成于 ${escapeHtml(fmtDate(job.terminal_at))}` : `创建于 ${escapeHtml(fmtDate(job.created_at))}`}`}
任务详情
- Job ID
${escapeHtml(job.job_id)}
- Capability
${escapeHtml(job.capability)}
- 输出目录
${escapeHtml(job.output_dir || "—")}
- 执行版本
- ${escapeHtml(jobRuntimeText(job))}
- 时间
- 创建 ${escapeHtml(fmtDate(job.created_at))}
开始 ${escapeHtml(fmtDate(job.started_at))}
结束 ${escapeHtml(fmtDate(job.terminal_at))}
${jobActions(job, active)}
`;
}
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 === "copy") {
try {
await navigator.clipboard.writeText(jobId);
message("Job ID 已复制", "success");
} catch (_) { message("复制失败,请手动复制", "error"); }
return;
}
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 (action === "results") {
await openSoftwareJobResults(taskId, jobs.find((item) => item.job_id === jobId)?.output_dir || "");
closeDrawer();
return;
}
if (action === "analyze") {
button.disabled = true;
try {
await api("POST", `/v1/software-jobs/${jobId}/analyze`);
if (taskId) await selectTask(taskId);
await refreshCurrentTaskAfterSoftwareJob(taskId);
message("已排队分析结果", "success");
closeDrawer();
refreshSoftwareJobs();
} catch (error) {
button.disabled = false;
message(error.message || "启动分析失败", "error");
}
return;
}
if (taskId) await selectTask(taskId);
closeDrawer();
}
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);
void refreshCurrentTaskAfterSoftwareJob(job.task_id);
}