diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx index 15f4aa1e..1c8eeee6 100644 --- a/web/src/views/Workflow/components/Nodes/AddNode.tsx +++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx @@ -44,7 +44,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { if (cycleId) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (parentNode) { - parentNode.addChild(newNode); + parentNode.addChild(newNode, { silent: true }); } } @@ -77,57 +77,40 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { } }); - graph.stopBatch('add-node'); - - setTimeout(() => { - addedEdges.forEach(e => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.isNode()) src.toFront(); - if (tgt?.isNode()) tgt.toFront(); - }); - }, 50); - // Automatically adjust loop node size const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (loopNode) { - const adjustLoopSize = () => { - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - if (childNodes.length > 0) { - const bounds = childNodes.reduce((acc, child) => { - const bbox = child.getBBox(); - return { - minX: Math.min(acc.minX, bbox.x), - minY: Math.min(acc.minY, bbox.y), - maxX: Math.max(acc.maxX, bbox.x + bbox.width), - maxY: Math.max(acc.maxY, bbox.y + bbox.height) - }; - }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); - - const padding = 50; - const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); - const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); - - loopNode.prop('size', { width: newWidth, height: newHeight }); - - // Update right port x position - const ports = loopNode.getPorts(); - ports.forEach(port => { - if (port.group === 'right' && port.args) { - loopNode.portProp(port.id!, 'args/x', newWidth); - } - }); - } - }; - - adjustLoopSize(); - - // Listen to child node movement events const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - childNodes.forEach((childNode: any) => { - childNode.on('change:position', adjustLoopSize); - }); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc, child) => { + const bbox = child.getBBox(); + return { + minX: Math.min(acc.minX, bbox.x), + minY: Math.min(acc.minY, bbox.y), + maxX: Math.max(acc.maxX, bbox.x + bbox.width), + maxY: Math.max(acc.maxY, bbox.y + bbox.height) + }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + const padding = 50; + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + loopNode.prop('size', { width: newWidth, height: newHeight }); + loopNode.getPorts().forEach(port => { + if (port.group === 'right' && port.args) { + loopNode.portProp(port.id!, 'args/x', newWidth); + } + }); + } } + + addedEdges.forEach(e => { + const src = graph.getCellById(e.getSourceCellId()); + const tgt = graph.getCellById(e.getTargetCellId()); + if (src?.isNode()) src.toFront(); + if (tgt?.isNode()) tgt.toFront(); + }); + + graph.stopBatch('add-node'); setOpen(false); }; diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx index 68aef867..402b5d37 100644 --- a/web/src/views/Workflow/components/PortClickHandler.tsx +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -43,71 +43,52 @@ const PortClickHandler: React.FC = ({ graph }) => { }; }, []); - // Handle node selection from popover menu and create new node with edge connection const handleNodeSelect = (selectedNodeType: any) => { if (!sourceNode || !graph) return; - graph.startBatch('add-node'); const sourceNodeData = sourceNode.getData(); const sourceNodeType = sourceNodeData?.type; - - // If it's a cycle-start node, handle the add-node placeholder + const isCycleSubNode = !!sourceNodeData.cycle; + const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration'; + const newNodeType = selectedNodeType.type; + + // Save add-node placeholder position before disabling history let addNodePosition = null; - const isCycleSubNode = sourceNodeData.cycle if (isCycleSubNode && sourceNodeType === 'cycle-start') { const cycleId = sourceNodeData.cycle; - const addNodes = graph.getNodes().filter((n: any) => + const addNodes = graph.getNodes().filter((n: any) => n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId ); - - if (addNodes.length > 0) { - const addNode = addNodes[0]; - addNodePosition = addNode.getBBox(); - addNode.remove(); - } + if (addNodes.length > 0) addNodePosition = addNodes[0].getBBox(); } - - // Calculate new node position to avoid overlapping + + // Calculate position const sourceBBox = sourceNode.getBBox(); - const nodeWidth = graphNodeLibrary[selectedNodeType.type]?.width || 120; - const nodeHeight = graphNodeLibrary[selectedNodeType.type]?.height || 88; - const horizontalSpacing = isCycleSubNode ? 48 : 80; - const verticalSpacing = 10; - - // Get source port group information + const nw = graphNodeLibrary[newNodeType]?.width || 120; + const nh = graphNodeLibrary[newNodeType]?.height || 88; + const hSpacing = isCycleSubNode ? 48 : 80; + const vSpacing = 10; const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort); const sourcePortGroup = sourcePortInfo?.group || sourcePort; - - // Calculate new node position - let newX, newY; + + let newX: number, newY: number; if (edgeInsertion) { - // Edge insertion: place new node on the same row as target, between source and target const targetBBox = edgeInsertion.targetCell.getBBox(); const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width); - const requiredSpace = nodeWidth + horizontalSpacing * 4; - - // New node x: right after source + spacing - newX = sourceBBox.x + sourceBBox.width + horizontalSpacing; - // Same row as target node - newY = targetBBox.y + (targetBBox.height - nodeHeight) / 2; - - // If not enough space, shift target and all downstream nodes to the right + const requiredSpace = nw + hSpacing * 4; + newX = sourceBBox.x + sourceBBox.width + hSpacing; + newY = targetBBox.y + (targetBBox.height - nh) / 2; if (gap < requiredSpace) { const shiftX = requiredSpace - gap; const visited = new Set(); const shiftDownstream = (cell: any) => { - const cellId = cell.id; - if (visited.has(cellId)) return; - visited.add(cellId); + if (visited.has(cell.id)) return; + visited.add(cell.id); const pos = cell.getPosition(); cell.setPosition(pos.x + shiftX, pos.y); - // Recursively shift nodes connected from right ports graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => { - const tId = e.getTargetCellId(); - if (tId && !visited.has(tId)) { - const tCell = graph.getCellById(tId); - if (tCell?.isNode()) shiftDownstream(tCell); - } + const tCell = graph.getCellById(e.getTargetCellId()); + if (tCell?.isNode()) shiftDownstream(tCell); }); }; shiftDownstream(edgeInsertion.targetCell); @@ -115,209 +96,170 @@ const PortClickHandler: React.FC = ({ graph }) => { } else if (addNodePosition) { newX = addNodePosition.x; newY = addNodePosition.y; + } else if (sourcePortGroup === 'left') { + newX = sourceBBox.x - nw * 2 - hSpacing; + newY = sourceBBox.y; } else { - // Determine node placement direction based on port position - if (sourcePortGroup === 'left') { - // Left port: add node to the left - newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing; - newY = sourceBBox.y; - } else { - // Right port: add node to the right - newX = sourceBBox.x + sourceBBox.width + horizontalSpacing; - newY = sourceBBox.y; - } - - // Check if position overlaps with existing nodes (only consider connected nodes) - const checkOverlap = (x: number, y: number) => { - // Get nodes connected to the source node - const connectedNodes = new Set(); - graph.getConnectedEdges(sourceNode).forEach((edge: any) => { - const sourceId = edge.getSourceCellId(); - const targetId = edge.getTargetCellId(); - if (sourceId !== sourceNode.id) connectedNodes.add(sourceId); - if (targetId !== sourceNode.id) connectedNodes.add(targetId); + newX = sourceBBox.x + sourceBBox.width + hSpacing; + newY = sourceBBox.y; + const connectedNodes = new Set(); + graph.getConnectedEdges(sourceNode).forEach((e: any) => { + [e.getSourceCellId(), e.getTargetCellId()].forEach((cid: string) => { + if (cid !== sourceNode.id) connectedNodes.add(cid); }); - - return graph.getNodes().some((node: any) => { - if (node.id === sourceNode.id) return false; - if (!connectedNodes.has(node.id)) return false; // Only consider connected nodes - const bbox = node.getBBox(); - return !(x + nodeWidth < bbox.x || x > bbox.x + bbox.width || - y + nodeHeight < bbox.y || y > bbox.y + bbox.height); + }); + const checkOverlap = (x: number, y: number) => + graph.getNodes().some((n: any) => { + if (n.id === sourceNode.id || !connectedNodes.has(n.id)) return false; + const b = n.getBBox(); + return !(x + nw < b.x || x > b.x + b.width || y + nh < b.y || y > b.y + b.height); }); - }; - - // If position is occupied, search downward for empty space - while (checkOverlap(newX, newY)) { - newY += nodeHeight + verticalSpacing; - } + while (checkOverlap(newX, newY)) newY += nh + vSpacing; } - - // Create new node - const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + // Disable history for all graph mutations + graph.disableHistory(); + + // Remove add-node placeholder + if (isCycleSubNode && sourceNodeType === 'cycle-start') { + const cycleId = sourceNodeData.cycle; + graph.getNodes() + .filter((n: any) => n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) + .forEach((n: any) => n.remove()); + } + + const id = `${newNodeType.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const newNode = graph.addNode({ - ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default), + ...(graphNodeLibrary[newNodeType] || graphNodeLibrary.default), x: newX, y: newY - (isCycleSubNode && sourceNodeType === 'cycle-start' ? 12 : 0), id, data: { id, - type: selectedNodeType.type, + type: newNodeType, icon: selectedNodeType.icon, - name: t(`workflow.${selectedNodeType.type}`), - cycle: sourceNodeData.cycle, // Inherit cycle from source node + name: t(`workflow.${newNodeType}`), + cycle: sourceNodeData.cycle, config: selectedNodeType.config || {} }, }); - // Add new node as child of parent node if (sourceNodeData.cycle) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle); - if (parentNode) { - parentNode.addChild(newNode); - } + if (parentNode) parentNode.addChild(newNode, { silent: true }); } - // Edge insertion: remove old edge immediately before creating new edges if (edgeInsertion) { const { edge: oldEdge } = edgeInsertion; - if (oldEdge.id && graph.getCellById(oldEdge.id)) { - graph.removeCell(oldEdge.id); - } else { - graph.removeEdge(oldEdge); - } + if (oldEdge.id && graph.getCellById(oldEdge.id)) graph.removeCell(oldEdge.id); + else graph.removeEdge(oldEdge); } - // Create edge connection - setTimeout(() => { - const newPorts = newNode.getPorts(); + const newPorts = newNode.getPorts(); + const addedCells: any[] = [newNode]; - const addedEdges: any[] = []; - if (edgeInsertion) { - // Edge insertion: create source→new and new→target edges - const { targetCell, targetPort: origTargetPort } = edgeInsertion; - const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; - const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; - addedEdges.push(graph.addEdge({ - source: { cell: sourceNode.id, port: sourcePort }, - target: { cell: newNode.id, port: newLeftPort }, - ...edgeAttrs - })); - addedEdges.push(graph.addEdge({ - source: { cell: newNode.id, port: newRightPort }, - target: { cell: targetCell.id, port: origTargetPort }, - ...edgeAttrs - })); - setEdgeInsertion(null); - } else if (sourcePortGroup === 'left') { - // Connect from left port to new node's right side - const targetPort = newPorts.find((port: any) => port.group === 'right')?.id || 'right'; - addedEdges.push(graph.addEdge({ - source: { cell: newNode.id, port: targetPort }, - target: { cell: sourceNode.id, port: sourcePort }, - ...edgeAttrs - })); - } else { - // Connect from right port to new node's left side - const targetPort = newPorts.find((port: any) => port.group === 'left')?.id || 'left'; - addedEdges.push(graph.addEdge({ - source: { cell: sourceNode.id, port: sourcePort }, - target: { cell: newNode.id, port: targetPort }, - ...edgeAttrs - })); - } - - // Adjust loop node size when child node is added via port within loop node - const cycleId = sourceNodeData.cycle; - if (cycleId) { - const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (edgeInsertion) { + const { targetCell, targetPort: origTargetPort } = edgeInsertion; + const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; + const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; + addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs })); + addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs })); + setEdgeInsertion(null); + } else if (sourcePortGroup === 'left') { + const tp = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; + addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: tp }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs })); + } else { + const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; + addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs })); + } - if (parentNode) { - const adjustLoopSize = () => { - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - if (childNodes.length > 0) { - const bounds = childNodes.reduce((acc: any, child: any) => { - const bbox = child.getBBox(); - return { - minX: Math.min(acc.minX, bbox.x), - minY: Math.min(acc.minY, bbox.y), - maxX: Math.max(acc.maxX, bbox.x + bbox.width), - maxY: Math.max(acc.maxY, bbox.y + bbox.height) - }; - }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + // If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type + if (isCycleContainer(newNodeType)) { + const parentBBox = newNode.getBBox(); + const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const cycleStartNode = graph.addNode({ + ...graphNodeLibrary.cycleStart, + x: parentBBox.x + 24, + y: parentBBox.y + 70, + id: cycleStartId, + data: { id: cycleStartId, type: 'cycle-start', parentId: id, isDefault: true, cycle: id }, + }); + const addNodePlaceholder = graph.addNode({ + ...graphNodeLibrary.addStart, + x: parentBBox.x + 24 + 84, + y: parentBBox.y + 70 + 4, + data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id }, + }); + newNode.addChild(cycleStartNode, { silent: true }); + newNode.addChild(addNodePlaceholder, { silent: true }); + const innerEdge = graph.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' }, + target: { cell: addNodePlaceholder.id, port: addNodePlaceholder.getPorts().find((p: any) => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }); + addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge); + } - const padding = 50; - const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); - const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); - - parentNode.prop('size', { width: newWidth, height: newHeight }); - - // Update right port x position - const ports = parentNode.getPorts(); - ports.forEach((port: any) => { - if (port.group === 'right' && port.args) { - parentNode.portProp(port.id!, 'args/x', newWidth); - } - }); - } - }; - - adjustLoopSize(); - - // Listen to child node movement events - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - childNodes.forEach((childNode: any) => { - childNode.on('change:position', adjustLoopSize); + // Adjust parent size if adding inside a cycle container + const cycleId = sourceNodeData.cycle; + if (cycleId) { + const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (parentNode) { + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc: any, child: any) => { + const b = child.getBBox(); + return { minX: Math.min(acc.minX, b.x), minY: Math.min(acc.minY, b.y), maxX: Math.max(acc.maxX, b.x + b.width), maxY: Math.max(acc.maxY, b.y + b.height) }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + const padding = 50; + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + parentNode.prop('size', { width: newWidth, height: newHeight }); + parentNode.getPorts().forEach((port: any) => { + if (port.group === 'right' && port.args) parentNode.portProp(port.id!, 'args/x', newWidth); }); } } + } - const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration'; - const newNodeType = selectedNodeType.type; + // toFront + const bringCycleChildrenToFront = (cycleContainerId: string) => { + graph.getEdges().forEach((e: any) => { + const src = graph.getCellById(e.getSourceCellId()); + const tgt = graph.getCellById(e.getTargetCellId()); + if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront(); + }); + graph.getNodes().forEach((n: any) => { if (n.getData()?.cycle === cycleContainerId) n.toFront(); }); + }; - // Helper: bring all child nodes and their edges of a cycle container to front - const bringCycleChildrenToFront = (cycleContainerId: string) => { - - graph.getEdges().forEach((e: any) => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront(); - }); - graph.getNodes().forEach((n: any) => { - if (n.getData()?.cycle === cycleContainerId) n.toFront(); - }); - }; + if (isCycleContainer(sourceNodeType)) { + newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(sourceNodeData.id); + if (isCycleContainer(newNodeType)) bringCycleChildrenToFront(id); + } else if (isCycleContainer(newNodeType)) { + newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(id); + } else { + addedCells.forEach(c => { if (c.isNode?.()) c.toFront(); }); + } - if (isCycleContainer(sourceNodeType)) { - console.log('isCycleContainer(sourceNodeType)') - // Case 4: source is a loop/iteration node — bring new node to front, then its children - newNode.toFront(); - sourceNode.toFront(); - bringCycleChildrenToFront(sourceNodeData.id); - } else if (isCycleContainer(newNodeType)) { - console.log('isCycleContainer(newNodeType)') - // Case 3: adding a loop/iteration node from a normal node — bring new node to front, then its children - newNode.toFront(); - sourceNode.toFront() - bringCycleChildrenToFront(id); - } else { - // Case 2: normal node → normal node - addedEdges.forEach(e => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.isNode()) src.toFront(); - if (tgt?.isNode()) tgt.toFront(); - }); - } - graph.stopBatch('add-node'); - }, 50); + // Re-enable history and manually push one batch frame for all added cells + graph.enableHistory(); + const history = graph.getPlugin('history') as any; + if (history) { + const batchFrame = addedCells.map((cell: any) => ({ + batch: true, + event: 'cell:added', + data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() }, + options: {}, + })); + history.undoStack.push(batchFrame); + history.redoStack = []; + graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-node' } }); + } - // Clean up temporary element if (tempElement) { document.body.removeChild(tempElement); setTempElement(null); } - setPopoverVisible(false); }; @@ -393,4 +335,4 @@ const PortClickHandler: React.FC = ({ graph }) => { ); }; -export default PortClickHandler; \ No newline at end of file +export default PortClickHandler; diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index c81974a4..500a4527 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-27 16:30:30 + * @Last Modified time: 2026-04-28 13:49:11 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -16,7 +16,7 @@ import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'; import { useUser } from '@/store/user'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant'; -import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types'; +import type { ChatVariable, HistoryRecord, NodeProperties, WorkflowConfig } from '../types'; import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'; import { useWorkflowStore } from '@/store/workflow'; @@ -85,6 +85,10 @@ export interface UseWorkflowGraphReturn { /** Get start node output variable list (user-defined + system variables) */ getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>; nodeClick: ({ node }: { node: Node }) => void; + /** All recorded history operations */ + historyRecords: HistoryRecord[]; + /** Clear history records */ + clearHistoryRecords: () => void; } /** @@ -118,7 +122,12 @@ export const useWorkflowGraph = ({ const featuresRef = useRef(undefined) const [canUndo, setCanUndo] = useState(false) const [canRedo, setCanRedo] = useState(false) - + const [historyRecords, setHistoryRecords] = useState([]) + const lastHistoryRef = useRef<{ cellIds: string[]; timestamp: number; type: string } | null>(null) + const undoRef = useRef<() => void>(() => {}) + const redoRef = useRef<() => void>(() => {}) + const syncChildRelationshipsRef = useRef<() => void>(() => {}) + const isSyncingRef = useRef(false) useEffect(() => { if (!graphRef.current) return graphRef.current.getNodes().forEach(node => { @@ -342,7 +351,7 @@ export const useWorkflowGraph = ({ if (parentNode) { const addedChild = graphRef.current?.addNode(childNode) if (addedChild) { - parentNode.addChild(addedChild) + parentNode.addChild(addedChild, { silent: true }) } } } @@ -373,8 +382,6 @@ export const useWorkflowGraph = ({ const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2) const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight) - console.log('newWidth', newHeight, newWidth) - parentNode.prop('size', { width: newWidth, height: newHeight }) // Update x position of right group ports @@ -523,30 +530,28 @@ export const useWorkflowGraph = ({ const syncChildRelationships = () => { if (!graphRef.current) return const graph = graphRef.current - // Re-establish parent-child relationships based on cycle data + graph.disableHistory() 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) + parentNode.addChild(node, { silent: true }) } }) - // 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 => { + if (!child.isNode()) return const childCycleId = (child as Node).getData?.()?.cycle if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) { - node.removeChild(child) + node.removeChild(child, { silent: true }) } }) }) - // 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()) @@ -557,7 +562,9 @@ export const useWorkflowGraph = ({ graph.getNodes().forEach(node => { if (node.getData()?.cycle) node.toFront() }) + graph.enableHistory() } + syncChildRelationshipsRef.current = syncChildRelationships /** * Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard) */ @@ -593,19 +600,44 @@ export const useWorkflowGraph = ({ new History({ enabled: false, beforeAddCommand(_event, args: any) { - const event = args?.key ? `cell:change:${args.key}` : _event; - const allowed = ['cell:added', 'cell:removed', 'cell:change:position', 'cell:change:source', 'cell:change:target']; - if (!allowed.includes(event)) return false; + const key = args?.key + if (key === 'attrs' || key === 'tools') return false }, }), ); - graphRef.current.on('history:change', () => { + const MERGE_INTERVAL = 1000 + graphRef.current.on('history:change', ({ cmds, options }: { cmds: any[]; options: any }) => { setCanUndo(graphRef.current?.canUndo() ?? false) setCanRedo(graphRef.current?.canRedo() ?? false) + console.log('history:change', cmds, options) + const batchName: string | undefined = options?.name + const actionType = batchName === 'undo' ? 'undo' : batchName === 'redo' ? 'redo' : batchName ? 'batch' : 'change' + const cellIds = [...new Set(cmds?.map((cmd: any) => cmd.data?.id).filter(Boolean))] + const now = Date.now() + const last = lastHistoryRef.current + const canMerge = + actionType === 'change' && + last?.type === 'change' && + now - last.timestamp < MERGE_INTERVAL && + cellIds.length > 0 && + cellIds.length === last.cellIds.length && + cellIds.every((id, i) => id === last.cellIds[i]) + if (canMerge) { + lastHistoryRef.current!.timestamp = now + setHistoryRecords(prev => { + const next = [...prev] + next[next.length - 1] = { ...next[next.length - 1], timestamp: now } + return next + }) + } else { + const record: HistoryRecord = { type: actionType, timestamp: now, batchName, cellIds } + lastHistoryRef.current = { cellIds, timestamp: now, type: actionType } + setHistoryRecords(prev => [...prev, record]) + } }) - graphRef.current.on('history:undo', syncChildRelationships) - graphRef.current.on('history:redo', syncChildRelationships) + graphRef.current.on('history:undo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() }) + graphRef.current.on('history:redo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() }) }; // 显示/隐藏连接桩 // const showPorts = (show: boolean) => { @@ -638,13 +670,13 @@ export const useWorkflowGraph = ({ vo.setData({ ...data, isSelected: false, - }); + }, { silent: true }); } }); node.setData({ ...nodeData, isSelected: true, - }); + }, { silent: true }); clearEdgeSelect() if (nodeData.type !== 'notes') { setSelectedNode(node); @@ -658,7 +690,7 @@ export const useWorkflowGraph = ({ const edgeClick = ({ edge }: { edge: Edge }) => { clearEdgeSelect(); edge.setAttrByPath('line/stroke', edge_selected_color); - edge.setData({ ...edge.getData(), isSelected: true }); + edge.setData({ ...edge.getData(), isSelected: true }, { silent: true }); clearNodeSelect(); }; /** @@ -673,7 +705,7 @@ export const useWorkflowGraph = ({ node.setData({ ...data, isSelected: false, - }); + }, { silent: true }); } }); setSelectedNode(null); @@ -683,7 +715,7 @@ export const useWorkflowGraph = ({ */ const clearEdgeSelect = () => { graphRef.current?.getEdges().forEach(e => { - e.setData({ ...e.getData(), isSelected: false, isNodeHover: false }); + e.setData({ ...e.getData(), isSelected: false, isNodeHover: false }, { silent: true }); e.setAttrByPath('line/stroke', edge_color); e.setAttrByPath('line/strokeWidth', edge_width); }); @@ -885,7 +917,7 @@ export const useWorkflowGraph = ({ y: bbox.y + 4, data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' }, }); - parentNode.addChild(addNode); + parentNode.addChild(addNode, { silent: true }); 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' }, @@ -1112,7 +1144,7 @@ export const useWorkflowGraph = ({ graphRef.current?.getConnectedEdges(node).forEach(edge => { if (!edge.getData()?.isSelected) { edge.setAttrByPath('line/stroke', edge_selected_color); - edge.setData({ ...edge.getData(), isNodeHover: true }); + edge.setData({ ...edge.getData(), isNodeHover: true }, { silent: true }); } }); }); @@ -1120,7 +1152,7 @@ export const useWorkflowGraph = ({ graphRef.current?.getConnectedEdges(node).forEach(edge => { if (!edge.getData()?.isSelected) { edge.setAttrByPath('line/stroke', edge_color); - edge.setData({ ...edge.getData(), isNodeHover: false }); + edge.setData({ ...edge.getData(), isNodeHover: false }, { silent: true }); } }); }); @@ -1202,8 +1234,8 @@ export const useWorkflowGraph = ({ // Delete selected nodes and edges graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); // Undo / Redo - graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); return false; }); - graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { graphRef.current?.redo(); return false; }); + graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { undo(); return false; }); + graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { redo(); return false; }); }; @@ -1269,14 +1301,14 @@ export const useWorkflowGraph = ({ }; if (dragData.type === 'loop' || dragData.type === 'iteration') { - graphRef.current.startBatch('add-group') + graph.disableHistory() 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({ @@ -1292,16 +1324,28 @@ export const useWorkflowGraph = ({ 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({ + parentNode.addChild(cycleStartNode, { silent: true }) + parentNode.addChild(addNode, { silent: true }) + const newEdge = 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') + graph.enableHistory() + // Manually push a single batch frame covering all 4 cells into undoStack + const history = graph.getPlugin('history') as History + const makeBatchCmd = (cell: any) => ({ + batch: true, + event: 'cell:added', + data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() }, + options: {}, + }) + const batchFrame = [parentNode, cycleStartNode, addNode, newEdge].map(makeBatchCmd) + ;(history as any).undoStack.push(batchFrame) + ;(history as any).redoStack = [] + graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-group' } }) } else if (dragData.type === 'if-else') { // Create condition node graphRef.current.addNode({ @@ -1548,8 +1592,80 @@ export const useWorkflowGraph = ({ return userVars } - const undo = () => graphRef.current?.undo() - const redo = () => graphRef.current?.redo() + const clearHistoryRecords = () => { + setHistoryRecords([]) + lastHistoryRef.current = null + } + + const getStackCellIds = (cmds: any): string[] => { + const arr = Array.isArray(cmds) ? cmds : [cmds] + return [...new Set(arr.map((c: any) => c.data?.id).filter(Boolean))] + } + + const isSkippableFrame = (frame: any): boolean => { + const arr = Array.isArray(frame) ? frame : [frame] + return arr.every((c: any) => ['zIndex', 'attrs', 'tools'].includes(c.data?.key)) + } + + const undo = () => { + const history = graphRef.current?.getPlugin('history') as History | undefined + if (!history || history.getUndoSize() === 0) return + const undoStack = (history as any).undoStack as any[] + isSyncingRef.current = true + while (undoStack.length > 0 && isSkippableFrame(undoStack[undoStack.length - 1])) { + graphRef.current!.undo() + } + if (undoStack.length === 0) { + isSyncingRef.current = false + return + } + const topIds = getStackCellIds(undoStack[undoStack.length - 1]) + graphRef.current!.undo() + while (undoStack.length > 0) { + if (isSkippableFrame(undoStack[undoStack.length - 1])) { + graphRef.current!.undo() + continue + } + const nextIds = getStackCellIds(undoStack[undoStack.length - 1]) + if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) { + graphRef.current!.undo() + } else { + break + } + } + isSyncingRef.current = false + syncChildRelationships() + } + + const redo = () => { + const history = graphRef.current?.getPlugin('history') as History | undefined + if (!history || history.getRedoSize() === 0) return + const redoStack = (history as any).redoStack as any[] + isSyncingRef.current = true + while (redoStack.length > 0 && isSkippableFrame(redoStack[redoStack.length - 1])) { + graphRef.current!.redo() + } + if (redoStack.length === 0) { + isSyncingRef.current = false + return + } + const topIds = getStackCellIds(redoStack[redoStack.length - 1]) + graphRef.current!.redo() + while (redoStack.length > 0) { + if (isSkippableFrame(redoStack[redoStack.length - 1])) { + graphRef.current!.redo() + continue + } + const nextIds = getStackCellIds(redoStack[redoStack.length - 1]) + if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) { + graphRef.current!.redo() + } else { + break + } + } + isSyncingRef.current = false + syncChildRelationships() + } const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => { const { statement = '' } = value?.opening_statement || {} @@ -1593,7 +1709,7 @@ export const useWorkflowGraph = ({ // Reset all node execution status on every chatHistory change nodes.forEach(node => { const data = node.getData(); - node.setData({ ...data, executionStatus: '' }); + node.setData({ ...data, executionStatus: '' }, { silent: true }); }); const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant'); @@ -1602,7 +1718,7 @@ export const useWorkflowGraph = ({ if (typeof sub.status === 'string') { const node = nodes.find(n => n.getData()?.id === sub.node_id); if (node) { - node.setData({ ...node.getData(), executionStatus: sub.status }); + node.setData({ ...node.getData(), executionStatus: sub.status }, { silent: true }); } } }); @@ -1635,5 +1751,7 @@ export const useWorkflowGraph = ({ canRedo, undo, redo, + historyRecords, + clearHistoryRecords, }; }; diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts index 1604aac2..16a64632 100644 --- a/web/src/views/Workflow/types.ts +++ b/web/src/views/Workflow/types.ts @@ -113,4 +113,13 @@ export interface ChatVariable { } export interface AddChatVariableRef { handleOpen: (value?: ChatVariable) => void; +} + +export type HistoryActionType = 'add' | 'remove' | 'change' | 'undo' | 'redo' | 'batch' + +export interface HistoryRecord { + type: HistoryActionType; + timestamp: number; + batchName?: string; + cellIds?: string[]; } \ No newline at end of file