mirror of https://github.com/langgenius/dify.git
feat: implement group node functionality and enhance grouping interactions
This commit is contained in:
parent
e3bfb95c52
commit
fc9d5b2a62
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { memo } from 'react'
|
||||
|
||||
const GroupPanel = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
GroupPanel.displayName = 'GroupPanel'
|
||||
|
||||
export default memo(GroupPanel)
|
||||
|
|
@ -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[]
|
||||
}>
|
||||
|
|
@ -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()
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue