512 lines
17 KiB
JavaScript
512 lines
17 KiB
JavaScript
// 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 `<span class="badge ${className}">${escapeHtml(status)}</span>`;
|
|
}
|
|
|
|
// 把 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) => `
|
|
<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()]);
|
|
}
|
|
|
|
// 注册节点:解析文本域中的 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) => `
|
|
<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 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)} <span class="muted">已运行 ${formatElapsed(run.updated_at)}</span>`
|
|
: run.status === "QUEUED"
|
|
? `${badge(run.status)} <span class="muted">排队中</span>`
|
|
: badge(run.status);
|
|
const actions =
|
|
run.status === "FAILED"
|
|
? `<button class="danger" data-retry-run="${escapeHtml(run.id)}">重试</button>`
|
|
: "";
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(run.id)}</td>
|
|
<td>${escapeHtml(run.workflow_id)}</td>
|
|
<td>${statusHtml}</td>
|
|
<td>${escapeHtml(run.current_node_id || "-")}</td>
|
|
<td>${percent}%</td>
|
|
<td>${escapeHtml(formatTime(run.created_at))}</td>
|
|
<td title="${escapeHtml(error)}">${error ? escapeHtml(error.slice(0, 60)) : "-"}</td>
|
|
<td>${actions}</td>
|
|
</tr>`;
|
|
})
|
|
.join("")
|
|
: '<tr><td colspan="8">暂无任务</td></tr>';
|
|
}
|
|
|
|
// 请求后端重试失败任务,成功后刷新列表。
|
|
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) => `
|
|
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(item.name)}">
|
|
下载 ${escapeHtml(item.name)}
|
|
</a>`,
|
|
)
|
|
.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);
|
|
}
|
|
});
|