feat: 完成静态管理后台与用户端

This commit is contained in:
cat-shark
2026-08-08 21:04:21 +08:00
commit 164d9971d0
7 changed files with 874 additions and 0 deletions
+409
View File
@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function badge(status) {
const className =
status === "ready" || status === "completed" || status === "ok"
? "ok"
: status === "stopped" || status === "error"
? "error"
: "warn";
return `<span class="badge ${className}">${escapeHtml(status)}</span>`;
}
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) => `
<tr>
<td>${escapeHtml(node.id)}</td>
<td>${escapeHtml(node.name)}</td>
<td>${escapeHtml(node.capability)}</td>
<td>${escapeHtml(node.version)}</td>
<td><button class="danger" data-delete-node="${escapeHtml(node.id)}">删除</button></td>
</tr>`,
)
.join("")
: '<tr><td colspan="5">暂无节点</td></tr>';
select.innerHTML = nodes
.map((node) => `<option value="${escapeHtml(node.id)}">${escapeHtml(node.name)}</option>`)
.join("");
}
async function loadInstances() {
const instances = await api("/api/admin/node-instances");
const tbody = document.getElementById("instanceList");
tbody.innerHTML = instances.length
? instances
.map(
(instance) => `
<tr>
<td>${escapeHtml(instance.id)}</td>
<td>${escapeHtml(instance.node_id)}</td>
<td>${badge(instance.status)}</td>
<td>${escapeHtml(instance.pid ?? "")}</td>
<td>${escapeHtml(instance.address ?? "")}</td>
<td><button class="danger" data-stop-instance="${escapeHtml(instance.id)}">停止</button></td>
</tr>`,
)
.join("")
: '<tr><td colspan="6">暂无实例</td></tr>';
}
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) => `
<tr>
<td>${escapeHtml(workflow.id)}</td>
<td>${escapeHtml(workflow.name)}</td>
<td>${escapeHtml(workflow.latest_version)}</td>
<td>${workflow.published ? badge("ok") : badge("draft")}</td>
<td>
<button class="danger" data-delete-workflow="${escapeHtml(workflow.id)}">删除</button>
${workflow.published ? "" : `<button data-publish-workflow="${escapeHtml(workflow.id)}">发布</button>`}
</td>
</tr>`,
)
.join("")
: '<tr><td colspan="5">暂无工作流</td></tr>';
}
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) => `
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(item.name)}">
下载 ${escapeHtml(item.name)}
</a>`,
)
.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();
}
});
+248
View File
@@ -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);
}