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, }; 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", }, }; 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) { const detail = data && data.detail ? JSON.stringify(data.detail) : response.statusText; throw new Error(`${response.status} ${detail}`); } return data; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function badge(status) { const className = status === "ready" || status === "completed" || status === "ok" ? "ok" : status === "stopped" || status === "error" ? "error" : "warn"; return `${escapeHtml(status)}`; } 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()]); } 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}`); } } 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 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}`; } } async function pollRun(runId, progress, downloads) { for (let i = 0; i < 600; 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 查看任务状态"; } function renderArtifacts(runId, artifacts, container) { container.innerHTML = artifacts .filter((item) => ["cn_srt", "ass"].includes(item.name)) .map( (item) => ` 下载 ${escapeHtml(item.name)} `, ) .join(""); } 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); } }); 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(); } });