"use client"; import { addEdge, Background, BackgroundVariant, type Connection, Controls, type Edge, type Node, type NodeChange, type OnConnectEnd, Panel, ReactFlow, useEdgesState, useNodesState, useReactFlow, } from "@xyflow/react"; import { Braces, Plus, Settings2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { EditableTitle } from "@/components/assistant-editor/editor-controls"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { AddNodeDialog } from "./AddNodeDialog"; import { edgeTypes } from "./ConditionEdge"; import { hasDefaultPath, newEdgeData } from "./edge-rules"; import { ActiveNodeContext, EdgeActionContext, NodeActionContext, NodeSpecsContext, } from "./context"; import { nodeTypes } from "./GenericNode"; import { EdgeSettingsPanel } from "./panels/EdgeSettingsPanel"; import { GlobalSettingsPanel } from "./panels/GlobalSettingsPanel"; import { NodeSettingsPanel } from "./panels/NodeSettingsPanel"; import { WorkflowSidePanel } from "./WorkflowSidePanel"; import { defaultGraph, type NodeSpecMap, type RuntimeNodeSpec, type WorkflowGraph, type WorkflowEdgeData, type WorkflowNodeData, type WorkflowNodeType, } from "./specs"; import type { WorkflowEditorProps } from "./types"; import { workflowGraphWithSettings } from "./graph-settings"; let nodeSeq = 0; function defaultNodeData(spec: RuntimeNodeSpec): WorkflowNodeData { const data: WorkflowNodeData = { name: spec.displayName, }; if (spec.type === "agent") { Object.assign(data, { contextPolicy: "inherit", inheritGlobalConfig: true, entryMode: "wait_user", }); } else if (spec.type === "action") { Object.assign(data, { arguments: {}, resultAssignmentMode: "inherit", userInputPolicy: "queue", }); } else if (spec.type === "message") { Object.assign(data, { speech: "", title: "重要提示", message: "", confirmLabel: "确认", completionPolicy: "playback", }); } for (const field of spec.fields) { if (field.default !== undefined) data[field.key] = field.default; } return data; } function toFlow(graph: WorkflowGraph): { nodes: Node[]; edges: Edge[] } { return { nodes: graph.nodes.map((n) => ({ id: n.id, type: n.type, position: n.position, data: n.data, })), edges: graph.edges.map((e) => ({ id: e.id, type: "condition", source: e.source, target: e.target, data: e.data ?? {}, })), }; } function fromFlow(nodes: Node[], edges: Edge[]): WorkflowGraph { return { specVersion: 3, settings: { globalPrompt: "", defaultLlmResourceId: "", defaultAsrResourceId: "", defaultTtsResourceId: "", visionEnabled: false, visionModelResourceId: "", toolIds: [], knowledgeBaseId: "", knowledgeMode: "automatic", knowledgeTopN: 5, knowledgeScoreThreshold: 0, enableInterrupt: true, turnConfig: defaultGraph().settings.turnConfig, }, nodes: nodes.map((n) => ({ id: n.id, type: n.type as WorkflowNodeType, position: n.position, data: n.data as WorkflowNodeData, })), edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target, data: (e.data ?? { mode: "always", priority: 10, }) as WorkflowGraph["edges"][number]["data"], })), }; } export function WorkflowCanvas({ value, onChange, settings, onSettingsChange, modelOptions, activeNodeId, dynamicVariablesOpen, onDynamicVariablesOpenChange, dynamicVariablesPanel, editingNodeId, onEditingNodeIdChange, editingEdgeId, onEditingEdgeIdChange, settingsOpen, onSettingsOpenChange: setSettingsOpen, debugOpen, onDebugOpenChange, debugPanel, specsByType, toolOptions = [], knowledgeOptions = [], }: WorkflowEditorProps & { specsByType: NodeSpecMap }) { const initial = useMemo(() => toFlow(value ?? defaultGraph()), [value]); const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges); const [addOpen, setAddOpen] = useState(false); const [addSourceId, setAddSourceId] = useState(null); const [addPosition, setAddPosition] = useState<{ x: number; y: number } | null>(null); // Fast Refresh 会替换模块级对象,而保留 React Flow 实例。用 state 固定本次 // 挂载使用的映射,避免热更新后被误判为每次渲染都在创建新对象。 const [flowNodeTypes] = useState(nodeTypes); const [flowEdgeTypes] = useState(edgeTypes); const { screenToFlowPosition } = useReactFlow(); // 只在可持久化的画布内容真正变化时回传。React Flow 挂载时会写入节点尺寸等 // 内部状态,这些不属于 workflow graph,不能因此把刚加载的助手标记为未保存。 const onChangeRef = useRef(onChange); const lastSyncedGraphRef = useRef( JSON.stringify( workflowGraphWithSettings( fromFlow(initial.nodes, initial.edges), settings, ), ), ); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { const graph = workflowGraphWithSettings(fromFlow(nodes, edges), settings); const nextGraphJson = JSON.stringify(graph); if (nextGraphJson === lastSyncedGraphRef.current) return; lastSyncedGraphRef.current = nextGraphJson; onChangeRef.current?.(graph); }, [nodes, edges, settings]); const onConnect = useCallback( (connection: Connection) => { if (!connection.source || !connection.target) return; const sourceType = nodes.find( (node) => node.id === connection.source, )?.type; setEdges((eds) => addEdge( { ...connection, id: `e-${connection.source}-${connection.target}-${Date.now()}`, type: "condition", animated: true, data: newEdgeData(eds, connection.source, sourceType), }, eds, ), ); }, [nodes, setEdges], ); // 连线约束:不能连入开始节点(无入边句柄),不能自连。 const isValidConnection = useCallback( (c: Connection | Edge) => { if (c.source === c.target) return false; const source = nodes.find((n) => n.id === c.source); const target = nodes.find((n) => n.id === c.target); if (!source || !target) return false; const sourceSpec = specsByType[source.type as string]; const targetSpec = specsByType[target.type as string]; if (!sourceSpec?.hasSource || !targetSpec?.hasTarget) return false; if (edges.some((e) => e.source === c.source && e.target === c.target)) { return false; } const sourceLimit = sourceSpec.constraints.maxOutgoing; if ( sourceLimit !== undefined && edges.filter((e) => e.source === c.source).length >= sourceLimit ) { return false; } const targetLimit = targetSpec.constraints.maxIncoming; if ( targetLimit !== undefined && edges.filter((e) => e.target === c.target).length >= targetLimit ) { return false; } return true; }, [edges, nodes, specsByType], ); const addNode = useCallback( (spec: RuntimeNodeSpec) => { nodeSeq += 1; const id = `${spec.type}-${Date.now()}-${nodeSeq}`; const source = addSourceId ? nodes.find((node) => node.id === addSourceId) : undefined; const position = addPosition ? { x: addPosition.x - 125, y: addPosition.y } : screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2, }); const data = defaultNodeData(spec); setNodes((ns) => [...ns, { id, type: spec.type, position, data }]); if (source) { setEdges((currentEdges) => { return addEdge( { id: `e-${source.id}-${id}-${Date.now()}`, source: source.id, target: id, type: "condition", animated: true, data: newEdgeData(currentEdges, source.id, source.type), }, currentEdges, ); }); } setAddOpen(false); setAddSourceId(null); setAddPosition(null); setSettingsOpen(false); onDynamicVariablesOpenChange(false); onDebugOpenChange(false); onEditingEdgeIdChange(null); onEditingNodeIdChange(id); }, [ addPosition, addSourceId, nodes, onDebugOpenChange, onDynamicVariablesOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, screenToFlowPosition, setEdges, setNodes, setSettingsOpen, ], ); const updateNodeData = useCallback( (id: string, patch: Partial) => { setNodes((ns) => ns.map((n) => n.id === id ? { ...n, data: { ...n.data, ...patch } } : n, ), ); }, [setNodes], ); const deleteNode = useCallback( (id: string) => { if (nodes.find((node) => node.id === id)?.type === "start") return; setNodes((ns) => ns.filter((n) => n.id !== id)); setEdges((es) => es.filter((e) => e.source !== id && e.target !== id)); if (editingNodeId === id) onEditingNodeIdChange(null); }, [editingNodeId, nodes, onEditingNodeIdChange, setNodes, setEdges], ); const duplicateNode = useCallback( (id: string) => { const source = nodes.find((node) => node.id === id); if (!source || source.type === "start") return; const spec = specsByType[source.type as string]; if (!spec) return; const limit = spec.constraints.maxInstances; if ( limit !== undefined && nodes.filter((node) => node.type === source.type).length >= limit ) { return; } nodeSeq += 1; const duplicateId = `${source.type}-${Date.now()}-${nodeSeq}`; const sourceData = source.data as WorkflowNodeData; const duplicateData = structuredClone(sourceData); duplicateData.name = `${sourceData.name || spec.displayName}(副本)`; setNodes((currentNodes) => [ ...currentNodes.map((node) => ({ ...node, selected: false })), { id: duplicateId, type: source.type, position: { x: source.position.x + 40, y: source.position.y + 40, }, data: duplicateData, selected: true, }, ]); onEditingNodeIdChange(null); }, [ nodes, onEditingNodeIdChange, setNodes, specsByType, ], ); const handleNodesChange = useCallback( (changes: NodeChange[]) => { const startIds = new Set( nodes.filter((node) => node.type === "start").map((node) => node.id), ); onNodesChange( changes.filter( (change) => change.type !== "remove" || !startIds.has(change.id), ), ); }, [nodes, onNodesChange], ); const updateEdgeData = useCallback( ( id: string, patch: Partial, ) => { setEdges((es) => es.map((e) => { if (e.id !== id) return e; if ( patch.mode === "always" && hasDefaultPath(es, e.source, e.id) ) { return e; } return { ...e, data: { ...(e.data ?? {}), ...patch } }; }), ); }, [setEdges], ); const deleteEdge = useCallback( (id: string) => { setEdges((es) => es.filter((e) => e.id !== id)); if (editingEdgeId === id) onEditingEdgeIdChange(null); }, [editingEdgeId, onEditingEdgeIdChange, setEdges], ); const canCreateFromSource = useCallback( (id: string) => { const source = nodes.find((node) => node.id === id); if (!source) return false; const spec = specsByType[source.type as string]; if (!spec?.hasSource) return false; const outgoingCount = edges.filter((edge) => edge.source === id).length; if ( spec.constraints.maxOutgoing !== undefined && outgoingCount >= spec.constraints.maxOutgoing ) { return false; } return true; }, [edges, nodes, specsByType], ); const onConnectEnd = useCallback( (event, connectionState) => { if ( connectionState.isValid || connectionState.toNode || connectionState.fromHandle?.type !== "source" || !connectionState.fromNode || !canCreateFromSource(connectionState.fromNode.id) ) { return; } const pointer = "changedTouches" in event ? event.changedTouches[0] : event; if (!pointer) return; setAddSourceId(connectionState.fromNode.id); setAddPosition( screenToFlowPosition({ x: pointer.clientX, y: pointer.clientY }), ); setAddOpen(true); }, [canCreateFromSource, screenToFlowPosition], ); const openSettings = useCallback(() => { onDebugOpenChange(false); onDynamicVariablesOpenChange(false); onEditingNodeIdChange(null); onEditingEdgeIdChange(null); setSettingsOpen(true); }, [ onDebugOpenChange, onDynamicVariablesOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, setSettingsOpen, ]); const nodeActions = useMemo( () => ({ edit: (id: string) => { setSettingsOpen(false); onDynamicVariablesOpenChange(false); onDebugOpenChange(false); onEditingEdgeIdChange(null); onEditingNodeIdChange(id); }, duplicate: duplicateNode, remove: deleteNode, }), [ deleteNode, duplicateNode, onDebugOpenChange, onDynamicVariablesOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, setSettingsOpen, ], ); const edgeActions = useMemo( () => ({ edit: (id: string) => { setSettingsOpen(false); onDynamicVariablesOpenChange(false); onDebugOpenChange(false); onEditingNodeIdChange(null); onEditingEdgeIdChange(id); }, remove: deleteEdge, }), [ deleteEdge, onDebugOpenChange, onDynamicVariablesOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, setSettingsOpen, ], ); const openDynamicVariables = useCallback(() => { setSettingsOpen(false); onDebugOpenChange(false); onEditingNodeIdChange(null); onEditingEdgeIdChange(null); onDynamicVariablesOpenChange(true); }, [ onDebugOpenChange, onDynamicVariablesOpenChange, onEditingEdgeIdChange, onEditingNodeIdChange, setSettingsOpen, ]); const editingNode = nodes.find((n) => n.id === editingNodeId); const editingSpec = editingNode ? specsByType[editingNode.type as string] : null; const editingEdge = edges.find((e) => e.id === editingEdgeId); const editingEdgeData = editingEdge?.data as WorkflowEdgeData | undefined; const addableSpecs = Object.values(specsByType).filter((s) => s.addable); const canAddSpec = useCallback( (spec: RuntimeNodeSpec) => { const limit = spec.constraints.maxInstances; if (limit === undefined) return true; return nodes.filter((node) => node.type === spec.type).length < limit; }, [nodes], ); return (
nodeActions.edit(node.id)} onEdgeClick={(_, edge) => edgeActions.edit(edge.id)} isValidConnection={isValidConnection} onPaneClick={() => { onEditingEdgeIdChange(null); }} fitView proOptions={{ hideAttribution: true }} defaultEdgeOptions={{ type: "condition", animated: true }} >
添加节点 工作流设置 动态变量
{ setAddOpen(open); if (!open) { setAddSourceId(null); setAddPosition(null); } }} onSelect={addNode} /> {(debugOpen || dynamicVariablesOpen || settingsOpen || (editingNode && editingSpec) || editingEdge) && ( updateEdgeData(editingEdge.id, { label: label || undefined, }) } /> ) : editingNode && editingSpec ? ( updateNodeData(editingNode.id, { name }) } /> ) : "编辑节点" } closeLabel={ dynamicVariablesOpen ? "关闭动态变量" : settingsOpen ? "关闭工作流设置" : editingEdge ? "关闭边编辑" : "关闭节点编辑" } onClose={ debugOpen ? undefined : () => { if (dynamicVariablesOpen) { onDynamicVariablesOpenChange(false); } else if (settingsOpen) setSettingsOpen(false); else if (editingEdge) onEditingEdgeIdChange(null); else onEditingNodeIdChange(null); } } > {debugOpen ? ( debugPanel ) : dynamicVariablesOpen ? ( dynamicVariablesPanel ) : (
{settingsOpen ? ( ) : editingNode && editingSpec ? ( updateNodeData(editingNode.id, patch) } /> ) : editingEdge ? ( node.id === editingEdge.source, )?.type as string | undefined } isOnlyOutgoing={ edges.filter( (edge) => edge.source === editingEdge.source, ).length === 1 } hasOtherDefaultPath={hasDefaultPath( edges, editingEdge.source, editingEdge.id, )} onChange={(patch) => updateEdgeData(editingEdge.id, patch) } /> ) : null}
)}
)}
); }