feat: 工作流画布增强——节点拖入、滚轮缩放、空白平移、节点/连线右键菜单、导入导出、侧边栏仅显示选中节点
This commit is contained in:
@@ -58,7 +58,7 @@ ui/src/
|
||||
├── AgentsPage.tsx 智能体列表(预制/自定义分组、新建/编辑/删除/恢复预制)
|
||||
├── AgentChatPage.tsx 智能体会话页(按路由加载智能体并渲染 ChatPage 智能体模式)
|
||||
├── WorkflowsPage.tsx 工作流列表(预制/自定义分组、新建/删除/恢复预制)
|
||||
├── WorkflowCanvasPage.tsx 工作流画布(节点拖拽/连线、节点编辑、运行与结果展示)
|
||||
├── WorkflowCanvasPage.tsx 工作流画布(节点拖入/拖拽/连线、滚轮缩放、空白平移、节点/连线右键菜单、导入导出、运行与结果展示)
|
||||
├── ModelsPage.tsx 模型管理(本地部署 + 在线 API 启用开关)
|
||||
├── ModelPlazaPage.tsx 模型广场(HF/ModelScope 搜索与下载)
|
||||
├── TasksPage.tsx 任务(下载/部署进度)
|
||||
|
||||
+7
-3
@@ -107,13 +107,16 @@
|
||||
|
||||
### 4.2 画布编辑器(`/workflows/:workflowId`)
|
||||
|
||||
- 左侧节点面板可添加 4 类节点:
|
||||
- 左侧节点面板可**点击或直接拖入** 4 类节点到画布:
|
||||
- **开始**:工作流入口,提供运行输入(提示词中可用 `{{input}}` 引用);
|
||||
- **大模型**:配置提示词模板、节点级大模型(可选)、temperature、max_tokens,提示词可用 `{{input}}` 与 `{{节点id}}` 引用上游节点输出;
|
||||
- **文本**:静态文本内容(如风格要求),可被其他节点引用;
|
||||
- **输出**:决定最终输出(模板留空时自动取上游输出)。
|
||||
- 节点可拖动布局;点击节点右侧圆点拖到另一节点左侧圆点建立连线(数据流),点击连线删除;删除节点时自动清理相关连线。
|
||||
- 右侧面板:运行工作流(输入内容 + 可选大模型)、节点设置、工作流设置(图标/描述/默认大模型/启用)、各节点输出列表。
|
||||
- **画布交互**:节点可拖动布局;滚轮缩放(25%–250%,以光标为中心);拖动画布空白处整体平移。
|
||||
- **连线**:点击节点右侧圆点拖到另一节点左侧圆点建立连线(数据流);单击连线不再删除,**右键**连线弹出菜单可删除。
|
||||
- **节点右键菜单**:修改节点名称、说明/备注(显示在节点上)、收藏(节点显示 ★)、复制节点、删除节点。
|
||||
- 右侧面板只显示**选中节点**的相关选项(未选中时提示点击节点);运行工作流(输入内容 + 可选大模型)、工作流设置(图标/描述/默认大模型/启用/导入导出)为工作流级设置。
|
||||
- **导入/导出**:导出为 JSON(含节点/连线/元数据),导入 JSON 覆盖当前画布并自动保存。
|
||||
- 运行前自动保存画布;后端按拓扑顺序执行,`workflow://node-status` 事件实时推送每个节点 running/done 状态与输出。
|
||||
|
||||
### 4.3 执行引擎
|
||||
@@ -190,6 +193,7 @@
|
||||
|
||||
### 2026-08-16
|
||||
|
||||
- 工作流画布增强:节点支持从左侧面板拖入;画布滚轮缩放(25%–250%)与空白处拖动平移;节点右键菜单(修改名称/说明/收藏/复制/删除)、连线右键删除(单击不再误删);右侧边栏只显示选中节点的选项并移除“各节点输出”总列表;新增画布导入/导出 JSON;顶栏「返回工作流」单行显示、名称框固定宽度、保存/运行右对齐。
|
||||
- 新增「工作流」选项卡:画布式节点编辑器(开始/大模型/文本/输出,节点连线传参、拖动布局),内置 4 个预制工作流(翻译助手、内容总结、两步润色、写作助手)并支持自定义与恢复;新增 `workflows` 表与 `run_workflow` 命令,后端 `apps/desktop/src/workflow.rs` 执行引擎支持拓扑排序、模板变量(`{{input}}`/`{{节点id}}`)、循环检测,LLM 节点支持本地/在线大模型(节点 → 工作流 → 运行参数 → 自动回退);`workflow://node-status` 事件实时推送节点状态;5 个引擎单元测试 + 1 个真实模型端到端测试全部跑通。
|
||||
- 新增「智能体」选项卡:内置 6 个预制智能体 + 支持自定义智能体(名称/图标/描述/系统提示词/默认模型/启用开关);每个智能体拥有独立会话区(`conversations.agent_id` 隔离),新建会话自动继承智能体系统提示词与默认模型;系统提示词统一注入 `chat_send` / `regenerate_message` / `edit_message` 链路;删除智能体时级联删除其会话;支持「恢复预制智能体」。
|
||||
- 工具页新增「技能(Skill)」与「MCP 服务」管理;对话工具协议上线:模型用 `[[skill:…]]` / `[[mcp:…]]` 标记调用技能与 MCP 工具,系统执行后回填结果(最多 3 轮)。新增本地 MCP 测试服务器 `scripts/test_mcp_server.py` 与 3 个示例技能。
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface WorkflowNode {
|
||||
model_id?: string | null;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
note?: string;
|
||||
favorite?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type {
|
||||
DragEvent as ReactDragEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
@@ -15,6 +18,8 @@ import Icon from "../components/Icon";
|
||||
|
||||
const NODE_W = 230;
|
||||
const PORT_Y = 18;
|
||||
const CANVAS_W = 1600;
|
||||
const CANVAS_H = 1000;
|
||||
|
||||
const NODE_META: Record<
|
||||
string,
|
||||
@@ -31,6 +36,13 @@ interface NodeRunState {
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
interface CtxMenuState {
|
||||
kind: "node" | "edge";
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function defaultData(type: string): WorkflowNode["data"] {
|
||||
if (type === "llm") {
|
||||
return { prompt: "", model_id: null, temperature: 0.7, max_tokens: 2048 };
|
||||
@@ -40,6 +52,10 @@ function defaultData(type: string): WorkflowNode["data"] {
|
||||
return {};
|
||||
}
|
||||
|
||||
function newId(prefix: string) {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
export default function WorkflowCanvasPage() {
|
||||
const { workflowId } = useParams<{ workflowId: string }>();
|
||||
const workflows = useStore((s) => s.workflows);
|
||||
@@ -59,18 +75,32 @@ export default function WorkflowCanvasPage() {
|
||||
const [edges, setEdges] = useState<WorkflowEdge[]>([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 });
|
||||
const viewportRef = useRef(viewport);
|
||||
viewportRef.current = viewport;
|
||||
|
||||
const [dragNode, setDragNode] = useState<{
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
scale: number;
|
||||
} | null>(null);
|
||||
const [panning, setPanning] = useState<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
} | null>(null);
|
||||
const [tempEdge, setTempEdge] = useState<{
|
||||
from: string;
|
||||
x: number;
|
||||
y: number;
|
||||
x0: number;
|
||||
y0: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
} | null>(null);
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
|
||||
|
||||
const [runInput, setRunInput] = useState("");
|
||||
const [runModelId, setRunModelId] = useState<string | null>(null);
|
||||
@@ -80,7 +110,8 @@ export default function WorkflowCanvasPage() {
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const canvasWrapRef = useRef<HTMLDivElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refreshWorkflows();
|
||||
@@ -101,6 +132,7 @@ export default function WorkflowCanvasPage() {
|
||||
setNodeStatus({});
|
||||
setRunResult(null);
|
||||
setRunError(null);
|
||||
setCtxMenu(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workflow?.id]);
|
||||
|
||||
@@ -122,10 +154,43 @@ export default function WorkflowCanvasPage() {
|
||||
};
|
||||
}, [workflow?.id]);
|
||||
|
||||
// 鼠标滚轮缩放(以光标为中心)
|
||||
useEffect(() => {
|
||||
const el = canvasWrapRef.current;
|
||||
if (!el) return;
|
||||
const target = el;
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
const rect = target.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
setViewport((vp) => {
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const scale = Math.min(2.5, Math.max(0.25, vp.scale * factor));
|
||||
const wx = (mx - vp.x) / vp.scale;
|
||||
const wy = (my - vp.y) / vp.scale;
|
||||
return { scale, x: mx - wx * scale, y: my - wy * scale };
|
||||
});
|
||||
}
|
||||
target.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => target.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
// 关闭右键菜单(Esc)
|
||||
useEffect(() => {
|
||||
if (!ctxMenu) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setCtxMenu(null);
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [ctxMenu]);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((n) => n.id === selectedNodeId) ?? null,
|
||||
[nodes, selectedNodeId],
|
||||
);
|
||||
const selectedNodeRun = selectedNode ? nodeStatus[selectedNode.id] ?? null : null;
|
||||
|
||||
const modelNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -134,10 +199,16 @@ export default function WorkflowCanvasPage() {
|
||||
}, [models]);
|
||||
|
||||
function canvasPoint(clientX: number, clientY: number) {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
const rect = canvasWrapRef.current?.getBoundingClientRect();
|
||||
return { x: clientX - (rect?.left ?? 0), y: clientY - (rect?.top ?? 0) };
|
||||
}
|
||||
|
||||
function graphPoint(clientX: number, clientY: number) {
|
||||
const p = canvasPoint(clientX, clientY);
|
||||
const vp = viewportRef.current;
|
||||
return { x: (p.x - vp.x) / vp.scale, y: (p.y - vp.y) / vp.scale };
|
||||
}
|
||||
|
||||
function updateNode(id: string, patch: Partial<WorkflowNode>) {
|
||||
setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, ...patch } : n)));
|
||||
}
|
||||
@@ -148,24 +219,31 @@ export default function WorkflowCanvasPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function addNode(type: string) {
|
||||
const count = nodes.length;
|
||||
const id = `n_${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
|
||||
function addNodeAt(type: string, x: number, y: number) {
|
||||
const meta = NODE_META[type] ?? { label: type, icon: "?", desc: "" };
|
||||
const node: WorkflowNode = {
|
||||
id,
|
||||
id: newId("n"),
|
||||
type,
|
||||
label: meta.label,
|
||||
position: { x: 320 + (count % 3) * 40, y: 160 + (count % 4) * 50 },
|
||||
position: { x, y },
|
||||
data: defaultData(type),
|
||||
};
|
||||
setNodes((prev) => [...prev, node]);
|
||||
setSelectedNodeId(id);
|
||||
setSelectedNodeId(node.id);
|
||||
}
|
||||
|
||||
function addNode(type: string) {
|
||||
const rect = canvasWrapRef.current?.getBoundingClientRect();
|
||||
const vp = viewportRef.current;
|
||||
const cx = rect ? (rect.width / 2 - vp.x) / vp.scale : CANVAS_W / 2;
|
||||
const cy = rect ? (rect.height / 2 - vp.y) / vp.scale : CANVAS_H / 2;
|
||||
addNodeAt(type, cx - NODE_W / 2, cy - 20);
|
||||
}
|
||||
|
||||
function startNodeDrag(e: ReactPointerEvent, node: WorkflowNode) {
|
||||
e.stopPropagation();
|
||||
setSelectedNodeId(node.id);
|
||||
setCtxMenu(null);
|
||||
const p = canvasPoint(e.clientX, e.clientY);
|
||||
setDragNode({
|
||||
id: node.id,
|
||||
@@ -173,56 +251,76 @@ export default function WorkflowCanvasPage() {
|
||||
startY: p.y,
|
||||
origX: node.position.x,
|
||||
origY: node.position.y,
|
||||
scale: viewportRef.current.scale,
|
||||
});
|
||||
}
|
||||
|
||||
function startTempEdge(nodeId: string, e: ReactPointerEvent) {
|
||||
e.stopPropagation();
|
||||
const p = canvasPoint(e.clientX, e.clientY);
|
||||
setTempEdge({ from: nodeId, x: p.x, y: p.y });
|
||||
setCtxMenu(null);
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const p = graphPoint(e.clientX, e.clientY);
|
||||
setTempEdge({
|
||||
from: nodeId,
|
||||
x0: (node?.position.x ?? 0) + NODE_W,
|
||||
y0: (node?.position.y ?? 0) + PORT_Y,
|
||||
x1: p.x,
|
||||
y1: p.y,
|
||||
});
|
||||
}
|
||||
|
||||
function startPan(e: ReactPointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
setCtxMenu(null);
|
||||
const p = canvasPoint(e.clientX, e.clientY);
|
||||
const vp = viewportRef.current;
|
||||
setPanning({ startX: p.x, startY: p.y, origX: vp.x, origY: vp.y });
|
||||
}
|
||||
|
||||
// 节点拖动 / 画布平移 / 连线拖拽共用指针监听
|
||||
useEffect(() => {
|
||||
if (!dragNode && !tempEdge) return;
|
||||
if (!dragNode && !tempEdge && !panning) return;
|
||||
function onMove(e: PointerEvent) {
|
||||
const p = canvasPoint(e.clientX, e.clientY);
|
||||
if (dragNode) {
|
||||
const dx = (p.x - dragNode.startX) / dragNode.scale;
|
||||
const dy = (p.y - dragNode.startY) / dragNode.scale;
|
||||
setNodes((prev) =>
|
||||
prev.map((n) =>
|
||||
n.id === dragNode.id
|
||||
? {
|
||||
...n,
|
||||
position: {
|
||||
x: dragNode.origX + (p.x - dragNode.startX),
|
||||
y: dragNode.origY + (p.y - dragNode.startY),
|
||||
},
|
||||
}
|
||||
? { ...n, position: { x: dragNode.origX + dx, y: dragNode.origY + dy } }
|
||||
: n,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (panning) {
|
||||
setViewport((vp) => ({
|
||||
...vp,
|
||||
x: panning.origX + (p.x - panning.startX),
|
||||
y: panning.origY + (p.y - panning.startY),
|
||||
}));
|
||||
}
|
||||
if (tempEdge) {
|
||||
setTempEdge((prev) => (prev ? { ...prev, x: p.x, y: p.y } : prev));
|
||||
const g = graphPoint(e.clientX, e.clientY);
|
||||
setTempEdge((prev) => (prev ? { ...prev, x1: g.x, y1: g.y } : prev));
|
||||
}
|
||||
}
|
||||
function onUp(e: PointerEvent) {
|
||||
if (tempEdge) {
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
||||
const targetNode = el?.closest("[data-port='input']")?.getAttribute("data-node");
|
||||
if (targetNode && targetNode !== tempEdge.from) {
|
||||
const target = el?.closest("[data-port='input']")?.getAttribute("data-node");
|
||||
if (target && target !== tempEdge.from) {
|
||||
setEdges((prev) => {
|
||||
const rest = prev.filter(
|
||||
(ed) => !(ed.from === tempEdge.from && ed.to === targetNode),
|
||||
(ed) => !(ed.from === tempEdge.from && ed.to === target),
|
||||
);
|
||||
return [
|
||||
...rest,
|
||||
{ id: `e_${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`, from: tempEdge.from, to: targetNode },
|
||||
];
|
||||
return [...rest, { id: newId("e"), from: tempEdge.from, to: target }];
|
||||
});
|
||||
}
|
||||
setTempEdge(null);
|
||||
}
|
||||
setDragNode(null);
|
||||
setPanning(null);
|
||||
}
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
@@ -230,7 +328,7 @@ export default function WorkflowCanvasPage() {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
};
|
||||
}, [dragNode, tempEdge]);
|
||||
}, [dragNode, tempEdge, panning]);
|
||||
|
||||
function deleteEdge(id: string) {
|
||||
setEdges((prev) => prev.filter((e) => e.id !== id));
|
||||
@@ -242,6 +340,57 @@ export default function WorkflowCanvasPage() {
|
||||
if (selectedNodeId === id) setSelectedNodeId(null);
|
||||
}
|
||||
|
||||
function handleNodeContextMenu(e: React.MouseEvent, node: WorkflowNode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedNodeId(node.id);
|
||||
setCtxMenu({ kind: "node", id: node.id, x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
function handleEdgeContextMenu(e: React.MouseEvent, edge: WorkflowEdge) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({ kind: "edge", id: edge.id, x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
function renameNode(id: string) {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
const label = prompt("修改节点名称", node.label);
|
||||
if (label !== null && label.trim()) {
|
||||
updateNode(id, { label: label.trim() });
|
||||
}
|
||||
}
|
||||
|
||||
function editNodeNote(id: string) {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
const note = prompt("节点说明(备注)", node.data.note ?? "");
|
||||
if (note !== null) {
|
||||
updateNodeData(id, { note: note.trim() });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNodeFavorite(id: string) {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
updateNodeData(id, { favorite: !node.data.favorite });
|
||||
}
|
||||
|
||||
function duplicateNode(id: string) {
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
const copy: WorkflowNode = {
|
||||
...node,
|
||||
id: newId("n"),
|
||||
label: `${node.label} 副本`,
|
||||
position: { x: node.position.x + 40, y: node.position.y + 40 },
|
||||
data: JSON.parse(JSON.stringify(node.data)),
|
||||
};
|
||||
setNodes((prev) => [...prev, copy]);
|
||||
setSelectedNodeId(copy.id);
|
||||
}
|
||||
|
||||
async function saveWorkflow(): Promise<boolean> {
|
||||
if (!workflow) return false;
|
||||
try {
|
||||
@@ -294,6 +443,69 @@ export default function WorkflowCanvasPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const data = {
|
||||
app: "xianren-studio",
|
||||
type: "xianren-workflow",
|
||||
version: 1,
|
||||
workflow: { name, icon, description, nodes, edges, model_id: modelId },
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${(name || "workflow").replace(/[\\/:*?"<>|]/g, "_")}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setMsg("已导出画布 JSON");
|
||||
}
|
||||
|
||||
async function handleImportFile(file: File) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const wf = data?.workflow ?? data;
|
||||
const rawNodes = Array.isArray(wf?.nodes) ? wf.nodes : null;
|
||||
if (!rawNodes) throw new Error("JSON 中缺少有效的 nodes 数组");
|
||||
const sanitized = rawNodes.map((n: WorkflowNode) => ({
|
||||
id: String(n.id ?? newId("n")),
|
||||
type: String(n.type ?? "llm"),
|
||||
label: typeof n.label === "string" ? n.label : String(n.type ?? "节点"),
|
||||
position: {
|
||||
x: Number(n.position?.x ?? 0),
|
||||
y: Number(n.position?.y ?? 0),
|
||||
},
|
||||
data: n.data && typeof n.data === "object" ? n.data : {},
|
||||
}));
|
||||
const rawEdges = Array.isArray(wf?.edges) ? wf.edges : [];
|
||||
const sanitizedEdges = rawEdges
|
||||
.filter(
|
||||
(ed: WorkflowEdge) =>
|
||||
ed && typeof ed.from === "string" && typeof ed.to === "string",
|
||||
)
|
||||
.map((ed: WorkflowEdge) => ({
|
||||
id: String(ed.id ?? newId("e")),
|
||||
from: String(ed.from),
|
||||
to: String(ed.to),
|
||||
}));
|
||||
setName(typeof wf.name === "string" && wf.name ? wf.name : name);
|
||||
setIcon(typeof wf.icon === "string" && wf.icon ? wf.icon : icon);
|
||||
setDescription(typeof wf.description === "string" ? wf.description : description);
|
||||
setModelId(wf.model_id || modelId);
|
||||
setNodes(sanitized);
|
||||
setEdges(sanitizedEdges);
|
||||
setSelectedNodeId(null);
|
||||
setRunResult(null);
|
||||
setNodeStatus({});
|
||||
await saveWorkflow();
|
||||
setMsg("导入成功并已保存");
|
||||
} catch (e) {
|
||||
setRunError(`导入失败:${String(e)}`);
|
||||
} finally {
|
||||
if (importInputRef.current) importInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflowsLoaded) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">
|
||||
@@ -315,16 +527,17 @@ export default function WorkflowCanvasPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center gap-3 border-b border-border bg-panel px-4 py-2.5">
|
||||
<Link
|
||||
to="/workflows"
|
||||
className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-slate-400 hover:bg-panel-2 hover:text-slate-200"
|
||||
className="flex shrink-0 items-center gap-1 whitespace-nowrap rounded-lg px-2 py-1 text-xs text-slate-400 hover:bg-panel-2 hover:text-slate-200"
|
||||
>
|
||||
<Icon name="chevron-left" className="h-3.5 w-3.5" />
|
||||
工作流
|
||||
返回工作流
|
||||
</Link>
|
||||
<input
|
||||
className="input w-52 px-2 py-1 text-sm"
|
||||
className="input w-44 flex-none px-2 py-1 text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="工作流名称"
|
||||
@@ -335,11 +548,11 @@ export default function WorkflowCanvasPage() {
|
||||
>
|
||||
{workflow.kind === "preset" ? "预制" : "自定义"}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="shrink-0 text-xs text-slate-500">
|
||||
{nodes.length} 节点 · {edges.length} 连线
|
||||
</span>
|
||||
{msg ? <span className="text-xs text-emerald-400">{msg}</span> : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{msg ? <span className="shrink-0 text-xs text-emerald-400">{msg}</span> : null}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<button className="btn-secondary" onClick={() => saveWorkflow()}>
|
||||
保存
|
||||
</button>
|
||||
@@ -350,37 +563,72 @@ export default function WorkflowCanvasPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* 节点面板 */}
|
||||
{/* 左侧节点面板(可拖入画布) */}
|
||||
<div className="flex w-44 shrink-0 flex-col gap-2 overflow-y-auto border-r border-border bg-panel p-3">
|
||||
<div className="text-xs uppercase text-slate-500">节点</div>
|
||||
<div className="text-xs uppercase text-slate-500">节点(拖入画布)</div>
|
||||
{Object.entries(NODE_META).map(([type, meta]) => (
|
||||
<button
|
||||
key={type}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-panel-2 px-3 py-2 text-left text-xs text-slate-300 hover:border-accent/50 hover:text-white"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", type);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
className="flex cursor-grab items-center gap-2 rounded-lg border border-border bg-panel-2 px-3 py-2 text-left text-xs text-slate-300 hover:border-accent/50 hover:text-white active:cursor-grabbing"
|
||||
onClick={() => addNode(type)}
|
||||
title={meta.desc}
|
||||
title={`${meta.desc}(点击或拖入画布)`}
|
||||
>
|
||||
<span className={`text-base ${meta.color}`}>{meta.icon}</span>
|
||||
<span className="flex-1">
|
||||
<span className="block font-medium">{meta.label}</span>
|
||||
<span className="block truncate text-[10px] text-slate-500">{meta.desc}</span>
|
||||
</span>
|
||||
<span className="text-slate-500">+</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-2 rounded-lg border border-dashed border-border p-2 text-[10px] leading-relaxed text-slate-500">
|
||||
提示词中可用 <code className="text-slate-300">{"{{input}}"}</code> 与{" "}
|
||||
<code className="text-slate-300">{"{{节点id}}"}</code> 引用上游输出。
|
||||
滚轮缩放 · 拖动空白平移 · 右键节点/连线操作。
|
||||
<br />
|
||||
提示词可用 <code className="text-slate-300">{"{{input}}"}</code> 与{" "}
|
||||
<code className="text-slate-300">{"{{节点id}}"}</code> 引用。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画布 */}
|
||||
<div className="min-w-0 flex-1 overflow-auto bg-[#0a0d13]">
|
||||
<div ref={canvasRef} className="relative" style={{ width: 1200, height: 720 }}>
|
||||
<div
|
||||
ref={canvasWrapRef}
|
||||
className={`relative min-w-0 flex-1 overflow-hidden bg-[#0a0d13] ${
|
||||
panning ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
onPointerDown={startPan}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e: ReactDragEvent) => {
|
||||
e.preventDefault();
|
||||
const type = e.dataTransfer.getData("text/plain");
|
||||
if (!NODE_META[type]) return;
|
||||
const g = graphPoint(e.clientX, e.clientY);
|
||||
addNodeAt(type, g.x - NODE_W / 2, g.y - 20);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0"
|
||||
style={{
|
||||
width: CANVAS_W,
|
||||
height: CANVAS_H,
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||||
transformOrigin: "0 0",
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, rgba(148,163,184,0.16) 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0"
|
||||
width={1200}
|
||||
height={720}
|
||||
width={CANVAS_W}
|
||||
height={CANVAS_H}
|
||||
>
|
||||
{edges.map((edge) => {
|
||||
const from = nodes.find((n) => n.id === edge.from);
|
||||
@@ -399,7 +647,8 @@ export default function WorkflowCanvasPage() {
|
||||
stroke="transparent"
|
||||
strokeWidth={16}
|
||||
style={{ pointerEvents: "stroke", cursor: "pointer" }}
|
||||
onClick={() => deleteEdge(edge.id)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => handleEdgeContextMenu(e, edge)}
|
||||
/>
|
||||
<path
|
||||
d={d}
|
||||
@@ -413,7 +662,7 @@ export default function WorkflowCanvasPage() {
|
||||
})}
|
||||
{tempEdge ? (
|
||||
<path
|
||||
d={`M ${tempEdge.x} ${tempEdge.y} L ${tempEdge.x} ${tempEdge.y}`}
|
||||
d={`M ${tempEdge.x0} ${tempEdge.y0} L ${tempEdge.x1} ${tempEdge.y1}`}
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth={2}
|
||||
@@ -428,19 +677,20 @@ export default function WorkflowCanvasPage() {
|
||||
selected={selectedNodeId === node.id}
|
||||
runState={nodeStatus[node.id] ?? null}
|
||||
onSelect={() => setSelectedNodeId(node.id)}
|
||||
onContextMenu={(e) => handleNodeContextMenu(e, node)}
|
||||
onDragStart={(e) => startNodeDrag(e, node)}
|
||||
onOutputPointerDown={(e) => startTempEdge(node.id, e)}
|
||||
/>
|
||||
))}
|
||||
{nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-slate-600">
|
||||
从左侧添加节点,开始编排工作流
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-slate-600">
|
||||
从左侧拖入节点,或点击节点添加到画布
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧面板 */}
|
||||
{/* 右侧面板:只显示选中节点的选项 */}
|
||||
<div className="flex w-80 shrink-0 flex-col gap-4 overflow-y-auto border-l border-border bg-panel p-3">
|
||||
<section>
|
||||
<div className="mb-2 text-xs uppercase text-slate-500">运行工作流</div>
|
||||
@@ -495,6 +745,7 @@ export default function WorkflowCanvasPage() {
|
||||
{selectedNode ? (
|
||||
<NodeEditor
|
||||
node={selectedNode}
|
||||
runState={selectedNodeRun}
|
||||
models={models}
|
||||
modelNameById={modelNameById}
|
||||
onChange={(patch) => updateNode(selectedNode.id, patch)}
|
||||
@@ -502,7 +753,9 @@ export default function WorkflowCanvasPage() {
|
||||
onDelete={() => deleteNode(selectedNode.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-slate-600">点击画布中的节点进行设置</div>
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-4 text-center text-xs text-slate-600">
|
||||
点击画布中的节点后,这里才显示该节点的选项
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -551,53 +804,95 @@ export default function WorkflowCanvasPage() {
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 text-xs uppercase text-slate-500">各节点输出</div>
|
||||
{Object.keys(nodeStatus).length === 0 ? (
|
||||
<div className="text-xs text-slate-600">运行工作流后在此查看每个节点的输出</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{nodes.map((n) => {
|
||||
const st = nodeStatus[n.id];
|
||||
if (!st) return null;
|
||||
return (
|
||||
<div key={n.id} className="rounded-lg border border-border bg-panel-2 px-2.5 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
st.status === "done"
|
||||
? "bg-emerald-400"
|
||||
: st.status === "running"
|
||||
? "animate-pulse bg-amber-400"
|
||||
: "bg-red-400"
|
||||
}`}
|
||||
/>
|
||||
{n.label}
|
||||
</div>
|
||||
{st.text != null ? (
|
||||
<div className="mt-1 line-clamp-3 whitespace-pre-wrap text-[11px] text-slate-500">
|
||||
{st.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="btn-secondary flex-1 text-xs" onClick={handleExport}>
|
||||
导出画布
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary flex-1 text-xs"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
>
|
||||
导入画布
|
||||
</button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleImportFile(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{ctxMenu ? (
|
||||
<div
|
||||
className="fixed z-50 w-44 rounded-lg border border-border bg-panel-2 p-1 shadow-xl"
|
||||
style={{
|
||||
left: Math.min(ctxMenu.x, window.innerWidth - 190),
|
||||
top: Math.min(ctxMenu.y, window.innerHeight - 240),
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{ctxMenu.kind === "node" ? (
|
||||
<>
|
||||
<CtxMenuItem label="修改节点名称" onClick={() => renameNode(ctxMenu.id)} />
|
||||
<CtxMenuItem label="说明 / 备注" onClick={() => editNodeNote(ctxMenu.id)} />
|
||||
<CtxMenuItem
|
||||
label={
|
||||
nodes.find((n) => n.id === ctxMenu.id)?.data.favorite
|
||||
? "取消收藏"
|
||||
: "收藏"
|
||||
}
|
||||
onClick={() => toggleNodeFavorite(ctxMenu.id)}
|
||||
/>
|
||||
<CtxMenuItem label="复制节点" onClick={() => duplicateNode(ctxMenu.id)} />
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
<CtxMenuItem danger label="删除节点" onClick={() => deleteNode(ctxMenu.id)} />
|
||||
</>
|
||||
) : (
|
||||
<CtxMenuItem danger label="删除连线" onClick={() => deleteEdge(ctxMenu.id)} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CtxMenuItem({
|
||||
label,
|
||||
danger,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`block w-full rounded px-2.5 py-1.5 text-left text-xs ${
|
||||
danger ? "text-red-300 hover:bg-red-500/20" : "text-slate-300 hover:bg-white/10"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasNode({
|
||||
node,
|
||||
selected,
|
||||
runState,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onDragStart,
|
||||
onOutputPointerDown,
|
||||
}: {
|
||||
@@ -605,10 +900,17 @@ function CanvasNode({
|
||||
selected: boolean;
|
||||
runState: NodeRunState | null;
|
||||
onSelect: () => void;
|
||||
onContextMenu: (e: React.MouseEvent) => void;
|
||||
onDragStart: (e: ReactPointerEvent) => void;
|
||||
onOutputPointerDown: (e: ReactPointerEvent) => void;
|
||||
}) {
|
||||
const meta = NODE_META[node.type] ?? { label: node.type, icon: "?", color: "text-slate-300", desc: "" };
|
||||
const meta =
|
||||
NODE_META[node.type] ?? {
|
||||
label: node.type,
|
||||
icon: "?",
|
||||
color: "text-slate-300",
|
||||
desc: "",
|
||||
};
|
||||
const statusColor =
|
||||
runState?.status === "done"
|
||||
? "border-emerald-500/60"
|
||||
@@ -636,6 +938,8 @@ function CanvasNode({
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-grab items-center gap-2 rounded-t-xl border-b border-border/60 bg-panel-2 px-3 py-1.5 active:cursor-grabbing"
|
||||
@@ -646,12 +950,22 @@ function CanvasNode({
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-slate-200">
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-slate-500">{node.id}</span>
|
||||
{node.data.favorite ? (
|
||||
<span className="shrink-0 text-xs text-amber-300" title="已收藏">
|
||||
★
|
||||
</span>
|
||||
) : null}
|
||||
<span className="shrink-0 truncate text-[10px] text-slate-500">{node.id}</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="line-clamp-2 whitespace-pre-wrap text-[11px] text-slate-400">
|
||||
{preview || "(空)"}
|
||||
</div>
|
||||
{node.data.note ? (
|
||||
<div className="mt-1 line-clamp-2 whitespace-pre-wrap text-[10px] text-amber-200/70">
|
||||
📝 {node.data.note}
|
||||
</div>
|
||||
) : null}
|
||||
{runState?.text != null ? (
|
||||
<div className="mt-1.5 rounded bg-panel-2 px-2 py-1 line-clamp-2 whitespace-pre-wrap text-[10px] text-emerald-300/90">
|
||||
{runState.text}
|
||||
@@ -684,6 +998,7 @@ function CanvasNode({
|
||||
|
||||
function NodeEditor({
|
||||
node,
|
||||
runState,
|
||||
models,
|
||||
modelNameById,
|
||||
onChange,
|
||||
@@ -691,6 +1006,7 @@ function NodeEditor({
|
||||
onDelete,
|
||||
}: {
|
||||
node: WorkflowNode;
|
||||
runState: NodeRunState | null;
|
||||
models: ModelInfo[];
|
||||
modelNameById: Map<string, string>;
|
||||
onChange: (patch: Partial<WorkflowNode>) => void;
|
||||
@@ -792,10 +1108,36 @@ function NodeEditor({
|
||||
className="input mt-1 min-h-[80px] resize-y text-xs"
|
||||
value={node.data.template ?? ""}
|
||||
onChange={(e) => onDataChange({ template: e.target.value })}
|
||||
placeholder='例如:{{n_llm}}'
|
||||
placeholder="例如:{{n_llm}}"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-slate-400">说明 / 备注(显示在节点上)</span>
|
||||
<input
|
||||
className="input mt-1 text-xs"
|
||||
value={node.data.note ?? ""}
|
||||
onChange={(e) => onDataChange({ note: e.target.value })}
|
||||
placeholder="可填写节点用途说明"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!node.data.favorite}
|
||||
onChange={() => onDataChange({ favorite: !node.data.favorite })}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
收藏该节点
|
||||
</label>
|
||||
{runState?.text != null ? (
|
||||
<div className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
|
||||
<div className="text-[10px] text-emerald-300">本节点输出</div>
|
||||
<div className="mt-1 max-h-28 overflow-y-auto whitespace-pre-wrap text-[11px] leading-relaxed text-slate-200">
|
||||
{runState.text}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="mt-1 w-full rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/20"
|
||||
onClick={onDelete}
|
||||
|
||||
Reference in New Issue
Block a user