228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
// 专业软件任务中心:右栏固定摘要、抽屉列表、游标分页和终态通知。
|
|
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: "节点已接收", 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>',
|
|
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-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 ? `<div class="sj-loading">${loadingMore ? "正在加载…" : "继续向下滚动加载"}</div>` : "")
|
|
: '<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 jobCard(job) {
|
|
const summary = job.request_summary || {};
|
|
const inputs = Array.isArray(job.input) ? job.input : [];
|
|
const inputNames = inputs.map((item) => item.filename).filter(Boolean).join("、");
|
|
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 `<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-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-meta">${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${inputNames ? ` · ${escapeHtml(inputNames)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}</div>
|
|
<div class="sj-actions">
|
|
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">${icons.open}打开对话</button>
|
|
${job.status === "succeeded" ? `<button class="small primary" data-job-action="analyze" data-task-id="${escapeHtml(job.task_id)}">${icons.analyze}分析结果</button>` : ""}
|
|
${active && job.status !== "cancelling" ? `<button class="small danger" data-job-action="cancel">${icons.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);
|
|
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);
|
|
}
|