// WOV 静态前端公共脚本:所有页面共用的 API 封装、渲染函数与事件绑定。 // Echo 演示节点的预置 manifest,便于管理后台一键填入。 const ECHO_MANIFEST = { id: "echo", name: "Echo Node", version: "0.1.0", capability: "echo", repo_dir: "wov-node-echo", command: ["python", "-m", "wov_node_echo"], env: { WOV_NODE_PORT: "0" }, input_schema: { text: "string" }, output_schema: { text: "string", file_uri: "file" }, max_concurrency: 1, idle_ttl_seconds: 15, health_timeout_seconds: 10, keep_warm: false, }; // 演示“视频字幕生成”工作流的 DAG 定义,预填在工作流编排页。 const DEMO_WORKFLOW = { name: "视频字幕生成", version: 1, nodes: [ { id: "extract", node_type: "ffmpeg-extract", params: { sample_rate: 16000, channels: 1 }, inputs: { video_uri: "input.video_uri" }, }, { id: "asr", node_type: "faster-whisper", params: { language: "ja" }, inputs: { audio_uri: "extract.audio_uri" }, }, { id: "translate", node_type: "llm-translate", params: { target_language: "zh-CN" }, inputs: { srt_uri: "asr.srt_uri" }, }, { id: "ass", node_type: "srt-to-dual-eye-ass", params: { resolution: "3840x1920" }, inputs: { cn_srt_uri: "translate.cn_srt_uri" }, }, ], edges: [ { from: "extract", to: "asr" }, { from: "asr", to: "translate" }, { from: "translate", to: "ass" }, ], entry_inputs: { video_uri: "file" }, final_outputs: { cn_srt: "translate.cn_srt_uri", ass: "ass.ass_uri", }, }; // 统一封装 fetch:自动携带 JSON 头、解析响应并在失败时抛出可读错误。 async function api(path, options = {}) { const response = await fetch(path, { headers: { "Content-Type": "application/json", ...(options.headers || {}) }, ...options, }); const data = await response.json().catch(() => null); if (!response.ok) { // FastAPI 的校验错误 detail 可能是数组,统一序列化为字符串展示。 const detail = data && data.detail ? JSON.stringify(data.detail) : response.statusText; throw new Error(`${response.status} ${detail}`); } return data; } // 转义用户可控文本,防止 XSS 注入到表格或状态 HTML 中。 function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // 根据状态生成带语义颜色的徽章 HTML。 function badge(status) { // 统一转小写比较,兼容后端返回的不同大小写。 const value = String(status).toLowerCase(); const className = value === "ready" || value === "completed" || value === "ok" ? "ok" : value === "stopped" || value === "error" || value === "failed" ? "error" : "warn"; return `${escapeHtml(status)}`; } // 把 ISO 时间格式化为本地时间;非法值原样返回。 function formatTime(value) { if (!value) return ""; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } // 计算距给定时间的流逝时长,用于任务页展示已运行时间。 function formatElapsed(value) { if (!value) return ""; const seconds = Math.floor((Date.now() - new Date(value).getTime()) / 1000); if (Number.isNaN(seconds) || seconds < 0) return ""; if (seconds < 60) return `${seconds} 秒`; return `${Math.floor(seconds / 60)} 分钟`; } // 健康检查失败时在页面顶部展示后端不可用提示。 async function loadHealth() { try { await api("/health"); } catch (error) { const banner = document.createElement("div"); banner.className = "result"; banner.textContent = `后端不可用:${error.message}`; document.body.prepend(banner); } } // 加载已注册节点并渲染节点表格与调用下拉框。 async function loadNodes() { const nodes = await api("/api/admin/nodes"); const tbody = document.getElementById("nodeList"); const select = document.getElementById("invokeNodeId"); tbody.innerHTML = nodes.length ? nodes .map( (node) => ` ${escapeHtml(node.id)} ${escapeHtml(node.name)} ${escapeHtml(node.capability)} ${escapeHtml(node.version)} `, ) .join("") : '暂无节点'; select.innerHTML = nodes .map((node) => ``) .join(""); } // 加载节点实例并渲染实例表格。 async function loadInstances() { const instances = await api("/api/admin/node-instances"); const tbody = document.getElementById("instanceList"); tbody.innerHTML = instances.length ? instances .map( (instance) => ` ${escapeHtml(instance.id)} ${escapeHtml(instance.node_id)} ${badge(instance.status)} ${escapeHtml(instance.pid ?? "")} ${escapeHtml(instance.address ?? "")} `, ) .join("") : '暂无实例'; } // 管理后台页面同时刷新节点与实例两个列表。 async function refresh() { await Promise.all([loadNodes(), loadInstances()]); } // 注册节点:解析文本域中的 manifest JSON 并提交到后端。 async function registerNode() { const raw = document.getElementById("manifestJson").value.trim(); try { const manifest = JSON.parse(raw); await api("/api/admin/nodes", { method: "POST", body: raw }); alert("节点注册成功"); await refresh(); } catch (error) { alert(`注册失败:${error.message}`); } } // 手动调用节点:读取表单参数并展示返回结果。 async function invokeNode() { const nodeId = document.getElementById("invokeNodeId").value; const runId = document.getElementById("invokeRunId").value.trim() || "run_web"; const resultBox = document.getElementById("invokeResult"); resultBox.textContent = "调用中..."; try { const inputs = JSON.parse(document.getElementById("invokeInputs").value); const result = await api(`/api/admin/nodes/${nodeId}/invoke`, { method: "POST", body: JSON.stringify({ run_id: runId, inputs, params: {} }), }); resultBox.textContent = JSON.stringify(result, null, 2); await loadInstances(); } catch (error) { resultBox.textContent = `调用失败:${error.message}`; } } // 删除节点前不做二次确认,由点击按钮触发。 async function deleteNode(nodeId) { try { await api(`/api/admin/nodes/${nodeId}`, { method: "DELETE" }); await refresh(); } catch (error) { alert(`删除失败:${error.message}`); } } // 请求后端停止指定节点实例。 async function stopInstance(instanceId) { try { await api(`/api/admin/node-instances/${instanceId}/stop`, { method: "POST" }); await loadInstances(); } catch (error) { alert(`停止失败:${error.message}`); } } // 应用中心的 Echo 演示:直接调用 echo 节点并展示结果。 async function runEcho() { const text = document.getElementById("echoText").value; const resultBox = document.getElementById("echoResult"); resultBox.textContent = "运行中..."; try { const result = await api("/api/admin/nodes/echo/invoke", { method: "POST", body: JSON.stringify({ run_id: `web_${Date.now()}`, inputs: { text }, params: {}, }), }); resultBox.textContent = JSON.stringify(result, null, 2); } catch (error) { resultBox.textContent = `运行失败:${error.message}`; } } // 加载工作流列表并渲染发布状态与操作按钮。 async function loadWorkflows() { const workflows = await api("/api/admin/workflows"); const tbody = document.getElementById("workflowList"); if (!tbody) return; tbody.innerHTML = workflows.length ? workflows .map( (workflow) => ` ${escapeHtml(workflow.id)} ${escapeHtml(workflow.name)} ${escapeHtml(workflow.latest_version)} ${workflow.published ? badge("ok") : badge("draft")} ${workflow.published ? "" : ``} `, ) .join("") : '暂无工作流'; } // 加载最近任务并渲染任务表格,失败任务提供重试按钮。 async function loadRuns() { const tbody = document.getElementById("runList"); if (!tbody) return; const runs = await api("/api/runs"); tbody.innerHTML = runs.length ? runs .map((run) => { const percent = Math.round((run.progress || 0) * 100); const error = run.error || ""; // RUNNING/QUEUED 附加耗时或排队提示,其余状态只显示徽章。 const statusHtml = run.status === "RUNNING" ? `${badge(run.status)} 已运行 ${formatElapsed(run.updated_at)}` : run.status === "QUEUED" ? `${badge(run.status)} 排队中` : badge(run.status); const actions = run.status === "FAILED" ? `` : ""; return ` ${escapeHtml(run.id)} ${escapeHtml(run.workflow_id)} ${statusHtml} ${escapeHtml(run.current_node_id || "-")} ${percent}% ${escapeHtml(formatTime(run.created_at))} ${error ? escapeHtml(error.slice(0, 60)) : "-"} ${actions} `; }) .join("") : '暂无任务'; } // 请求后端重试失败任务,成功后刷新列表。 async function retryRun(runId) { try { const result = await api(`/api/runs/${runId}/retry`, { method: "POST" }); alert(`任务 ${result.id} 已重新排队`); await loadRuns(); } catch (error) { alert(`重试失败:${error.message}`); } } // 创建或更新工作流:解析 DAG JSON 后提交,随后刷新列表。 async function createWorkflow() { const workflowId = document.getElementById("workflowId").value.trim(); const name = document.getElementById("workflowName").value.trim(); const description = document.getElementById("workflowDescription").value.trim(); let definition; try { definition = JSON.parse(document.getElementById("workflowDefinition").value); } catch (error) { alert(`DAG JSON 无效:${error.message}`); return; } try { await api("/api/admin/workflows", { method: "POST", body: JSON.stringify({ id: workflowId || undefined, name, description, definition, }), }); alert("工作流已保存"); await loadWorkflows(); } catch (error) { alert(`保存失败:${error.message}`); } } // 发布指定工作流,使其出现在用户应用中心。 async function publishWorkflow(workflowId) { try { await api(`/api/admin/workflows/${workflowId}/publish`, { method: "POST" }); await loadWorkflows(); } catch (error) { alert(`发布失败:${error.message}`); } } // 删除指定工作流。 async function deleteWorkflow(workflowId) { try { await api(`/api/admin/workflows/${workflowId}`, { method: "DELETE" }); await loadWorkflows(); } catch (error) { alert(`删除失败:${error.message}`); } } // 上传视频并轮询任务进度,完成后渲染下载链接。 async function uploadVideo() { const fileInput = document.getElementById("videoFile"); const progress = document.getElementById("runProgress"); const downloads = document.getElementById("downloads"); downloads.innerHTML = ""; if (!fileInput.files.length) { progress.textContent = "请先选择视频文件"; return; } const form = new FormData(); form.append("file", fileInput.files[0]); progress.textContent = "上传中..."; try { const response = await fetch("/api/apps/demo/runs", { method: "POST", body: form }); const data = await response.json().catch(() => null); if (!response.ok) { throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText); } await pollRun(data.id, progress, downloads); } catch (error) { progress.textContent = `运行失败:${error.message}`; } } // 每秒轮询一次任务状态,最多 3600 次(约 1 小时),超时后提示查看接口。 async function pollRun(runId, progress, downloads) { for (let i = 0; i < 3600; i += 1) { const run = await api(`/api/runs/${runId}`); const percent = Math.round((run.progress || 0) * 100); progress.textContent = `状态:${run.status} | 当前节点:${run.current_node_id || "-"} | 进度:${percent}%`; if (run.status === "COMPLETED") { renderArtifacts(runId, run.artifacts, downloads); return; } if (run.status === "FAILED") { progress.textContent = `运行失败:${run.error || "未知错误"}`; return; } await new Promise((resolve) => setTimeout(resolve, 1000)); } progress.textContent = "轮询超时,请到 /api/runs 查看任务状态"; } // 只展示用户关心的最终产物(中文 SRT 与 ASS)下载入口。 function renderArtifacts(runId, artifacts, container) { container.innerHTML = artifacts .filter((item) => ["cn_srt", "ass"].includes(item.name)) .map( (item) => ` 下载 ${escapeHtml(item.name)} `, ) .join(""); } // 全局点击委托:按按钮上的 data-* 属性分发到对应操作。 document.addEventListener("click", (event) => { const deleteNodeButton = event.target.closest("[data-delete-node]"); if (deleteNodeButton) { deleteNode(deleteNodeButton.dataset.deleteNode); return; } const stopButton = event.target.closest("[data-stop-instance]"); if (stopButton) { stopInstance(stopButton.dataset.stopInstance); return; } const deleteWorkflowButton = event.target.closest("[data-delete-workflow]"); if (deleteWorkflowButton) { deleteWorkflow(deleteWorkflowButton.dataset.deleteWorkflow); return; } const publishWorkflowButton = event.target.closest("[data-publish-workflow]"); if (publishWorkflowButton) { publishWorkflow(publishWorkflowButton.dataset.publishWorkflow); return; } const retryButton = event.target.closest("[data-retry-run]"); if (retryButton) { retryRun(retryButton.dataset.retryRun); } }); // 页面初始化:预填 manifest/DAG、绑定按钮事件并加载对应页面数据。 document.addEventListener("DOMContentLoaded", async () => { const manifestBox = document.getElementById("manifestJson"); if (manifestBox) { manifestBox.value = JSON.stringify(ECHO_MANIFEST, null, 2); } const loadEchoButton = document.getElementById("loadEchoManifest"); if (loadEchoButton) { loadEchoButton.addEventListener("click", () => { manifestBox.value = JSON.stringify(ECHO_MANIFEST, null, 2); }); } const registerButton = document.getElementById("registerNode"); if (registerButton) { registerButton.addEventListener("click", registerNode); } const invokeButton = document.getElementById("invokeNode"); if (invokeButton) { invokeButton.addEventListener("click", invokeNode); } const echoButton = document.getElementById("runEcho"); if (echoButton) { echoButton.addEventListener("click", runEcho); } const workflowDefinition = document.getElementById("workflowDefinition"); if (workflowDefinition) { workflowDefinition.value = JSON.stringify(DEMO_WORKFLOW, null, 2); } const createWorkflowButton = document.getElementById("createWorkflow"); if (createWorkflowButton) { createWorkflowButton.addEventListener("click", createWorkflow); } const publishWorkflowButton = document.getElementById("publishWorkflow"); if (publishWorkflowButton) { publishWorkflowButton.addEventListener("click", () => { publishWorkflow(document.getElementById("workflowId").value.trim()); }); } const uploadButton = document.getElementById("uploadVideo"); if (uploadButton) { uploadButton.addEventListener("click", uploadVideo); } await loadHealth(); if (document.getElementById("nodeList")) { await refresh(); } if (document.getElementById("workflowList")) { await loadWorkflows(); } // 任务管理页每 5 秒自动刷新一次状态。 if (document.getElementById("runList")) { await loadRuns(); setInterval(loadRuns, 5000); } });