mirror of https://github.com/langgenius/dify.git
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:
parent
b7a2957340
commit
37c748192d
|
|
@ -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 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue