// 专业软件任务中心:右栏固定摘要、抽屉列表、游标分页和终态通知。 import { api } from "./api.js"; import { state } from "./state.js"; import { $ } from "./dom.js"; import { escapeHtml } from "./format.js"; import { refreshCurrentTaskFiles, 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: "节点已接收", waiting_input: "正在下载输入文件", ready_to_run: "准备软件环境", origin_running: "Origin 正在生成图表", uploading_outputs: "正在上传结果", cancel_requested: "停止请求已发送", terminal: "任务已结束", }; const icons = { activity: '', analyze: '', cancel: '', cancelled: '', clock: '', failed: '', open: '', 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) && TERMINAL.has(job.status)) notifyTerminal(job); known.set(job.job_id, job.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)) ? 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 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); const opened = job.created_at; const openedText = opened ? new Date(opened).toLocaleString("zh-CN", { hour12: false }) : ""; return `
${escapeHtml(summary.display_name || job.capability)}
${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}
${icons.activity}${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}
${active ? `
` : ""}
${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${input.filename ? ` · ${escapeHtml(input.filename)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}
${job.status === "succeeded" ? `` : ""} ${active && job.status !== "cancelling" ? `` : ""}
`; } 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); closeDrawer(); 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); refreshCurrentTaskFiles(job.task_id); }