/** * 可视化编排器前端(无框架)。 * * 学习路径: * 1. init → loadCatalog:拉系统节点和插件节点 * 2. 示例图 / 拖节点、点端口连线;图数据在 state.nodes / state.edges * 3. runGraph POST /api/run;若 status=needsDecision 弹出确认框 * 4. 确认走 /api/run/{id}/decide;超时则 pollRun 等服务端自动采用默认方案 * * selected / pending 只用于点击交互。发给后端的 JSON 不含 API Key,只有 credentialId。 */ const state = { catalog: [], system: [], plugins: [], issues: [], credentials: [], pluginsRoot: "", nodes: [], edges: [], selected: null, pending: null, }; const canvas = document.getElementById("canvas"); const wires = document.getElementById("wires"); const inspector = document.getElementById("inspector"); const logEl = document.getElementById("log"); const decisionModal = document.getElementById("decisionModal"); const decisionPrompt = document.getElementById("decisionPrompt"); const decisionReason = document.getElementById("decisionReason"); const decisionRemain = document.getElementById("decisionRemain"); const decisionOptions = document.getElementById("decisionOptions"); const decisionText = document.getElementById("decisionText"); const decisionTextLabel = document.getElementById("decisionTextLabel"); const decisionError = document.getElementById("decisionError"); const decisionSubmit = document.getElementById("decisionSubmit"); const btnRun = document.getElementById("btnRun"); let decisionTimer = null; let pendingRunId = null; let selectedOptionId = ""; function uid(prefix) { return prefix + Math.random().toString(36).slice(2, 8); } function typeInfo(type) { return state.catalog.find((item) => item.type === type) || state.system.find((item) => item.type === type) || state.plugins.find((item) => item.type === type); } function pickNode(list, inputName) { return (list || []).find((item) => item.inputs?.some((p) => p.name === inputName)); } /** 生成「抽城市 → 有城市才查天气」的示例图。连线 when=hasValidCities。 */ function exampleGraph() { const fileInfo = pickNode(state.system, "filePath") || pickNode(state.catalog, "filePath"); const weatherInfo = pickNode(state.plugins, "cities") || pickNode(state.system, "cities") || pickNode(state.catalog, "cities"); if (!fileInfo || !weatherInfo) { logEl.textContent = "示例图需要目录里有「filePath 输入」和「cities 输入」的节点,请先刷新节点。"; return { nodes: [], edges: [] }; } const fileConfig = { credentialId: "llm-default" }; if (fileInfo.inputs?.some((p) => p.name === "filePath")) { fileConfig.filePath = "Data/cities.txt"; } return { nodes: [ { id: "n1", type: fileInfo.type, title: fileInfo.name, x: 60, y: 80, config: fileConfig }, { id: "n2", type: weatherInfo.type, title: weatherInfo.name, x: 420, y: 80, config: { credentialId: "llm-default" } }, ], edges: [ { id: "e1", from: "n1", to: "n2", fromPort: "cities", toPort: "cities", when: "hasValidCities" }, ], }; } function renderGroup(title, items) { if (!items.length) { return `
${title}

`; } return `
${title}
` + items.map((item) => `
${item.name}${item.origin === "plugin" ? "插件" : "系统"}
${item.description}
`).join(""); } function renderPalette() { const issues = (state.issues || []).map((item) => `
[${item.level}] ${item.source}: ${item.message}
`).join(""); document.getElementById("palette").innerHTML = `

${state.pluginsRoot || ""}

` + renderGroup("系统节点", state.system) + renderGroup("插件节点", state.plugins) + (issues ? `
扫描说明
${issues}` : ""); document.querySelectorAll(".palette-item").forEach((el) => { el.onclick = () => addNode(el.dataset.type); }); } function addNode(type) { const info = typeInfo(type); if (!info) { logEl.textContent = `没有类型 ${type},请先刷新节点。`; return; } const config = { credentialId: "llm-default" }; if (info.inputs?.some((p) => p.name === "filePath")) { config.filePath = "Data/cities.txt"; } state.nodes.push({ id: uid("n"), type, title: info.name, x: 80 + state.nodes.length * 40, y: 70 + state.nodes.length * 30, config, }); render(); } /** 根据 state 重画节点 DOM;连线是 SVG,在 drawWires。 */ function render() { canvas.innerHTML = ""; for (const node of state.nodes) { const info = typeInfo(node.type); if (!info) { continue; } const el = document.createElement("div"); el.className = `node ${node.type} ${info.origin}` + (state.selected?.kind === "node" && state.selected.id === node.id ? " selected" : ""); el.style.left = node.x + "px"; el.style.top = node.y + "px"; el.innerHTML = `
${node.title}
${info.inputs.map((p) => `
${p.name}
`).join("")}
${info.outputs.map((p) => `
${p.name}
`).join("")}
`; el.querySelector(".node-head").onmousedown = (e) => startDrag(e, node); el.onclick = (e) => { if (e.target.classList.contains("dot")) return; state.selected = { kind: "node", id: node.id }; render(); }; canvas.appendChild(el); } canvas.querySelectorAll(".dot").forEach((dot) => { dot.onclick = (e) => { e.stopPropagation(); onPort(dot.dataset.node, dot.dataset.port, dot.dataset.dir); }; }); drawWires(); renderInspector(); } function startDrag(e, node) { e.preventDefault(); const wrap = document.querySelector(".canvas-wrap").getBoundingClientRect(); const ox = e.clientX - wrap.left - node.x; const oy = e.clientY - wrap.top - node.y; const el = e.currentTarget.parentElement; const move = (ev) => { node.x = Math.max(0, ev.clientX - wrap.left - ox); node.y = Math.max(0, ev.clientY - wrap.top - oy); if (el) { el.style.left = node.x + "px"; el.style.top = node.y + "px"; } drawWires(); }; const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); } /** 先点输出端口记下 pending,再点输入端口生成一条边。 */ function onPort(nodeId, port, dir) { if (dir === "out") { state.pending = { nodeId, port }; logEl.textContent = `已选输出 ${nodeTitle(nodeId)}.${port},请点击下一个节点的输入端口。`; return; } if (!state.pending) { logEl.textContent = "请先点击上一节点的输出端口,再点本节点输入。"; return; } if (state.pending.nodeId === nodeId) return; state.edges.push({ id: uid("e"), from: state.pending.nodeId, to: nodeId, fromPort: state.pending.port, toPort: port, when: state.pending.port === "cities" || port === "cities" ? "hasValidCities" : "", }); state.pending = null; state.selected = { kind: "edge", id: state.edges.at(-1).id }; render(); } function nodeTitle(id) { return state.nodes.find((n) => n.id === id)?.title ?? id; } function drawWires() { const wrap = document.querySelector(".canvas-wrap").getBoundingClientRect(); wires.innerHTML = ""; for (const edge of state.edges) { const from = document.querySelector(`.dot[data-node="${edge.from}"][data-port="${edge.fromPort}"][data-dir="out"]`); const to = document.querySelector(`.dot[data-node="${edge.to}"][data-port="${edge.toPort}"][data-dir="in"]`); if (!from || !to) continue; const a = from.getBoundingClientRect(); const b = to.getBoundingClientRect(); const x1 = a.left + a.width / 2 - wrap.left; const y1 = a.top + a.height / 2 - wrap.top; const x2 = b.left + b.width / 2 - wrap.left; const y2 = b.top + b.height / 2 - wrap.top; const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", `M ${x1} ${y1} C ${x1 + 80} ${y1}, ${x2 - 80} ${y2}, ${x2} ${y2}`); path.setAttribute("class", "wire" + (state.selected?.kind === "edge" && state.selected.id === edge.id ? " selected" : "")); path.style.pointerEvents = "stroke"; path.onclick = () => { state.selected = { kind: "edge", id: edge.id }; render(); }; wires.appendChild(path); } } function renderInspector() { if (!state.selected) { inspector.innerHTML = `

选中节点可填固定输入;选中连线可改「上一节点输出 → 下一节点输入」和运行条件。

`; return; } if (state.selected.kind === "node") { const node = state.nodes.find((n) => n.id === state.selected.id); const info = typeInfo(node.type); inspector.innerHTML = `
${info.name} ${info.origin === "plugin" ? "插件" : "系统"}

${info.description}

endpoint / API Key 由宿主注入进程,不会出现在输入端口或导出的流程图里。

${info.inputs.map((p) => ` `).join("")}

若该输入已从上一节点连线,运行时以连线为准。

`; inspector.querySelector("#title").oninput = (e) => { node.title = e.target.value; }; inspector.querySelector("#credentialId")?.addEventListener("change", (e) => { node.config.credentialId = e.target.value; }); inspector.querySelectorAll("[data-config]").forEach((input) => { input.oninput = () => { node.config[input.dataset.config] = input.value; }; }); inspector.querySelector("#delNode").onclick = () => { state.edges = state.edges.filter((e) => e.from !== node.id && e.to !== node.id); state.nodes = state.nodes.filter((n) => n.id !== node.id); state.selected = null; render(); }; return; } const edge = state.edges.find((e) => e.id === state.selected.id); const from = state.nodes.find((n) => n.id === edge.from); const to = state.nodes.find((n) => n.id === edge.to); const fromInfo = typeInfo(from.type); const toInfo = typeInfo(to.type); inspector.innerHTML = `
连线映射

上一节点输出 → 下一节点输入

${from.title}
${to.title}
`; inspector.querySelector("#fromPort").onchange = (e) => { edge.fromPort = e.target.value; drawWires(); }; inspector.querySelector("#toPort").onchange = (e) => { edge.toPort = e.target.value; }; inspector.querySelector("#when").onchange = (e) => { edge.when = e.target.value; }; inspector.querySelector("#delEdge").onclick = () => { state.edges = state.edges.filter((item) => item.id !== edge.id); state.selected = null; render(); }; } /** 把画布图交给后端。可能直接完成,也可能弹出 decisionModal。 */ async function runGraph() { closeDecision(); logEl.textContent = "运行中…"; btnRun.disabled = true; try { const res = await fetch("/api/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nodes: state.nodes, edges: state.edges }), }); const data = await res.json(); handleRunResult(data); } catch (err) { logEl.textContent = err?.message ?? "运行失败"; btnRun.disabled = false; } } function formatRunLog(data) { const applied = data.appliedDecision ? `\n已选方案: ${data.appliedDecision.optionId}${data.appliedDecision.timedOut ? "(超时默认)" : ""}${data.appliedDecision.text ? " / " + data.appliedDecision.text : ""}\n` : ""; const steps = (data.steps || []).map((step) => { const head = `${step.title} (${step.type})${step.skipped ? " [跳过]" : ""}`; const inputs = JSON.stringify(step.inputs, null, 2); const outputs = JSON.stringify(step.outputs, null, 2); return `${head}\n输入:\n${inputs}\n输出:\n${outputs}\n${step.message ?? ""}`; }).join("\n\n-----\n\n"); return (applied + (steps || "没有步骤。")).trim(); } function handleRunResult(data) { if (data.status === "needsDecision" && data.decision) { logEl.textContent = "等待确认…\n\n" + formatRunLog(data); showDecision(data, data.error); return; } closeDecision(); btnRun.disabled = false; if (!data.ok) { logEl.textContent = data.error ?? "运行失败"; return; } logEl.textContent = formatRunLog(data); } function showDecision(data, errorText) { const decision = data.decision; pendingRunId = data.runId; selectedOptionId = decision.defaultOptionId; decisionPrompt.textContent = decision.prompt || "请选择一个方案。"; decisionReason.textContent = decision.reason ? `原因:${decision.reason}` : ""; decisionError.textContent = errorText || ""; decisionError.classList.toggle("hidden", !errorText); decisionOptions.innerHTML = (decision.options || []).map((option) => ` `).join(""); decisionOptions.querySelectorAll(".decision-option").forEach((el) => { el.onchange = () => { selectedOptionId = el.dataset.id; decisionOptions.querySelectorAll(".decision-option").forEach((item) => { item.classList.toggle("selected", item.dataset.id === selectedOptionId); }); syncDecisionText(); }; }); decisionText.placeholder = (decision.options || []).find((o) => o.requiresText)?.textPlaceholder || "请输入"; syncDecisionText(); decisionModal.classList.remove("hidden"); decisionModal.setAttribute("aria-hidden", "false"); startDecisionTimer(decision.deadline); } function syncDecisionText() { const option = decisionOptions.querySelector(`.decision-option[data-id="${selectedOptionId}"]`); const needsText = option?.dataset.requires === "1"; decisionText.classList.toggle("hidden", !needsText); decisionTextLabel.classList.toggle("hidden", !needsText); if (needsText) { decisionText.focus(); } } /** 倒计时到 0 后去 GET 运行结果,因为服务端会自己按默认方案继续。 */ function startDecisionTimer(deadline) { if (decisionTimer) { clearInterval(decisionTimer); } const tick = async () => { const remain = Math.max(0, Math.ceil((new Date(deadline).getTime() - Date.now()) / 1000)); decisionRemain.textContent = String(remain); if (remain <= 0) { clearInterval(decisionTimer); decisionTimer = null; if (pendingRunId) { await pollRun(pendingRunId); } } }; tick(); decisionTimer = setInterval(tick, 500); } async function pollRun(runId) { for (let i = 0; i < 8; i++) { try { const res = await fetch(`/api/run/${runId}`); const data = await res.json(); if (data.status !== "needsDecision") { handleRunResult(data); return; } } catch { btnRun.disabled = false; return; } await new Promise((resolve) => setTimeout(resolve, 250)); } } function closeDecision() { if (decisionTimer) { clearInterval(decisionTimer); decisionTimer = null; } pendingRunId = null; decisionModal.classList.add("hidden"); decisionModal.setAttribute("aria-hidden", "true"); } decisionSubmit.onclick = async () => { if (!pendingRunId || !selectedOptionId) { return; } decisionSubmit.disabled = true; decisionError.classList.add("hidden"); try { const res = await fetch(`/api/run/${pendingRunId}/decide`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ optionId: selectedOptionId, text: decisionText.value }), }); const data = await res.json(); handleRunResult(data); } catch (err) { decisionError.textContent = err?.message ?? "确认失败"; decisionError.classList.remove("hidden"); } finally { decisionSubmit.disabled = false; } }; /** 拉节点目录和凭据列表,刷新左侧面板。 */ async function loadCatalog() { const [catalog, credentials] = await Promise.all([ (await fetch("/api/catalog")).json(), (await fetch("/api/credentials")).json(), ]); state.system = catalog.system ?? []; state.plugins = catalog.plugins ?? []; state.catalog = catalog.nodes ?? []; state.issues = catalog.issues ?? []; state.pluginsRoot = catalog.pluginsRoot ?? ""; state.credentials = credentials ?? []; renderPalette(); if (state.nodes.length) { render(); } } document.getElementById("btnExample").onclick = () => { const graph = exampleGraph(); state.nodes = graph.nodes; state.edges = graph.edges; state.selected = { kind: "edge", id: "e1" }; render(); }; btnRun.onclick = runGraph; document.getElementById("btnRefresh").onclick = async () => { logEl.textContent = "正在重新扫描系统节点和 plugins 目录…"; await loadCatalog(); logEl.textContent = `已刷新。系统 ${state.system.length} 个,插件 ${state.plugins.length} 个。`; }; window.addEventListener("resize", drawWires); (async function init() { await loadCatalog(); document.getElementById("btnExample").click(); })();