feat(workflow): implement UI-only group functionality

- Added support for UI-only group nodes, including custom-group, custom-group-input, and custom-group-exit-port types.
- Enhanced edge interactions to manage temporary edges connected to groups, ensuring corresponding real edges are deleted when temp edges are removed.
- Updated node interaction hooks to restore hidden edges and remove temp edges efficiently.
- Implemented logic for creating and managing group structures, including entry and exit ports, while maintaining execution graph integrity.
This commit is contained in:
zhsama 2026-01-04 21:54:15 +08:00
parent b7a2957340
commit 37c748192d
3 changed files with 61 additions and 136 deletions

View File

@ -1,127 +0,0 @@
# 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

@ -10,6 +10,7 @@ import { useCallback } from 'react'
import {
useStoreApi,
} from 'reactflow'
import { BlockEnum } from '../types'
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useNodesReadOnly } from './use-workflow'
@ -108,6 +109,50 @@ export const useEdgesInteractions = () => {
return
const currentEdge = edges[currentEdgeIndex]
const nodes = getNodes()
// collect edges to delete (including corresponding real edges for temp edges)
const edgesToDelete: Set<string> = new Set([currentEdge.id])
// if deleting a temp edge connected to a group, also delete the corresponding real hidden edge
if (currentEdge.data?._isTemp) {
const groupNode = nodes.find(n =>
n.data.type === BlockEnum.Group
&& (n.id === currentEdge.source || n.id === currentEdge.target),
)
if (groupNode) {
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
if (currentEdge.target === groupNode.id) {
// inbound temp edge: find real edge with same source, target is a head node
edges.forEach((edge) => {
if (edge.source === currentEdge.source
&& memberIds.has(edge.target)
&& edge.sourceHandle === currentEdge.sourceHandle) {
edgesToDelete.add(edge.id)
}
})
}
else if (currentEdge.source === groupNode.id) {
// outbound temp edge: sourceHandle format is "leafNodeId-originalHandle"
const sourceHandle = currentEdge.sourceHandle || ''
const lastDashIndex = sourceHandle.lastIndexOf('-')
if (lastDashIndex > 0) {
const leafNodeId = sourceHandle.substring(0, lastDashIndex)
const originalHandle = sourceHandle.substring(lastDashIndex + 1)
edges.forEach((edge) => {
if (edge.source === leafNodeId
&& edge.target === currentEdge.target
&& (edge.sourceHandle || 'source') === originalHandle) {
edgesToDelete.add(edge.id)
}
})
}
}
}
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
{ type: 'remove', edge: currentEdge },
@ -126,7 +171,10 @@ export const useEdgesInteractions = () => {
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
draft.splice(currentEdgeIndex, 1)
for (let i = draft.length - 1; i >= 0; i--) {
if (edgesToDelete.has(draft[i].id))
draft.splice(i, 1)
}
})
setEdges(newEdges)
handleSyncWorkflowDraft()

View File

@ -2577,19 +2577,23 @@ export const useNodesInteractions = () => {
draft.splice(groupIndex, 1)
})
// restore hidden edges and remove temp edges
// restore hidden edges and remove temp edges in single pass O(E)
const newEdges = produce(edges, (draft) => {
// restore hidden edges that involve member nodes
draft.forEach((edge) => {
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
})
// remove temp edges connected to group (iterate backwards to safely splice)
for (let i = draft.length - 1; i >= 0; i--) {
const edge = draft[i]
// collect temp edges connected to group for removal
if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId))
draft.splice(i, 1)
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)