diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8718315..c1f79ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 任务(下载/部署进度) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ef8428c..f425475 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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 个示例技能。 diff --git a/ui/src/api.ts b/ui/src/api.ts index 0db2035..afffcdd 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -111,6 +111,8 @@ export interface WorkflowNode { model_id?: string | null; temperature?: number; max_tokens?: number; + note?: string; + favorite?: boolean; }; } diff --git a/ui/src/pages/WorkflowCanvasPage.tsx b/ui/src/pages/WorkflowCanvasPage.tsx index 38a15e7..14cb743 100644 --- a/ui/src/pages/WorkflowCanvasPage.tsx +++ b/ui/src/pages/WorkflowCanvasPage.tsx @@ -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([]); const [selectedNodeId, setSelectedNodeId] = useState(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(null); const [runInput, setRunInput] = useState(""); const [runModelId, setRunModelId] = useState(null); @@ -80,7 +110,8 @@ export default function WorkflowCanvasPage() { const [runError, setRunError] = useState(null); const [msg, setMsg] = useState(null); - const canvasRef = useRef(null); + const canvasWrapRef = useRef(null); + const importInputRef = useRef(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(); @@ -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) { 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 { 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 (
@@ -315,16 +527,17 @@ export default function WorkflowCanvasPage() { return (
+ {/* 顶部工具栏 */}
- 工作流 + 返回工作流 setName(e.target.value)} placeholder="工作流名称" @@ -335,11 +548,11 @@ export default function WorkflowCanvasPage() { > {workflow.kind === "preset" ? "预制" : "自定义"} - + {nodes.length} 节点 · {edges.length} 连线 - {msg ? {msg} : null} -
+ {msg ? {msg} : null} +
@@ -350,37 +563,72 @@ export default function WorkflowCanvasPage() {
- {/* 节点面板 */} + {/* 左侧节点面板(可拖入画布) */}
-
节点
+
节点(拖入画布)
{Object.entries(NODE_META).map(([type, meta]) => ( ))}
- 提示词中可用 {"{{input}}"} 与{" "} - {"{{节点id}}"} 引用上游输出。 + 滚轮缩放 · 拖动空白平移 · 右键节点/连线操作。 +
+ 提示词可用 {"{{input}}"} 与{" "} + {"{{节点id}}"} 引用。
{/* 画布 */} -
-
+
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); + }} + > +
{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)} /> setSelectedNodeId(node.id)} + onContextMenu={(e) => handleNodeContextMenu(e, node)} onDragStart={(e) => startNodeDrag(e, node)} onOutputPointerDown={(e) => startTempEdge(node.id, e)} /> ))} {nodes.length === 0 ? ( -
- 从左侧添加节点,开始编排工作流 +
+ 从左侧拖入节点,或点击节点添加到画布
) : null}
- {/* 右侧面板 */} + {/* 右侧面板:只显示选中节点的选项 */}
运行工作流
@@ -495,6 +745,7 @@ export default function WorkflowCanvasPage() { {selectedNode ? ( updateNode(selectedNode.id, patch)} @@ -502,7 +753,9 @@ export default function WorkflowCanvasPage() { onDelete={() => deleteNode(selectedNode.id)} /> ) : ( -
点击画布中的节点进行设置
+
+ 点击画布中的节点后,这里才显示该节点的选项 +
)}
@@ -551,53 +804,95 @@ export default function WorkflowCanvasPage() { /> 启用 - - -
-
各节点输出
- {Object.keys(nodeStatus).length === 0 ? ( -
运行工作流后在此查看每个节点的输出
- ) : ( -
- {nodes.map((n) => { - const st = nodeStatus[n.id]; - if (!st) return null; - return ( -
-
- - {n.label} -
- {st.text != null ? ( -
- {st.text} -
- ) : null} -
- ); - })} -
- )} +
+ + + { + if (e.target.files && e.target.files.length > 0) { + handleImportFile(e.target.files[0]); + } + }} + /> +
+ + {/* 右键菜单 */} + {ctxMenu ? ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {ctxMenu.kind === "node" ? ( + <> + renameNode(ctxMenu.id)} /> + editNodeNote(ctxMenu.id)} /> + n.id === ctxMenu.id)?.data.favorite + ? "取消收藏" + : "收藏" + } + onClick={() => toggleNodeFavorite(ctxMenu.id)} + /> + duplicateNode(ctxMenu.id)} /> +
+ deleteNode(ctxMenu.id)} /> + + ) : ( + deleteEdge(ctxMenu.id)} /> + )} +
+ ) : null}
); } +function CtxMenuItem({ + label, + danger, + onClick, +}: { + label: string; + danger?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + 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} >
{node.label} - {node.id} + {node.data.favorite ? ( + + ★ + + ) : null} + {node.id}
{preview || "(空)"}
+ {node.data.note ? ( +
+ 📝 {node.data.note} +
+ ) : null} {runState?.text != null ? (
{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; onChange: (patch: Partial) => 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}}" /> ) : null} + + + {runState?.text != null ? ( +
+
本节点输出
+
+ {runState.text} +
+
+ ) : null}