diff --git a/web/src/views/ToolManagement/components/CustomToolModal.tsx b/web/src/views/ToolManagement/components/CustomToolModal.tsx index cbcddb7c..08e18679 100644 --- a/web/src/views/ToolManagement/components/CustomToolModal.tsx +++ b/web/src/views/ToolManagement/components/CustomToolModal.tsx @@ -101,6 +101,7 @@ const CustomToolModal = forwardRef(({ }); }; const formatSchema = (value: string) => { + if (!value || value.trim() === '') return setParseSchemaData({} as ParseSchemaData) parseSchema({ schema_content: value }) .then(res => { diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index cffb62dd..ac81a667 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -1,134 +1,15 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next' import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next' -import { graphNodeLibrary, edgeAttrs } from '../../constant'; import NodeTools from './NodeTools' -const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { +const LoopNode: ReactShapeConfig['component'] = ({ node }) => { const data = node.getData() || {}; const { t } = useTranslation() - useEffect(() => { - // 使用setTimeout确保在所有节点都添加完成后再创建连线 - const timer = setTimeout(() => { - initNodes() - checkAndAddAddNode() - }, 50) - - return () => clearTimeout(timer) - }, [graph]) - - const checkAndAddAddNode = () => { - if (!graph) return; - - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === data.id); - const cycleStartNodes = childNodes.filter((n: any) => n.getData()?.type === 'cycle-start'); - - // 如果只有一个cycle-start节点且没有其他类型的子节点,则添加add-node - if (cycleStartNodes.length === 1 && childNodes.length === 1) { - const cycleStartNode = cycleStartNodes[0]; - const cycleStartBBox = cycleStartNode.getBBox(); - - const addNode = graph.addNode({ - ...graphNodeLibrary.addStart, - x: cycleStartBBox.x + 84, - y: cycleStartBBox.y + 4, - data: { - type: 'add-node', - label: t('workflow.addNode'), - icon: '+', - parentId: node.id, - cycle: data.id, - }, - }); - - node.addChild(addNode); - - // 连接cycle-start和add-node - const sourcePorts = cycleStartNode.getPorts(); - const targetPorts = addNode.getPorts(); - const sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; - const targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; - - // 然后创建连线 - graph.addEdge({ - source: { cell: cycleStartNode.id, port: sourcePort }, - target: { cell: addNode.id, port: targetPort }, - ...edgeAttrs, - }); - - cycleStartNode.toFront() - addNode.toFront() - } - } - - const initNodes = () => { - // 检查是否存在cycle为当前节点ID的子节点,若存在则不调用initNodes,避免重复创建 - const existingCycleNodes = graph.getNodes().filter((n: any) => - n.getData()?.cycle === data.id - ); - if (existingCycleNodes.length > 0) return; - // 添加默认子节点 - const parentBBox = node.getBBox(); - const centerX = parentBBox.x + 24; - const centerY = parentBBox.y + 70; - - const cycleStartNodeId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` - const cycleStartNode = graph.addNode({ - ...graphNodeLibrary.cycleStart, - x: centerX, - y: centerY, - id: cycleStartNodeId, - data: { - id: cycleStartNodeId, - type: 'cycle-start', - parentId: node.id, - isDefault: true, // 标记为默认节点,不可删除 - cycle: data.id, - }, - }); - const addNode = graph.addNode({ - ...graphNodeLibrary.addStart, - x: centerX + 84, - y: centerY + 4, - data: { - type: 'add-node', - label: t('workflow.addNode'), - icon: '+', - parentId: node.id, - cycle: data.id, - }, - }); - node.addChild(cycleStartNode) - node.addChild(addNode) - const sourcePorts = cycleStartNode.getPorts() - const targetPorts = addNode.getPorts() - let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; - - const edgeConfig = { - source: { - cell: cycleStartNode.id, - port: sourcePort - }, - target: { - cell: addNode.id, - port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left' - }, - ...edgeAttrs - } - graph.addEdge(edgeConfig) - - setTimeout(() => { - - cycleStartNode.toFront() - addNode.toFront() - }, 0) - } - return (
= { width: nodeWidth, height: 120, shape: 'notes-node', + }, + output: { + width: nodeWidth, + height: 76, + shape: 'normal-node', + ports: { + groups: { left: defaultPortGroup }, + items: [defaultPortItems[0]], + }, } } diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 060bc75c..c81974a4 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,10 +2,9 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-24 17:21:09 + * @Last Modified time: 2026-04-27 16:30:30 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; -import type { HistoryCommand as Command } from '@antv/x6/lib/plugin/history/type'; import { register } from '@antv/x6-react-shape'; import type { PortMetadata } from '@antv/x6/lib/model/port'; import { App } from 'antd'; @@ -493,6 +492,72 @@ export const useWorkflowGraph = ({ graphRef.current.cleanHistory() } } + + const resizeGroupNodes = (graph: Graph) => { + graph.getNodes().forEach(parentNode => { + const parentType = parentNode.getData()?.type + if (parentType !== 'loop' && parentType !== 'iteration') return + const children = graph.getNodes().filter( + n => n.getData()?.cycle === parentNode.getData()?.id && n.getData()?.type !== 'add-node' + ) + if (!children.length) return + const padding = 24 + const headerHeight = 50 + const childBounds = children.map(c => c.getBBox()) + const minX = Math.min(...childBounds.map(b => b.x)) + const minY = Math.min(...childBounds.map(b => b.y)) + const maxX = Math.max(...childBounds.map(b => b.x + b.width)) + const maxY = Math.max(...childBounds.map(b => b.y + b.height)) + const parentBBox = parentNode.getBBox() + const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2) + const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight) + parentNode.prop('size', { width: newWidth, height: newHeight }) + parentNode.getPorts().forEach(port => { + if (port.group === 'right' && port.args) { + parentNode.portProp(port.id!, 'args/x', newWidth) + } + }) + }) + } + + const syncChildRelationships = () => { + if (!graphRef.current) return + const graph = graphRef.current + // Re-establish parent-child relationships based on cycle data + graph.getNodes().forEach(node => { + const cycleId = node.getData()?.cycle + if (!cycleId) return + const parentNode = graph.getCellById(cycleId) as Node | null + if (!parentNode) return + if (!parentNode.getChildren()?.some(c => c.id === node.id)) { + parentNode.addChild(node) + } + }) + // Remove stale parent-child links (parent exists but child's cycle no longer points to it) + graph.getNodes().forEach(node => { + const children = node.getChildren() + if (!children?.length) return + children.forEach(child => { + const childCycleId = (child as Node).getData?.()?.cycle + if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) { + node.removeChild(child) + } + }) + }) + // Recalculate group node size based on current children + resizeGroupNodes(graph) + // Bring child edges and nodes to front + graph.getEdges().forEach(edge => { + const src = graph.getCellById(edge.getSourceCellId()) + const tgt = graph.getCellById(edge.getTargetCellId()) + if (src?.getData()?.cycle || tgt?.getData()?.cycle) { + edge.toFront() + } + }) + graph.getNodes().forEach(node => { + if (node.getData()?.cycle) node.toFront() + }) + } /** * Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard) */ @@ -538,6 +603,9 @@ export const useWorkflowGraph = ({ setCanUndo(graphRef.current?.canUndo() ?? false) setCanRedo(graphRef.current?.canRedo() ?? false) }) + + graphRef.current.on('history:undo', syncChildRelationships) + graphRef.current.on('history:redo', syncChildRelationships) }; // 显示/隐藏连接桩 // const showPorts = (show: boolean) => { @@ -781,48 +849,50 @@ export const useWorkflowGraph = ({ // Delete all collected nodes and edges if (cells.length > 0) { + // Pre-calculate which parents need an add-node restored (before removal changes the graph) + const parentsNeedingAddNode = parentNodesToUpdate + .filter(parentNode => { + const parentShape = parentNode.shape; + if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return false; + const parentData = parentNode.getData(); + const allChildren = graphRef.current!.getNodes().filter(n => n.getData()?.cycle === parentData.id); + const cycleStartNodes = allChildren.filter(n => n.getData()?.type === 'cycle-start'); + // After deletion, only cycle-start will remain + const nonCycleStartToDelete = cells.filter(c => + c.isNode() && + (c as Node).getData()?.cycle === parentData.id && + (c as Node).getData()?.type !== 'cycle-start' + ); + return cycleStartNodes.length === 1 && (allChildren.length - nonCycleStartToDelete.length) === 1; + }) + .map(parentNode => ({ + parentNode, + cycleStartNode: graphRef.current!.getNodes().find( + n => n.getData()?.cycle === parentNode.getData().id && n.getData()?.type === 'cycle-start' + )! + })) + .filter(({ cycleStartNode }) => !!cycleStartNode); + graphRef.current?.startBatch('delete'); - // Remove parent-child relationships before removeCells - parentNodesToUpdate.forEach(parentNode => { - cells.filter(c => c.isNode() && (c as Node).getData()?.cycle === parentNode.getData()?.id) - .forEach(child => parentNode.removeChild(child)); - }); graphRef.current?.removeCells(cells); - // If parent is iteration/loop and only cycle-start remains, add add-node connected to it - parentNodesToUpdate.forEach(parentNode => { - const parentShape = parentNode.shape; - if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return; + parentsNeedingAddNode.forEach(({ parentNode, cycleStartNode }) => { const parentData = parentNode.getData(); - const remainingChildren = graphRef.current!.getNodes().filter( - n => n.getData()?.cycle === parentData.id - ); - const cycleStartNodes = remainingChildren.filter(n => n.getData()?.type === 'cycle-start'); - if (cycleStartNodes.length === 1 && remainingChildren.length === 1) { - const cycleStartNode = cycleStartNodes[0]; - const bbox = cycleStartNode.getBBox(); - const addNode = graphRef.current!.addNode({ - ...graphNodeLibrary.addStart, - x: bbox.x + 84, - y: bbox.y + 4, - data: { - type: 'add-node', - parentId: parentNode.id, - cycle: parentData.id, - label: t('workflow.addNode'), - icon: '+', - }, - }); - parentNode.addChild(addNode); - const sourcePort = cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right'; - const targetPort = addNode.getPorts().find(p => p.group === 'left')?.id || 'left'; - graphRef.current!.addEdge({ - source: { cell: cycleStartNode.id, port: sourcePort }, - target: { cell: addNode.id, port: targetPort }, - ...edgeAttrs, - }); - } + const bbox = cycleStartNode.getBBox(); + const addNode = graphRef.current!.addNode({ + ...graphNodeLibrary.addStart, + x: bbox.x + 84, + y: bbox.y + 4, + data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' }, + }); + parentNode.addChild(addNode); + graphRef.current!.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, + target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }); }); + graphRef.current?.stopBatch('delete'); } return false; @@ -1199,13 +1269,39 @@ export const useWorkflowGraph = ({ }; if (dragData.type === 'loop' || dragData.type === 'iteration') { - graphRef.current.addNode({ + graphRef.current.startBatch('add-group') + const parentNode = graphRef.current.addNode({ ...graphNodeLibrary[dragData.type], x: point.x - 150, y: point.y - 100, id: cleanNodeData.id, data: { ...cleanNodeData, isGroup: true }, }); + const parentBBox = parentNode.getBBox() + const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + const cycleStartNode = graphRef.current.addNode({ + ...graphNodeLibrary.cycleStart, + x: parentBBox.x + 24, + y: parentBBox.y + 70, + id: cycleStartId, + data: { id: cycleStartId, type: 'cycle-start', parentId: cleanNodeData.id, isDefault: true, cycle: cleanNodeData.id }, + }) + const addNode = graphRef.current.addNode({ + ...graphNodeLibrary.addStart, + x: parentBBox.x + 24 + 84, + y: parentBBox.y + 70 + 4, + data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: cleanNodeData.id, cycle: cleanNodeData.id }, + }) + parentNode.addChild(cycleStartNode) + parentNode.addChild(addNode) + graphRef.current.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, + target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }) + cycleStartNode.toFront() + addNode.toFront() + graphRef.current.stopBatch('add-group') } else if (dragData.type === 'if-else') { // Create condition node graphRef.current.addNode({ @@ -1494,20 +1590,16 @@ export const useWorkflowGraph = ({ if (!graphRef.current) return; const nodes = graphRef.current.getNodes(); - const lastWithSub = [...chatHistory].reverse().find(item => item.subContent?.length); - // Reset all node execution status first + // Reset all node execution status on every chatHistory change nodes.forEach(node => { const data = node.getData(); - if (typeof data.executionStatus === 'string') { - node.setData({ ...data, executionStatus: undefined }); - } + node.setData({ ...data, executionStatus: '' }); }); - if (!lastWithSub?.subContent) return; - // Build a nodeId -> status map first - const statusMap: Record = {}; - lastWithSub.subContent.forEach(sub => { + + const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant'); + if (!lastAssistant?.subContent?.length) return; + lastAssistant.subContent.forEach(sub => { if (typeof sub.status === 'string') { - statusMap[sub.node_id] = sub.status; const node = nodes.find(n => n.getData()?.id === sub.node_id); if (node) { node.setData({ ...node.getData(), executionStatus: sub.status });