diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index a4f53f2a64..ce6098c304 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -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.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', diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index bb908a26e4..76be3766f4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -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() + 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, diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index d4689ebe68..f8c3fad700 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -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 }) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index ac9f2051c3..4b4d3b77c6 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -56,6 +56,7 @@ const singleRunFormParamsHooks: Record = { [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.DataSource]: undefined, [BlockEnum.DataSourceEmpty]: undefined, [BlockEnum.KnowledgeBase]: undefined, + [BlockEnum.Group]: undefined, [BlockEnum.TriggerWebhook]: undefined, [BlockEnum.TriggerSchedule]: undefined, [BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore, diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index d8da8b9dae..8b17141560 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -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> = { [BlockEnum.Start]: StartNode, @@ -75,6 +77,7 @@ export const NodeComponentMap: Record> = { [BlockEnum.TriggerSchedule]: TriggerScheduleNode, [BlockEnum.TriggerWebhook]: TriggerWebhookNode, [BlockEnum.TriggerPlugin]: TriggerPluginNode, + [BlockEnum.Group]: GroupNode, } export const PanelComponentMap: Record> = { @@ -103,4 +106,5 @@ export const PanelComponentMap: Record> = { [BlockEnum.TriggerSchedule]: TriggerSchedulePanel, [BlockEnum.TriggerWebhook]: TriggerWebhookPanel, [BlockEnum.TriggerPlugin]: TriggerPluginPanel, + [BlockEnum.Group]: GroupPanel, } diff --git a/web/app/components/workflow/nodes/group/node.tsx b/web/app/components/workflow/nodes/group/node.tsx new file mode 100644 index 0000000000..0d59b3fbb5 --- /dev/null +++ b/web/app/components/workflow/nodes/group/node.tsx @@ -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) => { + 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 ( +
+ {members.length > 0 && ( +
+
+ {members.slice(0, MAX_MEMBER_ICONS).map(member => ( +
+ +
+ ))} + {members.length > MAX_MEMBER_ICONS && ( +
+ +{members.length - MAX_MEMBER_ICONS} +
+ )} +
+ +
+ )} + {handlers.length > 0 && ( +
+ {handlers.map(handler => ( +
+ {handler.label || handler.id} +
+ ))} +
+ )} +
+ ) +} + +GroupNode.displayName = 'GroupNode' + +export default memo(GroupNode) diff --git a/web/app/components/workflow/nodes/group/panel.tsx b/web/app/components/workflow/nodes/group/panel.tsx new file mode 100644 index 0000000000..a36d074e9d --- /dev/null +++ b/web/app/components/workflow/nodes/group/panel.tsx @@ -0,0 +1,9 @@ +import { memo } from 'react' + +const GroupPanel = () => { + return null +} + +GroupPanel.displayName = 'GroupPanel' + +export default memo(GroupPanel) diff --git a/web/app/components/workflow/nodes/group/types.ts b/web/app/components/workflow/nodes/group/types.ts new file mode 100644 index 0000000000..e54119381e --- /dev/null +++ b/web/app/components/workflow/nodes/group/types.ts @@ -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[] +}> diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index e3ef16acc2..309870243b 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -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() }} > diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 5ae8d530a8..77fabd038c 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -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 = { _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