zcbot/web/static/js/software_jobs.js

512 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 软件作业面板:右侧工作区列表、游标分页、活动数与终态未读通知。
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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2-6 4 12 2-6h6"/></svg>',
analyze: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V9m6 10V5m6 14v-7m4 7H2"/></svg>',
cancel: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
cancelled: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6m0-6-6 6"/></svg>',
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
failed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5m0 3h.01"/></svg>',
open: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 5h5v5m0-5-8 8"/><path d="M19 13v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg>',
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>',
pending: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
running: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/></svg>',
succeeded: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/></svg>',
};
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";
rail.classList.remove("pinned");
if (mobileResults) mobileResults.hidden = true;
return;
}
const pinned = rail.classList.contains("pinned");
rail.innerHTML = `<button type="button" class="conversation-rail-caption"`
+ ` aria-expanded="${pinned}" title="点击固定展开或收起软件作业">作业 ${taskJobs.length}</button>`
+ taskJobs.map((job) => `
<button type="button" class="sr-item ${escapeHtml(job.status)}"
data-result-job-id="${escapeHtml(job.job_id)}"
title="${escapeHtml(taskResultLabel(job))} · ${escapeHtml(taskResultMeta(job))}"
aria-label="打开软件作业:${escapeHtml(taskResultLabel(job))}${escapeHtml(taskResultMeta(job))}">
<span class="sr-label"><strong>${escapeHtml(taskResultLabel(job))}</strong>
<small>${escapeHtml(taskResultMeta(job))}</small></span>
</button>`).join("");
rail.style.display = "flex";
if (mobileResults) {
mobileResults.hidden = false;
mobileResults.textContent = `软件结果 ${taskJobs.length}`;
}
const caption = rail.querySelector(".conversation-rail-caption");
if (caption) {
caption.onclick = () => {
const next = rail.classList.toggle("pinned");
caption.setAttribute("aria-expanded", String(next));
};
}
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");
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
? `<div class="sj-empty">${escapeHtml(loadError)}</div>` : "";
list.innerHTML = errorNotice + (jobs.length
? jobs.map(jobCard).join("") + (nextCursor ? `<div class="sj-loading">${loadingMore ? "正在加载…" : "继续向下滚动加载"}</div>` : "")
: (loadError ? "" : '<div class="sj-empty">暂无专业软件任务</div>'));
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 `
<button class="small" data-job-action="open" data-task-id="${task}">${icons.open}打开对话</button>
${job.status !== "cancelling" ? `<button class="small danger" data-job-action="cancel">${icons.cancel}停止</button>` : ""}`;
if (job.status !== "succeeded") {
return `<button class="small" data-job-action="open" data-task-id="${task}">${icons.open}${job.status === "failed" ? "查看错误" : "打开对话"}</button>`;
}
const view = `<button class="small primary" data-job-action="results" data-task-id="${task}">${icons.open}查看结果</button>`;
if (["pending", "running"].includes(job.followup_status) && job.completion_action === "analyze") {
const label = job.followup_status === "pending" ? "等待分析" : "分析中";
return `${view}<button class="small" disabled>${icons.analyze}${label}</button>`;
}
if (job.completion_action === "analyze" && job.followup_status === "completed") {
return `${view}<button class="small" data-job-action="open" data-task-id="${task}">${icons.analyze}查看分析</button><button class="small" data-job-action="analyze" data-task-id="${task}">重新分析</button>`;
}
const label = job.followup_status === "failed" ? "重试分析" : "深入分析";
return `${view}<button class="small" data-job-action="analyze" data-task-id="${task}">${icons.analyze}${label}</button>`;
}
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 `<article class="sj-card ${escapeHtml(job.status)}" data-job-id="${escapeHtml(job.job_id)}">
<div class="sj-title"><div class="sj-title-main"><strong>${escapeHtml(summary.display_name || job.capability)}</strong></div>
<span class="sj-status">${statusIcon(job.status)}${escapeHtml(statusLabel[job.status] || job.status)}</span></div>
<div class="sj-context"><span>${escapeHtml(job.task_name || "未命名对话")}</span><code>Job ${escapeHtml(shortId)}</code></div>
<div class="sj-sub">${icons.activity}<span>${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</span></div>
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
<div class="sj-facts">
${inputNames ? `<span>输入:${escapeHtml(inputNames)}</span>` : ""}
<span>工程关系:${escapeHtml(workspaceRelation)}</span>
${job.status === "succeeded" ? `<span>${job.workspace_id ? `预览:${previews.length} 个 · 工程保存在节点` : `输出:${artifacts.length} 个文件${formats ? ` · ${escapeHtml(formats)}` : ""}`}</span>` : ""}
<span>完成方式:${escapeHtml(followupLabel(job))}</span>
<span>${active ? `创建于 ${escapeHtml(fmtDate(job.created_at))}` : `${elapsed ? `耗时 ${escapeHtml(elapsed)} · ` : ""}${job.terminal_at ? `完成于 ${escapeHtml(fmtDate(job.terminal_at))}` : `创建于 ${escapeHtml(fmtDate(job.created_at))}`}`}</span>
</div>
<details class="sj-details"><summary>任务详情</summary><dl>
<div><dt>Job ID</dt><dd><code>${escapeHtml(job.job_id)}</code><button type="button" class="sj-copy" data-job-action="copy">${icons.copy}复制</button></dd></div>
<div><dt>来源 Job</dt><dd>${sourceJobId ? `<code>${escapeHtml(sourceJobId)}</code>` : "—(新建工程)"}</dd></div>
<div><dt>Capability</dt><dd><code>${escapeHtml(job.capability)}</code></dd></div>
<div><dt>${job.workspace_id ? "Workspace ID" : "输出目录"}</dt><dd><code>${escapeHtml(job.workspace_id || job.output_dir || "—")}</code></dd></div>
<div><dt>执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}</dd></div>
<div><dt>时间</dt><dd>创建 ${escapeHtml(fmtDate(job.created_at))}<br>开始 ${escapeHtml(fmtDate(job.started_at))}<br>结束 ${escapeHtml(fmtDate(job.terminal_at))}</dd></div>
</dl></details>
<div class="sj-actions">${jobActions(job, active)}</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 === "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);
}