import type { MouseEvent } from 'react' import type { NodeDragHandler, NodeMouseHandler, OnConnect, OnConnectEnd, OnConnectStart, ResizeParamsWithDirection, } from 'reactflow' import type { PluginDefaultValue } from '../block-selector/types' import type { GroupHandler, GroupMember, GroupNodeData } from '../nodes/group/types' import type { IterationNodeType } from '../nodes/iteration/types' import type { LoopNodeType } from '../nodes/loop/types' import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types' import type { Edge, Node, OnNodeAdd } from '../types' import type { RAGPipelineVariables } from '@/models/pipeline' import { produce } from 'immer' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { getConnectedEdges, getOutgoers, useReactFlow, useStoreApi, } from 'reactflow' import { CUSTOM_EDGE, ITERATION_CHILDREN_Z_INDEX, ITERATION_PADDING, LOOP_CHILDREN_Z_INDEX, LOOP_PADDING, NODE_WIDTH_X_OFFSET, X_OFFSET, Y_OFFSET, } from '../constants' import { getNodeUsedVars } from '../nodes/_base/components/variable/utils' import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants' import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions' import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' import { CUSTOM_NOTE_NODE } from '../note-node/constants' import { useWorkflowStore } from '../store' import { BlockEnum, isTriggerNode } from '../types' import { generateNewNode, genNewNodeTitleFromOld, getNestedNodePosition, getNodeCustomTypeByNodeDataType, getNodesConnectedSourceOrTargetHandleIdsMap, getTopLeftNodePosition, } from '../utils' import { useWorkflowHistoryStore } from '../workflow-history-store' import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url' import { useHelpline } from './use-helpline' import useInspectVarsCrud from './use-inspect-vars-crud' import { checkMakeGroupAvailability } from './use-make-group' import { useNodesMetaData } from './use-nodes-meta-data' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly, useWorkflow, useWorkflowReadOnly, } from './use-workflow' import { useWorkflowHistory, WorkflowHistoryEvent, } from './use-workflow-history' // Entry node deletion restriction has been removed to allow empty workflows // Entry node (Start/Trigger) wrapper offsets for alignment // Must match the values in use-helpline.ts const ENTRY_NODE_WRAPPER_OFFSET = { x: 0, y: 21, // Adjusted based on visual testing feedback } as const /** * Parse group handler id to get original node id and sourceHandle * Handler id format: `${nodeId}-${sourceHandle}` */ function parseGroupHandlerId(handlerId: string): { originalNodeId: string, originalSourceHandle: string } { const lastDashIndex = handlerId.lastIndexOf('-') return { originalNodeId: handlerId.substring(0, lastDashIndex), originalSourceHandle: handlerId.substring(lastDashIndex + 1), } } /** * Create a pair of edges for group node connections: * - realEdge: hidden edge from original node to target (persisted to backend) * - uiEdge: visible temp edge from group to target (UI-only, not persisted) */ function createGroupEdgePair(params: { groupNodeId: string handlerId: string targetNodeId: string targetHandle: string nodes: Node[] baseEdgeData?: Partial zIndex?: number }): { realEdge: Edge, uiEdge: Edge } | null { const { groupNodeId, handlerId, targetNodeId, targetHandle, nodes, baseEdgeData = {}, zIndex = 0 } = params const groupNode = nodes.find(node => node.id === groupNodeId) const groupData = groupNode?.data as GroupNodeData | undefined const handler = groupData?.handlers?.find(h => h.id === handlerId) let originalNodeId: string let originalSourceHandle: string if (handler?.nodeId && handler?.sourceHandle) { originalNodeId = handler.nodeId originalSourceHandle = handler.sourceHandle } else { const parsed = parseGroupHandlerId(handlerId) originalNodeId = parsed.originalNodeId originalSourceHandle = parsed.originalSourceHandle } const originalNode = nodes.find(node => node.id === originalNodeId) const targetNode = nodes.find(node => node.id === targetNodeId) if (!originalNode || !targetNode) return null // Create the real edge (from original node to target) - hidden because original node is in group const realEdge: Edge = { id: `${originalNodeId}-${originalSourceHandle}-${targetNodeId}-${targetHandle}`, type: CUSTOM_EDGE, source: originalNodeId, sourceHandle: originalSourceHandle, target: targetNodeId, targetHandle, hidden: true, data: { ...baseEdgeData, sourceType: originalNode.data.type, targetType: targetNode.data.type, _hiddenInGroupId: groupNodeId, }, zIndex, } // Create the UI edge (from group to target) - temporary, not persisted to backend const uiEdge: Edge = { id: `${groupNodeId}-${handlerId}-${targetNodeId}-${targetHandle}`, type: CUSTOM_EDGE, source: groupNodeId, sourceHandle: handlerId, target: targetNodeId, targetHandle, data: { ...baseEdgeData, sourceType: BlockEnum.Group, targetType: targetNode.data.type, _isTemp: true, }, zIndex, } return { realEdge, uiEdge } } function createGroupInboundEdges(params: { sourceNodeId: string sourceHandle: string groupNodeId: string groupData: GroupNodeData nodes: Node[] baseEdgeData?: Partial zIndex?: number }): { realEdges: Edge[], uiEdge: Edge } | null { const { sourceNodeId, sourceHandle, groupNodeId, groupData, nodes, baseEdgeData = {}, zIndex = 0 } = params const sourceNode = nodes.find(node => node.id === sourceNodeId) const headNodeIds = groupData.headNodeIds || [] if (!sourceNode || headNodeIds.length === 0) return null const realEdges: Edge[] = headNodeIds.map((headNodeId) => { const headNode = nodes.find(node => node.id === headNodeId) return { id: `${sourceNodeId}-${sourceHandle}-${headNodeId}-target`, type: CUSTOM_EDGE, source: sourceNodeId, sourceHandle, target: headNodeId, targetHandle: 'target', hidden: true, data: { ...baseEdgeData, sourceType: sourceNode.data.type, targetType: headNode?.data.type, _hiddenInGroupId: groupNodeId, }, zIndex, } as Edge }) const uiEdge: Edge = { id: `${sourceNodeId}-${sourceHandle}-${groupNodeId}-target`, type: CUSTOM_EDGE, source: sourceNodeId, sourceHandle, target: groupNodeId, targetHandle: 'target', data: { ...baseEdgeData, sourceType: sourceNode.data.type, targetType: BlockEnum.Group, _isTemp: true, }, zIndex, } return { realEdges, uiEdge } } export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() const { store: workflowHistoryStore } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { getAfterNodesInSameBranch } = useWorkflow() const { getNodesReadOnly } = useNodesReadOnly() const { getWorkflowReadOnly } = useWorkflowReadOnly() const { handleSetHelpline } = useHelpline() const { handleNodeIterationChildDrag, handleNodeIterationChildrenCopy } = useNodeIterationInteractions() const { handleNodeLoopChildDrag, handleNodeLoopChildrenCopy } = useNodeLoopInteractions() const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number y: number }) const { nodesMap: nodesMetaDataMap } = useNodesMetaData() const { saveStateToHistory, undo, redo } = useWorkflowHistory() const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl() const handleNodeDragStart = useCallback( (_, node) => { workflowStore.setState({ nodeAnimation: false }) if (getNodesReadOnly()) return if ( node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_NOTE_NODE ) { return } if ( node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE ) { return } dragNodeStartPosition.current = { x: node.position.x, y: node.position.y, } }, [workflowStore, getNodesReadOnly], ) const handleNodeDrag = useCallback( (e, node: Node) => { if (getNodesReadOnly()) return if (node.type === CUSTOM_ITERATION_START_NODE) return if (node.type === CUSTOM_LOOP_START_NODE) return const { getNodes, setNodes } = store.getState() e.stopPropagation() const nodes = getNodes() const { restrictPosition } = handleNodeIterationChildDrag(node) const { restrictPosition: restrictLoopPosition } = handleNodeLoopChildDrag(node) const { showHorizontalHelpLineNodes, showVerticalHelpLineNodes } = handleSetHelpline(node) const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length const newNodes = produce(nodes, (draft) => { const currentNode = draft.find(n => n.id === node.id)! // Check if current dragging node is an entry node const isCurrentEntryNode = isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start // X-axis alignment with offset consideration if (showVerticalHelpLineNodesLength > 0) { const targetNode = showVerticalHelpLineNodes[0] const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start // Calculate the wrapper position needed to align the inner nodes // Target inner position = target.position + target.offset // Current inner position should equal target inner position // So: current.position + current.offset = target.position + target.offset // Therefore: current.position = target.position + target.offset - current.offset const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.x : 0 currentNode.position.x = targetNode.position.x + targetOffset - currentOffset } else if (restrictPosition.x !== undefined) { currentNode.position.x = restrictPosition.x } else if (restrictLoopPosition.x !== undefined) { currentNode.position.x = restrictLoopPosition.x } else { currentNode.position.x = node.position.x } // Y-axis alignment with offset consideration if (showHorizontalHelpLineNodesLength > 0) { const targetNode = showHorizontalHelpLineNodes[0] const isTargetEntryNode = isTriggerNode(targetNode.data.type as any) || targetNode.data.type === BlockEnum.Start const targetOffset = isTargetEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 const currentOffset = isCurrentEntryNode ? ENTRY_NODE_WRAPPER_OFFSET.y : 0 currentNode.position.y = targetNode.position.y + targetOffset - currentOffset } else if (restrictPosition.y !== undefined) { currentNode.position.y = restrictPosition.y } else if (restrictLoopPosition.y !== undefined) { currentNode.position.y = restrictLoopPosition.y } else { currentNode.position.y = node.position.y } }) setNodes(newNodes) }, [ getNodesReadOnly, store, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline, ], ) const handleNodeDragStop = useCallback( (_, node) => { const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() if (getNodesReadOnly()) return const { x, y } = dragNodeStartPosition.current if (!(x === node.position.x && y === node.position.y)) { setHelpLineHorizontal() setHelpLineVertical() handleSyncWorkflowDraft() if (x !== 0 && y !== 0) { // selecting a note will trigger a drag stop event with x and y as 0 saveStateToHistory(WorkflowHistoryEvent.NodeDragStop, { nodeId: node.id, }) } } }, [ workflowStore, getNodesReadOnly, saveStateToHistory, handleSyncWorkflowDraft, ], ) const handleNodeEnter = useCallback( (_, node) => { if (getNodesReadOnly()) return if ( node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE ) { return } if ( node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE ) { return } const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const { connectingNodePayload, setEnteringNodePayload } = workflowStore.getState() if (connectingNodePayload) { if (connectingNodePayload.nodeId === node.id) return const connectingNode: Node = nodes.find( n => n.id === connectingNodePayload.nodeId, )! const sameLevel = connectingNode.parentId === node.parentId if (sameLevel) { setEnteringNodePayload({ nodeId: node.id, nodeData: node.data as VariableAssignerNodeType, }) const fromType = connectingNodePayload.handleType const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { if ( n.id === node.id && fromType === 'source' && (node.data.type === BlockEnum.VariableAssigner || node.data.type === BlockEnum.VariableAggregator) ) { if (!node.data.advanced_settings?.group_enabled) n.data._isEntering = true } if ( n.id === node.id && fromType === 'target' && (connectingNode.data.type === BlockEnum.VariableAssigner || connectingNode.data.type === BlockEnum.VariableAggregator) && node.data.type !== BlockEnum.IfElse && node.data.type !== BlockEnum.QuestionClassifier ) { n.data._isEntering = true } }) }) setNodes(newNodes) } } const newEdges = produce(edges, (draft) => { const connectedEdges = getConnectedEdges([node], edges) connectedEdges.forEach((edge) => { const currentEdge = draft.find(e => e.id === edge.id) if (currentEdge) currentEdge.data._connectedNodeIsHovering = true }) }) setEdges(newEdges) }, [store, workflowStore, getNodesReadOnly], ) const handleNodeLeave = useCallback( (_, node) => { if (getNodesReadOnly()) return if ( node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE ) { return } if ( node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE ) { return } const { setEnteringNodePayload } = workflowStore.getState() setEnteringNodePayload(undefined) const { getNodes, setNodes, edges, setEdges } = store.getState() const newNodes = produce(getNodes(), (draft) => { draft.forEach((node) => { node.data._isEntering = false }) }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { draft.forEach((edge) => { edge.data._connectedNodeIsHovering = false }) }) setEdges(newEdges) }, [store, workflowStore, getNodesReadOnly], ) const handleNodeSelect = useCallback( ( nodeId: string, cancelSelection?: boolean, initShowLastRunTab?: boolean, ) => { if (initShowLastRunTab) workflowStore.setState({ initShowLastRunTab: true }) const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const selectedNode = nodes.find(node => node.data.selected) if (!cancelSelection && selectedNode?.id === nodeId) return const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { if (node.id === nodeId) node.data.selected = !cancelSelection else node.data.selected = false }) }) setNodes(newNodes) const connectedEdges = getConnectedEdges( [{ id: nodeId } as Node], edges, ).map(edge => edge.id) const newEdges = produce(edges, (draft) => { draft.forEach((edge) => { if (connectedEdges.includes(edge.id)) { edge.data = { ...edge.data, _connectedNodeIsSelected: !cancelSelection, } } else { edge.data = { ...edge.data, _connectedNodeIsSelected: false, } } }) }) setEdges(newEdges) handleSyncWorkflowDraft() }, [store, handleSyncWorkflowDraft], ) const handleNodeClick = useCallback( (_, node) => { if (node.type === CUSTOM_ITERATION_START_NODE) return if (node.type === CUSTOM_LOOP_START_NODE) return if (node.data.type === BlockEnum.DataSourceEmpty) return if (node.data._pluginInstallLocked) return handleNodeSelect(node.id) }, [handleNodeSelect], ) const handleNodeConnect = useCallback( ({ source, sourceHandle, target, targetHandle }) => { if (source === target) return if (getNodesReadOnly()) return const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const targetNode = nodes.find(node => node.id === target!) const sourceNode = nodes.find(node => node.id === source!) if (targetNode?.parentId !== sourceNode?.parentId) return if ( sourceNode?.type === CUSTOM_NOTE_NODE || targetNode?.type === CUSTOM_NOTE_NODE ) { return } // Check if source is a group node - need special handling const isSourceGroup = sourceNode?.data.type === BlockEnum.Group if (isSourceGroup && sourceHandle && target && targetHandle) { const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(sourceHandle) // Check if real edge already exists if (edges.find(edge => edge.source === originalNodeId && edge.sourceHandle === originalSourceHandle && edge.target === target && edge.targetHandle === targetHandle, )) { return } const parentNode = nodes.find(node => node.id === targetNode?.parentId) const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop const edgePair = createGroupEdgePair({ groupNodeId: source!, handlerId: sourceHandle, targetNodeId: target, targetHandle, nodes, baseEdgeData: { isInIteration, iteration_id: isInIteration ? targetNode?.parentId : undefined, isInLoop, loop_id: isInLoop ? targetNode?.parentId : undefined, }, }) if (!edgePair) return const { realEdge, uiEdge } = edgePair // Update connected handle ids for the original node const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( [{ type: 'add', edge: realEdge }], nodes, ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } }) }) const newEdges = produce(edges, (draft) => { draft.push(realEdge) draft.push(uiEdge) }) setNodes(newNodes) setEdges(newEdges) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeConnect, { nodeId: targetNode?.id, }) return } const isTargetGroup = targetNode?.data.type === BlockEnum.Group if (isTargetGroup && source && sourceHandle) { const groupData = targetNode.data as GroupNodeData const headNodeIds = groupData.headNodeIds || [] if (edges.find(edge => edge.source === source && edge.sourceHandle === sourceHandle && edge.target === target && edge.targetHandle === targetHandle, )) { return } const parentNode = nodes.find(node => node.id === sourceNode?.parentId) const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop const inboundResult = createGroupInboundEdges({ sourceNodeId: source, sourceHandle, groupNodeId: target!, groupData, nodes, baseEdgeData: { isInIteration, iteration_id: isInIteration ? sourceNode?.parentId : undefined, isInLoop, loop_id: isInLoop ? sourceNode?.parentId : undefined, }, }) if (!inboundResult) return const { realEdges, uiEdge } = inboundResult const edgeChanges = realEdges.map(edge => ({ type: 'add' as const, edge })) const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } }) }) const newEdges = produce(edges, (draft) => { realEdges.forEach((edge) => { draft.push(edge) }) draft.push(uiEdge) }) setNodes(newNodes) setEdges(newEdges) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeConnect, { nodeId: headNodeIds[0], }) return } if ( edges.find( edge => edge.source === source && edge.sourceHandle === sourceHandle && edge.target === target && edge.targetHandle === targetHandle, ) ) { return } const parendNode = nodes.find(node => node.id === targetNode?.parentId) const isInIteration = parendNode && parendNode.data.type === BlockEnum.Iteration const isInLoop = !!parendNode && parendNode.data.type === BlockEnum.Loop const newEdge = { id: `${source}-${sourceHandle}-${target}-${targetHandle}`, type: CUSTOM_EDGE, source: source!, target: target!, sourceHandle, targetHandle, data: { sourceType: nodes.find(node => node.id === source)!.data.type, targetType: nodes.find(node => node.id === target)!.data.type, isInIteration, iteration_id: isInIteration ? targetNode?.parentId : undefined, isInLoop, loop_id: isInLoop ? targetNode?.parentId : undefined, }, zIndex: targetNode?.parentId ? isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX : 0, } const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( [{ type: 'add', edge: newEdge }], nodes, ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } }) }) const newEdges = produce(edges, (draft) => { draft.push(newEdge) }) setNodes(newNodes) setEdges(newEdges) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeConnect, { nodeId: targetNode?.id, }) }, [ getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory, ], ) const handleNodeConnectStart = useCallback( (_, { nodeId, handleType, handleId }) => { if (getNodesReadOnly()) return if (nodeId && handleType) { const { setConnectingNodePayload } = workflowStore.getState() const { getNodes } = store.getState() const node = getNodes().find(n => n.id === nodeId)! if (node.type === CUSTOM_NOTE_NODE) return if ( node.data.type === BlockEnum.VariableAggregator || node.data.type === BlockEnum.VariableAssigner ) { if (handleType === 'target') return } setConnectingNodePayload({ nodeId, nodeType: node.data.type, handleType, handleId, }) } }, [store, workflowStore, getNodesReadOnly], ) const handleNodeConnectEnd = useCallback( (e: any) => { if (getNodesReadOnly()) return const { connectingNodePayload, setConnectingNodePayload, enteringNodePayload, setEnteringNodePayload, } = workflowStore.getState() if (connectingNodePayload && enteringNodePayload) { const { setShowAssignVariablePopup, hoveringAssignVariableGroupId } = workflowStore.getState() const { screenToFlowPosition } = reactflow const { getNodes, setNodes } = store.getState() const nodes = getNodes() const fromHandleType = connectingNodePayload.handleType const fromHandleId = connectingNodePayload.handleId const fromNode = nodes.find( n => n.id === connectingNodePayload.nodeId, )! const toNode = nodes.find(n => n.id === enteringNodePayload.nodeId)! const toParentNode = nodes.find(n => n.id === toNode.parentId) if (fromNode.parentId !== toNode.parentId) return const { x, y } = screenToFlowPosition({ x: e.x, y: e.y }) if ( fromHandleType === 'source' && (toNode.data.type === BlockEnum.VariableAssigner || toNode.data.type === BlockEnum.VariableAggregator) ) { const groupEnabled = toNode.data.advanced_settings?.group_enabled const firstGroupId = toNode.data.advanced_settings?.groups[0].groupId let handleId = 'target' if (groupEnabled) { if (hoveringAssignVariableGroupId) handleId = hoveringAssignVariableGroupId else handleId = firstGroupId } const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { if (node.id === toNode.id) { node.data._showAddVariablePopup = true node.data._holdAddVariablePopup = true } }) }) setNodes(newNodes) setShowAssignVariablePopup({ nodeId: fromNode.id, nodeData: fromNode.data, variableAssignerNodeId: toNode.id, variableAssignerNodeData: toNode.data, variableAssignerNodeHandleId: handleId, parentNode: toParentNode, x: x - toNode.positionAbsolute!.x, y: y - toNode.positionAbsolute!.y, }) handleNodeConnect({ source: fromNode.id, sourceHandle: fromHandleId, target: toNode.id, targetHandle: 'target', }) } } setConnectingNodePayload(undefined) setEnteringNodePayload(undefined) }, [store, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow], ) const { deleteNodeInspectorVars } = useInspectVarsCrud() const handleNodeDelete = useCallback( (nodeId: string) => { if (getNodesReadOnly()) return const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const currentNodeIndex = nodes.findIndex(node => node.id === nodeId) const currentNode = nodes[currentNodeIndex] if (!currentNode) return if ( nodesMetaDataMap?.[currentNode.data.type as BlockEnum]?.metaData .isUndeletable ) { return } deleteNodeInspectorVars(nodeId) if (currentNode.data.type === BlockEnum.Iteration) { const iterationChildren = nodes.filter( node => node.parentId === currentNode.id, ) if (iterationChildren.length) { if (currentNode.data._isBundled) { iterationChildren.forEach((child) => { handleNodeDelete(child.id) }) return handleNodeDelete(nodeId) } else { if (iterationChildren.length === 1) { handleNodeDelete(iterationChildren[0].id) handleNodeDelete(nodeId) return } const { setShowConfirm, showConfirm } = workflowStore.getState() if (!showConfirm) { setShowConfirm({ title: t('nodes.iteration.deleteTitle', { ns: 'workflow' }), desc: t('nodes.iteration.deleteDesc', { ns: 'workflow' }) || '', onConfirm: () => { iterationChildren.forEach((child) => { handleNodeDelete(child.id) }) handleNodeDelete(nodeId) handleSyncWorkflowDraft() setShowConfirm(undefined) }, }) return } } } } if (currentNode.data.type === BlockEnum.Loop) { const loopChildren = nodes.filter( node => node.parentId === currentNode.id, ) if (loopChildren.length) { if (currentNode.data._isBundled) { loopChildren.forEach((child) => { handleNodeDelete(child.id) }) return handleNodeDelete(nodeId) } else { if (loopChildren.length === 1) { handleNodeDelete(loopChildren[0].id) handleNodeDelete(nodeId) return } const { setShowConfirm, showConfirm } = workflowStore.getState() if (!showConfirm) { setShowConfirm({ title: t('nodes.loop.deleteTitle', { ns: 'workflow' }), desc: t('nodes.loop.deleteDesc', { ns: 'workflow' }) || '', onConfirm: () => { loopChildren.forEach((child) => { handleNodeDelete(child.id) }) handleNodeDelete(nodeId) handleSyncWorkflowDraft() setShowConfirm(undefined) }, }) return } } } } if (currentNode.data.type === BlockEnum.DataSource) { const { id } = currentNode const { ragPipelineVariables, setRagPipelineVariables } = workflowStore.getState() if (ragPipelineVariables && setRagPipelineVariables) { const newRagPipelineVariables: RAGPipelineVariables = [] ragPipelineVariables.forEach((variable) => { if (variable.belong_to_node_id === id) return newRagPipelineVariables.push(variable) }) setRagPipelineVariables(newRagPipelineVariables) } } const connectedEdges = getConnectedEdges([{ id: nodeId } as Node], edges) const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( connectedEdges.map(edge => ({ type: 'remove', edge })), nodes, ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } if (node.id === currentNode.parentId) { node.data._children = node.data._children?.filter( child => child.nodeId !== nodeId, ) } }) draft.splice(currentNodeIndex, 1) }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { return draft.filter( edge => !connectedEdges.find( connectedEdge => connectedEdge.id === edge.id, ), ) }) setEdges(newEdges) handleSyncWorkflowDraft() if (currentNode.type === CUSTOM_NOTE_NODE) { saveStateToHistory(WorkflowHistoryEvent.NoteDelete, { nodeId: currentNode.id, }) } else { saveStateToHistory(WorkflowHistoryEvent.NodeDelete, { nodeId: currentNode.id, }) } }, [ getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, t, nodesMetaDataMap, deleteNodeInspectorVars, ], ) const handleNodeAdd = useCallback( ( { nodeType, sourceHandle = 'source', targetHandle = 'target', pluginDefaultValue, }, { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle }, ) => { if (getNodesReadOnly()) return const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const nodesWithSameType = nodes.filter( node => node.data.type === nodeType, ) const { defaultValue } = nodesMetaDataMap![nodeType] const { newNode, newIterationStartNode, newLoopStartNode } = generateNewNode({ type: getNodeCustomTypeByNodeDataType(nodeType), data: { ...(defaultValue as any), title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, ...pluginDefaultValue, selected: true, _showAddVariablePopup: (nodeType === BlockEnum.VariableAssigner || nodeType === BlockEnum.VariableAggregator) && !!prevNodeId, _holdAddVariablePopup: false, }, position: { x: 0, y: 0, }, }) if (prevNodeId && !nextNodeId) { const prevNodeIndex = nodes.findIndex(node => node.id === prevNodeId) const prevNode = nodes[prevNodeIndex] const outgoers = getOutgoers(prevNode, nodes, edges).sort( (a, b) => a.position.y - b.position.y, ) const lastOutgoer = outgoers[outgoers.length - 1] newNode.data._connectedTargetHandleIds = nodeType === BlockEnum.DataSource ? [] : [targetHandle] newNode.data._connectedSourceHandleIds = [] newNode.position = { x: lastOutgoer ? lastOutgoer.position.x : prevNode.position.x + prevNode.width! + X_OFFSET, y: lastOutgoer ? lastOutgoer.position.y + lastOutgoer.height! + Y_OFFSET : prevNode.position.y, } newNode.parentId = prevNode.parentId newNode.extent = prevNode.extent const parentNode = nodes.find(node => node.id === prevNode.parentId) || null const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop if (prevNode.parentId) { newNode.data.isInIteration = isInIteration newNode.data.isInLoop = isInLoop if (isInIteration) { newNode.data.iteration_id = parentNode.id newNode.zIndex = ITERATION_CHILDREN_Z_INDEX } if (isInLoop) { newNode.data.loop_id = parentNode.id newNode.zIndex = LOOP_CHILDREN_Z_INDEX } if ( isInIteration && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner) ) { const iterNodeData: IterationNodeType = parentNode.data iterNodeData._isShowTips = true } if ( isInLoop && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner) ) { const iterNodeData: IterationNodeType = parentNode.data iterNodeData._isShowTips = true } } // Check if prevNode is a group node - need special handling const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group let newEdge: Edge | null = null let newUiEdge: Edge | null = null if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) { const edgePair = createGroupEdgePair({ groupNodeId: prevNodeId, handlerId: prevNodeSourceHandle, targetNodeId: newNode.id, targetHandle, nodes: [...nodes, newNode], baseEdgeData: { isInIteration, isInLoop, iteration_id: isInIteration ? prevNode.parentId : undefined, loop_id: isInLoop ? prevNode.parentId : undefined, _connectedNodeIsSelected: true, }, }) if (edgePair) { newEdge = edgePair.realEdge newUiEdge = edgePair.uiEdge } } else if (nodeType !== BlockEnum.DataSource) { // Normal case: prevNode is not a group newEdge = { id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, type: CUSTOM_EDGE, source: prevNodeId, sourceHandle: prevNodeSourceHandle, target: newNode.id, targetHandle, data: { sourceType: prevNode.data.type, targetType: newNode.data.type, isInIteration, isInLoop, iteration_id: isInIteration ? prevNode.parentId : undefined, loop_id: isInLoop ? prevNode.parentId : undefined, _connectedNodeIsSelected: true, }, zIndex: prevNode.parentId ? isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX : 0, } } const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge: edge! })) const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( edgesToAdd, nodes, ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { node.data.selected = false if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } if ( node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id ) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } if ( node.data.type === BlockEnum.Loop && prevNode.parentId === node.id ) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } }) draft.push(newNode) if (newIterationStartNode) draft.push(newIterationStartNode) if (newLoopStartNode) draft.push(newLoopStartNode) }) if ( newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator ) { const { setShowAssignVariablePopup } = workflowStore.getState() setShowAssignVariablePopup({ nodeId: prevNode.id, nodeData: prevNode.data, variableAssignerNodeId: newNode.id, variableAssignerNodeData: newNode.data as VariableAssignerNodeType, variableAssignerNodeHandleId: targetHandle, parentNode: nodes.find(node => node.id === newNode.parentId), x: -25, y: 44, }) } const newEdges = produce(edges, (draft) => { draft.forEach((item) => { item.data = { ...item.data, _connectedNodeIsSelected: false, } }) if (newEdge) draft.push(newEdge) if (newUiEdge) draft.push(newUiEdge) }) setNodes(newNodes) setEdges(newEdges) } if (!prevNodeId && nextNodeId) { const nextNodeIndex = nodes.findIndex(node => node.id === nextNodeId) const nextNode = nodes[nextNodeIndex]! if ( nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier ) { newNode.data._connectedSourceHandleIds = [sourceHandle] } newNode.data._connectedTargetHandleIds = [] newNode.position = { x: nextNode.position.x, y: nextNode.position.y, } newNode.parentId = nextNode.parentId newNode.extent = nextNode.extent const parentNode = nodes.find(node => node.id === nextNode.parentId) || null const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop if (parentNode && nextNode.parentId) { newNode.data.isInIteration = isInIteration newNode.data.isInLoop = isInLoop if (isInIteration) { newNode.data.iteration_id = parentNode.id newNode.zIndex = ITERATION_CHILDREN_Z_INDEX } if (isInLoop) { newNode.data.loop_id = parentNode.id newNode.zIndex = LOOP_CHILDREN_Z_INDEX } } let newEdge if ( nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.LoopEnd ) { newEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, type: CUSTOM_EDGE, source: newNode.id, sourceHandle, target: nextNodeId, targetHandle: nextNodeTargetHandle, data: { sourceType: newNode.data.type, targetType: nextNode.data.type, isInIteration, isInLoop, iteration_id: isInIteration ? nextNode.parentId : undefined, loop_id: isInLoop ? nextNode.parentId : undefined, _connectedNodeIsSelected: true, }, zIndex: nextNode.parentId ? isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX : 0, } } let nodesConnectedSourceOrTargetHandleIdsMap: Record if (newEdge) { nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( [{ type: 'add', edge: newEdge }], nodes, ) } const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!) const afterNodesInSameBranchIds = afterNodesInSameBranch.map( (node: Node) => node.id, ) const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { node.data.selected = false if (afterNodesInSameBranchIds.includes(node.id)) node.position.x += NODE_WIDTH_X_OFFSET if (nodesConnectedSourceOrTargetHandleIdsMap?.[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } if ( node.data.type === BlockEnum.Iteration && nextNode.parentId === node.id ) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } if ( node.data.type === BlockEnum.Iteration && node.data.start_node_id === nextNodeId ) { node.data.start_node_id = newNode.id node.data.startNodeType = newNode.data.type } if ( node.data.type === BlockEnum.Loop && nextNode.parentId === node.id ) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } if ( node.data.type === BlockEnum.Loop && node.data.start_node_id === nextNodeId ) { node.data.start_node_id = newNode.id node.data.startNodeType = newNode.data.type } }) draft.push(newNode) if (newIterationStartNode) draft.push(newIterationStartNode) if (newLoopStartNode) draft.push(newLoopStartNode) }) if (newEdge) { const newEdges = produce(edges, (draft) => { draft.forEach((item) => { item.data = { ...item.data, _connectedNodeIsSelected: false, } }) draft.push(newEdge) }) setNodes(newNodes) setEdges(newEdges) } else { setNodes(newNodes) } } if (prevNodeId && nextNodeId) { const prevNode = nodes.find(node => node.id === prevNodeId)! const nextNode = nodes.find(node => node.id === nextNodeId)! newNode.data._connectedTargetHandleIds = nodeType === BlockEnum.DataSource ? [] : [targetHandle] newNode.data._connectedSourceHandleIds = [sourceHandle] newNode.position = { x: nextNode.position.x, y: nextNode.position.y, } newNode.parentId = prevNode.parentId newNode.extent = prevNode.extent const parentNode = nodes.find(node => node.id === prevNode.parentId) || null const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop if (parentNode && prevNode.parentId) { newNode.data.isInIteration = isInIteration newNode.data.isInLoop = isInLoop if (isInIteration) { newNode.data.iteration_id = parentNode.id newNode.zIndex = ITERATION_CHILDREN_Z_INDEX } if (isInLoop) { newNode.data.loop_id = parentNode.id newNode.zIndex = LOOP_CHILDREN_Z_INDEX } } // Check if prevNode is a group node - need special handling const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group let newPrevEdge: Edge | null = null let newPrevUiEdge: Edge | null = null const edgesToRemove: string[] = [] if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) { const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(prevNodeSourceHandle) // Find edges to remove: both hidden real edge and UI temp edge from group to nextNode const hiddenEdge = edges.find( edge => edge.source === originalNodeId && edge.sourceHandle === originalSourceHandle && edge.target === nextNodeId, ) const uiTempEdge = edges.find( edge => edge.source === prevNodeId && edge.sourceHandle === prevNodeSourceHandle && edge.target === nextNodeId, ) if (hiddenEdge) edgesToRemove.push(hiddenEdge.id) if (uiTempEdge) edgesToRemove.push(uiTempEdge.id) const edgePair = createGroupEdgePair({ groupNodeId: prevNodeId, handlerId: prevNodeSourceHandle, targetNodeId: newNode.id, targetHandle, nodes: [...nodes, newNode], baseEdgeData: { isInIteration, isInLoop, iteration_id: isInIteration ? prevNode.parentId : undefined, loop_id: isInLoop ? prevNode.parentId : undefined, _connectedNodeIsSelected: true, }, }) if (edgePair) { newPrevEdge = edgePair.realEdge newPrevUiEdge = edgePair.uiEdge } } else { const isNextNodeGroupForRemoval = nextNode.data.type === BlockEnum.Group if (isNextNodeGroupForRemoval) { const groupData = nextNode.data as GroupNodeData const headNodeIds = groupData.headNodeIds || [] headNodeIds.forEach((headNodeId) => { const realEdge = edges.find( edge => edge.source === prevNodeId && edge.sourceHandle === prevNodeSourceHandle && edge.target === headNodeId, ) if (realEdge) edgesToRemove.push(realEdge.id) }) const uiEdge = edges.find( edge => edge.source === prevNodeId && edge.sourceHandle === prevNodeSourceHandle && edge.target === nextNodeId, ) if (uiEdge) edgesToRemove.push(uiEdge.id) } else { const currentEdge = edges.find( edge => edge.source === prevNodeId && edge.target === nextNodeId, ) if (currentEdge) edgesToRemove.push(currentEdge.id) } if (nodeType !== BlockEnum.DataSource) { newPrevEdge = { id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, type: CUSTOM_EDGE, source: prevNodeId, sourceHandle: prevNodeSourceHandle, target: newNode.id, targetHandle, data: { sourceType: prevNode.data.type, targetType: newNode.data.type, isInIteration, isInLoop, iteration_id: isInIteration ? prevNode.parentId : undefined, loop_id: isInLoop ? prevNode.parentId : undefined, _connectedNodeIsSelected: true, }, zIndex: prevNode.parentId ? isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX : 0, } } } let newNextEdge: Edge | null = null let newNextUiEdge: Edge | null = null const newNextRealEdges: Edge[] = [] const nextNodeParentNode = nodes.find(node => node.id === nextNode.parentId) || null const isNextNodeInIteration = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Iteration const isNextNodeInLoop = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Loop const isNextNodeGroup = nextNode.data.type === BlockEnum.Group if ( nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.LoopEnd ) { if (isNextNodeGroup) { const groupData = nextNode.data as GroupNodeData const headNodeIds = groupData.headNodeIds || [] headNodeIds.forEach((headNodeId) => { const headNode = nodes.find(node => node.id === headNodeId) newNextRealEdges.push({ id: `${newNode.id}-${sourceHandle}-${headNodeId}-target`, type: CUSTOM_EDGE, source: newNode.id, sourceHandle, target: headNodeId, targetHandle: 'target', hidden: true, data: { sourceType: newNode.data.type, targetType: headNode?.data.type, isInIteration: isNextNodeInIteration, isInLoop: isNextNodeInLoop, iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined, loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, _hiddenInGroupId: nextNodeId, _connectedNodeIsSelected: true, }, zIndex: nextNode.parentId ? isNextNodeInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX : 0, } as Edge) }) newNextUiEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-target`, type: CUSTOM_EDGE, source: newNode.id, sourceHandle, target: nextNodeId, targetHandle: 'target', data: { sourceType: newNode.data.type, targetType: BlockEnum.Group, isInIteration: isNextNodeInIteration, isInLoop: isNextNodeInLoop, iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined, loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, _isTemp: true, _connectedNodeIsSelected: true, }, zIndex: nextNode.parentId ? isNextNodeInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX : 0, } } else { newNextEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, type: CUSTOM_EDGE, source: newNode.id, sourceHandle, target: nextNodeId, targetHandle: nextNodeTargetHandle, data: { sourceType: newNode.data.type, targetType: nextNode.data.type, isInIteration: isNextNodeInIteration, isInLoop: isNextNodeInLoop, iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined, loop_id: isNextNodeInLoop ? nextNode.parentId : undefined, _connectedNodeIsSelected: true, }, zIndex: nextNode.parentId ? isNextNodeInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX : 0, } } } const edgeChanges = [ ...edgesToRemove.map(id => ({ type: 'remove' as const, edge: edges.find(e => e.id === id)! })).filter(c => c.edge), ...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []), ...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []), ...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []), ...newNextRealEdges.map(edge => ({ type: 'add' as const, edge })), ...(newNextUiEdge ? [{ type: 'add' as const, edge: newNextUiEdge }] : []), ] const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( edgeChanges, [...nodes, newNode], ) const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!) const afterNodesInSameBranchIds = afterNodesInSameBranch.map( (node: Node) => node.id, ) const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { node.data.selected = false if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } if (afterNodesInSameBranchIds.includes(node.id)) node.position.x += NODE_WIDTH_X_OFFSET if ( node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id ) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } if ( node.data.type === BlockEnum.Loop && prevNode.parentId === node.id ) { node.data._children?.push({ nodeId: newNode.id, nodeType: newNode.data.type, }) } }) draft.push(newNode) if (newIterationStartNode) draft.push(newIterationStartNode) if (newLoopStartNode) draft.push(newLoopStartNode) }) setNodes(newNodes) if ( newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator ) { const { setShowAssignVariablePopup } = workflowStore.getState() setShowAssignVariablePopup({ nodeId: prevNode.id, nodeData: prevNode.data, variableAssignerNodeId: newNode.id, variableAssignerNodeData: newNode.data as VariableAssignerNodeType, variableAssignerNodeHandleId: targetHandle, parentNode: nodes.find(node => node.id === newNode.parentId), x: -25, y: 44, }) } const newEdges = produce(edges, (draft) => { const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id)) draft.length = 0 draft.push(...filteredDraft) draft.forEach((item) => { item.data = { ...item.data, _connectedNodeIsSelected: false, } }) if (newPrevEdge) draft.push(newPrevEdge) if (newPrevUiEdge) draft.push(newPrevUiEdge) if (newNextEdge) draft.push(newNextEdge) newNextRealEdges.forEach((edge) => { draft.push(edge) }) if (newNextUiEdge) draft.push(newNextUiEdge) }) setEdges(newEdges) } handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: newNode.id }) }, [ getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch, nodesMetaDataMap, ], ) const handleNodeChange = useCallback( ( currentNodeId: string, nodeType: BlockEnum, sourceHandle: string, pluginDefaultValue?: PluginDefaultValue, ) => { if (getNodesReadOnly()) return const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const currentNode = nodes.find(node => node.id === currentNodeId)! const connectedEdges = getConnectedEdges([currentNode], edges) const nodesWithSameType = nodes.filter( node => node.data.type === nodeType, ) const { defaultValue } = nodesMetaDataMap![nodeType] const { newNode: newCurrentNode, newIterationStartNode, newLoopStartNode, } = generateNewNode({ type: getNodeCustomTypeByNodeDataType(nodeType), data: { ...(defaultValue as any), title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, ...pluginDefaultValue, _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], selected: currentNode.data.selected, isInIteration: currentNode.data.isInIteration, isInLoop: currentNode.data.isInLoop, iteration_id: currentNode.data.iteration_id, loop_id: currentNode.data.loop_id, }, position: { x: currentNode.position.x, y: currentNode.position.y, }, parentId: currentNode.parentId, extent: currentNode.extent, zIndex: currentNode.zIndex, }) const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( connectedEdges.map(edge => ({ type: 'remove', edge })), nodes, ) const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { node.data.selected = false if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } }) const index = draft.findIndex(node => node.id === currentNodeId) draft.splice(index, 1, newCurrentNode) if (newIterationStartNode) draft.push(newIterationStartNode) if (newLoopStartNode) draft.push(newLoopStartNode) }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { const filtered = draft.filter( edge => !connectedEdges.find( connectedEdge => connectedEdge.id === edge.id, ), ) return filtered }) setEdges(newEdges) if (nodeType === BlockEnum.TriggerWebhook) { handleSyncWorkflowDraft(true, true, { onSuccess: () => autoGenerateWebhookUrl(newCurrentNode.id), }) } else { handleSyncWorkflowDraft() } saveStateToHistory(WorkflowHistoryEvent.NodeChange, { nodeId: currentNodeId, }) }, [ getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, nodesMetaDataMap, autoGenerateWebhookUrl, ], ) const handleNodesCancelSelected = useCallback(() => { const { getNodes, setNodes } = store.getState() const nodes = getNodes() const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { node.data.selected = false }) }) setNodes(newNodes) }, [store]) const handleNodeContextMenu = useCallback( (e: MouseEvent, node: Node) => { if ( node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE ) { return } if ( node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE ) { return } e.preventDefault() const container = document.querySelector('#workflow-container') const { x, y } = container!.getBoundingClientRect() workflowStore.setState({ nodeMenu: { top: e.clientY - y, left: e.clientX - x, nodeId: node.id, }, }) handleNodeSelect(node.id) }, [workflowStore, handleNodeSelect], ) const handleNodesCopy = useCallback( (nodeId?: string) => { if (getNodesReadOnly()) return const { setClipboardElements } = workflowStore.getState() const { getNodes } = store.getState() const nodes = getNodes() if (nodeId) { // If nodeId is provided, copy that specific node const nodeToCopy = nodes.find( node => node.id === nodeId && node.data.type !== BlockEnum.Start && node.type !== CUSTOM_ITERATION_START_NODE && node.type !== CUSTOM_LOOP_START_NODE && node.data.type !== BlockEnum.LoopEnd && node.data.type !== BlockEnum.KnowledgeBase && node.data.type !== BlockEnum.DataSourceEmpty, ) if (nodeToCopy) setClipboardElements([nodeToCopy]) } else { // If no nodeId is provided, fall back to the current behavior const bundledNodes = nodes.filter((node) => { if (!node.data._isBundled) return false if (node.type === CUSTOM_NOTE_NODE) return true const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum] if (metaData.isSingleton) return false return !node.data.isInIteration && !node.data.isInLoop }) if (bundledNodes.length) { setClipboardElements(bundledNodes) return } const selectedNode = nodes.find((node) => { if (!node.data.selected) return false if (node.type === CUSTOM_NOTE_NODE) return true const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum] return !metaData.isSingleton }) if (selectedNode) setClipboardElements([selectedNode]) } }, [getNodesReadOnly, store, workflowStore], ) const handleNodesPaste = useCallback(() => { if (getNodesReadOnly()) return const { clipboardElements, mousePosition } = workflowStore.getState() const { getNodes, setNodes, edges, setEdges } = store.getState() const nodesToPaste: Node[] = [] const edgesToPaste: Edge[] = [] const nodes = getNodes() if (clipboardElements.length) { const { x, y } = getTopLeftNodePosition(clipboardElements) const { screenToFlowPosition } = reactflow const currentPosition = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY, }) const offsetX = currentPosition.x - x const offsetY = currentPosition.y - y let idMapping: Record = {} const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = [] clipboardElements.forEach((nodeToPaste, index) => { const nodeType = nodeToPaste.data.type const { newNode, newIterationStartNode, newLoopStartNode } = generateNewNode({ type: nodeToPaste.type, data: { ...(nodeToPaste.type !== CUSTOM_NOTE_NODE && nodesMetaDataMap![nodeType].defaultValue), ...nodeToPaste.data, selected: false, _isBundled: false, _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], _dimmed: false, title: genNewNodeTitleFromOld(nodeToPaste.data.title), }, position: { x: nodeToPaste.position.x + offsetX, y: nodeToPaste.position.y + offsetY, }, extent: nodeToPaste.extent, zIndex: nodeToPaste.zIndex, }) newNode.id = newNode.id + index // This new node is movable and can be placed anywhere let newChildren: Node[] = [] if (nodeToPaste.data.type === BlockEnum.Iteration) { newIterationStartNode!.parentId = newNode.id; (newNode.data as IterationNodeType).start_node_id = newIterationStartNode!.id const oldIterationStartNode = nodes.find( n => n.parentId === nodeToPaste.id && n.type === CUSTOM_ITERATION_START_NODE, ) idMapping[oldIterationStartNode!.id] = newIterationStartNode!.id const { copyChildren, newIdMapping } = handleNodeIterationChildrenCopy( nodeToPaste.id, newNode.id, idMapping, ) newChildren = copyChildren idMapping = newIdMapping newChildren.forEach((child) => { newNode.data._children?.push({ nodeId: child.id, nodeType: child.data.type, }) }) newChildren.push(newIterationStartNode!) } else if (nodeToPaste.data.type === BlockEnum.Loop) { newLoopStartNode!.parentId = newNode.id; (newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id newChildren = handleNodeLoopChildrenCopy(nodeToPaste.id, newNode.id) newChildren.forEach((child) => { newNode.data._children?.push({ nodeId: child.id, nodeType: child.data.type, }) }) newChildren.push(newLoopStartNode!) } else { // single node paste const selectedNode = nodes.find(node => node.selected) if (selectedNode) { const commonNestedDisallowPasteNodes = [ // end node only can be placed outermost layer BlockEnum.End, ] // handle disallow paste node if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) return // handle paste to nested block if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) { const isIteration = selectedNode.data.type === BlockEnum.Iteration newNode.data.isInIteration = isIteration newNode.data.iteration_id = isIteration ? selectedNode.id : undefined newNode.data.isInLoop = !isIteration newNode.data.loop_id = !isIteration ? selectedNode.id : undefined newNode.parentId = selectedNode.id newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX newNode.positionAbsolute = { x: newNode.position.x, y: newNode.position.y, } // set position base on parent node newNode.position = getNestedNodePosition(newNode, selectedNode) // update parent children array like native add parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type }) } } } nodesToPaste.push(newNode) if (newChildren.length) nodesToPaste.push(...newChildren) }) // only handle edge when paste nested block edges.forEach((edge) => { const sourceId = idMapping[edge.source] const targetId = idMapping[edge.target] if (sourceId && targetId) { const newEdge: Edge = { ...edge, id: `${sourceId}-${edge.sourceHandle}-${targetId}-${edge.targetHandle}`, source: sourceId, target: targetId, data: { ...edge.data, _connectedNodeIsSelected: false, }, } edgesToPaste.push(newEdge) } }) const newNodes = produce(nodes, (draft: Node[]) => { parentChildrenToAppend.forEach(({ parentId, childId, childType }) => { const p = draft.find(n => n.id === parentId) if (p) { p.data._children?.push({ nodeId: childId, nodeType: childType }) } }) draft.push(...nodesToPaste) }) setNodes(newNodes) setEdges([...edges, ...edgesToPaste]) saveStateToHistory(WorkflowHistoryEvent.NodePaste, { nodeId: nodesToPaste?.[0]?.id, }) handleSyncWorkflowDraft() } }, [ getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy, nodesMetaDataMap, ]) const handleNodesDuplicate = useCallback( (nodeId?: string) => { if (getNodesReadOnly()) return handleNodesCopy(nodeId) handleNodesPaste() }, [getNodesReadOnly, handleNodesCopy, handleNodesPaste], ) const handleNodesDelete = useCallback(() => { if (getNodesReadOnly()) return const { getNodes, edges } = store.getState() const nodes = getNodes() const bundledNodes = nodes.filter( node => node.data._isBundled, ) if (bundledNodes.length) { bundledNodes.forEach(node => handleNodeDelete(node.id)) return } const edgeSelected = edges.some(edge => edge.selected) if (edgeSelected) return const selectedNode = nodes.find( node => node.data.selected, ) if (selectedNode) handleNodeDelete(selectedNode.id) }, [store, getNodesReadOnly, handleNodeDelete]) const handleNodeResize = useCallback( (nodeId: string, params: ResizeParamsWithDirection) => { if (getNodesReadOnly()) return const { getNodes, setNodes } = store.getState() const { x, y, width, height } = params const nodes = getNodes() const currentNode = nodes.find(n => n.id === nodeId)! const childrenNodes = nodes.filter(n => currentNode.data._children?.find((c: any) => c.nodeId === n.id), ) let rightNode: Node let bottomNode: Node childrenNodes.forEach((n) => { if (rightNode) { if (n.position.x + n.width! > rightNode.position.x + rightNode.width!) rightNode = n } else { rightNode = n } if (bottomNode) { if ( n.position.y + n.height! > bottomNode.position.y + bottomNode.height! ) { bottomNode = n } } else { bottomNode = n } }) if (rightNode! && bottomNode!) { const parentNode = nodes.find(n => n.id === rightNode.parentId) const paddingMap = parentNode?.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING if (width < rightNode!.position.x + rightNode.width! + paddingMap.right) return if ( height < bottomNode.position.y + bottomNode.height! + paddingMap.bottom ) { return } } const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { if (n.id === nodeId) { n.data.width = width n.data.height = height n.width = width n.height = height n.position.x = x n.position.y = y } }) }) setNodes(newNodes) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeResize, { nodeId }) }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory], ) const handleNodeDisconnect = useCallback( (nodeId: string) => { if (getNodesReadOnly()) return const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const currentNode = nodes.find(node => node.id === nodeId)! const connectedEdges = getConnectedEdges([currentNode], edges) const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( connectedEdges.map(edge => ({ type: 'remove', edge })), nodes, ) const newNodes = produce(nodes, (draft: Node[]) => { draft.forEach((node) => { if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) { node.data = { ...node.data, ...nodesConnectedSourceOrTargetHandleIdsMap[node.id], } } }) }) setNodes(newNodes) const newEdges = produce(edges, (draft) => { return draft.filter( edge => !connectedEdges.find( connectedEdge => connectedEdge.id === edge.id, ), ) }) setEdges(newEdges) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeDelete) }, [store, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory], ) const handleHistoryBack = useCallback(() => { if (getNodesReadOnly() || getWorkflowReadOnly()) return const { setEdges, setNodes } = store.getState() undo() const { edges, nodes } = workflowHistoryStore.getState() if (edges.length === 0 && nodes.length === 0) return setEdges(edges) setNodes(nodes) }, [ store, undo, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly, ]) const handleHistoryForward = useCallback(() => { if (getNodesReadOnly() || getWorkflowReadOnly()) return const { setEdges, setNodes } = store.getState() redo() const { edges, nodes } = workflowHistoryStore.getState() if (edges.length === 0 && nodes.length === 0) return setEdges(edges) setNodes(nodes) }, [ redo, store, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly, ]) const [isDimming, setIsDimming] = useState(false) /** Add opacity-30 to all nodes except the nodeId */ const dimOtherNodes = useCallback(() => { if (isDimming) return const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const selectedNode = nodes.find(n => n.data.selected) if (!selectedNode) return setIsDimming(true) // const workflowNodes = useStore(s => s.getNodes()) const workflowNodes = nodes const usedVars = getNodeUsedVars(selectedNode) const dependencyNodes: Node[] = [] usedVars.forEach((valueSelector) => { const node = workflowNodes.find(node => node.id === valueSelector?.[0]) if (node) { if (!dependencyNodes.includes(node)) dependencyNodes.push(node) } }) const outgoers = getOutgoers(selectedNode as Node, nodes as Node[], edges) for (let currIdx = 0; currIdx < outgoers.length; currIdx++) { const node = outgoers[currIdx] const outgoersForNode = getOutgoers(node, nodes as Node[], edges) outgoersForNode.forEach((item) => { const existed = outgoers.some(v => v.id === item.id) if (!existed) outgoers.push(item) }) } const dependentNodes: Node[] = [] outgoers.forEach((node) => { const usedVars = getNodeUsedVars(node) const used = usedVars.some(v => v?.[0] === selectedNode.id) if (used) { const existed = dependentNodes.some(v => v.id === node.id) if (!existed) dependentNodes.push(node) } }) const dimNodes = [...dependencyNodes, ...dependentNodes, selectedNode] const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { const dimNode = dimNodes.find(v => v.id === n.id) if (!dimNode) n.data._dimmed = true }) }) setNodes(newNodes) const tempEdges: Edge[] = [] dependencyNodes.forEach((n) => { tempEdges.push({ id: `tmp_${n.id}-source-${selectedNode.id}-target`, type: CUSTOM_EDGE, source: n.id, sourceHandle: 'source_tmp', target: selectedNode.id, targetHandle: 'target_tmp', animated: true, data: { sourceType: n.data.type, targetType: selectedNode.data.type, _isTemp: true, _connectedNodeIsHovering: true, }, }) }) dependentNodes.forEach((n) => { tempEdges.push({ id: `tmp_${selectedNode.id}-source-${n.id}-target`, type: CUSTOM_EDGE, source: selectedNode.id, sourceHandle: 'source_tmp', target: n.id, targetHandle: 'target_tmp', animated: true, data: { sourceType: selectedNode.data.type, targetType: n.data.type, _isTemp: true, _connectedNodeIsHovering: true, }, }) }) const newEdges = produce(edges, (draft) => { draft.forEach((e) => { e.data._dimmed = true }) draft.push(...tempEdges) }) setEdges(newEdges) }, [isDimming, store]) /** Restore all nodes to full opacity */ const undimAllNodes = useCallback(() => { const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() setIsDimming(false) const newNodes = produce(nodes, (draft) => { draft.forEach((n) => { n.data._dimmed = false }) }) setNodes(newNodes) const newEdges = produce( edges.filter(e => !e.data._isTemp), (draft) => { draft.forEach((e) => { e.data._dimmed = false }) }, ) setEdges(newEdges) }, [store]) // Check if there are any nodes selected via box selection const hasBundledNodes = useCallback(() => { const { getNodes } = store.getState() const nodes = getNodes() return nodes.some(node => node.data._isBundled) }, [store]) const getCanMakeGroup = useCallback(() => { const { getNodes, edges } = store.getState() const nodes = getNodes() const bundledNodes = nodes.filter(node => node.data._isBundled) if (bundledNodes.length <= 1) return false const bundledNodeIds = bundledNodes.map(node => node.id) const minimalEdges = edges.map(edge => ({ id: edge.id, source: edge.source, sourceHandle: edge.sourceHandle || 'source', target: edge.target, })) const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group) const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode) return canMakeGroup }, [store]) const handleMakeGroup = useCallback(() => { const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const bundledNodes = nodes.filter(node => node.data._isBundled) if (bundledNodes.length <= 1) return const bundledNodeIds = bundledNodes.map(node => node.id) const minimalEdges = edges.map(edge => ({ id: edge.id, source: edge.source, sourceHandle: edge.sourceHandle || 'source', target: edge.target, })) const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group) const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode) if (!canMakeGroup) return const bundledNodeIdSet = new Set(bundledNodeIds) const bundledNodeIdIsLeaf = new Set() const inboundEdges = edges.filter(edge => !bundledNodeIdSet.has(edge.source) && bundledNodeIdSet.has(edge.target)) const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target)) // leaf node: no outbound edges to other nodes in the selection const handlers: GroupHandler[] = [] const leafNodeIdSet = new Set() bundledNodes.forEach((node: Node) => { const targetBranches = node.data._targetBranches || [{ id: 'source', name: node.data.title }] targetBranches.forEach((branch) => { // A branch should be a handler if it's either: // 1. Connected to a node OUTSIDE the group // 2. NOT connected to any node INSIDE the group const isConnectedInside = edges.some(edge => edge.source === node.id && (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source')) && bundledNodeIdSet.has(edge.target), ) const isConnectedOutside = edges.some(edge => edge.source === node.id && (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source')) && !bundledNodeIdSet.has(edge.target), ) if (isConnectedOutside || !isConnectedInside) { const handlerId = `${node.id}-${branch.id}` handlers.push({ id: handlerId, label: branch.name || node.data.title || node.id, nodeId: node.id, sourceHandle: branch.id, }) leafNodeIdSet.add(node.id) } }) }) const leafNodeIds = Array.from(leafNodeIdSet) leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id)) const members: GroupMember[] = bundledNodes.map((node) => { return { id: node.id, type: node.data.type, label: node.data.title, } }) // head nodes: nodes that receive input from outside the group const headNodeIds = [...new Set(inboundEdges.map(edge => edge.target))] // put the group node at the top-left corner of the selection, slightly offset const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes) const groupNodeData: GroupNodeData = { title: t('operator.makeGroup', { ns: 'workflow' }), desc: '', type: BlockEnum.Group, members, handlers, headNodeIds, leafNodeIds, selected: true, _targetBranches: handlers.map(handler => ({ id: handler.id, name: handler.label || handler.id, })), } const { newNode: groupNode } = generateNewNode({ data: groupNodeData, position: { x: minX - 20, y: minY - 20, }, }) const nodeTypeMap = new Map(nodes.map(node => [node.id, node.data.type])) const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { if (bundledNodeIdSet.has(node.id)) { node.data._isBundled = false node.selected = false node.hidden = true node.data._hiddenInGroupId = groupNode.id } else { node.data._isBundled = false } }) draft.push(groupNode) }) const newEdges = produce(edges, (draft) => { draft.forEach((edge) => { if (bundledNodeIdSet.has(edge.source) || bundledNodeIdSet.has(edge.target)) { edge.hidden = true edge.data = { ...edge.data, _hiddenInGroupId: groupNode.id, _isBundled: false, } } else if (edge.data?._isBundled) { edge.data._isBundled = false } }) // re-add the external inbound edges to the group node as UI-only edges (not persisted to backend) inboundEdges.forEach((edge) => { draft.push({ id: `${edge.id}__to-${groupNode.id}`, type: edge.type || CUSTOM_EDGE, source: edge.source, target: groupNode.id, sourceHandle: edge.sourceHandle, targetHandle: 'target', data: { ...edge.data, sourceType: nodeTypeMap.get(edge.source)!, targetType: BlockEnum.Group, _hiddenInGroupId: undefined, _isBundled: false, _isTemp: true, // UI-only edge, not persisted to backend }, zIndex: edge.zIndex, }) }) // outbound edges of the group node as UI-only edges (not persisted to backend) outboundEdges.forEach((edge) => { if (!bundledNodeIdIsLeaf.has(edge.source)) return // Use the same handler id format: nodeId-sourceHandle const originalSourceHandle = edge.sourceHandle || 'source' const handlerId = `${edge.source}-${originalSourceHandle}` draft.push({ id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`, type: edge.type || CUSTOM_EDGE, source: groupNode.id, target: edge.target, sourceHandle: handlerId, targetHandle: edge.targetHandle, data: { ...edge.data, sourceType: BlockEnum.Group, targetType: nodeTypeMap.get(edge.target)!, _hiddenInGroupId: undefined, _isBundled: false, _isTemp: true, }, zIndex: edge.zIndex, }) }) }) setNodes(newNodes) setEdges(newEdges) workflowStore.setState({ selectionMenu: undefined, }) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: groupNode.id, }) }, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore]) // check if the current selection can be ungrouped (single selected Group node) const getCanUngroup = useCallback(() => { const { getNodes } = store.getState() const nodes = getNodes() const selectedNodes = nodes.filter(node => node.selected) if (selectedNodes.length !== 1) return false return selectedNodes[0].data.type === BlockEnum.Group }, [store]) // get the selected group node id for ungroup operation const getSelectedGroupId = useCallback(() => { const { getNodes } = store.getState() const nodes = getNodes() const selectedNodes = nodes.filter(node => node.selected) if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group) return selectedNodes[0].id return undefined }, [store]) const handleUngroup = useCallback((groupId: string) => { const { getNodes, setNodes, edges, setEdges } = store.getState() const nodes = getNodes() const groupNode = nodes.find(n => n.id === groupId) if (!groupNode || groupNode.data.type !== BlockEnum.Group) return const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id)) // restore hidden member nodes const newNodes = produce(nodes, (draft) => { draft.forEach((node) => { if (memberIds.has(node.id)) { node.hidden = false delete node.data._hiddenInGroupId } }) // remove group node const groupIndex = draft.findIndex(n => n.id === groupId) if (groupIndex !== -1) draft.splice(groupIndex, 1) }) // restore hidden edges and remove temp edges in single pass O(E) const newEdges = produce(edges, (draft) => { const indicesToRemove: number[] = [] for (let i = 0; i < draft.length; i++) { const edge = draft[i] // restore hidden edges that involve member nodes if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target))) edge.hidden = false // collect temp edges connected to group for removal if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId)) indicesToRemove.push(i) } // remove collected indices in reverse order to avoid index shift for (let i = indicesToRemove.length - 1; i >= 0; i--) draft.splice(indicesToRemove[i], 1) }) setNodes(newNodes) setEdges(newEdges) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.NodeDelete, { nodeId: groupId, }) }, [handleSyncWorkflowDraft, saveStateToHistory, store]) return { handleNodeDragStart, handleNodeDrag, handleNodeDragStop, handleNodeEnter, handleNodeLeave, handleNodeSelect, handleNodeClick, handleNodeConnect, handleNodeConnectStart, handleNodeConnectEnd, handleNodeDelete, handleNodeChange, handleNodeAdd, handleNodesCancelSelected, handleNodeContextMenu, handleNodesCopy, handleNodesPaste, handleNodesDuplicate, handleNodesDelete, handleMakeGroup, handleUngroup, handleNodeResize, handleNodeDisconnect, handleHistoryBack, handleHistoryForward, dimOtherNodes, undimAllNodes, hasBundledNodes, getCanMakeGroup, getCanUngroup, getSelectedGroupId, } }