From 04bc9a2c163057e3a2e7028d7be985b6802ba731 Mon Sep 17 00:00:00 2001 From: v-zhangjc9 Date: Mon, 14 Jul 2025 19:10:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=AE=8C=E6=88=90=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E8=8A=82=E7=82=B9=E7=9A=84=E5=9F=BA=E6=9C=AC=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/flow/FlowChecker.tsx | 5 ++ .../client/src/components/flow/FlowEditor.tsx | 20 ++++++- .../client/src/components/flow/Helper.tsx | 44 +++++++++++++- .../src/components/flow/NodeRegistry.tsx | 23 ++++---- .../src/components/flow/node/AmisNode.tsx | 57 ++++++++++++------- .../src/components/flow/node/CodeNode.tsx | 3 +- .../components/flow/node/KnowledgeNode.tsx | 3 +- .../src/components/flow/node/LlmNode.tsx | 3 +- .../src/components/flow/node/LoopNode.tsx | 46 +++++++++++++++ .../src/components/flow/node/OutputNode.tsx | 3 +- .../src/components/flow/node/SwitchNode.tsx | 3 +- .../src/components/flow/node/TemplateNode.tsx | 3 +- .../src/components/flow/store/FlowStore.ts | 11 ++++ .../client/src/components/flow/types.ts | 14 +++++ service-web/client/src/pages/Test.tsx | 2 +- 15 files changed, 196 insertions(+), 44 deletions(-) create mode 100644 service-web/client/src/components/flow/node/LoopNode.tsx diff --git a/service-web/client/src/components/flow/FlowChecker.tsx b/service-web/client/src/components/flow/FlowChecker.tsx index db7bbdb..f68ce7d 100644 --- a/service-web/client/src/components/flow/FlowChecker.tsx +++ b/service-web/client/src/components/flow/FlowChecker.tsx @@ -28,6 +28,7 @@ export const sourceNodeNotFoundError = () => new CheckError(200, '连线起始 export const targetNodeNotFoundError = () => new CheckError(201, '连线目标节点未找到') export const nodeToSelfError = () => new CheckError(203, '节点不能直连自身') export const hasCycleError = () => new CheckError(204, '禁止流程循环') +export const differentParent = () => new CheckError(205, '子流程禁止连接外部节点') const hasCycle = (sourceNode: Node, targetNode: Node, nodes: Node[], edges: Edge[], visited = new Set()) => { if (visited.has(targetNode.id)) return false @@ -48,6 +49,10 @@ export const checkAddConnection: (connection: Connection, nodes: Node[], edges: throw targetNodeNotFoundError() } + if (!isEqual(sourceNode.parentId, targetNode.parentId)) { + throw differentParent() + } + // 禁止流程出现环,必须是有向无环图 if (isEqual(sourceNode.id, targetNode.id)) { throw nodeToSelfError() diff --git a/service-web/client/src/components/flow/FlowEditor.tsx b/service-web/client/src/components/flow/FlowEditor.tsx index d0f4cf3..acc631b 100644 --- a/service-web/client/src/components/flow/FlowEditor.tsx +++ b/service-web/client/src/components/flow/FlowEditor.tsx @@ -8,11 +8,12 @@ import styled from 'styled-components' import '@xyflow/react/dist/style.css' import {commonInfo} from '../../util/amis.tsx' import {checkAddConnection, checkAddNode, checkSave} from './FlowChecker.tsx' +import {useNodeDrag} from './Helper.tsx' import {NodeRegistry, NodeRegistryMap} from './NodeRegistry.tsx' import {useContextStore} from './store/ContextStore.ts' import {useDataStore} from './store/DataStore.ts' import {useFlowStore} from './store/FlowStore.ts' -import type {FlowEditorProps} from './types.ts' +import {flowDotColor, type FlowEditorProps} from './types.ts' const FlowableDiv = styled.div` .react-flow__node.selectable { @@ -75,10 +76,17 @@ function FlowEditor(props: FlowEditorProps) { setInputSchema(props.inputSchema) }, [props.graphData]) + const { + onNodeDragStart, + onNodeDrag, + onNodeDragEnd, + } = useNodeDrag([props.graphData]) + return ( {contextHolder} NodeRegistryMap[key]!.component)} + onNodeDragStart={onNodeDragStart} + onNodeDrag={onNodeDrag} + onNodeDragStop={onNodeDragEnd} > @@ -176,7 +187,12 @@ function FlowEditor(props: FlowEditorProps) { - + ) diff --git a/service-web/client/src/components/flow/Helper.tsx b/service-web/client/src/components/flow/Helper.tsx index 7f69900..3666fda 100644 --- a/service-web/client/src/components/flow/Helper.tsx +++ b/service-web/client/src/components/flow/Helper.tsx @@ -1,7 +1,9 @@ import {type Edge, getIncomers, type Node} from '@xyflow/react' import type {Option} from 'amis/lib/Schema' -import {find, has, isEmpty, isEqual, unique} from 'licia' +import {find, has, isEmpty, isEqual, max, min, unique} from 'licia' +import {type DependencyList, type MouseEvent as ReactMouseEvent, useCallback, useRef} from 'react' import Queue from 'yocto-queue' +import {useFlowStore} from './store/FlowStore.ts' import type {InputFormOptions, InputFormOptionsGroup} from './types.ts' export const getAllIncomerNodeById: (id: string, nodes: Node[], edges: Edge[]) => string[] = (id, nodes, edges) => { @@ -82,3 +84,43 @@ export const generateAllIncomerOutputVariablesFormOptions: (id: string, inputSch })), ] } + +// 处理循环节点的边界问题 +export const useNodeDrag = (deps: DependencyList) => { + const currentPosition = useRef({x: 0, y: 0} as { x: number, y: number }) + const {setNode, getNodeById} = useFlowStore() + + const onNodeDragStart = useCallback(() => { + }, deps) + const onNodeDrag = useCallback((event: ReactMouseEvent, node: Node) => { + event.stopPropagation() + if (node.parentId) { + let parentNode = getNodeById(node.parentId) + if (parentNode) { + let newPosition = { + x: max(min(node.position.x, (parentNode.measured?.width ?? 0) - (node.measured?.width ?? 0) - 28), 28), + y: max(min(node.position.y, (parentNode.measured?.height ?? 0) - (node.measured?.height ?? 0) - 28), 90), + } + setNode({ + ...node, + position: newPosition, + }) + currentPosition.current = newPosition + } + } + }, deps) + const onNodeDragEnd = useCallback((_event: ReactMouseEvent, node: Node) => { + if (node.parentId) { + setNode({ + ...node, + position: currentPosition.current, + }) + } + }, deps) + + return { + onNodeDragStart, + onNodeDrag, + onNodeDragEnd, + } +} diff --git a/service-web/client/src/components/flow/NodeRegistry.tsx b/service-web/client/src/components/flow/NodeRegistry.tsx index 43c40f6..41e517c 100644 --- a/service-web/client/src/components/flow/NodeRegistry.tsx +++ b/service-web/client/src/components/flow/NodeRegistry.tsx @@ -1,13 +1,13 @@ import {has, isEmpty} from 'licia' -import type {JSX} from 'react' import {getAllIncomerNodeOutputVariables} from './Helper.tsx' import CodeNode from './node/CodeNode.tsx' import KnowledgeNode from './node/KnowledgeNode.tsx' import LlmNode from './node/LlmNode.tsx' +import LoopNode from './node/LoopNode.tsx' import OutputNode from './node/OutputNode.tsx' import SwitchNode from './node/SwitchNode.tsx' import TemplateNode from './node/TemplateNode.tsx' -import type {NodeChecker} from './types.ts' +import type {NodeChecker, NodeDefine} from './types.ts' const inputSingleVariableChecker: (field: string) => NodeChecker = field => { return (id, inputSchema, nodes, edges, data) => { @@ -54,16 +54,6 @@ const inputMultiVariableChecker: NodeChecker = (id, inputSchema, nodes, edges, d return {error: false} } -type NodeDefine = { - key: string, - group: string, - name: string, - icon: JSX.Element, - description: string, - component: any, - checkers: NodeChecker[], -} - export const NodeRegistry: NodeDefine[] = [ { key: 'llm-node', @@ -110,6 +100,15 @@ export const NodeRegistry: NodeDefine[] = [ component: SwitchNode, checkers: [], }, + { + key: 'loop-node', + group: '逻辑节点', + name: '循环', + icon: , + description: '实现循环执行流程', + component: LoopNode, + checkers: [], + }, { key: 'output-node', group: '输出节点', diff --git a/service-web/client/src/components/flow/node/AmisNode.tsx b/service-web/client/src/components/flow/node/AmisNode.tsx index d25f547..36e1548 100644 --- a/service-web/client/src/components/flow/node/AmisNode.tsx +++ b/service-web/client/src/components/flow/node/AmisNode.tsx @@ -1,8 +1,8 @@ import {CopyFilled, DeleteFilled, EditFilled} from '@ant-design/icons' -import {type Edge, Handle, type Node, type NodeProps, NodeToolbar, Position} from '@xyflow/react' -import type {Schema} from 'amis' -import {Button, Card, Drawer, Space, Tooltip} from 'antd' -import {type JSX, useCallback, useState} from 'react' +import {type Edge, Handle, type Node, type NodeProps, NodeResizeControl, NodeToolbar, Position} from '@xyflow/react' +import {type ClassName, classnames, type Schema} from 'amis' +import {Button, Drawer, Space, Tooltip} from 'antd' +import {type CSSProperties, type JSX, useCallback, useState} from 'react' import styled from 'styled-components' import {amisRender, commonInfo, horizontalFormOptions} from '../../../util/amis.tsx' import {generateAllIncomerOutputVariablesFormOptions} from '../Helper.tsx' @@ -95,20 +95,16 @@ export function outputsFormColumns(editable: boolean = false, required: boolean } type AmisNodeProps = { + className: ClassName, + style?: CSSProperties, nodeProps: NodeProps extraNodeDescription?: JSX.Element handler: JSX.Element columnSchema?: () => Schema[] + resize?: { minWidth: number, minHeight: number } } const AmisNodeContainerDiv = styled.div` - .ant-card { - .ant-card-actions { - & > li { - margin: 0; - } - } - } ` export const StartNodeHandler = () => { @@ -128,11 +124,18 @@ export const NormalNodeHandler = () => { ) } +export const nodeClassName = (name: string) => { + return `flow-node flow-node-${name}` +} + const AmisNode: (props: AmisNodeProps) => JSX.Element = ({ + className, + style, nodeProps, extraNodeDescription, handler, columnSchema, + resize, }) => { const {removeNode} = useFlowStore() const {getDataById, setDataById, removeDataById} = useDataStore() @@ -236,7 +239,7 @@ const AmisNode: (props: AmisNodeProps) => JSX.Element = ({ removeDataById(id) }, []) return ( - + JSX.Element = ({ - {id}} - size="small" - > -
- {nodeDescription} - {extraNodeDescription} +
+
+ {nodeName} + {id}
- +
+
+ {nodeDescription} +
+
+ {extraNodeDescription} +
+
+
+ {resize ? <> + + : undefined} {handler} ) diff --git a/service-web/client/src/components/flow/node/CodeNode.tsx b/service-web/client/src/components/flow/node/CodeNode.tsx index 18186cb..6619b41 100644 --- a/service-web/client/src/components/flow/node/CodeNode.tsx +++ b/service-web/client/src/components/flow/node/CodeNode.tsx @@ -4,7 +4,7 @@ import React, {useCallback, useEffect} from 'react' import {useContextStore} from '../store/ContextStore.ts' import {useDataStore} from '../store/DataStore.ts' import {useFlowStore} from '../store/FlowStore.ts' -import AmisNode, {inputsFormColumns, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx' +import AmisNode, {inputsFormColumns, nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx' const languageMap: Record = { 'javascript': 'Javascript', @@ -62,6 +62,7 @@ const CodeNode = (props: NodeProps) => { ], [props.id]) return ( { const {getNodes, getEdges} = useFlowStore() @@ -79,6 +79,7 @@ const KnowledgeNode = (props: NodeProps) => { ], [props.id]) return ( } diff --git a/service-web/client/src/components/flow/node/LlmNode.tsx b/service-web/client/src/components/flow/node/LlmNode.tsx index 05d853e..2c46375 100644 --- a/service-web/client/src/components/flow/node/LlmNode.tsx +++ b/service-web/client/src/components/flow/node/LlmNode.tsx @@ -4,7 +4,7 @@ import React, {useCallback, useEffect} from 'react' import {useContextStore} from '../store/ContextStore.ts' import {useDataStore} from '../store/DataStore.ts' import {useFlowStore} from '../store/FlowStore.ts' -import AmisNode, {inputsFormColumns, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx' +import AmisNode, {inputsFormColumns, nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx' const modelMap: Record = { qwen3: 'Qwen3', @@ -57,6 +57,7 @@ const LlmNode = (props: NodeProps) => { ], [props.id]) return ( { + return ( + + +
+ } + handler={} + resize={{ + minWidth: 256, + minHeight: 208, + }} + /> + ) +} + +export default React.memo(LoopNode) \ No newline at end of file diff --git a/service-web/client/src/components/flow/node/OutputNode.tsx b/service-web/client/src/components/flow/node/OutputNode.tsx index 15d4687..61951b7 100644 --- a/service-web/client/src/components/flow/node/OutputNode.tsx +++ b/service-web/client/src/components/flow/node/OutputNode.tsx @@ -4,7 +4,7 @@ import {generateAllIncomerOutputVariablesFormOptions} from '../Helper.tsx' import {useContextStore} from '../store/ContextStore.ts' import {useDataStore} from '../store/DataStore.ts' import {useFlowStore} from '../store/FlowStore.ts' -import AmisNode, {EndNodeHandler} from './AmisNode.tsx' +import AmisNode, {EndNodeHandler, nodeClassName} from './AmisNode.tsx' const OutputNode = (props: NodeProps) => { const {getNodes, getEdges} = useFlowStore() @@ -33,6 +33,7 @@ const OutputNode = (props: NodeProps) => { return ( } diff --git a/service-web/client/src/components/flow/node/SwitchNode.tsx b/service-web/client/src/components/flow/node/SwitchNode.tsx index 5f3426c..60a1ab3 100644 --- a/service-web/client/src/components/flow/node/SwitchNode.tsx +++ b/service-web/client/src/components/flow/node/SwitchNode.tsx @@ -1,7 +1,7 @@ import {Handle, type NodeProps, Position} from '@xyflow/react' import {Tag} from 'antd' import React from 'react' -import AmisNode from './AmisNode.tsx' +import AmisNode, {nodeClassName} from './AmisNode.tsx' const cases = [ { @@ -18,6 +18,7 @@ const cases = [ const SwitchNode = (props: NodeProps) => { return ( diff --git a/service-web/client/src/components/flow/node/TemplateNode.tsx b/service-web/client/src/components/flow/node/TemplateNode.tsx index 22123d6..98941e5 100644 --- a/service-web/client/src/components/flow/node/TemplateNode.tsx +++ b/service-web/client/src/components/flow/node/TemplateNode.tsx @@ -4,7 +4,7 @@ import React, {useCallback, useEffect} from 'react' import {useContextStore} from '../store/ContextStore.ts' import {useDataStore} from '../store/DataStore.ts' import {useFlowStore} from '../store/FlowStore.ts' -import AmisNode, {inputsFormColumns, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx' +import AmisNode, {inputsFormColumns, nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx' const typeMap: Record = { default: '默认', @@ -75,6 +75,7 @@ const TemplateNode = (props: NodeProps) => { return ( void, removeNode: (id: string) => void, setNodes: (nodes: Node[]) => void, + setNode: (node: Node) => void, edges: Edge[], getEdges: () => Edge[], @@ -42,6 +43,16 @@ export const useFlowStore = create<{ }) }, setNodes: nodes => set({nodes}), + setNode: node => { + set({ + nodes: get().nodes.map(n => { + if (isEqual(node.id, n.id)) { + return node + } + return n + }), + }) + }, edges: [], getEdges: () => get().edges, diff --git a/service-web/client/src/components/flow/types.ts b/service-web/client/src/components/flow/types.ts index 6b0c5a2..39d3523 100644 --- a/service-web/client/src/components/flow/types.ts +++ b/service-web/client/src/components/flow/types.ts @@ -1,4 +1,8 @@ import type {Edge, Node} from '@xyflow/react' +import type {JSX} from 'react' + +export const flowBackgroundColor = "#fafafa" +export const flowDotColor = "#dedede" export type InputFormOptions = { label: string @@ -23,4 +27,14 @@ export type FlowEditorProps = { inputSchema: Record>, graphData: GraphData, onGraphDataChange: (graphData: GraphData) => void, +} + +export type NodeDefine = { + key: string, + group: string, + name: string, + icon: JSX.Element, + description: string, + component: any, + checkers: NodeChecker[], } \ No newline at end of file diff --git a/service-web/client/src/pages/Test.tsx b/service-web/client/src/pages/Test.tsx index aa7a933..12f6f49 100644 --- a/service-web/client/src/pages/Test.tsx +++ b/service-web/client/src/pages/Test.tsx @@ -4,7 +4,7 @@ import type {GraphData} from '../components/flow/types.ts' function Test() { // language=JSON - const [graphData] = useState(JSON.parse('{\n "nodes": [\n {\n "id": "MzEitlOusl",\n "type": "llm-node",\n "position": {\n "x": 47,\n "y": 92\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 130\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "bivXSpiLaI",\n "type": "code-node",\n "position": {\n "x": 381,\n "y": 181\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 130\n },\n "selected": true,\n "dragging": false\n }\n ],\n "edges": [\n {\n "source": "MzEitlOusl",\n "sourceHandle": "source",\n "target": "bivXSpiLaI",\n "targetHandle": "target",\n "id": "xy-edge__MzEitlOuslsource-bivXSpiLaItarget"\n }\n ],\n "data": {\n "MzEitlOusl": {\n "node": {\n "name": "大模型",\n "description": "使用大模型对话"\n },\n "outputs": {\n "text": {\n "type": "string"\n }\n },\n "model": "qwen3",\n "systemPrompt": "你是个好人",\n "finished": true\n },\n "bivXSpiLaI": {\n "node": {\n "name": "代码执行",\n "description": "执行自定义的处理代码"\n },\n "outputs": {\n "result": {\n "type": "string"\n }\n },\n "type": "javascript",\n "content": "console.log(\'hello\')",\n "inputs": {\n "text": {\n "variable": "MzEitlOusl.text"\n }\n },\n "finished": true\n }\n }\n}')) + const [graphData] = useState(JSON.parse('{\n "nodes": [\n {\n "id": "QxNrkChBWQ",\n "type": "loop-node",\n "position": {\n "x": 742,\n "y": 119\n },\n "data": {},\n "measured": {\n "width": 458,\n "height": 368\n },\n "selected": true,\n "dragging": false,\n "width": 458,\n "height": 368,\n "resizing": false\n },\n {\n "id": "MzEitlOusl",\n "type": "llm-node",\n "position": {\n "x": 47,\n "y": 92\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 110\n },\n "selected": false,\n "dragging": false,\n "extent": "parent",\n "parentId": "QxNrkChBWQ"\n },\n {\n "id": "bivXSpiLaI",\n "type": "code-node",\n "position": {\n "x": 381,\n "y": 181\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 110\n },\n "selected": false,\n "dragging": false\n }\n ],\n "edges": [],\n "data": {\n "MzEitlOusl": {\n "node": {\n "name": "大模型",\n "description": "使用大模型对话"\n },\n "outputs": {\n "text": {\n "type": "string"\n }\n },\n "model": "qwen3",\n "systemPrompt": "你是个好人",\n "finished": true\n },\n "bivXSpiLaI": {\n "node": {\n "name": "代码执行",\n "description": "执行自定义的处理代码"\n },\n "outputs": {\n "result": {\n "type": "string"\n }\n },\n "type": "javascript",\n "content": "console.log(\'hello\')",\n "inputs": {\n "text": {\n "variable": "MzEitlOusl.text"\n }\n },\n "finished": true\n },\n "QxNrkChBWQ": {\n "node": {\n "name": "循环",\n "description": "实现循环执行流程"\n },\n "finished": true\n }\n }\n}')) return (