2026-08-28 11:51:48 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 可视化编排器前端(无框架)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 学习路径:
|
|
|
|
|
|
* 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。
|
|
|
|
|
|
*/
|
2026-08-26 18:06:02 +08:00
|
|
|
|
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");
|
2026-08-28 10:43:57 +08:00
|
|
|
|
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 = "";
|
2026-08-26 18:06:02 +08:00
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/** 生成「抽城市 → 有城市才查天气」的示例图。连线 when=hasValidCities。 */
|
2026-08-26 18:06:02 +08:00
|
|
|
|
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 `<div class="palette-group">${title}</div><p class="hint">无</p>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<div class="palette-group">${title}</div>` + items.map((item) => `
|
|
|
|
|
|
<div class="palette-item" data-type="${item.type}">
|
|
|
|
|
|
<strong>${item.name}</strong><span class="tag">${item.origin === "plugin" ? "插件" : "系统"}</span>
|
|
|
|
|
|
<div class="hint">${item.description}</div>
|
|
|
|
|
|
</div>`).join("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderPalette() {
|
|
|
|
|
|
const issues = (state.issues || []).map((item) => `<div class="issue">[${item.level}] ${item.source}: ${item.message}</div>`).join("");
|
|
|
|
|
|
document.getElementById("palette").innerHTML =
|
|
|
|
|
|
`<p class="hint">${state.pluginsRoot || ""}</p>` +
|
|
|
|
|
|
renderGroup("系统节点", state.system) +
|
|
|
|
|
|
renderGroup("插件节点", state.plugins) +
|
|
|
|
|
|
(issues ? `<div class="palette-group">扫描说明</div>${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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/** 根据 state 重画节点 DOM;连线是 SVG,在 drawWires。 */
|
2026-08-26 18:06:02 +08:00
|
|
|
|
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 = `
|
|
|
|
|
|
<div class="node-head">${node.title}</div>
|
|
|
|
|
|
<div class="ports">
|
|
|
|
|
|
<div class="port-col">
|
|
|
|
|
|
${info.inputs.map((p) => `<div class="port in"><i class="dot" data-node="${node.id}" data-port="${p.name}" data-dir="in"></i>${p.name}</div>`).join("")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="port-col">
|
|
|
|
|
|
${info.outputs.map((p) => `<div class="port out">${p.name}<i class="dot" data-node="${node.id}" data-port="${p.name}" data-dir="out"></i></div>`).join("")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/** 先点输出端口记下 pending,再点输入端口生成一条边。 */
|
2026-08-26 18:06:02 +08:00
|
|
|
|
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 = `<p class="hint">选中节点可填固定输入;选中连线可改「上一节点输出 → 下一节点输入」和运行条件。</p>`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state.selected.kind === "node") {
|
|
|
|
|
|
const node = state.nodes.find((n) => n.id === state.selected.id);
|
|
|
|
|
|
const info = typeInfo(node.type);
|
|
|
|
|
|
inspector.innerHTML = `
|
|
|
|
|
|
<div><strong>${info.name}</strong> <span class="tag">${info.origin === "plugin" ? "插件" : "系统"}</span></div>
|
|
|
|
|
|
<p class="hint">${info.description}</p>
|
|
|
|
|
|
<label>显示名</label>
|
|
|
|
|
|
<input id="title" value="${node.title}" />
|
|
|
|
|
|
<label>LLM 凭据</label>
|
|
|
|
|
|
<select id="credentialId">${(state.credentials || []).map((c) => `<option value="${c.id}" ${ (node.config.credentialId || "llm-default") === c.id ? "selected" : ""}>${c.name}(${c.hasApiKey ? "已配置 Key" : "缺少 Key"})</option>`).join("")}</select>
|
|
|
|
|
|
<p class="hint">endpoint / API Key 由宿主注入进程,不会出现在输入端口或导出的流程图里。</p>
|
|
|
|
|
|
${info.inputs.map((p) => `
|
|
|
|
|
|
<label>固定输入 ${p.name}(${p.type})${p.required ? " *" : ""}</label>
|
|
|
|
|
|
<input data-config="${p.name}" value="${node.config[p.name] ?? ""}" placeholder="${p.description}" />
|
|
|
|
|
|
`).join("")}
|
|
|
|
|
|
<p class="hint">若该输入已从上一节点连线,运行时以连线为准。</p>
|
|
|
|
|
|
<button type="button" id="delNode">删除节点</button>`;
|
|
|
|
|
|
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 = `
|
|
|
|
|
|
<div><strong>连线映射</strong></div>
|
|
|
|
|
|
<p class="hint">上一节点输出 → 下一节点输入</p>
|
|
|
|
|
|
<label>来自</label>
|
|
|
|
|
|
<div>${from.title}</div>
|
|
|
|
|
|
<label>输出端口</label>
|
|
|
|
|
|
<select id="fromPort">${fromInfo.outputs.map((p) => `<option ${p.name === edge.fromPort ? "selected" : ""}>${p.name}</option>`).join("")}</select>
|
|
|
|
|
|
<label>到达</label>
|
|
|
|
|
|
<div>${to.title}</div>
|
|
|
|
|
|
<label>输入端口</label>
|
|
|
|
|
|
<select id="toPort">${toInfo.inputs.map((p) => `<option ${p.name === edge.toPort ? "selected" : ""}>${p.name}</option>`).join("")}</select>
|
|
|
|
|
|
<label>运行条件</label>
|
|
|
|
|
|
<select id="when">
|
|
|
|
|
|
<option value="">始终执行下一节点</option>
|
|
|
|
|
|
<option value="hasValidCities" ${edge.when === "hasValidCities" ? "selected" : ""}>仅当 hasValidCities 为 true</option>
|
|
|
|
|
|
<option value="!hasValidCities" ${edge.when === "!hasValidCities" ? "selected" : ""}>仅当 hasValidCities 为 false</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<button type="button" id="delEdge">删除连线</button>`;
|
|
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/** 把画布图交给后端。可能直接完成,也可能弹出 decisionModal。 */
|
2026-08-26 18:06:02 +08:00
|
|
|
|
async function runGraph() {
|
2026-08-28 10:43:57 +08:00
|
|
|
|
closeDecision();
|
2026-08-26 18:06:02 +08:00
|
|
|
|
logEl.textContent = "运行中…";
|
2026-08-28 10:43:57 +08:00
|
|
|
|
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;
|
2026-08-26 18:06:02 +08:00
|
|
|
|
}
|
2026-08-28 10:43:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-08-26 18:06:02 +08:00
|
|
|
|
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");
|
2026-08-28 10:43:57 +08:00
|
|
|
|
return (applied + (steps || "没有步骤。")).trim();
|
2026-08-26 18:06:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 10:43:57 +08:00
|
|
|
|
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) => `
|
|
|
|
|
|
<label class="decision-option ${option.id === selectedOptionId ? "selected" : ""}" data-id="${option.id}" data-requires="${option.requiresText ? "1" : "0"}">
|
|
|
|
|
|
<input type="radio" name="decisionOption" value="${option.id}" ${option.id === selectedOptionId ? "checked" : ""} />
|
|
|
|
|
|
<strong>${option.label}</strong>
|
|
|
|
|
|
<span>${option.description || ""}${option.isDefault ? "(超时将自动选择)" : ""}</span>
|
|
|
|
|
|
</label>`).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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/** 倒计时到 0 后去 GET 运行结果,因为服务端会自己按默认方案继续。 */
|
2026-08-28 10:43:57 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-28 11:51:48 +08:00
|
|
|
|
/** 拉节点目录和凭据列表,刷新左侧面板。 */
|
2026-08-26 18:06:02 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
2026-08-28 10:43:57 +08:00
|
|
|
|
btnRun.onclick = runGraph;
|
2026-08-26 18:06:02 +08:00
|
|
|
|
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();
|
|
|
|
|
|
})();
|