diff --git a/web/app/components/workflow/custom-group-node/custom-group-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-node.tsx index 9d137a36c6..b53864bad7 100644 --- a/web/app/components/workflow/custom-group-node/custom-group-node.tsx +++ b/web/app/components/workflow/custom-group-node/custom-group-node.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import { memo } from 'react' import { Handle, Position } from 'reactflow' +import { Plus02 } from '@/app/components/base/icons/src/vender/line/general' import type { CustomGroupNodeData } from './types' import { cn } from '@/utils/classnames' @@ -11,13 +12,15 @@ type CustomGroupNodeProps = { data: CustomGroupNodeData } -const CustomGroupNode: FC = ({ id: _id, data }) => { +const CustomGroupNode: FC = ({ data }) => { const { group } = data + const exitPorts = group.exitPorts ?? [] + const connectedSourceHandleIds = data._connectedSourceHandleIds ?? [] return (
= ({ id: _id, data }) => { id="target" type="target" position={Position.Left} - className="!h-4 !w-4 !border-2 !border-white !bg-primary-500" + className={cn( + '!h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none', + 'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle', + 'transition-all hover:scale-125', + )} style={{ top: '50%' }} /> - {/* Source handles will be rendered by exit port nodes */} +
+ {exitPorts.map((port, index) => { + const connected = connectedSourceHandleIds.includes(port.portNodeId) + + return ( +
+
+ {port.name} +
+ + + + {/* Visual "+" indicator (styling aligned with existing branch handles) */} + +
+ ) + })} +
) } diff --git a/web/app/components/workflow/custom-group-node/types.ts b/web/app/components/workflow/custom-group-node/types.ts index 7e30d6f630..baf7b2362a 100644 --- a/web/app/components/workflow/custom-group-node/types.ts +++ b/web/app/components/workflow/custom-group-node/types.ts @@ -19,6 +19,8 @@ export type CustomGroupNodeData = { type: '' // Empty string bypasses backend NodeType validation title: string desc?: string + _connectedSourceHandleIds?: string[] + _connectedTargetHandleIds?: string[] group: { groupId: string title: string diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 76be3766f4..72658a8aae 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2063,14 +2063,43 @@ export const useNodesInteractions = () => { label: node.data.title, } }) - const handlers: GroupHandler[] = leafNodeIds.map((nodeId) => { + // Build handlers from all leaf nodes + // For multi-branch nodes (if-else, classifier), create one handler per branch + // For regular nodes, create one handler with 'source' handle + const handlerMap = new Map() + + leafNodeIds.forEach((nodeId) => { const node = bundledNodes.find(n => n.id === nodeId) - return { - id: nodeId, - label: node?.data.title || nodeId, + if (!node) + return + + const targetBranches = node.data._targetBranches + if (targetBranches && targetBranches.length > 0) { + // Multi-branch node: create handler for each branch + targetBranches.forEach((branch: { id: string; name?: string }) => { + const handlerId = `${nodeId}-${branch.id}` + handlerMap.set(handlerId, { + id: handlerId, + label: branch.name || node.data.title || nodeId, + nodeId, + sourceHandle: branch.id, + }) + }) + } + else { + // Regular node: single 'source' handler + const handlerId = `${nodeId}-source` + handlerMap.set(handlerId, { + id: handlerId, + label: node.data.title || nodeId, + nodeId, + sourceHandle: 'source', + }) } }) + const handlers: GroupHandler[] = Array.from(handlerMap.values()) + // put the group node at the top-left corner of the selection, slightly offset const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes) @@ -2123,7 +2152,7 @@ export const useNodesInteractions = () => { } }) - // re-add the external inbound edges to the group node (previous order is not lost) + // 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}`, @@ -2138,22 +2167,27 @@ export const useNodesInteractions = () => { 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: only map the outbound edges of the leaf nodes to the corresponding handlers + // 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'}-${edge.source}`, + id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`, type: edge.type || CUSTOM_EDGE, source: groupNode.id, target: edge.target, - sourceHandle: edge.source, // handler id corresponds to the leaf node id + sourceHandle: handlerId, // handler id: nodeId-sourceHandle targetHandle: edge.targetHandle, data: { ...edge.data, @@ -2161,6 +2195,7 @@ export const useNodesInteractions = () => { targetType: nodeTypeMap.get(edge.target)!, _hiddenInGroupId: undefined, _isBundled: false, + _isTemp: true, // UI-only edge, not persisted to backend }, zIndex: edge.zIndex, }) diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 263732cd70..5ddc997d8a 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -221,7 +221,7 @@ const BaseNode: FC = ({ ) } { - data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && ( + data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.Group && !data._isCandidate && ( ) => {
{handler.label || handler.id} +
))} diff --git a/web/app/components/workflow/nodes/group/types.ts b/web/app/components/workflow/nodes/group/types.ts index e54119381e..92838357cf 100644 --- a/web/app/components/workflow/nodes/group/types.ts +++ b/web/app/components/workflow/nodes/group/types.ts @@ -9,6 +9,8 @@ export type GroupMember = { export type GroupHandler = { id: string label?: string + nodeId?: string // leaf node id for multi-branch nodes + sourceHandle?: string // original sourceHandle (e.g., case_id for if-else) } export type GroupNodeData = CommonNodeType<{