mirror of https://github.com/langgenius/dify.git
feat: add UI-only group node types and enhance workflow graph processing
This commit is contained in:
parent
fc9d5b2a62
commit
93b516a4ec
|
|
@ -210,6 +210,7 @@ web/.vscode
|
|||
.history
|
||||
|
||||
.idea/
|
||||
web/migration/
|
||||
|
||||
# pnpm
|
||||
/.pnpm-store
|
||||
|
|
|
|||
|
|
@ -130,12 +130,15 @@ class Graph:
|
|||
|
||||
@classmethod
|
||||
def _build_edges(
|
||||
cls, edge_configs: list[dict[str, object]]
|
||||
cls,
|
||||
edge_configs: list[dict[str, object]],
|
||||
valid_node_ids: set[str] | None = None,
|
||||
) -> tuple[dict[str, Edge], dict[str, list[str]], dict[str, list[str]]]:
|
||||
"""
|
||||
Build edge objects and mappings from edge configurations.
|
||||
|
||||
:param edge_configs: list of edge configurations
|
||||
:param valid_node_ids: optional set of valid node IDs to filter edges
|
||||
:return: tuple of (edges dict, in_edges dict, out_edges dict)
|
||||
"""
|
||||
edges: dict[str, Edge] = {}
|
||||
|
|
@ -305,7 +308,12 @@ class Graph:
|
|||
if not node_configs:
|
||||
raise ValueError("Graph must have at least one node")
|
||||
|
||||
node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"]
|
||||
# Filter out UI-only nodes that should not participate in workflow execution
|
||||
# These include ReactFlow node types (custom-*) and data types that UI-only nodes may use
|
||||
ui_only_node_types = {"custom-note", "custom-group", "custom-group-input", "custom-group-exit-port", "group"}
|
||||
node_configs = [
|
||||
node_config for node_config in node_configs if node_config.get("type", "") not in ui_only_node_types
|
||||
]
|
||||
|
||||
# Parse node configurations
|
||||
node_configs_map = cls._parse_node_configs(node_configs)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
export const CUSTOM_GROUP_NODE = 'custom-group'
|
||||
export const CUSTOM_GROUP_INPUT_NODE = 'custom-group-input'
|
||||
export const CUSTOM_GROUP_EXIT_PORT_NODE = 'custom-group-exit-port'
|
||||
|
||||
export const GROUP_CHILDREN_Z_INDEX = 1002
|
||||
|
||||
export const UI_ONLY_GROUP_NODE_TYPES = new Set([
|
||||
CUSTOM_GROUP_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
])
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import type { CustomGroupExitPortNodeData } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type CustomGroupExitPortNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupExitPortNodeData
|
||||
}
|
||||
|
||||
const CustomGroupExitPortNode: FC<CustomGroupExitPortNodeProps> = ({ id: _id, data }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'h-8 w-8 rounded-full',
|
||||
'bg-util-colors-green-green-500 shadow-md',
|
||||
data.selected && 'ring-2 ring-primary-400',
|
||||
)}
|
||||
>
|
||||
{/* Target handle - receives internal connections from leaf nodes */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Source handle - connects to external nodes */}
|
||||
<Handle
|
||||
id="source"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className="h-4 w-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupExitPortNode)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import type { CustomGroupInputNodeData } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type CustomGroupInputNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupInputNodeData
|
||||
}
|
||||
|
||||
const CustomGroupInputNode: FC<CustomGroupInputNodeProps> = ({ id: _id, data }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'h-8 w-8 rounded-full',
|
||||
'bg-util-colors-blue-blue-500 shadow-md',
|
||||
data.selected && 'ring-2 ring-primary-400',
|
||||
)}
|
||||
>
|
||||
{/* Target handle - receives external connections */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Source handle - connects to entry nodes */}
|
||||
<Handle
|
||||
id="source"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className="h-4 w-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupInputNode)
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import type { CustomGroupNodeData } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type CustomGroupNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupNodeData
|
||||
}
|
||||
|
||||
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ id: _id, data }) => {
|
||||
const { group } = data
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-workflow-block-parma-bg/50 relative rounded-2xl border-2 border-dashed border-components-panel-border',
|
||||
data.selected && 'border-primary-400',
|
||||
)}
|
||||
style={{
|
||||
width: data.width || 280,
|
||||
height: data.height || 200,
|
||||
}}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div className="absolute -top-7 left-0 flex items-center gap-1 px-2">
|
||||
<span className="text-xs font-medium text-text-tertiary">
|
||||
{group.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Target handle for incoming connections */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-4 !w-4 !border-2 !border-white !bg-primary-500"
|
||||
style={{ top: '50%' }}
|
||||
/>
|
||||
|
||||
{/* Source handles will be rendered by exit port nodes */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupNode)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export {
|
||||
CUSTOM_GROUP_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
GROUP_CHILDREN_Z_INDEX,
|
||||
UI_ONLY_GROUP_NODE_TYPES,
|
||||
} from './constants'
|
||||
|
||||
export type {
|
||||
CustomGroupNodeData,
|
||||
CustomGroupInputNodeData,
|
||||
CustomGroupExitPortNodeData,
|
||||
ExitPortInfo,
|
||||
GroupMember,
|
||||
} from './types'
|
||||
|
||||
export { default as CustomGroupNode } from './custom-group-node'
|
||||
export { default as CustomGroupInputNode } from './custom-group-input-node'
|
||||
export { default as CustomGroupExitPortNode } from './custom-group-exit-port-node'
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { BlockEnum } from '../types'
|
||||
|
||||
/**
|
||||
* Exit port info stored in Group node
|
||||
*/
|
||||
export type ExitPortInfo = {
|
||||
portNodeId: string
|
||||
leafNodeId: string
|
||||
sourceHandle: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Group node data structure
|
||||
* node.type = 'custom-group'
|
||||
* node.data.type = '' (empty string to bypass backend NodeType validation)
|
||||
*/
|
||||
export type CustomGroupNodeData = {
|
||||
type: '' // Empty string bypasses backend NodeType validation
|
||||
title: string
|
||||
desc?: string
|
||||
group: {
|
||||
groupId: string
|
||||
title: string
|
||||
memberNodeIds: string[]
|
||||
entryNodeIds: string[]
|
||||
inputNodeId: string
|
||||
exitPorts: ExitPortInfo[]
|
||||
collapsed: boolean
|
||||
}
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Input node data structure
|
||||
* node.type = 'custom-group-input'
|
||||
* node.data.type = ''
|
||||
*/
|
||||
export type CustomGroupInputNodeData = {
|
||||
type: ''
|
||||
title: string
|
||||
desc?: string
|
||||
groupInput: {
|
||||
groupId: string
|
||||
title: string
|
||||
}
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit Port node data structure
|
||||
* node.type = 'custom-group-exit-port'
|
||||
* node.data.type = ''
|
||||
*/
|
||||
export type CustomGroupExitPortNodeData = {
|
||||
type: ''
|
||||
title: string
|
||||
desc?: string
|
||||
exitPort: {
|
||||
groupId: string
|
||||
leafNodeId: string
|
||||
sourceHandle: string
|
||||
name: string
|
||||
}
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Member node info for display
|
||||
*/
|
||||
export type GroupMember = {
|
||||
id: string
|
||||
type: BlockEnum
|
||||
label?: string
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
# UI-only Group(含 Group Input / Exit Port)方案
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 目标
|
||||
|
||||
- Group 可持久化:刷新后仍保留分组/命名/布局。
|
||||
- Group 不影响执行:Run Workflow 时不执行 Group/Input/ExitPort,也不改变真实执行图语义。
|
||||
- 新增入边:任意外部节点连到 Group(或 Group Input)时,等价于“通过 Group Input fan-out 到每个 entry”。
|
||||
- handler 粒度:以 leaf 节点的 `sourceHandle` 为粒度生成 Exit Port(If-Else / Classifier 等多 handler 需要拆分)。
|
||||
- 支持改名:Group 标题、每个 Exit Port 名称可编辑并保存。
|
||||
- 最小化副作用:真实节点/真实边不被“重接到 Group”,只做 UI 折叠;状态订阅尽量只取最小字段,避免雪崩式 rerender。
|
||||
|
||||
### 核心模型(两层图)
|
||||
|
||||
1) **真实图(可执行、可保存)**
|
||||
|
||||
- 真实 workflow nodes + 真实 edges(执行图语义只由它们决定)。
|
||||
- Group 相关 UI 节点也会被保存到 graph.nodes,但后端运行时会过滤掉(不进入执行图)。
|
||||
|
||||
2) **展示图(仅 UI)**
|
||||
|
||||
- 组内成员节点与其相关真实边标记 `hidden=true`(保存,用于刷新后仍保持折叠)。
|
||||
- 额外生成 **临时 UI 边**(`edge.data._isTemp = true`,不会 sync 到后端),用于:
|
||||
- 外部 → Group Input(表示外部连到该组的入边)
|
||||
- Exit Port → 外部(表示该组 handler 的出边)
|
||||
|
||||
## 影响范围
|
||||
|
||||
### 前端(`web/`)
|
||||
|
||||
- 新增 3 个 UI-only node type:`custom-group` / `custom-group-input` / `custom-group-exit-port`(组件、样式、panel/rename 交互)。
|
||||
- `workflow/index.tsx` 与 `workflow-preview/index.tsx`:注册 nodeTypes。
|
||||
- `hooks/use-nodes-interactions.ts`:
|
||||
- 重做 `handleMakeGroup`:创建 group + input + exit ports;隐藏成员节点/相关真实边;不做“重接真实边到 group”。
|
||||
- 扩展 `handleNodeConnect`:遇到 group/input/exitPort 时做连线翻译。
|
||||
- 扩展 edge delete:若删除的是临时 UI 边,反向删除对应真实边。
|
||||
- 新增派生 UI 边的 hook(示例):`hooks/use-group-ui-edges.ts`(从真实图派生临时 UI 边并写入 ReactFlow edges state)。
|
||||
- 新增 `utils/get-node-source-handles.ts`:从节点数据提取可用 `sourceHandle`(If-Else/Classifer 等)。
|
||||
- 复用现有 `use-make-group.ts`:继续以“共同 pre node handler(直接前序 handler)”控制 `Make group` disabled。
|
||||
|
||||
### 后端(`api/`)
|
||||
|
||||
- `api/core/workflow/graph/graph.py`:运行时过滤 `type in {'custom-note','custom-group','custom-group-input','custom-group-exit-port'}`,确保 UI 节点不进入执行图。
|
||||
|
||||
## 具体实施
|
||||
|
||||
### 1) 节点类型与数据结构(可持久化、无 `_` 前缀)
|
||||
|
||||
#### Group 容器节点(UI-only)
|
||||
|
||||
- `node.type = 'custom-group'`
|
||||
- `node.data.type = ''`
|
||||
- `node.data.group`:
|
||||
- `groupId: string`(可等于 node.id)
|
||||
- `title: string`
|
||||
- `memberNodeIds: string[]`
|
||||
- `entryNodeIds: string[]`
|
||||
- `inputNodeId: string`
|
||||
- `exitPorts: Array<{ portNodeId: string; leafNodeId: string; sourceHandle: string; name: string }>`
|
||||
- `collapsed: boolean`
|
||||
|
||||
#### Group Input 节点(UI-only)
|
||||
|
||||
- `node.type = 'custom-group-input'`
|
||||
- `node.data.type = ''`
|
||||
- `node.data.groupInput`:
|
||||
- `groupId: string`
|
||||
- `title: string`
|
||||
|
||||
#### Exit Port 节点(UI-only)
|
||||
|
||||
- `node.type = 'custom-group-exit-port'`
|
||||
- `node.data.type = ''`
|
||||
- `node.data.exitPort`:
|
||||
- `groupId: string`
|
||||
- `leafNodeId: string`
|
||||
- `sourceHandle: string`
|
||||
- `name: string`
|
||||
|
||||
### 2) entry / leaf / handler 计算
|
||||
|
||||
- entry(branch 头结点):选区内“有入边且所有入边 source 在选区外”的节点。
|
||||
- 禁止 side-entrance:若存在 `outside -> selectedNonEntry` 入边,则不可 group。
|
||||
- 共同 pre node handler(直接前序 handler):
|
||||
- 对每个 entry,收集其来自选区外的所有入边的 `(source, sourceHandle)` 集合
|
||||
- 要求每个 entry 的集合 `size === 1`,且所有 entry 的该值完全一致
|
||||
- 否则 `Make group` disabled
|
||||
- leaf:选区内“没有指向选区内节点的出边”的节点。
|
||||
- leaf sourceHandles:通过 `getNodeSourceHandles(node)` 枚举(普通 `'source'`、If-Else/Classifier 等拆分)。
|
||||
|
||||
### 3) Make group
|
||||
|
||||
- 创建 `custom-group` + `custom-group-input` + 多个 `custom-group-exit-port` 节点:
|
||||
- group/input/exitPort 坐标按选区包围盒计算,input 在左侧,exitPort 右侧按 handler 列表排列
|
||||
- 隐藏成员节点:对 `memberNodeIds` 设 `node.hidden = true`(持久化)
|
||||
- 隐藏相关真实边:凡是 `edge.source/edge.target` 在 `memberNodeIds` 的真实边设 `edge.hidden = true`(持久化)
|
||||
- 不创建/不重接任何“指向 group/input/exitPort 的真实边”
|
||||
|
||||
### 4) UI edge 派生
|
||||
|
||||
- 从“真实边 + group 定义”派生临时 UI 边并写入 edges state:
|
||||
- inbound:真实 `outside -> entry` 映射为 `outside -> groupInput`
|
||||
- outbound:真实 `leaf(sourceHandle) -> outside` 映射为 `exitPort -> outside`
|
||||
- 临时 UI 边统一标记 `edge.data._isTemp = true`,并在需要时写入用于反向映射的最小字段(`groupId / leafNodeId / sourceHandle / target / targetHandle` 等)。
|
||||
- 为避免雪崩 rerender:
|
||||
- 派生逻辑只订阅最小字段(edges 的 `source/sourceHandle/target/targetHandle/hidden` + group 定义),用 `shallow` 比较 key 列表
|
||||
- UI 边增量更新:仅当派生 key 变化时才 `setEdges`
|
||||
|
||||
### 5) 连线翻译(拖线到 UI 节点最终只改真实边)
|
||||
|
||||
- `onConnect(target is custom-group or custom-group-input)`:
|
||||
- 翻译为:对该 group 的每个 `entryNodeId` 创建真实边 `source -> entryNodeId`(fan-out)
|
||||
- 复用现有合法性校验(available blocks + cycle check),要求每条 fan-out 都合法
|
||||
- `onConnect(source is custom-group-exit-port)`:
|
||||
- 翻译为:创建真实边 `leafNodeId(sourceHandle) -> target`
|
||||
|
||||
### 6) 删除 UI 边(反向翻译)
|
||||
|
||||
- 若选中并删除的是临时 inbound UI 边:删除所有匹配的真实边 `source -> entryNodeId`(entryNodeIds 来自 group 定义,source/sourceHandle 来自 UI 边)
|
||||
- 若选中并删除的是临时 outbound UI 边:删除对应真实边 `leafNodeId(sourceHandle) -> target`
|
||||
|
||||
### 7) 可编辑
|
||||
|
||||
- Group 标题:更新 `custom-group.data.group.title`
|
||||
- Exit Port 名称:更新 `custom-group-exit-port.data.exitPort.name`
|
||||
- 通过 `useNodeDataUpdateWithSyncDraft` 写回并 sync draft
|
||||
|
|
@ -60,6 +60,14 @@ import CustomSimpleNode from './simple-node'
|
|||
import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
|
||||
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
|
||||
import { CUSTOM_DATA_SOURCE_EMPTY_NODE } from './nodes/data-source-empty/constants'
|
||||
import {
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_NODE,
|
||||
CustomGroupExitPortNode,
|
||||
CustomGroupInputNode,
|
||||
CustomGroupNode,
|
||||
} from './custom-group-node'
|
||||
import Operator from './operator'
|
||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||
import Control from './operator/control'
|
||||
|
|
@ -111,6 +119,9 @@ const nodeTypes = {
|
|||
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
|
||||
[CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
|
||||
[CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode,
|
||||
[CUSTOM_GROUP_NODE]: CustomGroupNode,
|
||||
[CUSTOM_GROUP_INPUT_NODE]: CustomGroupInputNode,
|
||||
[CUSTOM_GROUP_EXIT_PORT_NODE]: CustomGroupExitPortNode,
|
||||
}
|
||||
const edgeTypes = {
|
||||
[CUSTOM_EDGE]: CustomEdge,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ import {
|
|||
getLoopStartNode,
|
||||
} from '.'
|
||||
import { correctModelProvider } from '@/utils'
|
||||
import {
|
||||
CUSTOM_GROUP_NODE,
|
||||
GROUP_CHILDREN_Z_INDEX,
|
||||
} from '../custom-group-node'
|
||||
import type { CustomGroupNodeData } from '../custom-group-node'
|
||||
|
||||
const WHITE = 'WHITE'
|
||||
const GRAY = 'GRAY'
|
||||
|
|
@ -91,8 +96,9 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
|
|||
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
|
||||
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
|
||||
const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE)
|
||||
|
||||
if (!hasIterationNode && !hasLoopNode) {
|
||||
if (!hasIterationNode && !hasLoopNode && !hasGroupNode) {
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
|
|
@ -189,9 +195,67 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
|||
(node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id
|
||||
})
|
||||
|
||||
// Derive Group internal edges (input → entries, leaves → exits)
|
||||
const groupInternalEdges: Edge[] = []
|
||||
const groupNodes = nodes.filter(node => node.type === CUSTOM_GROUP_NODE)
|
||||
|
||||
for (const groupNode of groupNodes) {
|
||||
const groupData = groupNode.data as unknown as CustomGroupNodeData
|
||||
const { group } = groupData
|
||||
|
||||
if (!group)
|
||||
continue
|
||||
|
||||
const { inputNodeId, entryNodeIds, exitPorts } = group
|
||||
|
||||
// Derive edges: input → each entry node
|
||||
for (const entryId of entryNodeIds) {
|
||||
const entryNode = nodesMap[entryId]
|
||||
if (entryNode) {
|
||||
groupInternalEdges.push({
|
||||
id: `group-internal-${inputNodeId}-source-${entryId}-target`,
|
||||
type: 'custom',
|
||||
source: inputNodeId,
|
||||
sourceHandle: 'source',
|
||||
target: entryId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: '' as any, // Group input has empty type
|
||||
targetType: entryNode.data.type,
|
||||
_isGroupInternal: true,
|
||||
_groupId: groupNode.id,
|
||||
},
|
||||
zIndex: GROUP_CHILDREN_Z_INDEX,
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive edges: each leaf node → exit port
|
||||
for (const exitPort of exitPorts) {
|
||||
const leafNode = nodesMap[exitPort.leafNodeId]
|
||||
if (leafNode) {
|
||||
groupInternalEdges.push({
|
||||
id: `group-internal-${exitPort.leafNodeId}-${exitPort.sourceHandle}-${exitPort.portNodeId}-target`,
|
||||
type: 'custom',
|
||||
source: exitPort.leafNodeId,
|
||||
sourceHandle: exitPort.sourceHandle,
|
||||
target: exitPort.portNodeId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: leafNode.data.type,
|
||||
targetType: '' as any, // Exit port has empty type
|
||||
_isGroupInternal: true,
|
||||
_groupId: groupNode.id,
|
||||
},
|
||||
zIndex: GROUP_CHILDREN_Z_INDEX,
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes],
|
||||
edges: [...edges, ...newEdges],
|
||||
edges: [...edges, ...newEdges, ...groupInternalEdges],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue