// 软件作业面板:右侧工作区列表、游标分页、活动数与终态未读通知。 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"; import { mqPhone, setMobileView, setRightPanelTab } from "./layout.js"; import { openSoftwarePreviewGallery } from "./preview.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 loadingMore = false; let initialized = false; let loadError = ""; let taskJobs = []; let taskJobsTaskId = ""; let taskJobsLoadSeq = 0; const unreadTerminal = new Set(); 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-list")) return; initialized = true; $("software-job-refresh").onclick = refreshSoftwareJobs; const mobileResults = $("mobile-software-results"); if (mobileResults) { mobileResults.onclick = () => { setRightPanelTab("jobs"); setMobileView("mv-right", "jobs"); }; } $("software-job-list").addEventListener("scroll", onListScroll); document.addEventListener("software-job-submitted", refreshSoftwareJobs); document.addEventListener("software-job-submitted", () => { if (state.taskId) void refreshTaskSoftwareResults(state.taskId); }); document.addEventListener("task-selection-changed", (event) => { void refreshTaskSoftwareResults(event.detail?.taskId || ""); }); document.addEventListener("right-panel-changed", (event) => { if (event.detail?.tab === "jobs") { unreadTerminal.clear(); render(); } }); refreshSoftwareJobs(); void refreshTaskSoftwareResults(state.taskId || ""); } async function refreshTaskSoftwareResults(taskId) { const seq = ++taskJobsLoadSeq; taskJobsTaskId = taskId || ""; if (!taskJobsTaskId) { taskJobs = []; renderTaskResultRail(); return; } try { const params = new URLSearchParams({ task_id: taskJobsTaskId, limit: "50" }); const data = await api("GET", `/v1/software-jobs?${params}`); if (seq !== taskJobsLoadSeq || state.taskId !== taskId) return; taskJobs = data.results || []; } catch (_) { if (seq !== taskJobsLoadSeq) return; taskJobs = []; } renderTaskResultRail(); } function mergeTaskJobs(currentPage) { const taskId = state.taskId || ""; if (!taskId || taskJobsTaskId !== taskId) return; const updates = new Map( (currentPage || []).filter((job) => job.task_id === taskId).map((job) => [job.job_id, job]), ); if (!updates.size) return; const seen = new Set(); taskJobs = taskJobs.map((job) => { const updated = updates.get(job.job_id); if (updated) seen.add(job.job_id); return updated || job; }); for (const [jobId, job] of updates) { if (!seen.has(jobId)) taskJobs.unshift(job); } taskJobs.sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || ""))); renderTaskResultRail(); } function taskResultLabel(job) { const summary = job.request_summary || {}; return summary.title || summary.display_name || job.capability || "专业软件任务"; } function taskResultMeta(job) { const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest.length : 0; if (job.status === "succeeded") { return previews ? `${previews} 个预览 · 点击查看` : "已完成 · 点击查看"; } if (job.status === "failed") return "执行失败 · 点击查看详情"; if (job.status === "cancelled") return "已取消 · 点击查看详情"; return `${statusLabel[job.status] || "处理中"}${job.progress ? ` · ${job.progress}%` : ""}`; } function renderTaskResultRail() { const rail = $("software-result-rail"); const mobileResults = $("mobile-software-results"); if (!rail) return; if (!state.taskId || taskJobsTaskId !== state.taskId || !taskJobs.length) { rail.innerHTML = ""; rail.style.display = "none"; if (mobileResults) mobileResults.hidden = true; return; } rail.innerHTML = taskJobs.map((job) => ` `).join(""); rail.style.display = "flex"; if (mobileResults) { mobileResults.hidden = false; mobileResults.textContent = `软件结果 ${taskJobs.length}`; } rail.querySelectorAll("[data-result-job-id]").forEach((button) => { button.onclick = () => void openTaskResult(button.dataset.resultJobId); }); } async function openTaskResult(jobId) { const job = taskJobs.find((item) => item.job_id === jobId) || jobs.find((item) => item.job_id === jobId); if (!job) return; const previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : []; if (job.status === "succeeded" && previews.length) { try { await openSoftwarePreviewGallery(taskResultLabel(job), previews); } catch (error) { message(error.message || "预览打开失败", "error"); } return; } if (job.status === "succeeded" && !job.workspace_id) { await openSoftwareJobResults(job.task_id, job.output_dir || ""); setRightPanelTab("files"); if (mqPhone.matches) setMobileView("mv-right", "files"); return; } setRightPanelTab("jobs"); if (mqPhone.matches) setMobileView("mv-right", "jobs"); requestAnimationFrame(() => { const card = document.querySelector( `#software-job-list [data-job-id="${CSS.escape(jobId)}"]`, ); if (card) card.scrollIntoView({ block: "center", behavior: "smooth" }); }); } 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()); loadError = ""; 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) ) { const reloadMessages = job.completion_action === "analyze" && job.followup_status === "completed"; void refreshCurrentTaskAfterSoftwareJob(job.task_id, reloadMessages); } 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))]; mergeTaskJobs(next); if (!hadLoadedMore) nextCursor = data.next_cursor; render(); } catch (_) { loadError = "作业列表加载失败,请点击刷新重试"; render(); } 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 render() { const active = jobs.filter((job) => ACTIVE.has(job.status)); const latest = jobs[0]; const unreadJob = jobs.find((job) => unreadTerminal.has(job.job_id)); const entryDot = $("software-job-entry-dot"); entryDot.className = "sj-entry-dot"; if (unreadJob?.status === "failed") entryDot.classList.add("failed"); else if (unreadJob) entryDot.classList.add("succeeded"); else if (active.length) entryDot.classList.add("active"); for (const id of ["software-job-badge", "software-job-mobile-badge"]) { const badge = $(id); badge.hidden = active.length === 0; badge.textContent = active.length ? String(active.length) : ""; } $("mv-tab-jobs").classList.toggle("has-unread", unreadTerminal.size > 0); $("mv-tab-jobs").classList.toggle("has-unread-failed", unreadJob?.status === "failed"); $("software-job-count").textContent = active.length ? `${active.length} 个进行中 · ${statusLabel[active[0].status]}` : (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无作业"); renderList(); renderTaskResultRail(); } function renderList() { const list = $("software-job-list"); const errorNotice = loadError ? `
${escapeHtml(loadError)}
` : ""; list.innerHTML = errorNotice + (jobs.length ? jobs.map(jobCard).join("") + (nextCursor ? `
${loadingMore ? "正在加载…" : "继续向下滚动加载"}
` : "") : (loadError ? "" : '
暂无专业软件任务
')); 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 previews = Array.isArray(job.preview_manifest) ? job.preview_manifest : []; const formats = (summary.formats || []).filter(Boolean).join("、"); const elapsed = elapsedText(job); const shortId = String(job.job_id || "").slice(0, 8); const sourceJobId = String(job.source_job_id || ""); const workspaceRelation = sourceJobId ? `续接自 Job ${sourceJobId.slice(0, 8)}` : (job.workspace_id ? "新建工程" : "独立任务"); 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)}` : ""} 工程关系:${escapeHtml(workspaceRelation)} ${job.status === "succeeded" ? `${job.workspace_id ? `预览:${previews.length} 个 · 工程保存在节点` : `输出:${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)}
来源 Job
${sourceJobId ? `${escapeHtml(sourceJobId)}` : "—(新建工程)"}
Capability
${escapeHtml(job.capability)}
${job.workspace_id ? "Workspace ID" : "输出目录"}
${escapeHtml(job.workspace_id || 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") { const selected = jobs.find((item) => item.job_id === jobId); if (selected?.workspace_id) { const previews = Array.isArray(selected.preview_manifest) ? selected.preview_manifest : []; if (previews.length) { try { await openSoftwarePreviewGallery(taskResultLabel(selected), previews); } catch (error) { message(error.message || "预览打开失败", "error"); } } else { await selectTask(taskId); } } else { await openSoftwareJobResults(taskId, selected?.output_dir || ""); setRightPanelTab("files"); if (mqPhone.matches) setMobileView("mv-right", "files"); } 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"); refreshSoftwareJobs(); } catch (error) { button.disabled = false; message(error.message || "启动分析失败", "error"); } return; } if (taskId) await selectTask(taskId); } function notifyTerminal(job) { const ok = job.status === "succeeded"; const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败"); const summary = job.request_summary || {}; if (!document.body.classList.contains("right-tab-jobs")) unreadTerminal.add(job.job_id); message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000); if (state.taskId === job.task_id) void refreshTaskSoftwareResults(job.task_id); void refreshCurrentTaskAfterSoftwareJob(job.task_id, false); }