diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 76854ebf0a..eaed90217c 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -10,6 +10,7 @@ import type { import type { CommonNodeType, NodeDefault, + OnNodeAdd, OnSelectBlock, ToolWithProvider, } from '../types' @@ -65,6 +66,7 @@ export type NodeSelectorProps = { ignoreNodeIds?: string[] forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type). allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist. + snippetInsertPayload?: Parameters[1] } const NodeSelector: FC = ({ open: openFromProps, @@ -90,6 +92,7 @@ const NodeSelector: FC = ({ ignoreNodeIds = [], forceEnableStartTab = false, allowUserInputSelection, + snippetInsertPayload, }) => { const { t } = useTranslation() const nodes = useNodes() @@ -335,7 +338,14 @@ const NodeSelector: FC = ({ noTools={noTools} onTagsChange={setTags} forceShowStartContent={forceShowStartContent} - snippetsElem={} + snippetsElem={( + handleOpenChange(false)} + /> + )} /> diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx index 56b33067fc..34cc35e47b 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx @@ -1,6 +1,27 @@ import { act, renderHook } from '@testing-library/react' import { useInsertSnippet } from '../use-insert-snippet' +type TestNode = { + id: string + position: { x: number, y: number } + selected?: boolean + parentId?: string + data: { + selected?: boolean + _children?: { nodeId: string, nodeType: string }[] + _connectedSourceHandleIds?: string[] + _connectedTargetHandleIds?: string[] + } +} + +type TestEdge = { + id: string + source: string + sourceHandle?: string + target: string + targetHandle?: string +} + const mockFetchQuery = vi.fn() const mockHandleSyncWorkflowDraft = vi.fn() const mockSaveStateToHistory = vi.fn() @@ -8,6 +29,7 @@ const mockToastError = vi.fn() const mockGetNodes = vi.fn() const mockSetNodes = vi.fn() const mockSetEdges = vi.fn() +let mockEdges: unknown[] = [{ id: 'existing-edge', source: 'old', target: 'old-2' }] vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ @@ -20,7 +42,7 @@ vi.mock('reactflow', () => ({ getState: () => ({ getNodes: mockGetNodes, setNodes: mockSetNodes, - edges: [{ id: 'existing-edge', source: 'old', target: 'old-2' }], + edges: mockEdges, setEdges: mockSetEdges, }), }), @@ -47,6 +69,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ describe('useInsertSnippet', () => { beforeEach(() => { vi.clearAllMocks() + mockEdges = [{ id: 'existing-edge', source: 'old', target: 'old-2' }] mockGetNodes.mockReturnValue([ { id: 'existing-node', @@ -96,25 +119,127 @@ describe('useInsertSnippet', () => { expect(mockSetNodes).toHaveBeenCalledTimes(1) expect(mockSetEdges).toHaveBeenCalledTimes(1) - const nextNodes = mockSetNodes.mock.calls[0][0] - expect(nextNodes[0].selected).toBe(false) - expect(nextNodes[0].data.selected).toBe(false) + const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[] + expect(nextNodes[0]!.selected).toBe(false) + expect(nextNodes[0]!.data.selected).toBe(false) expect(nextNodes).toHaveLength(3) - expect(nextNodes[1].id).not.toBe('snippet-node-1') - expect(nextNodes[2].parentId).toBe(nextNodes[1].id) - expect(nextNodes[1].data._children[0].nodeId).toBe(nextNodes[2].id) + expect(nextNodes[1]!.id).not.toBe('snippet-node-1') + expect(nextNodes[2]!.parentId).toBe(nextNodes[1]!.id) + expect(nextNodes[1]!.data._children![0]!.nodeId).toBe(nextNodes[2]!.id) - const nextEdges = mockSetEdges.mock.calls[0][0] + const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[] expect(nextEdges).toHaveLength(2) - expect(nextEdges[1].source).toBe(nextNodes[1].id) - expect(nextEdges[1].target).toBe(nextNodes[2].id) + expect(nextEdges[1]!.source).toBe(nextNodes[1]!.id) + expect(nextEdges[1]!.target).toBe(nextNodes[2]!.id) expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodePaste', { - nodeId: nextNodes[1].id, + nodeId: nextNodes[1]!.id, }) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) }) + it('should connect inserted snippet nodes to the requested edge position', async () => { + mockGetNodes.mockReturnValue([ + { + id: 'prev-node', + position: { x: 0, y: 0 }, + width: 240, + data: { type: 'start', selected: true, _connectedSourceHandleIds: ['source'] }, + }, + { + id: 'next-node', + position: { x: 300, y: 0 }, + data: { type: 'answer', selected: false, _connectedTargetHandleIds: ['target'] }, + }, + ]) + mockEdges = [ + { + id: 'prev-node-source-next-node-target', + source: 'prev-node', + sourceHandle: 'source', + target: 'next-node', + targetHandle: 'target', + data: { + sourceType: 'start', + targetType: 'answer', + }, + }, + ] + mockFetchQuery.mockResolvedValue({ + graph: { + nodes: [ + { + id: 'snippet-entry', + position: { x: 0, y: 0 }, + data: { type: 'llm', selected: false }, + }, + { + id: 'snippet-exit', + position: { x: 300, y: 0 }, + data: { type: 'code', selected: false }, + }, + ], + edges: [ + { + id: 'snippet-entry-source-snippet-exit-target', + source: 'snippet-entry', + sourceHandle: 'source', + target: 'snippet-exit', + targetHandle: 'target', + data: { + sourceType: 'llm', + targetType: 'code', + }, + }, + ], + }, + }) + + const { result } = renderHook(() => useInsertSnippet()) + + await act(async () => { + await result.current.handleInsertSnippet('snippet-1', { + prevNodeId: 'prev-node', + prevNodeSourceHandle: 'source', + nextNodeId: 'next-node', + nextNodeTargetHandle: 'target', + }) + }) + + const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[] + const insertedEntry = nextNodes.find(node => node.id !== 'prev-node' && node.id !== 'next-node' && node.id.includes('snippet-entry'))! + const insertedExit = nextNodes.find(node => node.id !== 'prev-node' && node.id !== 'next-node' && node.id.includes('snippet-exit'))! + const shiftedNextNode = nextNodes.find(node => node.id === 'next-node')! + expect(insertedEntry.position).toEqual({ x: 300, y: 0 }) + expect(shiftedNextNode.position.x).toBe(600) + expect(nextNodes.find(node => node.id === 'prev-node')!.data._connectedSourceHandleIds).toEqual(['source']) + expect(insertedEntry.data._connectedTargetHandleIds).toEqual(['target']) + expect(insertedExit.data._connectedSourceHandleIds).toEqual(['source']) + expect(shiftedNextNode.data._connectedTargetHandleIds).toEqual(['target']) + + const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[] + expect(nextEdges).toHaveLength(3) + expect(nextEdges.some(edge => edge.id === 'prev-node-source-next-node-target')).toBe(false) + expect(nextEdges).toEqual(expect.arrayContaining([ + expect.objectContaining({ + source: 'prev-node', + sourceHandle: 'source', + target: insertedEntry.id, + targetHandle: 'target', + }), + expect.objectContaining({ + source: insertedEntry.id, + target: insertedExit.id, + }), + expect.objectContaining({ + source: insertedExit.id, + sourceHandle: 'source', + target: 'next-node', + targetHandle: 'target', + }), + ])) + }) + it('should show error toast when fetching snippet workflow fails', async () => { mockFetchQuery.mockRejectedValue(new Error('insert failed')) diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx index 75854b26e9..83c9fe6efd 100644 --- a/web/app/components/workflow/block-selector/snippets/index.tsx +++ b/web/app/components/workflow/block-selector/snippets/index.tsx @@ -1,3 +1,4 @@ +import type { OnNodeAdd } from '../../types' import { cn } from '@langgenius/dify-ui/cn' import { ScrollAreaContent, @@ -14,6 +15,7 @@ import { import { useInfiniteScroll } from 'ahooks' import { memo, + useCallback, useDeferredValue, useMemo, useRef, @@ -31,6 +33,8 @@ import { useInsertSnippet } from './use-insert-snippet' type SnippetsProps = { loading?: boolean searchText: string + insertPayload?: Parameters[1] + onInserted?: () => void } const LoadingSkeleton = () => { @@ -60,6 +64,8 @@ const LoadingSkeleton = () => { const Snippets = ({ loading = false, searchText, + insertPayload, + onInserted, }: SnippetsProps) => { const { createSnippetMutation, @@ -95,6 +101,11 @@ const Snippets = ({ }, [data?.pages]) const isNoMore = hasNextPage === false + const handleSnippetClick = useCallback(async (snippetId: string) => { + const inserted = await handleInsertSnippet(snippetId, insertPayload) + if (inserted) + onInserted?.() + }, [handleInsertSnippet, insertPayload, onInserted]) useInfiniteScroll( async () => { @@ -129,7 +140,7 @@ const Snippets = ({ handleInsertSnippet(item.id)} + onClick={() => handleSnippetClick(item.id)} onMouseEnter={() => setHoveredSnippetId(item.id)} onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)} /> @@ -146,7 +157,6 @@ const Snippets = ({ /> diff --git a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts index 14b860152c..775b7fc2eb 100644 --- a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts +++ b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts @@ -1,11 +1,16 @@ -import type { Edge, Node } from '../../types' +import type { Edge, Node, OnNodeAdd } from '../../types' import { toast } from '@langgenius/dify-ui/toast' import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { consoleQuery } from '@/service/client' +import { CUSTOM_EDGE, ITERATION_CHILDREN_Z_INDEX, LOOP_CHILDREN_Z_INDEX, NODE_WIDTH_X_OFFSET, X_OFFSET } from '../../constants' import { useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks' +import { BlockEnum } from '../../types' +import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../../utils' + +type SnippetInsertPayload = Parameters[1] const getSnippetGraph = (graph: Record | undefined) => { if (!graph) @@ -17,7 +22,74 @@ const getSnippetGraph = (graph: Record | undefined) => { } } -const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEdges: Edge[]) => { +const getRootNodes = (nodes: Node[]) => { + const rootNodes = nodes.filter(node => !node.parentId) + return rootNodes.length ? rootNodes : nodes +} + +const getSnippetBoundaryNodes = (nodes: Node[], edges: Edge[]) => { + const rootNodes = getRootNodes(nodes) + const rootNodeIds = new Set(rootNodes.map(node => node.id)) + const incomingNodeIds = new Set() + const outgoingNodeIds = new Set() + + edges.forEach((edge) => { + if (!rootNodeIds.has(edge.source) || !rootNodeIds.has(edge.target)) + return + + outgoingNodeIds.add(edge.source) + incomingNodeIds.add(edge.target) + }) + + return { + entryNodes: rootNodes.filter(node => !incomingNodeIds.has(node.id)), + exitNodes: rootNodes.filter(node => !outgoingNodeIds.has(node.id)), + } +} + +const canConnectToTarget = (node: Node) => { + return node.data.type !== BlockEnum.DataSource +} + +const canConnectFromSource = (node: Node) => { + return node.data.type !== BlockEnum.IfElse + && node.data.type !== BlockEnum.QuestionClassifier + && node.data.type !== BlockEnum.HumanInput + && node.data.type !== BlockEnum.LoopEnd +} + +const getInsertAnchor = ( + currentNodes: Node[], + insertPayload?: SnippetInsertPayload, +) => { + const prevNode = insertPayload?.prevNodeId + ? currentNodes.find(node => node.id === insertPayload.prevNodeId) + : undefined + const nextNode = insertPayload?.nextNodeId + ? currentNodes.find(node => node.id === insertPayload.nextNodeId) + : undefined + + if (nextNode) { + return { + x: nextNode.position.x, + y: nextNode.position.y, + } + } + + if (prevNode) { + return { + x: prevNode.position.x + (prevNode.width ?? 0) + X_OFFSET, + y: prevNode.position.y, + } + } +} + +const remapSnippetGraph = ( + currentNodes: Node[], + snippetNodes: Node[], + snippetEdges: Edge[], + insertPayload?: SnippetInsertPayload, +) => { const existingIds = new Set(currentNodes.map(node => node.id)) const idMapping = new Map() const rootNodes = snippetNodes.filter(node => !node.parentId) @@ -32,8 +104,9 @@ const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEd const currentMinY = currentNodes.length ? Math.min(...currentNodes.map(node => node.positionAbsolute?.y ?? node.position.y)) : 0 - const offsetX = (currentNodes.length ? currentMaxX + 80 : 80) - minRootX - const offsetY = (currentNodes.length ? currentMinY : 80) - minRootY + const insertAnchor = getInsertAnchor(currentNodes, insertPayload) + const offsetX = (insertAnchor?.x ?? (currentNodes.length ? currentMaxX + 80 : 80)) - minRootX + const offsetY = (insertAnchor?.y ?? (currentNodes.length ? currentMinY : 80)) - minRootY snippetNodes.forEach((node, index) => { let nextId = `${node.id}-${Date.now()}-${index}` @@ -94,6 +167,111 @@ const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEd return { nodes, edges } } +const getCurrentEdge = (edges: Edge[], insertPayload?: SnippetInsertPayload) => { + if (!insertPayload?.prevNodeId || !insertPayload.nextNodeId) + return undefined + + return edges.find(edge => + edge.source === insertPayload.prevNodeId + && edge.target === insertPayload.nextNodeId + && (edge.sourceHandle || 'source') === (insertPayload.prevNodeSourceHandle || 'source') + && (edge.targetHandle || 'target') === (insertPayload.nextNodeTargetHandle || 'target'), + ) +} + +const getParentNode = (nodes: Node[], insertPayload?: SnippetInsertPayload) => { + const prevNode = insertPayload?.prevNodeId + ? nodes.find(node => node.id === insertPayload.prevNodeId) + : undefined + const nextNode = insertPayload?.nextNodeId + ? nodes.find(node => node.id === insertPayload.nextNodeId) + : undefined + const parentId = prevNode?.parentId ?? nextNode?.parentId + + return parentId ? nodes.find(node => node.id === parentId) : undefined +} + +const createBoundaryEdges = ({ + currentNodes, + insertPayload, + entryNodes, + exitNodes, +}: { + currentNodes: Node[] + insertPayload?: SnippetInsertPayload + entryNodes: Node[] + exitNodes: Node[] +}) => { + const prevNode = insertPayload?.prevNodeId + ? currentNodes.find(node => node.id === insertPayload.prevNodeId) + : undefined + const nextNode = insertPayload?.nextNodeId + ? currentNodes.find(node => node.id === insertPayload.nextNodeId) + : undefined + const parentNode = getParentNode(currentNodes, insertPayload) + const isInIteration = parentNode?.data.type === BlockEnum.Iteration + const isInLoop = parentNode?.data.type === BlockEnum.Loop + const zIndex = parentNode + ? isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX + : 0 + const incomingEdges: Edge[] = [] + const outgoingEdges: Edge[] = [] + + if (prevNode) { + incomingEdges.push(...entryNodes.filter(canConnectToTarget).map((entryNode) => { + const sourceHandle = insertPayload?.prevNodeSourceHandle || 'source' + const targetHandle = 'target' + + return { + id: `${prevNode.id}-${sourceHandle}-${entryNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: prevNode.id, + sourceHandle, + target: entryNode.id, + targetHandle, + data: { + sourceType: prevNode.data.type, + targetType: entryNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? parentNode?.id : undefined, + loop_id: isInLoop ? parentNode?.id : undefined, + _connectedNodeIsSelected: true, + }, + zIndex, + } as Edge + })) + } + + if (nextNode) { + outgoingEdges.push(...exitNodes.filter(canConnectFromSource).map((exitNode) => { + const sourceHandle = 'source' + const targetHandle = insertPayload?.nextNodeTargetHandle || 'target' + + return { + id: `${exitNode.id}-${sourceHandle}-${nextNode.id}-${targetHandle}`, + type: CUSTOM_EDGE, + source: exitNode.id, + sourceHandle, + target: nextNode.id, + targetHandle, + data: { + sourceType: exitNode.data.type, + targetType: nextNode.data.type, + isInIteration, + isInLoop, + iteration_id: isInIteration ? parentNode?.id : undefined, + loop_id: isInLoop ? parentNode?.id : undefined, + _connectedNodeIsSelected: true, + }, + zIndex, + } as Edge + })) + } + + return [...incomingEdges, ...outgoingEdges] +} + export const useInsertSnippet = () => { const { t } = useTranslation() const queryClient = useQueryClient() @@ -101,7 +279,7 @@ export const useInsertSnippet = () => { const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() - const handleInsertSnippet = useCallback(async (snippetId: string) => { + const handleInsertSnippet = useCallback(async (snippetId: string, insertPayload?: SnippetInsertPayload) => { try { const workflow = await queryClient.fetchQuery(consoleQuery.snippets.publishedWorkflow.queryOptions({ input: { @@ -115,25 +293,107 @@ export const useInsertSnippet = () => { const { getNodes, setNodes, edges, setEdges } = store.getState() const currentNodes = getNodes() - const remappedGraph = remapSnippetGraph(currentNodes, snippetNodes, snippetEdges) + const remappedGraph = remapSnippetGraph(currentNodes, snippetNodes, snippetEdges, insertPayload) + const parentNode = getParentNode(currentNodes, insertPayload) + const rootNodeIds = new Set(getRootNodes(remappedGraph.nodes).map(node => node.id)) + const rootSnippetNodes = remappedGraph.nodes.filter(node => rootNodeIds.has(node.id)) + const currentEdge = getCurrentEdge(edges, insertPayload) + const { entryNodes, exitNodes } = getSnippetBoundaryNodes(remappedGraph.nodes, remappedGraph.edges) + const boundaryEdges = createBoundaryEdges({ + currentNodes, + insertPayload, + entryNodes, + exitNodes, + }) + const changes = [ + ...(currentEdge ? [{ type: 'remove', edge: currentEdge }] : []), + ...boundaryEdges.map(edge => ({ type: 'add', edge })), + ] + const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap( + changes, + [...currentNodes, ...remappedGraph.nodes], + ) + const firstEntryNode = entryNodes.find(canConnectToTarget) ?? entryNodes[0] const clearedNodes = currentNodes.map(node => ({ ...node, selected: false, + position: insertPayload?.nextNodeId && node.id === insertPayload.nextNodeId + ? { + ...node.position, + x: node.position.x + NODE_WIDTH_X_OFFSET, + } + : node.position, data: { ...node.data, selected: false, + ...(nodesConnectedSourceOrTargetHandleIdsMap[node.id] ?? {}), + _children: parentNode?.id === node.id + ? [ + ...(node.data._children ?? []), + ...rootSnippetNodes.map(rootNode => ({ + nodeId: rootNode.id, + nodeType: rootNode.data.type, + })), + ] + : node.data._children, + start_node_id: node.id === parentNode?.id + && node.data.start_node_id === insertPayload?.nextNodeId + && firstEntryNode + ? firstEntryNode.id + : node.data.start_node_id, + startNodeType: node.id === parentNode?.id + && node.data.start_node_id === insertPayload?.nextNodeId + && firstEntryNode + ? firstEntryNode.data.type + : node.data.startNodeType, }, })) + const insertedNodes = remappedGraph.nodes.map((node) => { + const shouldMoveIntoParent = !!parentNode && rootNodeIds.has(node.id) + const isInIteration = parentNode?.data.type === BlockEnum.Iteration + const isInLoop = parentNode?.data.type === BlockEnum.Loop - setNodes([...clearedNodes, ...remappedGraph.nodes]) - setEdges([...edges, ...remappedGraph.edges]) + return { + ...node, + parentId: shouldMoveIntoParent ? parentNode.id : node.parentId, + extent: shouldMoveIntoParent ? parentNode.extent : node.extent, + zIndex: shouldMoveIntoParent + ? isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX + : node.zIndex, + data: { + ...node.data, + ...(nodesConnectedSourceOrTargetHandleIdsMap[node.id] ?? {}), + isInIteration: shouldMoveIntoParent ? isInIteration : node.data.isInIteration, + isInLoop: shouldMoveIntoParent ? isInLoop : node.data.isInLoop, + iteration_id: shouldMoveIntoParent && isInIteration ? parentNode.id : node.data.iteration_id, + loop_id: shouldMoveIntoParent && isInLoop ? parentNode.id : node.data.loop_id, + }, + } + }) + + setNodes([...clearedNodes, ...insertedNodes]) + setEdges([ + ...edges + .filter(edge => edge.id !== currentEdge?.id) + .map(edge => ({ + ...edge, + data: { + ...edge.data, + _connectedNodeIsSelected: false, + }, + })), + ...remappedGraph.edges, + ...boundaryEdges, + ]) saveStateToHistory(WorkflowHistoryEvent.NodePaste, { nodeId: remappedGraph.nodes[0]?.id, }) handleSyncWorkflowDraft() + return true } catch (error) { toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' })) + return false } }, [handleSyncWorkflowDraft, queryClient, saveStateToHistory, store, t]) diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index d8c3b59a4a..8028fd0b40 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -163,6 +163,12 @@ const CustomEdge = ({ onOpenChange={handleOpenChange} asChild onSelect={handleInsert} + snippetInsertPayload={{ + prevNodeId: source, + prevNodeSourceHandle: sourceHandleId || 'source', + nextNodeId: target, + nextNodeTargetHandle: targetHandleId || 'target', + }} availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)} triggerClassName={() => 'hover:scale-150 transition-all'} /> diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index cb0d2410d5..f5aa8ac744 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx @@ -91,6 +91,10 @@ const Add = ({ onOpenChange={handleOpenChange} disabled={nodesReadOnly} onSelect={handleSelect} + snippetInsertPayload={{ + prevNodeId: nodeId, + prevNodeSourceHandle: sourceHandle, + }} placement="top" offset={0} trigger={renderTrigger} diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 58597a1670..a3658e4d4f 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -110,6 +110,10 @@ export const NodeTargetHandle = memo(({ open={open} onOpenChange={handleOpenChange} onSelect={handleSelect} + snippetInsertPayload={{ + nextNodeId: id, + nextNodeTargetHandle: handleId, + }} asChild placement="left" triggerClassName={open => ` @@ -229,6 +233,10 @@ export const NodeSourceHandle = memo(({ open={open} onOpenChange={handleOpenChange} onSelect={handleSelect} + snippetInsertPayload={{ + prevNodeId: id, + prevNodeSourceHandle: handleId, + }} asChild triggerClassName={open => ` absolute top-0 left-0 opacity-0 pointer-events-none transition-opacity duration-150 diff --git a/web/app/components/workflow/nodes/iteration/add-block.tsx b/web/app/components/workflow/nodes/iteration/add-block.tsx index c33b1269eb..a034b01a09 100644 --- a/web/app/components/workflow/nodes/iteration/add-block.tsx +++ b/web/app/components/workflow/nodes/iteration/add-block.tsx @@ -68,6 +68,10 @@ const AddBlock = ({ 'hover:scale-125 transition-all'} />