Files
MAF1/wwwroot/js/app.js
T
2026-08-26 18:06:02 +08:00

339 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
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));
}
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();
}
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);
}
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();
};
}
async function runGraph() {
logEl.textContent = "运行中…";
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();
if (!data.ok) {
logEl.textContent = data.error ?? "运行失败";
return;
}
logEl.textContent = 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");
}
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();
};
document.getElementById("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();
})();