feat: implement group node functionality and enhance grouping interactions

This commit is contained in:
zhsama 2025-12-19 15:17:45 +08:00
parent e3bfb95c52
commit fc9d5b2a62
10 changed files with 284 additions and 5 deletions

View File

@ -26,6 +26,7 @@ import {
VariableX,
WebhookLine,
} from '@/app/components/base/icons/src/vender/workflow'
import { Folder as FolderLine } from '@/app/components/base/icons/src/vender/line/files'
import AppIcon from '@/app/components/base/app-icon'
import cn from '@/utils/classnames'
@ -54,6 +55,7 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin
[BlockEnum.TemplateTransform]: TemplatingTransform,
[BlockEnum.VariableAssigner]: VariableX,
[BlockEnum.VariableAggregator]: VariableX,
[BlockEnum.Group]: FolderLine,
[BlockEnum.Assigner]: Assigner,
[BlockEnum.Tool]: VariableX,
[BlockEnum.IterationStart]: VariableX,
@ -97,6 +99,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[BlockEnum.VariableAssigner]: 'bg-util-colors-blue-blue-500',
[BlockEnum.VariableAggregator]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Tool]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Group]: 'bg-util-colors-blue-blue-500',
[BlockEnum.Assigner]: 'bg-util-colors-blue-blue-500',
[BlockEnum.ParameterExtractor]: 'bg-util-colors-blue-blue-500',
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',

View File

