commit 164d9971d0743f58767b7b62de99bbb9ca1e0650 Author: cat-shark Date: Sat Aug 8 21:04:21 2026 +0800 feat: 完成静态管理后台与用户端 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b98998 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +__pycache__/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0a47916 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# WOV Web + +本仓库是 WOV 平台的静态前端,当前阶段不依赖构建工具,由 `wov-api` 直接挂载服务。 + +## 页面 + +- `index.html`:用户端应用中心。 +- `admin.html`:节点注册、调用、实例管理。 +- `workflow.html`:工作流编排入口,后端工作流 API 就绪后启用。 + +## 约束 + +- 页面不直接暴露 API 调用细节给普通用户。 +- 前端只通过 `/api/...` 调用后端,不内联业务逻辑。 +- 后续迁移到 React 时,页面行为应与当前静态页面保持一致。 diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..5f8be70 --- /dev/null +++ b/admin.html @@ -0,0 +1,92 @@ + + + + + + WOV 管理后台 + + + +
+ WOV + +
+ +
+

节点管理

+ +
+

注册节点

+ + +
+ + +
+
+ +
+

已注册节点

+
+ + + + + + + + + + + +
ID名称能力版本操作
+
+
+ +
+

调用节点

+
+ + + +
+ +
等待调用...
+
+ +
+

节点实例

+
+ + + + + + + + + + + + +
实例 ID节点状态PID地址操作
+
+
+
+ + + + diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..4ebce40 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,409 @@ +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(); + } +}); diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..0ff304f --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,248 @@ +:root { + --bg: #f5f7fa; + --surface: #ffffff; + --border: #d7dde6; + --text: #1c2733; + --muted: #66748a; + --primary: #1769aa; + --danger: #b42318; + --ok: #177245; + --radius: 8px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "Microsoft YaHei", sans-serif; + color: var(--text); + background: var(--bg); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 28px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.brand { + font-size: 20px; + font-weight: 700; + color: var(--primary); + text-decoration: none; +} + +nav { + display: flex; + gap: 18px; +} + +nav a { + color: var(--muted); + text-decoration: none; + font-weight: 600; +} + +nav a:hover { + color: var(--primary); +} + +.container { + max-width: 1080px; + margin: 0 auto; + padding: 28px 20px 60px; +} + +h1 { + margin: 0 0 8px; + font-size: 28px; +} + +h2 { + margin: 0 0 12px; + font-size: 18px; +} + +.muted { + color: var(--muted); +} + +.app-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 18px; + margin-top: 24px; +} + +.card, +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.panel { + margin-top: 22px; +} + +.disabled { + opacity: 0.6; +} + +label { + display: block; + margin: 10px 0 6px; + font-size: 13px; + font-weight: 600; + color: var(--muted); +} + +input, +select, +textarea { + width: 100%; + padding: 9px 11px; + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; + background: #fff; +} + +textarea { + font-family: Consolas, monospace; + resize: vertical; +} + +button { + margin-top: 12px; + padding: 9px 16px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button:hover { + border-color: var(--primary); + color: var(--primary); +} + +button.primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +button.ghost { + background: transparent; +} + +.actions, +.form-grid { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); +} + +.form-grid .wide { + grid-column: 1 / -1; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +th, +td { + padding: 10px; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +.result { + margin: 16px 0 0; + padding: 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: #0f1720; + color: #d7e5f3; + font-family: Consolas, monospace; + font-size: 13px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.downloads { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 14px; +} + +.download-link { + display: inline-block; + padding: 8px 14px; + border: 1px solid var(--primary); + border-radius: 6px; + color: var(--primary); + font-weight: 600; + text-decoration: none; +} + +.download-link:hover { + background: #e8f1f9; +} + +.badge { + display: inline-block; + padding: 3px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.badge.ok { + background: #e2f4ea; + color: var(--ok); +} + +.badge.warn { + background: #fff2d9; + color: #8a5a00; +} + +.badge.error { + background: #fde8e6; + color: var(--danger); +} + +.danger { + color: var(--danger); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..d3213be --- /dev/null +++ b/index.html @@ -0,0 +1,47 @@ + + + + + + WOV 应用中心 + + + +
+ WOV + +
+ +
+

WOV 应用中心

+

选择一个已发布的应用,上传或输入内容后由后台自动执行。

+ +
+
+

Echo 演示

+

调用已注册的 echo 节点,验证前端到后端再到节点进程的完整链路。

+ + + +
等待运行...
+
+ +
+

视频字幕生成

+

上传视频后自动执行提音、转写、翻译和 VR 双眼 ASS 生成。

+ + + +
等待上传...
+
+
+
+
+ + + + diff --git a/workflow.html b/workflow.html new file mode 100644 index 0000000..04ea238 --- /dev/null +++ b/workflow.html @@ -0,0 +1,59 @@ + + + + + + WOV 工作流 + + + +
+ WOV + +
+ +
+

工作流编排

+ +
+

工作流定义

+ + + + + + + + +
+ + +
+
+ +
+

已发布工作流

+
+ + + + + + + + + + + +
ID名称版本状态操作
+
+
+
+ + + +