初始提交

This commit is contained in:
2026-08-26 18:06:02 +08:00
commit 5544788917
53 changed files with 4101 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
:root {
--bg: #0f1419;
--panel: #171e26;
--line: #2a3542;
--text: #e8eef4;
--muted: #8b9aab;
--accent: #4ea1ff;
--file: #3dd6c6;
--weather: #f0b429;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
height: 100%;
font-family: "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
}
.top h1 {
margin: 0 0 4px;
font-size: 18px;
}
.top p {
margin: 0;
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
gap: 8px;
}
button {
background: #243040;
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
}
button.primary {
background: var(--accent);
color: #061018;
border: 0;
font-weight: 600;
}
main {
display: grid;
grid-template-columns: 220px 1fr 280px;
min-height: calc(100vh - 220px);
}
.palette, .inspector {
background: var(--panel);
padding: 16px;
border-right: 1px solid var(--line);
}
.inspector {
border-right: 0;
border-left: 1px solid var(--line);
}
h2 {
margin: 0 0 10px;
font-size: 14px;
}
.hint {
color: var(--muted);
font-size: 12px;
}
.palette-item .tag {
display: inline-block;
margin-left: 6px;
font-size: 10px;
color: var(--accent);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0 6px;
}
.palette-group {
margin-top: 12px;
color: var(--muted);
font-size: 12px;
}
.issue {
font-size: 11px;
color: #f0b429;
margin: 6px 0;
}
.node.plugin {
border-top: 3px solid #9b8afb;
}
.node.overridden {
opacity: 0.95;
}
.canvas-wrap {
position: relative;
overflow: hidden;
background: radial-gradient(#1c252f 1px, transparent 1px) 0 0 / 18px 18px;
}
#canvas {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
#wires {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
}
.node {
position: absolute;
width: 240px;
background: #1b2430;
border: 1px solid var(--line);
border-radius: 12px;
pointer-events: auto;
}
.node.selected {
border-color: var(--accent);
}
.node.fileCity {
border-top: 3px solid var(--file);
}
.node.weather {
border-top: 3px solid var(--weather);
}
.node-head {
padding: 10px 12px;
font-weight: 600;
cursor: move;
user-select: none;
}
.ports {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 10px 12px;
}
.port-col {
display: flex;
flex-direction: column;
gap: 6px;
}
.port {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted);
}
.port.out {
justify-content: flex-end;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
flex: 0 0 10px;
cursor: pointer;
}
.inspector label {
display: block;
font-size: 12px;
color: var(--muted);
margin: 10px 0 4px;
}
.inspector input, .inspector select {
width: 100%;
background: #10161d;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
padding: 8px;
}
.log {
border-top: 1px solid var(--line);
padding: 12px 20px 20px;
}
.log pre {
background: #10161d;
border-radius: 8px;
padding: 12px;
min-height: 80px;
white-space: pre-wrap;
font-size: 12px;
}
.wire {
fill: none;
stroke: var(--accent);
stroke-width: 2;
pointer-events: stroke;
}
.wire.selected {
stroke: var(--weather);
}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MAF 工作流编排</title>
<link rel="stylesheet" href="/css/app.css" />
</head>
<body>
<header class="top">
<div>
<h1>工作流编排</h1>
<p>系统节点和 plugins 目录里的插件会一起出现。LLM 的 endpoint / API Key 由宿主注入,不会画成输入端口。</p>
</div>
<div class="actions">
<button type="button" id="btnRefresh">刷新节点</button>
<button type="button" id="btnExample">示例图</button>
<button type="button" id="btnRun" class="primary">运行</button>
</div>
</header>
<main>
<aside class="palette">
<h2>节点</h2>
<p class="hint">点击添加到画布</p>
<div id="palette"></div>
</aside>
<section class="canvas-wrap">
<svg id="wires"></svg>
<div id="canvas"></div>
</section>
<aside class="inspector">
<h2>节点配置</h2>
<div id="inspector"><p class="hint">选中节点或连线后在这里改输入 / 输出映射。</p></div>
</aside>
</main>
<section class="log">
<h2>运行结果</h2>
<pre id="log">尚未运行。</pre>
</section>
<script src="/js/app.js"></script>
</body>
</html>
+338
View File
@@ -0,0 +1,338 @@
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();
})();