@ -44,6 +44,7 @@ import type { LoopNodeType } from '../nodes/loop/types'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
import type { GroupHandler, GroupMember, GroupNodeData } from '../nodes/group/types'
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
import { useWorkflowHistoryStore } from '../workflow-history-store'
@ -2024,6 +2025,159 @@ export const useNodesInteractions = () => {
return canMakeGroup
}, [store])
const handleMakeGroup = useCallback(() => {
const { getNodes, setNodes, edges, setEdges } = store.getState()
const nodes = getNodes()
const bundledNodes = nodes.filter(node => node.data._isBundled)
const bundledNodeIds = bundledNodes.map(node => node.id)
if (bundledNodeIds.length <= 1)
return
const minimalEdges = edges.map(edge => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle || 'source',
target: edge.target,
}))
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges)
if (!canMakeGroup)
return
const bundledNodeIdSet = new Set(bundledNodeIds)
const bundledNodeIdIsLeaf = new Set<string>()
const inboundEdges = edges.filter(edge => !bundledNodeIdSet.has(edge.source) && bundledNodeIdSet.has(edge.target))
const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target))
// leaf node: no outbound edges to other nodes in the selection
const leafNodeIds = bundledNodes
.filter(node => !edges.some(edge => edge.source === node.id && bundledNodeIdSet.has(edge.target)))
.map(node => node.id)
leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id))
const members: GroupMember[] = bundledNodes.map((node) => {
return {
id: node.id,
type: node.data.type,
label: node.data.title,
}
})
const handlers: GroupHandler[] = leafNodeIds.map((nodeId) => {
const node = bundledNodes.find(n => n.id === nodeId)
return {
id: nodeId,
label: node?.data.title || nodeId,
}
})
// put the group node at the top-left corner of the selection, slightly offset
const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes)
const groupNodeData: GroupNodeData = {
title: t('workflow.operator.makeGroup'),
desc: '',
type: BlockEnum.Group,
members,
handlers,
selected: true,
}
const { newNode: groupNode } = generateNewNode({
data: groupNodeData,
position: {
x: minX - 20,
y: minY - 20,
},
})
const nodeTypeMap = new Map(nodes.map(node => [node.id, node.data.type]))
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (bundledNodeIdSet.has(node.id)) {
node.data._isBundled = false
node.selected = false
node.hidden = true
node.data._hiddenInGroupId = groupNode.id
}
else {
node.data._isBundled = false
}
})
draft.push(groupNode)
})
const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => {
if (bundledNodeIdSet.has(edge.source) || bundledNodeIdSet.has(edge.target)) {
edge.hidden = true
edge.data = {
...edge.data,
_hiddenInGroupId: groupNode.id,
_isBundled: false,
}
}
else if (edge.data?._isBundled) {
edge.data._isBundled = false
}
})
// re-add the external inbound edges to the group node (previous order is not lost)
inboundEdges.forEach((edge) => {
draft.push({
id: `${edge.id}__to-${groupNode.id}`,
type: edge.type || CUSTOM_EDGE,
source: edge.source,
target: groupNode.id,
sourceHandle: edge.sourceHandle,
targetHandle: 'target',
data: {
...edge.data,
sourceType: nodeTypeMap.get(edge.source)!,
targetType: BlockEnum.Group,
_hiddenInGroupId: undefined,
_isBundled: false,
},
zIndex: edge.zIndex,
})
})
// outbound edges of the group node: only map the outbound edges of the leaf nodes to the corresponding handlers
outboundEdges.forEach((edge) => {
if (!bundledNodeIdIsLeaf.has(edge.source))
return
draft.push({
id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${edge.source}`,
type: edge.type || CUSTOM_EDGE,
source: groupNode.id,
target: edge.target,
sourceHandle: edge.source, // handler id corresponds to the leaf node id
targetHandle: edge.targetHandle,
data: {
...edge.data,
sourceType: BlockEnum.Group,
targetType: nodeTypeMap.get(edge.target)!,
_hiddenInGroupId: undefined,
_isBundled: false,
},
zIndex: edge.zIndex,
})
})
})
setNodes(newNodes)
setEdges(newEdges)
workflowStore.setState({
selectionMenu: undefined,
})
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, {
nodeId: groupNode.id,
})
}, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore])
return {
handleNodeDragStart,
handleNodeDrag,
@ -2044,6 +2198,7 @@ export const useNodesInteractions = () => {
handleNodesPaste,
handleNodesDuplicate,
handleNodesDelete,
handleMakeGroup,
handleNodeResize,
handleNodeDisconnect,
handleHistoryBack,

View File

@ -29,6 +29,7 @@ export const useShortcuts = (): void => {
undimAllNodes,
hasBundledNodes,
getCanMakeGroup,
handleMakeGroup,
} = useNodesInteractions()
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
@ -103,8 +104,7 @@ export const useShortcuts = (): void => {
e.preventDefault()
// Close selection context menu if open
workflowStore.setState({ selectionMenu: undefined })
// TODO: handleMakeGroup() - Make group functionality to be implemented
console.info('make group')
handleMakeGroup()
}
}, { exactMatch: true, useCapture: true })

View File

@ -56,6 +56,7 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = {
[BlockEnum.VariableAggregator]: useVariableAggregatorSingleRunFormParams,
[BlockEnum.Assigner]: useVariableAssignerSingleRunFormParams,
[BlockEnum.KnowledgeBase]: useKnowledgeBaseSingleRunFormParams,
[BlockEnum.Group]: undefined,
[BlockEnum.VariableAssigner]: undefined,
[BlockEnum.End]: undefined,
[BlockEnum.Answer]: undefined,
@ -103,6 +104,7 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = {
[BlockEnum.DataSource]: undefined,
[BlockEnum.DataSourceEmpty]: undefined,
[BlockEnum.KnowledgeBase]: undefined,
[BlockEnum.Group]: undefined,
[BlockEnum.TriggerWebhook]: undefined,
[BlockEnum.TriggerSchedule]: undefined,
[BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore,

View File

@ -48,6 +48,8 @@ import TriggerWebhookNode from './trigger-webhook/node'
import TriggerWebhookPanel from './trigger-webhook/panel'
import TriggerPluginNode from './trigger-plugin/node'
import TriggerPluginPanel from './trigger-plugin/panel'
import GroupNode from './group/node'
import GroupPanel from './group/panel'
export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Start]: StartNode,
@ -75,6 +77,7 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
[BlockEnum.Group]: GroupNode,
}
export const PanelComponentMap: Record<string, ComponentType<any>> = {
@ -103,4 +106,5 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
[BlockEnum.Group]: GroupPanel,
}

View File

@ -0,0 +1,86 @@
import { memo, useMemo } from 'react'
import { RiArrowRightSLine } from '@remixicon/react'
import BlockIcon from '@/app/components/workflow/block-icon'
import type { NodeProps } from '@/app/components/workflow/types'
import type { GroupHandler, GroupMember, GroupNodeData } from './types'
import type { BlockEnum } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
const MAX_MEMBER_ICONS = 12
const GroupNode = (props: NodeProps<GroupNodeData>) => {
const { data } = props
// show the explicitly passed members first; otherwise use the _children information to fill the type
const members: GroupMember[] = useMemo(() => (
data.members?.length
? data.members
: data._children?.length
? data._children.map(child => ({
id: child.nodeId,
type: child.nodeType as BlockEnum,
label: child.nodeType,
}))
: []
), [data._children, data.members])
// handler 列表:优先使用传入的 handlers缺省时用 members 的 label 填充。
const handlers: GroupHandler[] = useMemo(() => (
data.handlers?.length
? data.handlers
: members.length
? members.map(member => ({
id: member.id,
label: member.label || member.id,
}))
: []
), [data.handlers, members])
return (
<div className='space-y-2 px-3 pb-3'>
{members.length > 0 && (
<div className='flex items-center gap-1 overflow-hidden'>
<div className='flex flex-wrap items-center gap-1 overflow-hidden'>
{members.slice(0, MAX_MEMBER_ICONS).map(member => (
<div
key={member.id}
className='flex h-7 items-center rounded-full bg-components-input-bg-normal px-1.5 shadow-xs'
>
<BlockIcon
type={member.type}
size='xs'
className='!shadow-none'
/>
</div>
))}
{members.length > MAX_MEMBER_ICONS && (
<div className='system-xs-medium rounded-full bg-components-input-bg-normal px-2 py-1 text-text-tertiary'>
+{members.length - MAX_MEMBER_ICONS}
</div>
)}
</div>
<RiArrowRightSLine className='ml-auto h-4 w-4 shrink-0 text-text-tertiary' />
</div>
)}
{handlers.length > 0 && (
<div className='space-y-1'>
{handlers.map(handler => (
<div
key={handler.id}
className={cn(
'system-sm-semibold uppercase',
'flex h-9 items-center rounded-md bg-components-panel-on-panel-item-bg px-3 text-text-primary shadow-xs',
)}
>
{handler.label || handler.id}
</div>
))}
</div>
)}
</div>
)
}
GroupNode.displayName = 'GroupNode'
export default memo(GroupNode)

View File

@ -0,0 +1,9 @@
import { memo } from 'react'
const GroupPanel = () => {
return null
}
GroupPanel.displayName = 'GroupPanel'
export default memo(GroupPanel)

View File

@ -0,0 +1,17 @@
import type { BlockEnum, CommonNodeType } from '../../types'
export type GroupMember = {
id: string
type: BlockEnum
label?: string
}
export type GroupHandler = {
id: string
label?: string
}
export type GroupNodeData = CommonNodeType<{
members?: GroupMember[]
handlers?: GroupHandler[]
}>

View File

@ -24,7 +24,7 @@ import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hoo
import { produce } from 'immer'
import { WorkflowHistoryEvent, useWorkflowHistory } from './hooks/use-workflow-history'
import { useStore } from './store'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useSelectionInteractions } from '@/app/components/workflow/hooks'
import { useMakeGroupAvailability } from './hooks/use-make-group'
import { useWorkflowStore } from './store'
@ -84,6 +84,7 @@ const SelectionContextmenu = () => {
handleNodesCopy,
handleNodesDuplicate,
handleNodesDelete,
handleMakeGroup,
} = useNodesInteractions()
const selectionMenu = useStore(s => s.selectionMenu)
@ -439,8 +440,7 @@ const SelectionContextmenu = () => {
onClick={() => {
if (!canMakeGroup)
return
console.log('make group')
// TODO: Make group functionality
handleMakeGroup()
handleSelectionContextmenuCancel()
}}
>

View File

@ -31,6 +31,7 @@ export enum BlockEnum {
Code = 'code',
TemplateTransform = 'template-transform',
HttpRequest = 'http-request',
Group = 'group',
VariableAssigner = 'variable-assigner',
VariableAggregator = 'variable-aggregator',
Tool = 'tool',
@ -80,6 +81,7 @@ export type CommonNodeType<T = {}> = {
_isEntering?: boolean
_showAddVariablePopup?: boolean
_holdAddVariablePopup?: boolean
_hiddenInGroupId?: string
_iterationLength?: number
_iterationIndex?: number
_waitingRun?: boolean
@ -114,6 +116,7 @@ export type CommonEdgeType = {
_connectedNodeIsHovering?: boolean
_connectedNodeIsSelected?: boolean
_isBundled?: boolean
_hiddenInGroupId?: string
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean