feat: add UI-only group node types and enhance workflow graph processing

This commit is contained in:
zhsama 2025-12-22 17:35:33 +08:00
parent fc9d5b2a62
commit 93b516a4ec
11 changed files with 483 additions and 4 deletions

1
.gitignore vendored
View File

@ -210,6 +210,7 @@ web/.vscode
.history
.idea/
web/migration/
# pnpm
/.pnpm-store

View File

@ -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)

View File

@ -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,
])

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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'

View File

@ -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
}

View File

@ -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 PortIf-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 计算
- entrybranch 头结点):选区内“有入边且所有入边 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

View File

@ -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,

View File

@ -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],
}
}