diff --git a/service-web/client/src/pages/ai/flow/FlowEditor.tsx b/service-web/client/src/pages/ai/flow/FlowEditor.tsx index c556e4d..afeab5c 100644 --- a/service-web/client/src/pages/ai/flow/FlowEditor.tsx +++ b/service-web/client/src/pages/ai/flow/FlowEditor.tsx @@ -1,5 +1,16 @@ import {PlusCircleFilled, SaveFilled} from '@ant-design/icons' -import {Background, BackgroundVariant, Controls, MiniMap, type Node, type NodeProps, ReactFlow} from '@xyflow/react' +import { + Background, + BackgroundVariant, + type Connection, + Controls, + getIncomers, + getOutgoers, + MiniMap, + type Node, + type NodeProps, + ReactFlow, +} from '@xyflow/react' import {useMount} from 'ahooks' import type {Schema} from 'amis' import {Button, Drawer, Dropdown, message, Space} from 'antd' @@ -57,6 +68,7 @@ function FlowEditor() { const {data, setData, getDataById, setDataById} = useDataStore() const { nodes, + getNodeById, addNode, removeNode, setNodes, @@ -137,90 +149,135 @@ function FlowEditor() { } } + const checkNode = (type: string) => { + if (isEqual(type, 'start-amis-node') && findIdx(nodes, (node: Node) => isEqual(type, node.type)) > -1) { + throw new Error('只能存在1个开始节点') + } + if (isEqual(type, 'end-amis-node') && findIdx(nodes, (node: Node) => isEqual(type, node.type)) > -1) { + throw new Error('只能存在1个结束节点') + } + } + + const checkConnection = (connection: Connection) => { + let sourceNode = getNodeById(connection.source) + if (!sourceNode) { + throw new Error('连线起始节点未找到') + } + let targetNode = getNodeById(connection.target) + if (!targetNode) { + throw new Error('连线目标节点未找到') + } + console.log(sourceNode, targetNode, connection) + // 禁止短路整个流程 + if (isEqual('start-amis-node', sourceNode.type) && isEqual('end-amis-node', targetNode.type)) { + throw new Error('开始节点不能直连结束节点') + } + + // 禁止流程出现环,必须是有向无环图 + const hasCycle = (node: Node, visited = new Set()) => { + if (visited.has(node.id)) return false + visited.add(node.id) + for (const outgoer of getOutgoers(node, nodes, edges)) { + if (isEqual(outgoer.id, sourceNode?.id)) return true + if (hasCycle(outgoer, visited)) return true + } + } + if (isEqual(sourceNode.id, targetNode.id)) { + throw new Error('节点不能直连自身') + } else if (hasCycle(targetNode)) { + throw new Error('禁止流程循环') + } + + const hasShortcut = (node: Node, visited = new Set()) => { + if (visited.has(node.id)) return false + visited.add(node.id) + for (const incomer of getIncomers(node, nodes, edges)) { + if (isEqual(incomer.id, sourceNode?.id)) return true + if (hasShortcut(incomer, visited)) return true + } + } + console.log(getIncomers(targetNode, nodes, edges)) + } + useMount(() => { // language=JSON let initialData = JSON.parse(`{ - "nodes": [ - { - "id": "BMFP3Eov94", - "type": "start-amis-node", - "position": { - "x": 10, - "y": 100 - }, - "data": {}, - "measured": { - "width": 256, - "height": 83 - }, - "selected": false - }, - { - "id": "PYK8LjduQ1", - "type": "end-amis-node", - "position": { - "x": 654, - "y": 332 - }, - "data": {}, - "measured": { - "width": 256, - "height": 83 - }, - "selected": false, - "dragging": false - }, - { - "id": "nCm-ij5I6o", - "type": "llm-amis-node", - "position": { - "x": 318, - "y": 208 - }, - "data": {}, - "measured": { - "width": 256, - "height": 83 - }, - "selected": true, - "dragging": false - } - ], - "edges": [ - { - "source": "BMFP3Eov94", - "target": "nCm-ij5I6o", - "id": "xy-edge__BMFP3Eov94-nCm-ij5I6o" - }, - { - "source": "nCm-ij5I6o", - "target": "PYK8LjduQ1", - "id": "xy-edge__nCm-ij5I6o-PYK8LjduQ1" - } - ], - "data": { - "BMFP3Eov94": { - "inputs": { - "文件名": { - "type": "text" + "nodes": [ + { + "id": "BMFP3Eov94", + "type": "start-amis-node", + "position": { + "x": 10, + "y": 100 + }, + "data": {}, + "measured": { + "width": 256, + "height": 83 + }, + "selected": false }, - "文件描述": { - "type": "text", - "description": "文件描述" + { + "id": "PYK8LjduQ1", + "type": "end-amis-node", + "position": { + "x": 654, + "y": 332 + }, + "data": {}, + "measured": { + "width": 256, + "height": 83 + }, + "selected": false, + "dragging": false + }, + { + "id": "nCm-ij5I6o", + "type": "llm-amis-node", + "position": { + "x": 318, + "y": 208 + }, + "data": {}, + "measured": { + "width": 256, + "height": 83 + }, + "selected": true, + "dragging": false + } + ], + "edges": [ + { + "source": "BMFP3Eov94", + "target": "nCm-ij5I6o", + "id": "xy-edge__BMFP3Eov94-nCm-ij5I6o" + } + ], + "data": { + "BMFP3Eov94": { + "inputs": { + "文件名": { + "type": "text" + }, + "文件描述": { + "type": "text", + "description": "文件描述" + } + } + }, + "nCm-ij5I6o": { + "model": "qwen3", + "outputs": { + "text": { + "type": "string" + } + }, + "systemPrompt": "你是个沙雕" } } - }, - "nCm-ij5I6o": { - "model": "qwen3", - "outputs": { - "text": { - "type": "string" - } - }, - "systemPrompt": "你是个沙雕" - } - } -}`) + }`) let initialNodes = initialData['nodes'] ?? [] let initialEdges = initialData['edges'] ?? [] @@ -254,26 +311,23 @@ function FlowEditor() { menu={{ items: nodeDef.map(def => ({key: def.key, label: def.name})), onClick: ({key}) => { - if (isEqual(key, 'start-amis-node') && findIdx(nodes, (node: Node) => isEqual(key, node.type)) > -1) { - messageApi.error('只能存在1个开始节点') - return + try { + checkNode(key) + addNode({ + id: randomId(10), + type: key, + position: {x: 100, y: 100}, + data: { + getDataById, + setDataById, + removeNode, + editNode, + }, + }) + } catch (e) { + // @ts-ignore + messageApi.error(e.message) } - if (isEqual(key, 'end-amis-node') && findIdx(nodes, (node: Node) => isEqual(key, node.type)) > -1) { - messageApi.error('只能存在1个结束节点') - return - } - - addNode({ - id: randomId(10), - type: key, - position: {x: 100, y: 100}, - data: { - getDataById, - setDataById, - removeNode, - editNode, - }, - }) }, }} > @@ -298,7 +352,15 @@ function FlowEditor() { edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} - onConnect={onConnect} + onConnect={(connection) => { + try { + checkConnection(connection) + onConnect(connection) + } catch (e) { + // @ts-ignore + messageApi.error(e.message) + } + }} // @ts-ignore nodeTypes={arrToMap( nodeDef.map(def => def.key), diff --git a/service-web/client/src/pages/ai/flow/store/FlowStore.ts b/service-web/client/src/pages/ai/flow/store/FlowStore.ts index a1425d4..5287e96 100644 --- a/service-web/client/src/pages/ai/flow/store/FlowStore.ts +++ b/service-web/client/src/pages/ai/flow/store/FlowStore.ts @@ -1,4 +1,3 @@ -import {create} from 'zustand/react' import { addEdge, applyEdgeChanges, @@ -7,13 +6,15 @@ import { type Node, type OnConnect, type OnEdgesChange, - type OnNodesChange + type OnNodesChange, } from '@xyflow/react' -import {filter, isEqual} from 'licia' +import {filter, find, isEqual} from 'licia' +import {create} from 'zustand/react' export const useFlowStore = create<{ nodes: Node[], onNodesChange: OnNodesChange, + getNodeById: (id: string) => Node | undefined, addNode: (node: Node) => void, removeNode: (id: string) => void, setNodes: (nodes: Node[]) => void, @@ -30,6 +31,7 @@ export const useFlowStore = create<{ nodes: applyNodeChanges(changes, get().nodes), }) }, + getNodeById: (id: string) => find(get().nodes, node => isEqual(node.id, id)), addNode: node => set({nodes: get().nodes.concat(node)}), removeNode: id => { set({