From 2f4daa16ff1c8646c30110957a437cb2d7044b61 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Wed, 11 Mar 2026 10:46:52 +0800 Subject: [PATCH 1/9] copy nodes cross apps --- .../__tests__/use-panel-interactions.spec.ts | 39 ++ .../workflow/hooks/use-nodes-interactions.ts | 634 ++++++++++++------ .../workflow/hooks/use-panel-interactions.ts | 6 + .../store/__tests__/workflow-store.spec.ts | 13 + .../workflow/store/workflow/workflow-slice.ts | 12 + .../utils/__tests__/clipboard.spec.ts | 100 +++ .../components/workflow/utils/clipboard.ts | 89 +++ web/app/components/workflow/utils/index.ts | 1 + web/i18n/en-US/workflow.json | 1 + 9 files changed, 682 insertions(+), 213 deletions(-) create mode 100644 web/app/components/workflow/utils/__tests__/clipboard.spec.ts create mode 100644 web/app/components/workflow/utils/clipboard.ts diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index ec689f23f9..23f1abde95 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -1,11 +1,22 @@ import type * as React from 'react' +import { waitFor } from '@testing-library/react' +import { createEdge, createNode } from '../../__tests__/fixtures' import { renderWorkflowHook } from '../../__tests__/workflow-test-env' import { usePanelInteractions } from '../use-panel-interactions' describe('usePanelInteractions', () => { let container: HTMLDivElement + let readTextMock: ReturnType beforeEach(() => { + readTextMock = vi.fn().mockResolvedValue('') + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText: readTextMock, + }, + }) + container = document.createElement('div') container.id = 'workflow-container' container.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -56,6 +67,34 @@ describe('usePanelInteractions', () => { }).toThrow() }) + it('handlePaneContextMenu should sync clipboard from navigator clipboard', async () => { + const clipboardNode = createNode({ id: 'clipboard-node' }) + const clipboardEdge = createEdge({ + id: 'clipboard-edge', + source: clipboardNode.id, + target: 'target-node', + }) + readTextMock.mockResolvedValue(JSON.stringify({ + kind: 'dify-workflow-clipboard', + version: 1, + nodes: [clipboardNode], + edges: [clipboardEdge], + })) + + const { result, store } = renderWorkflowHook(() => usePanelInteractions()) + + result.current.handlePaneContextMenu({ + preventDefault: vi.fn(), + clientX: 350, + clientY: 250, + } as unknown as React.MouseEvent) + + await waitFor(() => { + expect(store.getState().clipboardElements).toEqual([clipboardNode]) + expect(store.getState().clipboardEdges).toEqual([clipboardEdge]) + }) + }) + it('handlePaneContextmenuCancel should clear panelMenu', () => { const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { initialStoreState: { panelMenu: { top: 10, left: 20 } }, diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index e18405b196..09db9508c4 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -22,6 +22,7 @@ import { useReactFlow, useStoreApi, } from 'reactflow' +import Toast from '@/app/components/base/toast' import { CUSTOM_EDGE, ITERATION_CHILDREN_Z_INDEX, @@ -47,6 +48,8 @@ import { getNodeCustomTypeByNodeDataType, getNodesConnectedSourceOrTargetHandleIdsMap, getTopLeftNodePosition, + readWorkflowClipboard, + writeWorkflowClipboard, } from '../utils' import { useWorkflowHistoryStore } from '../workflow-history-store' import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url' @@ -450,13 +453,11 @@ export const useNodesInteractions = () => { } if ( - edges.find( - edge => - edge.source === source - && edge.sourceHandle === sourceHandle - && edge.target === target - && edge.targetHandle === targetHandle, - ) + edges.some(edge => + edge.source === source + && edge.sourceHandle === sourceHandle + && edge.target === target + && edge.targetHandle === targetHandle) ) { return } @@ -778,9 +779,7 @@ export const useNodesInteractions = () => { const newEdges = produce(edges, (draft) => { return draft.filter( edge => - !connectedEdges.find( - connectedEdge => connectedEdge.id === edge.id, - ), + !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id), ) }) setEdges(newEdges) @@ -856,7 +855,7 @@ export const useNodesInteractions = () => { const outgoers = getOutgoers(prevNode, nodes, edges).sort( (a, b) => a.position.y - b.position.y, ) - const lastOutgoer = outgoers[outgoers.length - 1] + const lastOutgoer = outgoers.at(-1) newNode.data._connectedTargetHandleIds = nodeType === BlockEnum.DataSource ? [] : [targetHandle] @@ -1582,9 +1581,7 @@ export const useNodesInteractions = () => { setNodes(newNodes) const remainingEdges = edges.filter( edge => - !connectedEdges.find( - connectedEdge => connectedEdge.id === edge.id, - ), + !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id), ) setEdges([...remainingEdges, ...reconnectedEdges]) if (nodeType === BlockEnum.TriggerWebhook) { @@ -1653,71 +1650,154 @@ export const useNodesInteractions = () => { [workflowStore, handleNodeSelect], ) + const isNodeCopyable = useCallback((node: Node) => { + if ( + node.type === CUSTOM_ITERATION_START_NODE + || node.type === CUSTOM_LOOP_START_NODE + ) { + return false + } + + if ( + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.LoopEnd + || node.data.type === BlockEnum.KnowledgeBase + || node.data.type === BlockEnum.DataSourceEmpty + ) { + return false + } + + if (node.type === CUSTOM_NOTE_NODE) + return true + + const nodeMeta = nodesMetaDataMap?.[node.data.type as BlockEnum] + if (!nodeMeta) + return false + + const { metaData } = nodeMeta + return !metaData.isSingleton + }, [nodesMetaDataMap]) + + const getNodeDefaultValueForPaste = useCallback((node: Node) => { + if (node.type === CUSTOM_NOTE_NODE) + return {} + + const nodeMeta = nodesMetaDataMap?.[node.data.type as BlockEnum] + return nodeMeta?.defaultValue + }, [nodesMetaDataMap]) + const handleNodesCopy = useCallback( (nodeId?: string) => { if (getNodesReadOnly()) return - const { setClipboardElements } = workflowStore.getState() - - const { getNodes } = store.getState() - + const { setClipboardData } = workflowStore.getState() + const { getNodes, edges } = store.getState() const nodes = getNodes() + let nodesToCopy: Node[] = [] if (nodeId) { - // If nodeId is provided, copy that specific node - const nodeToCopy = nodes.find( - node => - node.id === nodeId - && node.data.type !== BlockEnum.Start - && node.type !== CUSTOM_ITERATION_START_NODE - && node.type !== CUSTOM_LOOP_START_NODE - && node.data.type !== BlockEnum.LoopEnd - && node.data.type !== BlockEnum.KnowledgeBase - && node.data.type !== BlockEnum.DataSourceEmpty, - ) + const nodeToCopy = nodes.find(node => node.id === nodeId && isNodeCopyable(node)) if (nodeToCopy) - setClipboardElements([nodeToCopy]) + nodesToCopy = [nodeToCopy] } else { - // If no nodeId is provided, fall back to the current behavior const bundledNodes = nodes.filter((node) => { if (!node.data._isBundled) return false + + if (!isNodeCopyable(node)) + return false + if (node.type === CUSTOM_NOTE_NODE) return true - const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum] - if (metaData.isSingleton) - return false + return !node.data.isInIteration && !node.data.isInLoop }) if (bundledNodes.length) { - setClipboardElements(bundledNodes) - return + nodesToCopy = bundledNodes } + else { + const selectedNodes = nodes.filter( + node => node.data.selected && isNodeCopyable(node), + ) - const selectedNode = nodes.find((node) => { - if (!node.data.selected) - return false - if (node.type === CUSTOM_NOTE_NODE) - return true - const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum] - return !metaData.isSingleton - }) - - if (selectedNode) - setClipboardElements([selectedNode]) + if (selectedNodes.length) + nodesToCopy = selectedNodes + } } + + if (!nodesToCopy.length) + return + + const copiedNodesMap = new Map(nodesToCopy.map(node => [node.id, node])) + const queue = nodesToCopy + .filter(node => node.data.type === BlockEnum.Iteration || node.data.type === BlockEnum.Loop) + .map(node => node.id) + + while (queue.length) { + const parentId = queue.shift()! + nodes.forEach((node) => { + if (node.parentId !== parentId || copiedNodesMap.has(node.id)) + return + + copiedNodesMap.set(node.id, node) + if (node.data.type === BlockEnum.Iteration || node.data.type === BlockEnum.Loop) + queue.push(node.id) + }) + } + + const copiedNodes = [...copiedNodesMap.values()] + const copiedNodeIds = new Set(copiedNodes.map(node => node.id)) + const copiedEdges = edges.filter( + edge => copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target), + ) + + const clipboardData = { + nodes: copiedNodes, + edges: copiedEdges, + } + + setClipboardData(clipboardData) + void writeWorkflowClipboard(clipboardData).catch(() => {}) }, - [getNodesReadOnly, store, workflowStore], + [getNodesReadOnly, workflowStore, store, isNodeCopyable], ) - const handleNodesPaste = useCallback(() => { + const handleNodesPaste = useCallback(async () => { if (getNodesReadOnly()) return - const { clipboardElements, mousePosition } = workflowStore.getState() + const { + clipboardElements: storeClipboardElements, + clipboardEdges: storeClipboardEdges, + mousePosition, + setClipboardData, + } = workflowStore.getState() + const clipboardData = await readWorkflowClipboard() + const hasSystemClipboard = clipboardData.nodes.length > 0 + if (hasSystemClipboard && clipboardData.isVersionMismatch) { + Toast.notify({ + type: 'warning', + message: t('common.clipboardVersionCompatibilityWarning', { + ns: 'workflow', + }), + }) + } + + const clipboardElements = hasSystemClipboard + ? clipboardData.nodes + : storeClipboardElements + const clipboardEdges = hasSystemClipboard + ? clipboardData.edges + : storeClipboardEdges + + if (hasSystemClipboard) + setClipboardData(clipboardData) + + if (!clipboardElements.length) + return const { getNodes, setNodes, edges, setEdges } = store.getState() @@ -1725,55 +1805,130 @@ export const useNodesInteractions = () => { const edgesToPaste: Edge[] = [] const nodes = getNodes() - if (clipboardElements.length) { - const { x, y } = getTopLeftNodePosition(clipboardElements) - const { screenToFlowPosition } = reactflow - const currentPosition = screenToFlowPosition({ - x: mousePosition.pageX, - y: mousePosition.pageY, - }) - const offsetX = currentPosition.x - x - const offsetY = currentPosition.y - y - let idMapping: Record = {} - const pastedNodesMap: Record = {} - const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = [] - clipboardElements.forEach((nodeToPaste, index) => { - const nodeType = nodeToPaste.data.type + const supportedClipboardElements = clipboardElements.filter((node) => { + if (node.type === CUSTOM_NOTE_NODE) + return true + return !!nodesMetaDataMap?.[node.data.type as BlockEnum] + }) - const { newNode, newIterationStartNode, newLoopStartNode } - = generateNewNode({ - type: nodeToPaste.type, - data: { - ...(nodeToPaste.type !== CUSTOM_NOTE_NODE && nodesMetaDataMap![nodeType].defaultValue), - ...nodeToPaste.data, - selected: false, - _isBundled: false, - _connectedSourceHandleIds: [], - _connectedTargetHandleIds: [], - _dimmed: false, - title: genNewNodeTitleFromOld(nodeToPaste.data.title), - }, - position: { - x: nodeToPaste.position.x + offsetX, - y: nodeToPaste.position.y + offsetY, - }, - extent: nodeToPaste.extent, - zIndex: nodeToPaste.zIndex, + if (!supportedClipboardElements.length) + return + + const clipboardNodeIds = new Set(supportedClipboardElements.map(node => node.id)) + const rootClipboardNodes = supportedClipboardElements.filter( + node => !node.parentId || !clipboardNodeIds.has(node.parentId), + ) + const positionReferenceNodes = rootClipboardNodes.length + ? rootClipboardNodes + : supportedClipboardElements + const { x, y } = getTopLeftNodePosition(positionReferenceNodes) + const { screenToFlowPosition } = reactflow + const currentPosition = screenToFlowPosition({ + x: mousePosition.pageX, + y: mousePosition.pageY, + }) + const offsetX = currentPosition.x - x + const offsetY = currentPosition.y - y + let idMapping: Record = {} + const pastedNodesMap: Record = {} + const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = [] + const selectedNode = nodes.find(node => node.selected) + + rootClipboardNodes.forEach((nodeToPaste, index) => { + const nodeDefaultValue = getNodeDefaultValueForPaste(nodeToPaste) + if (nodeToPaste.type !== CUSTOM_NOTE_NODE && !nodeDefaultValue) + return + + const { newNode, newIterationStartNode, newLoopStartNode } + = generateNewNode({ + type: nodeToPaste.type, + data: { + ...(nodeToPaste.type !== CUSTOM_NOTE_NODE ? nodeDefaultValue : {}), + ...nodeToPaste.data, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + isInIteration: false, + iteration_id: undefined, + isInLoop: false, + loop_id: undefined, + title: genNewNodeTitleFromOld(nodeToPaste.data.title), + }, + position: { + x: nodeToPaste.position.x + offsetX, + y: nodeToPaste.position.y + offsetY, + }, + extent: nodeToPaste.extent, + zIndex: nodeToPaste.zIndex, + }) + newNode.id = newNode.id + index + + let newChildren: Node[] = [] + if (nodeToPaste.data.type === BlockEnum.Iteration) { + if (newIterationStartNode) { + newIterationStartNode.parentId = newNode.id + const iterationNodeData = newNode.data as IterationNodeType + iterationNodeData.start_node_id = newIterationStartNode.id + } + + const oldIterationStartNodeInClipboard = supportedClipboardElements.find( + n => + n.parentId === nodeToPaste.id + && n.type === CUSTOM_ITERATION_START_NODE, + ) + if (oldIterationStartNodeInClipboard && newIterationStartNode) + idMapping[oldIterationStartNodeInClipboard.id] = newIterationStartNode.id + + const copiedIterationChildren = supportedClipboardElements.filter( + n => + n.parentId === nodeToPaste.id + && n.type !== CUSTOM_ITERATION_START_NODE, + ) + if (copiedIterationChildren.length) { + copiedIterationChildren.forEach((child, childIndex) => { + const childType = child.data.type + const childDefaultValue = getNodeDefaultValueForPaste(child) + if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) + return + + const { newNode: newChild } = generateNewNode({ + type: child.type, + data: { + ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), + ...child.data, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + title: genNewNodeTitleFromOld(child.data.title), + isInIteration: true, + iteration_id: newNode.id, + isInLoop: false, + loop_id: undefined, + type: childType, + }, + position: child.position, + positionAbsolute: child.positionAbsolute, + parentId: newNode.id, + extent: child.extent, + zIndex: ITERATION_CHILDREN_Z_INDEX, + }) + newChild.id = `${newNode.id}${newChild.id + childIndex}` + idMapping[child.id] = newChild.id + newChildren.push(newChild) }) - newNode.id = newNode.id + index - // This new node is movable and can be placed anywhere - let newChildren: Node[] = [] - if (nodeToPaste.data.type === BlockEnum.Iteration) { - newIterationStartNode!.parentId = newNode.id; - (newNode.data as IterationNodeType).start_node_id - = newIterationStartNode!.id - + } + else { const oldIterationStartNode = nodes.find( n => n.parentId === nodeToPaste.id && n.type === CUSTOM_ITERATION_START_NODE, ) - idMapping[oldIterationStartNode!.id] = newIterationStartNode!.id + if (oldIterationStartNode && newIterationStartNode) + idMapping[oldIterationStartNode.id] = newIterationStartNode.id const { copyChildren, newIdMapping } = handleNodeIterationChildrenCopy( @@ -1783,24 +1938,80 @@ export const useNodesInteractions = () => { ) newChildren = copyChildren idMapping = newIdMapping - newChildren.forEach((child) => { - newNode.data._children?.push({ - nodeId: child.id, - nodeType: child.data.type, - }) - }) - newChildren.push(newIterationStartNode!) } - else if (nodeToPaste.data.type === BlockEnum.Loop) { - newLoopStartNode!.parentId = newNode.id; - (newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id + newChildren.forEach((child) => { + newNode.data._children?.push({ + nodeId: child.id, + nodeType: child.data.type, + }) + }) + if (newIterationStartNode) + newChildren.push(newIterationStartNode) + } + else if (nodeToPaste.data.type === BlockEnum.Loop) { + if (newLoopStartNode) { + newLoopStartNode.parentId = newNode.id + const loopNodeData = newNode.data as LoopNodeType + loopNodeData.start_node_id = newLoopStartNode.id + } + + const oldLoopStartNodeInClipboard = supportedClipboardElements.find( + n => + n.parentId === nodeToPaste.id + && n.type === CUSTOM_LOOP_START_NODE, + ) + if (oldLoopStartNodeInClipboard && newLoopStartNode) + idMapping[oldLoopStartNodeInClipboard.id] = newLoopStartNode.id + + const copiedLoopChildren = supportedClipboardElements.filter( + n => + n.parentId === nodeToPaste.id + && n.type !== CUSTOM_LOOP_START_NODE, + ) + if (copiedLoopChildren.length) { + copiedLoopChildren.forEach((child, childIndex) => { + const childType = child.data.type + const childDefaultValue = getNodeDefaultValueForPaste(child) + if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) + return + + const { newNode: newChild } = generateNewNode({ + type: child.type, + data: { + ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), + ...child.data, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + title: genNewNodeTitleFromOld(child.data.title), + isInIteration: false, + iteration_id: undefined, + isInLoop: true, + loop_id: newNode.id, + type: childType, + }, + position: child.position, + positionAbsolute: child.positionAbsolute, + parentId: newNode.id, + extent: child.extent, + zIndex: LOOP_CHILDREN_Z_INDEX, + }) + newChild.id = `${newNode.id}${newChild.id + childIndex}` + idMapping[child.id] = newChild.id + newChildren.push(newChild) + }) + } + else { const oldLoopStartNode = nodes.find( n => n.parentId === nodeToPaste.id && n.type === CUSTOM_LOOP_START_NODE, ) - idMapping[oldLoopStartNode!.id] = newLoopStartNode!.id + if (oldLoopStartNode && newLoopStartNode) + idMapping[oldLoopStartNode.id] = newLoopStartNode.id const { copyChildren, newIdMapping } = handleNodeLoopChildrenCopy( @@ -1810,126 +2021,125 @@ export const useNodesInteractions = () => { ) newChildren = copyChildren idMapping = newIdMapping - newChildren.forEach((child) => { - newNode.data._children?.push({ - nodeId: child.id, - nodeType: child.data.type, - }) + } + + newChildren.forEach((child) => { + newNode.data._children?.push({ + nodeId: child.id, + nodeType: child.data.type, }) - newChildren.push(newLoopStartNode!) - } - else { - // single node paste - const selectedNode = nodes.find(node => node.selected) - if (selectedNode) { - const commonNestedDisallowPasteNodes = [ - // end node only can be placed outermost layer - BlockEnum.End, - ] - - // handle disallow paste node - if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) - return - - // handle paste to nested block - if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) { - const isIteration = selectedNode.data.type === BlockEnum.Iteration - - newNode.data.isInIteration = isIteration - newNode.data.iteration_id = isIteration ? selectedNode.id : undefined - newNode.data.isInLoop = !isIteration - newNode.data.loop_id = !isIteration ? selectedNode.id : undefined - - newNode.parentId = selectedNode.id - newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX - newNode.positionAbsolute = { - x: newNode.position.x, - y: newNode.position.y, - } - // set position base on parent node - newNode.position = getNestedNodePosition(newNode, selectedNode) - // update parent children array like native add - parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type }) - } - } - } - - idMapping[nodeToPaste.id] = newNode.id - nodesToPaste.push(newNode) - pastedNodesMap[newNode.id] = newNode - - if (newChildren.length) { - newChildren.forEach((child) => { - pastedNodesMap[child.id] = child - }) - nodesToPaste.push(...newChildren) - } - }) - - // Rebuild edges where both endpoints are part of the pasted set. - edges.forEach((edge) => { - const sourceId = idMapping[edge.source] - const targetId = idMapping[edge.target] - - if (sourceId && targetId) { - const sourceNode = pastedNodesMap[sourceId] - const targetNode = pastedNodesMap[targetId] - const parentNode = sourceNode?.parentId && sourceNode.parentId === targetNode?.parentId - ? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId) - : null - const isInIteration = parentNode?.data.type === BlockEnum.Iteration - const isInLoop = parentNode?.data.type === BlockEnum.Loop - const newEdge: Edge = { - ...edge, - id: `${sourceId}-${edge.sourceHandle}-${targetId}-${edge.targetHandle}`, - source: sourceId, - target: targetId, - data: { - ...edge.data, - isInIteration, - iteration_id: isInIteration ? parentNode?.id : undefined, - isInLoop, - loop_id: isInLoop ? parentNode?.id : undefined, - _connectedNodeIsSelected: false, - }, - zIndex: parentNode - ? isInIteration - ? ITERATION_CHILDREN_Z_INDEX - : isInLoop - ? LOOP_CHILDREN_Z_INDEX - : 0 - : 0, - } - edgesToPaste.push(newEdge) - } - }) - - const newNodes = produce(nodes, (draft: Node[]) => { - parentChildrenToAppend.forEach(({ parentId, childId, childType }) => { - const p = draft.find(n => n.id === parentId) - if (p) { - p.data._children?.push({ nodeId: childId, nodeType: childType }) - } }) - draft.push(...nodesToPaste) - }) + if (newLoopStartNode) + newChildren.push(newLoopStartNode) + } + else if (selectedNode) { + const commonNestedDisallowPasteNodes = [ + BlockEnum.End, + ] - setNodes(newNodes) - setEdges([...edges, ...edgesToPaste]) - saveStateToHistory(WorkflowHistoryEvent.NodePaste, { - nodeId: nodesToPaste?.[0]?.id, + if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) + return + + if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) { + const isIteration = selectedNode.data.type === BlockEnum.Iteration + + newNode.data.isInIteration = isIteration + newNode.data.iteration_id = isIteration ? selectedNode.id : undefined + newNode.data.isInLoop = !isIteration + newNode.data.loop_id = !isIteration ? selectedNode.id : undefined + + newNode.parentId = selectedNode.id + newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX + newNode.positionAbsolute = { + x: newNode.position.x, + y: newNode.position.y, + } + newNode.position = getNestedNodePosition(newNode, selectedNode) + parentChildrenToAppend.push({ + parentId: selectedNode.id, + childId: newNode.id, + childType: newNode.data.type, + }) + } + } + + idMapping[nodeToPaste.id] = newNode.id + nodesToPaste.push(newNode) + pastedNodesMap[newNode.id] = newNode + + if (newChildren.length) { + newChildren.forEach((child) => { + pastedNodesMap[child.id] = child + }) + nodesToPaste.push(...newChildren) + } + }) + + const sourceEdges = clipboardEdges.length ? clipboardEdges : edges + + sourceEdges.forEach((edge) => { + const sourceId = idMapping[edge.source] + const targetId = idMapping[edge.target] + + if (sourceId && targetId) { + const sourceNode = pastedNodesMap[sourceId] + const targetNode = pastedNodesMap[targetId] + const parentNode = sourceNode?.parentId && sourceNode.parentId === targetNode?.parentId + ? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId) + : null + const isInIteration = parentNode?.data.type === BlockEnum.Iteration + const isInLoop = parentNode?.data.type === BlockEnum.Loop + const newEdge: Edge = { + ...edge, + id: `${sourceId}-${edge.sourceHandle}-${targetId}-${edge.targetHandle}`, + source: sourceId, + target: targetId, + data: { + ...edge.data, + isInIteration, + iteration_id: isInIteration ? parentNode?.id : undefined, + isInLoop, + loop_id: isInLoop ? parentNode?.id : undefined, + _connectedNodeIsSelected: false, + }, + zIndex: parentNode + ? isInIteration + ? ITERATION_CHILDREN_Z_INDEX + : isInLoop + ? LOOP_CHILDREN_Z_INDEX + : 0 + : 0, + } + edgesToPaste.push(newEdge) + } + }) + + const newNodes = produce(nodes, (draft: Node[]) => { + parentChildrenToAppend.forEach(({ parentId, childId, childType }) => { + const p = draft.find(n => n.id === parentId) + if (p) + p.data._children?.push({ nodeId: childId, nodeType: childType }) }) - handleSyncWorkflowDraft() - } + draft.push(...nodesToPaste) + }) + + setNodes(newNodes) + setEdges([...edges, ...edgesToPaste]) + saveStateToHistory(WorkflowHistoryEvent.NodePaste, { + nodeId: nodesToPaste?.[0]?.id, + }) + handleSyncWorkflowDraft() }, [ getNodesReadOnly, workflowStore, store, reactflow, + t, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy, + getNodeDefaultValueForPaste, nodesMetaDataMap, ]) @@ -2073,9 +2283,7 @@ export const useNodesInteractions = () => { const newEdges = produce(edges, (draft) => { return draft.filter( edge => - !connectedEdges.find( - connectedEdge => connectedEdge.id === edge.id, - ), + !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id), ) }) setEdges(newEdges) diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts index 1f02ac7c74..85169b4af3 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -1,12 +1,18 @@ import type { MouseEvent } from 'react' import { useCallback } from 'react' import { useWorkflowStore } from '../store' +import { readWorkflowClipboard } from '../utils' export const usePanelInteractions = () => { const workflowStore = useWorkflowStore() const handlePaneContextMenu = useCallback((e: MouseEvent) => { e.preventDefault() + void readWorkflowClipboard().then(({ nodes, edges }) => { + if (nodes.length) + workflowStore.getState().setClipboardData({ nodes, edges }) + }) + const container = document.querySelector('#workflow-container') const { x, y } = container!.getBoundingClientRect() workflowStore.setState({ diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index c917986953..0b218a974f 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -1,6 +1,7 @@ import type { Shape, SliceFromInjection } from '../workflow' import { renderHook } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' +import { createEdge, createNode } from '../../__tests__/fixtures' import { createTestWorkflowStore, renderWorkflowHook } from '../../__tests__/workflow-test-env' import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow' @@ -51,6 +52,7 @@ describe('createWorkflowStore', () => { ['listeningTriggerNodeIds', 'setListeningTriggerNodeIds', ['n1', 'n2']], ['listeningTriggerIsAll', 'setListeningTriggerIsAll', true], ['clipboardElements', 'setClipboardElements', []], + ['clipboardEdges', 'setClipboardEdges', []], ['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }], ['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }], ['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }], @@ -68,6 +70,17 @@ describe('createWorkflowStore', () => { expect(store.getState().controlMode).toBe('pointer') expect(localStorage.setItem).toHaveBeenCalledWith('workflow-operation-mode', 'pointer') }) + + it('should update clipboard nodes and edges with setClipboardData', () => { + const store = createStore() + const nodes = [createNode({ id: 'n-1' })] + const edges = [createEdge({ id: 'e-1', source: 'n-1', target: 'n-2' })] + + store.getState().setClipboardData({ nodes, edges }) + + expect(store.getState().clipboardElements).toEqual(nodes) + expect(store.getState().clipboardEdges).toEqual(edges) + }) }) describe('Node Slice Setters', () => { diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index 5a479d3e44..cb5c3c602c 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -1,5 +1,6 @@ import type { StateCreator } from 'zustand' import type { + Edge, Node, TriggerNodeType, WorkflowRunningData, @@ -27,7 +28,10 @@ export type WorkflowSliceShape = { listeningTriggerIsAll: boolean setListeningTriggerIsAll: (isAll: boolean) => void clipboardElements: Node[] + clipboardEdges: Edge[] setClipboardElements: (clipboardElements: Node[]) => void + setClipboardEdges: (clipboardEdges: Edge[]) => void + setClipboardData: (clipboardData: { nodes: Node[], edges: Edge[] }) => void selection: null | { x1: number, y1: number, x2: number, y2: number } setSelection: (selection: WorkflowSliceShape['selection']) => void bundleNodeSize: { width: number, height: number } | null @@ -60,7 +64,15 @@ export const createWorkflowSlice: StateCreator = set => ({ listeningTriggerIsAll: false, setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })), clipboardElements: [], + clipboardEdges: [], setClipboardElements: clipboardElements => set(() => ({ clipboardElements })), + setClipboardEdges: clipboardEdges => set(() => ({ clipboardEdges })), + setClipboardData: ({ nodes, edges }) => { + set(() => ({ + clipboardElements: nodes, + clipboardEdges: edges, + })) + }, selection: null, setSelection: selection => set(() => ({ selection })), bundleNodeSize: null, diff --git a/web/app/components/workflow/utils/__tests__/clipboard.spec.ts b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts new file mode 100644 index 0000000000..05fe97a74b --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts @@ -0,0 +1,100 @@ +import { createEdge, createNode } from '../../__tests__/fixtures' +import { + parseWorkflowClipboardText, + readWorkflowClipboard, + stringifyWorkflowClipboardData, + writeWorkflowClipboard, +} from '../clipboard' + +describe('workflow clipboard storage', () => { + const readTextMock = vi.fn<() => Promise>() + const writeTextMock = vi.fn<(text: string) => Promise>() + + beforeEach(() => { + readTextMock.mockReset() + writeTextMock.mockReset() + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText: readTextMock, + writeText: writeTextMock, + }, + }) + }) + + it('should return empty clipboard data when clipboard text is empty', async () => { + readTextMock.mockResolvedValue('') + + await expect(readWorkflowClipboard()).resolves.toEqual({ + nodes: [], + edges: [], + isVersionMismatch: false, + }) + }) + + it('should write and read clipboard data', async () => { + const nodes = [createNode({ id: 'node-1' })] + const edges = [createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' })] + + const serialized = stringifyWorkflowClipboardData({ nodes, edges }) + readTextMock.mockResolvedValue(serialized) + + await writeWorkflowClipboard({ nodes, edges }) + expect(writeTextMock).toHaveBeenCalledWith(serialized) + await expect(readWorkflowClipboard()).resolves.toEqual({ + nodes, + edges, + sourceVersion: 1, + isVersionMismatch: false, + }) + }) + + it('should allow reading clipboard data with different version', async () => { + const nodes = [createNode({ id: 'node-1' })] + const edges = [createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' })] + readTextMock.mockResolvedValue(JSON.stringify({ + kind: 'dify-workflow-clipboard', + version: 2, + nodes, + edges, + })) + + await expect(readWorkflowClipboard()).resolves.toEqual({ + nodes, + edges, + sourceVersion: 2, + isVersionMismatch: true, + }) + }) + + it('should return empty clipboard data for invalid JSON', () => { + expect(parseWorkflowClipboardText('{invalid-json')).toEqual({ + nodes: [], + edges: [], + isVersionMismatch: false, + }) + }) + + it('should return empty clipboard data for invalid structure', () => { + expect(parseWorkflowClipboardText(JSON.stringify({ + kind: 'unknown', + version: 1, + nodes: [], + edges: [], + }))).toEqual({ + nodes: [], + edges: [], + isVersionMismatch: false, + }) + }) + + it('should return empty clipboard data when clipboard read fails', async () => { + readTextMock.mockRejectedValue(new Error('clipboard denied')) + + await expect(readWorkflowClipboard()).resolves.toEqual({ + nodes: [], + edges: [], + isVersionMismatch: false, + }) + }) +}) diff --git a/web/app/components/workflow/utils/clipboard.ts b/web/app/components/workflow/utils/clipboard.ts new file mode 100644 index 0000000000..4be52c8b13 --- /dev/null +++ b/web/app/components/workflow/utils/clipboard.ts @@ -0,0 +1,89 @@ +import type { Edge, Node } from '../types' + +const WORKFLOW_CLIPBOARD_VERSION = 1 +const WORKFLOW_CLIPBOARD_KIND = 'dify-workflow-clipboard' + +type WorkflowClipboardPayload = { + kind: string + version: number + nodes: Node[] + edges: Edge[] +} + +export type WorkflowClipboardData = { + nodes: Node[] + edges: Edge[] +} + +export type WorkflowClipboardReadResult = WorkflowClipboardData & { + sourceVersion?: number + isVersionMismatch: boolean +} + +const emptyClipboardData: WorkflowClipboardData = { + nodes: [], + edges: [], +} + +const emptyClipboardReadResult: WorkflowClipboardReadResult = { + ...emptyClipboardData, + isVersionMismatch: false, +} + +const isNodeArray = (value: unknown): value is Node[] => Array.isArray(value) +const isEdgeArray = (value: unknown): value is Edge[] => Array.isArray(value) + +export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadResult => { + if (!text) + return emptyClipboardReadResult + + try { + const parsed = JSON.parse(text) as Partial + if ( + parsed.kind !== WORKFLOW_CLIPBOARD_KIND + || typeof parsed.version !== 'number' + || !isNodeArray(parsed.nodes) + || !isEdgeArray(parsed.edges) + ) { + return emptyClipboardReadResult + } + + const sourceVersion = parsed.version + + return { + nodes: parsed.nodes, + edges: parsed.edges, + sourceVersion, + isVersionMismatch: sourceVersion !== WORKFLOW_CLIPBOARD_VERSION, + } + } + catch { + return emptyClipboardReadResult + } +} + +export const stringifyWorkflowClipboardData = (payload: WorkflowClipboardData): string => { + const data: WorkflowClipboardPayload = { + kind: WORKFLOW_CLIPBOARD_KIND, + version: WORKFLOW_CLIPBOARD_VERSION, + nodes: payload.nodes, + edges: payload.edges, + } + + return JSON.stringify(data) +} + +export const writeWorkflowClipboard = async (payload: WorkflowClipboardData): Promise => { + const text = stringifyWorkflowClipboardData(payload) + await navigator.clipboard.writeText(text) +} + +export const readWorkflowClipboard = async (): Promise => { + try { + const text = await navigator.clipboard.readText() + return parseWorkflowClipboardText(text) + } + catch { + return emptyClipboardReadResult + } +} diff --git a/web/app/components/workflow/utils/index.ts b/web/app/components/workflow/utils/index.ts index 715ce081a3..b84483f655 100644 --- a/web/app/components/workflow/utils/index.ts +++ b/web/app/components/workflow/utils/index.ts @@ -1,3 +1,4 @@ +export * from './clipboard' export * from './common' export * from './data-source' export * from './edge' diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 4d9f5adbac..f7ada4d4c6 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -120,6 +120,7 @@ "common.branch": "BRANCH", "common.chooseDSL": "Choose DSL file", "common.chooseStartNodeToRun": "Choose the start node to run", + "common.clipboardVersionCompatibilityWarning": "This content was copied from a different Dify app version. Some parts may be incompatible.", "common.configure": "Configure", "common.configureRequired": "Configure Required", "common.conversationLog": "Conversation Log", From 16acfd305a8bf1b5b594f2930154a955fdd42968 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 12 Mar 2026 13:08:16 +0800 Subject: [PATCH 2/9] add CompatibilityCheck --- .../workflow/hooks/use-nodes-interactions.ts | 162 +++++++++++++++--- .../components/workflow/utils/clipboard.ts | 110 +++++++++++- 2 files changed, 248 insertions(+), 24 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 09db9508c4..ae218da559 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -48,7 +48,11 @@ import { getNodeCustomTypeByNodeDataType, getNodesConnectedSourceOrTargetHandleIdsMap, getTopLeftNodePosition, + isClipboardEdgeStructurallyValid, + isClipboardNodeStructurallyValid, + isClipboardValueCompatibleWithDefault, readWorkflowClipboard, + sanitizeClipboardValueByDefault, writeWorkflowClipboard, } from '../utils' import { useWorkflowHistoryStore } from '../workflow-history-store' @@ -76,6 +80,46 @@ const ENTRY_NODE_WRAPPER_OFFSET = { y: 21, // Adjusted based on visual testing feedback } as const +const pruneClipboardNodesWithFilteredAncestors = ( + sourceNodes: Node[], + candidateNodes: Node[], +): Node[] => { + const candidateNodeIds = new Set(candidateNodes.map(node => node.id)) + const filteredRootIds = sourceNodes + .filter(node => !candidateNodeIds.has(node.id)) + .map(node => node.id) + + if (!filteredRootIds.length) + return candidateNodes + + const childrenByParent = new Map() + sourceNodes.forEach((node) => { + if (!node.parentId) + return + + const children = childrenByParent.get(node.parentId) ?? [] + children.push(node.id) + childrenByParent.set(node.parentId, children) + }) + + const filteredNodeIds = new Set(filteredRootIds) + const queue = [...filteredRootIds] + + while (queue.length) { + const currentNodeId = queue.shift()! + const children = childrenByParent.get(currentNodeId) ?? [] + children.forEach((childId) => { + if (filteredNodeIds.has(childId)) + return + + filteredNodeIds.add(childId) + queue.push(childId) + }) + } + + return candidateNodes.filter(node => !filteredNodeIds.has(node.id)) +} + export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() @@ -1777,6 +1821,7 @@ export const useNodesInteractions = () => { } = workflowStore.getState() const clipboardData = await readWorkflowClipboard() const hasSystemClipboard = clipboardData.nodes.length > 0 + const shouldRunCompatibilityCheck = hasSystemClipboard && clipboardData.isVersionMismatch if (hasSystemClipboard && clipboardData.isVersionMismatch) { Toast.notify({ type: 'warning', @@ -1796,7 +1841,10 @@ export const useNodesInteractions = () => { if (hasSystemClipboard) setClipboardData(clipboardData) - if (!clipboardElements.length) + const validatedClipboardElements = clipboardElements.filter(isClipboardNodeStructurallyValid) + const validatedClipboardEdges = clipboardEdges.filter(isClipboardEdgeStructurallyValid) + + if (!validatedClipboardElements.length) return const { getNodes, setNodes, edges, setEdges } = store.getState() @@ -1805,22 +1853,41 @@ export const useNodesInteractions = () => { const edgesToPaste: Edge[] = [] const nodes = getNodes() - const supportedClipboardElements = clipboardElements.filter((node) => { + let compatibleClipboardElements = validatedClipboardElements.filter((node) => { if (node.type === CUSTOM_NOTE_NODE) return true - return !!nodesMetaDataMap?.[node.data.type as BlockEnum] + + const nodeDefaultValue = getNodeDefaultValueForPaste(node) + if (!nodeDefaultValue) + return false + + if ( + shouldRunCompatibilityCheck + && !isClipboardValueCompatibleWithDefault(nodeDefaultValue, node.data) + ) { + return false + } + + return true }) - if (!supportedClipboardElements.length) + if (shouldRunCompatibilityCheck) { + compatibleClipboardElements = pruneClipboardNodesWithFilteredAncestors( + validatedClipboardElements, + compatibleClipboardElements, + ) + } + + if (!compatibleClipboardElements.length) return - const clipboardNodeIds = new Set(supportedClipboardElements.map(node => node.id)) - const rootClipboardNodes = supportedClipboardElements.filter( + const clipboardNodeIds = new Set(compatibleClipboardElements.map(node => node.id)) + const rootClipboardNodes = compatibleClipboardElements.filter( node => !node.parentId || !clipboardNodeIds.has(node.parentId), ) const positionReferenceNodes = rootClipboardNodes.length ? rootClipboardNodes - : supportedClipboardElements + : compatibleClipboardElements const { x, y } = getTopLeftNodePosition(positionReferenceNodes) const { screenToFlowPosition } = reactflow const currentPosition = screenToFlowPosition({ @@ -1839,12 +1906,30 @@ export const useNodesInteractions = () => { if (nodeToPaste.type !== CUSTOM_NOTE_NODE && !nodeDefaultValue) return + const mergedData = shouldRunCompatibilityCheck + ? sanitizeClipboardValueByDefault(nodeDefaultValue ?? {}, nodeToPaste.data) as Record + : { + ...(nodeToPaste.type !== CUSTOM_NOTE_NODE ? nodeDefaultValue : {}), + ...nodeToPaste.data, + } + const sourceTitle = typeof mergedData.title === 'string' + ? mergedData.title + : typeof nodeToPaste.data.title === 'string' + ? nodeToPaste.data.title + : 'Node' + const sourceDesc = typeof mergedData.desc === 'string' + ? mergedData.desc + : typeof nodeToPaste.data.desc === 'string' + ? nodeToPaste.data.desc + : '' + const { newNode, newIterationStartNode, newLoopStartNode } = generateNewNode({ type: nodeToPaste.type, data: { - ...(nodeToPaste.type !== CUSTOM_NOTE_NODE ? nodeDefaultValue : {}), - ...nodeToPaste.data, + ...mergedData, + type: nodeToPaste.data.type, + desc: sourceDesc, selected: false, _isBundled: false, _connectedSourceHandleIds: [], @@ -1854,7 +1939,7 @@ export const useNodesInteractions = () => { iteration_id: undefined, isInLoop: false, loop_id: undefined, - title: genNewNodeTitleFromOld(nodeToPaste.data.title), + title: genNewNodeTitleFromOld(sourceTitle), }, position: { x: nodeToPaste.position.x + offsetX, @@ -1873,7 +1958,7 @@ export const useNodesInteractions = () => { iterationNodeData.start_node_id = newIterationStartNode.id } - const oldIterationStartNodeInClipboard = supportedClipboardElements.find( + const oldIterationStartNodeInClipboard = compatibleClipboardElements.find( n => n.parentId === nodeToPaste.id && n.type === CUSTOM_ITERATION_START_NODE, @@ -1881,7 +1966,7 @@ export const useNodesInteractions = () => { if (oldIterationStartNodeInClipboard && newIterationStartNode) idMapping[oldIterationStartNodeInClipboard.id] = newIterationStartNode.id - const copiedIterationChildren = supportedClipboardElements.filter( + const copiedIterationChildren = compatibleClipboardElements.filter( n => n.parentId === nodeToPaste.id && n.type !== CUSTOM_ITERATION_START_NODE, @@ -1893,17 +1978,34 @@ export const useNodesInteractions = () => { if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) return + const mergedChildData = shouldRunCompatibilityCheck + ? sanitizeClipboardValueByDefault(childDefaultValue ?? {}, child.data) as Record + : { + ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), + ...child.data, + } + const childSourceTitle = typeof mergedChildData.title === 'string' + ? mergedChildData.title + : typeof child.data.title === 'string' + ? child.data.title + : 'Node' + const childSourceDesc = typeof mergedChildData.desc === 'string' + ? mergedChildData.desc + : typeof child.data.desc === 'string' + ? child.data.desc + : '' + const { newNode: newChild } = generateNewNode({ type: child.type, data: { - ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), - ...child.data, + ...mergedChildData, + desc: childSourceDesc, selected: false, _isBundled: false, _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], _dimmed: false, - title: genNewNodeTitleFromOld(child.data.title), + title: genNewNodeTitleFromOld(childSourceTitle), isInIteration: true, iteration_id: newNode.id, isInLoop: false, @@ -1956,7 +2058,7 @@ export const useNodesInteractions = () => { loopNodeData.start_node_id = newLoopStartNode.id } - const oldLoopStartNodeInClipboard = supportedClipboardElements.find( + const oldLoopStartNodeInClipboard = compatibleClipboardElements.find( n => n.parentId === nodeToPaste.id && n.type === CUSTOM_LOOP_START_NODE, @@ -1964,7 +2066,7 @@ export const useNodesInteractions = () => { if (oldLoopStartNodeInClipboard && newLoopStartNode) idMapping[oldLoopStartNodeInClipboard.id] = newLoopStartNode.id - const copiedLoopChildren = supportedClipboardElements.filter( + const copiedLoopChildren = compatibleClipboardElements.filter( n => n.parentId === nodeToPaste.id && n.type !== CUSTOM_LOOP_START_NODE, @@ -1976,17 +2078,34 @@ export const useNodesInteractions = () => { if (child.type !== CUSTOM_NOTE_NODE && !childDefaultValue) return + const mergedChildData = shouldRunCompatibilityCheck + ? sanitizeClipboardValueByDefault(childDefaultValue ?? {}, child.data) as Record + : { + ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), + ...child.data, + } + const childSourceTitle = typeof mergedChildData.title === 'string' + ? mergedChildData.title + : typeof child.data.title === 'string' + ? child.data.title + : 'Node' + const childSourceDesc = typeof mergedChildData.desc === 'string' + ? mergedChildData.desc + : typeof child.data.desc === 'string' + ? child.data.desc + : '' + const { newNode: newChild } = generateNewNode({ type: child.type, data: { - ...(child.type !== CUSTOM_NOTE_NODE ? childDefaultValue : {}), - ...child.data, + ...mergedChildData, + desc: childSourceDesc, selected: false, _isBundled: false, _connectedSourceHandleIds: [], _connectedTargetHandleIds: [], _dimmed: false, - title: genNewNodeTitleFromOld(child.data.title), + title: genNewNodeTitleFromOld(childSourceTitle), isInIteration: false, iteration_id: undefined, isInLoop: true, @@ -2075,7 +2194,7 @@ export const useNodesInteractions = () => { } }) - const sourceEdges = clipboardEdges.length ? clipboardEdges : edges + const sourceEdges = validatedClipboardEdges.length ? validatedClipboardEdges : edges sourceEdges.forEach((edge) => { const sourceId = idMapping[edge.source] @@ -2140,7 +2259,6 @@ export const useNodesInteractions = () => { handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy, getNodeDefaultValueForPaste, - nodesMetaDataMap, ]) const handleNodesDuplicate = useCallback( diff --git a/web/app/components/workflow/utils/clipboard.ts b/web/app/components/workflow/utils/clipboard.ts index 4be52c8b13..4445ce6980 100644 --- a/web/app/components/workflow/utils/clipboard.ts +++ b/web/app/components/workflow/utils/clipboard.ts @@ -32,6 +32,109 @@ const emptyClipboardReadResult: WorkflowClipboardReadResult = { const isNodeArray = (value: unknown): value is Node[] => Array.isArray(value) const isEdgeArray = (value: unknown): value is Edge[] => Array.isArray(value) +const isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + +export const sanitizeClipboardValueByDefault = (defaultValue: unknown, incomingValue: unknown): unknown => { + if (defaultValue === undefined) + return incomingValue + + if (Array.isArray(defaultValue)) + return Array.isArray(incomingValue) ? incomingValue : [...defaultValue] + + if (isPlainObject(defaultValue)) { + if (!isPlainObject(incomingValue)) { + return Object.fromEntries( + Object.entries(defaultValue).map(([key, value]) => [ + key, + sanitizeClipboardValueByDefault(value, undefined), + ]), + ) + } + + const merged: Record = {} + const keys = new Set([ + ...Object.keys(defaultValue), + ...Object.keys(incomingValue), + ]) + + keys.forEach((key) => { + const hasDefault = Object.hasOwn(defaultValue, key) + const hasIncoming = Object.hasOwn(incomingValue, key) + if (hasDefault && hasIncoming) { + merged[key] = sanitizeClipboardValueByDefault( + defaultValue[key], + incomingValue[key], + ) + return + } + + if (hasIncoming) { + merged[key] = incomingValue[key] + return + } + + merged[key] = sanitizeClipboardValueByDefault(defaultValue[key], undefined) + }) + + return merged + } + + if (typeof defaultValue === 'number') + return typeof incomingValue === 'number' && Number.isFinite(incomingValue) ? incomingValue : defaultValue + + return typeof incomingValue === typeof defaultValue ? incomingValue : defaultValue +} + +export const isClipboardValueCompatibleWithDefault = (defaultValue: unknown, incomingValue: unknown): boolean => { + if (incomingValue === undefined) + return true + + if (defaultValue === undefined) + return true + + if (Array.isArray(defaultValue)) + return Array.isArray(incomingValue) + + if (isPlainObject(defaultValue)) { + if (!isPlainObject(incomingValue)) + return false + + return Object.entries(defaultValue).every(([key, value]) => { + return isClipboardValueCompatibleWithDefault( + value, + incomingValue[key], + ) + }) + } + + if (typeof defaultValue === 'number') + return typeof incomingValue === 'number' && Number.isFinite(incomingValue) + + return typeof incomingValue === typeof defaultValue +} + +export const isClipboardNodeStructurallyValid = (value: unknown): value is Node => { + if (!isPlainObject(value)) + return false + + if (typeof value.id !== 'string' || typeof value.type !== 'string') + return false + + if (!isPlainObject(value.data) || !isPlainObject(value.position)) + return false + + return Number.isFinite(value.position.x) && Number.isFinite(value.position.y) +} + +export const isClipboardEdgeStructurallyValid = (value: unknown): value is Edge => { + if (!isPlainObject(value)) + return false + + return typeof value.id === 'string' + && typeof value.source === 'string' + && typeof value.target === 'string' +} export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadResult => { if (!text) @@ -50,9 +153,12 @@ export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadR const sourceVersion = parsed.version + const validatedNodes = parsed.nodes.filter(isClipboardNodeStructurallyValid) + const validatedEdges = parsed.edges.filter(isClipboardEdgeStructurallyValid) + return { - nodes: parsed.nodes, - edges: parsed.edges, + nodes: validatedNodes, + edges: validatedEdges, sourceVersion, isVersionMismatch: sourceVersion !== WORKFLOW_CLIPBOARD_VERSION, } From 6b6f1e46b3a1f308efe2b7a6cd446c62ee735758 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 12 Mar 2026 15:08:01 +0800 Subject: [PATCH 3/9] use DSL version instead of custom clipboard version --- api/services/feature_service.py | 4 +++ .../header/__tests__/index.spec.tsx | 1 + .../workflow/hooks/use-nodes-interactions.ts | 9 +++-- .../workflow/hooks/use-panel-interactions.ts | 6 ++-- .../utils/__tests__/clipboard.spec.ts | 23 +++++++------ .../components/workflow/utils/clipboard.ts | 34 ++++++++++++------- web/types/feature.ts | 2 ++ 7 files changed, 51 insertions(+), 28 deletions(-) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index fda3a15144..5659e94768 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -157,6 +157,7 @@ class PluginManagerModel(BaseModel): class SystemFeatureModel(BaseModel): + app_dsl_version: str = "" sso_enforced_for_signin: bool = False sso_enforced_for_signin_protocol: str = "" enable_marketplace: bool = False @@ -223,7 +224,10 @@ class FeatureService: @classmethod def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel: + from services.app_dsl_service import CURRENT_DSL_VERSION + system_features = SystemFeatureModel() + system_features.app_dsl_version = CURRENT_DSL_VERSION cls._fulfill_system_params_from_env(system_features) diff --git a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx index 0ebcc647ac..1c9bd9bf79 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx @@ -60,6 +60,7 @@ describe('EmbeddedChatbot Header', () => { } const defaultSystemFeatures: SystemFeatures = { + app_dsl_version: '', trial_models: [], plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL, diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index ae218da559..4c369fc066 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -23,6 +23,7 @@ import { useStoreApi, } from 'reactflow' import Toast from '@/app/components/base/toast' +import { useGlobalPublicStore } from '@/context/global-public-context' import { CUSTOM_EDGE, ITERATION_CHILDREN_Z_INDEX, @@ -122,6 +123,7 @@ const pruneClipboardNodesWithFilteredAncestors = ( export const useNodesInteractions = () => { const { t } = useTranslation() + const appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version) const store = useStoreApi() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() @@ -1804,9 +1806,9 @@ export const useNodesInteractions = () => { } setClipboardData(clipboardData) - void writeWorkflowClipboard(clipboardData).catch(() => {}) + void writeWorkflowClipboard(clipboardData, appDslVersion).catch(() => {}) }, - [getNodesReadOnly, workflowStore, store, isNodeCopyable], + [getNodesReadOnly, workflowStore, store, isNodeCopyable, appDslVersion], ) const handleNodesPaste = useCallback(async () => { @@ -1819,7 +1821,7 @@ export const useNodesInteractions = () => { mousePosition, setClipboardData, } = workflowStore.getState() - const clipboardData = await readWorkflowClipboard() + const clipboardData = await readWorkflowClipboard(appDslVersion) const hasSystemClipboard = clipboardData.nodes.length > 0 const shouldRunCompatibilityCheck = hasSystemClipboard && clipboardData.isVersionMismatch if (hasSystemClipboard && clipboardData.isVersionMismatch) { @@ -2259,6 +2261,7 @@ export const useNodesInteractions = () => { handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy, getNodeDefaultValueForPaste, + appDslVersion, ]) const handleNodesDuplicate = useCallback( diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts index 85169b4af3..754b0005a7 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -1,14 +1,16 @@ import type { MouseEvent } from 'react' import { useCallback } from 'react' +import { useGlobalPublicStore } from '@/context/global-public-context' import { useWorkflowStore } from '../store' import { readWorkflowClipboard } from '../utils' export const usePanelInteractions = () => { const workflowStore = useWorkflowStore() + const appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version) const handlePaneContextMenu = useCallback((e: MouseEvent) => { e.preventDefault() - void readWorkflowClipboard().then(({ nodes, edges }) => { + void readWorkflowClipboard(appDslVersion).then(({ nodes, edges }) => { if (nodes.length) workflowStore.getState().setClipboardData({ nodes, edges }) }) @@ -21,7 +23,7 @@ export const usePanelInteractions = () => { left: e.clientX - x, }, }) - }, [workflowStore]) + }, [workflowStore, appDslVersion]) const handlePaneContextmenuCancel = useCallback(() => { workflowStore.setState({ diff --git a/web/app/components/workflow/utils/__tests__/clipboard.spec.ts b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts index 05fe97a74b..ccb3f426d4 100644 --- a/web/app/components/workflow/utils/__tests__/clipboard.spec.ts +++ b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts @@ -7,6 +7,7 @@ import { } from '../clipboard' describe('workflow clipboard storage', () => { + const currentVersion = '0.6.0' const readTextMock = vi.fn<() => Promise>() const writeTextMock = vi.fn<(text: string) => Promise>() @@ -25,7 +26,7 @@ describe('workflow clipboard storage', () => { it('should return empty clipboard data when clipboard text is empty', async () => { readTextMock.mockResolvedValue('') - await expect(readWorkflowClipboard()).resolves.toEqual({ + await expect(readWorkflowClipboard(currentVersion)).resolves.toEqual({ nodes: [], edges: [], isVersionMismatch: false, @@ -36,15 +37,15 @@ describe('workflow clipboard storage', () => { const nodes = [createNode({ id: 'node-1' })] const edges = [createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' })] - const serialized = stringifyWorkflowClipboardData({ nodes, edges }) + const serialized = stringifyWorkflowClipboardData({ nodes, edges }, currentVersion) readTextMock.mockResolvedValue(serialized) - await writeWorkflowClipboard({ nodes, edges }) + await writeWorkflowClipboard({ nodes, edges }, currentVersion) expect(writeTextMock).toHaveBeenCalledWith(serialized) - await expect(readWorkflowClipboard()).resolves.toEqual({ + await expect(readWorkflowClipboard(currentVersion)).resolves.toEqual({ nodes, edges, - sourceVersion: 1, + sourceVersion: currentVersion, isVersionMismatch: false, }) }) @@ -54,21 +55,21 @@ describe('workflow clipboard storage', () => { const edges = [createEdge({ id: 'edge-1', source: 'node-1', target: 'node-2' })] readTextMock.mockResolvedValue(JSON.stringify({ kind: 'dify-workflow-clipboard', - version: 2, + version: '0.5.0', nodes, edges, })) - await expect(readWorkflowClipboard()).resolves.toEqual({ + await expect(readWorkflowClipboard(currentVersion)).resolves.toEqual({ nodes, edges, - sourceVersion: 2, + sourceVersion: '0.5.0', isVersionMismatch: true, }) }) it('should return empty clipboard data for invalid JSON', () => { - expect(parseWorkflowClipboardText('{invalid-json')).toEqual({ + expect(parseWorkflowClipboardText('{invalid-json', currentVersion)).toEqual({ nodes: [], edges: [], isVersionMismatch: false, @@ -81,7 +82,7 @@ describe('workflow clipboard storage', () => { version: 1, nodes: [], edges: [], - }))).toEqual({ + }), currentVersion)).toEqual({ nodes: [], edges: [], isVersionMismatch: false, @@ -91,7 +92,7 @@ describe('workflow clipboard storage', () => { it('should return empty clipboard data when clipboard read fails', async () => { readTextMock.mockRejectedValue(new Error('clipboard denied')) - await expect(readWorkflowClipboard()).resolves.toEqual({ + await expect(readWorkflowClipboard(currentVersion)).resolves.toEqual({ nodes: [], edges: [], isVersionMismatch: false, diff --git a/web/app/components/workflow/utils/clipboard.ts b/web/app/components/workflow/utils/clipboard.ts index 4445ce6980..db3e92c54a 100644 --- a/web/app/components/workflow/utils/clipboard.ts +++ b/web/app/components/workflow/utils/clipboard.ts @@ -1,11 +1,10 @@ import type { Edge, Node } from '../types' -const WORKFLOW_CLIPBOARD_VERSION = 1 const WORKFLOW_CLIPBOARD_KIND = 'dify-workflow-clipboard' type WorkflowClipboardPayload = { kind: string - version: number + version: string nodes: Node[] edges: Edge[] } @@ -16,7 +15,7 @@ export type WorkflowClipboardData = { } export type WorkflowClipboardReadResult = WorkflowClipboardData & { - sourceVersion?: number + sourceVersion?: string isVersionMismatch: boolean } @@ -136,7 +135,10 @@ export const isClipboardEdgeStructurallyValid = (value: unknown): value is Edge && typeof value.target === 'string' } -export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadResult => { +export const parseWorkflowClipboardText = ( + text: string, + currentClipboardVersion: string, +): WorkflowClipboardReadResult => { if (!text) return emptyClipboardReadResult @@ -144,7 +146,7 @@ export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadR const parsed = JSON.parse(text) as Partial if ( parsed.kind !== WORKFLOW_CLIPBOARD_KIND - || typeof parsed.version !== 'number' + || typeof parsed.version !== 'string' || !isNodeArray(parsed.nodes) || !isEdgeArray(parsed.edges) ) { @@ -160,7 +162,7 @@ export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadR nodes: validatedNodes, edges: validatedEdges, sourceVersion, - isVersionMismatch: sourceVersion !== WORKFLOW_CLIPBOARD_VERSION, + isVersionMismatch: sourceVersion !== currentClipboardVersion, } } catch { @@ -168,10 +170,13 @@ export const parseWorkflowClipboardText = (text: string): WorkflowClipboardReadR } } -export const stringifyWorkflowClipboardData = (payload: WorkflowClipboardData): string => { +export const stringifyWorkflowClipboardData = ( + payload: WorkflowClipboardData, + currentClipboardVersion: string, +): string => { const data: WorkflowClipboardPayload = { kind: WORKFLOW_CLIPBOARD_KIND, - version: WORKFLOW_CLIPBOARD_VERSION, + version: currentClipboardVersion, nodes: payload.nodes, edges: payload.edges, } @@ -179,15 +184,20 @@ export const stringifyWorkflowClipboardData = (payload: WorkflowClipboardData): return JSON.stringify(data) } -export const writeWorkflowClipboard = async (payload: WorkflowClipboardData): Promise => { - const text = stringifyWorkflowClipboardData(payload) +export const writeWorkflowClipboard = async ( + payload: WorkflowClipboardData, + currentClipboardVersion: string, +): Promise => { + const text = stringifyWorkflowClipboardData(payload, currentClipboardVersion) await navigator.clipboard.writeText(text) } -export const readWorkflowClipboard = async (): Promise => { +export const readWorkflowClipboard = async ( + currentClipboardVersion: string, +): Promise => { try { const text = await navigator.clipboard.readText() - return parseWorkflowClipboardText(text) + return parseWorkflowClipboardText(text, currentClipboardVersion) } catch { return emptyClipboardReadResult diff --git a/web/types/feature.ts b/web/types/feature.ts index a5c12a453e..362ef93fab 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -28,6 +28,7 @@ type License = { } export type SystemFeatures = { + app_dsl_version: string trial_models: ModelProviderQuotaGetPaid[] plugin_installation_permission: { plugin_installation_scope: InstallationScope @@ -67,6 +68,7 @@ export type SystemFeatures = { } export const defaultSystemFeatures: SystemFeatures = { + app_dsl_version: '', trial_models: [], plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL, From db0c3b6dabd546a509afffdcd43b4b8f416651bc Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 12 Mar 2026 15:13:32 +0800 Subject: [PATCH 4/9] fix i18n --- web/i18n/ar-TN/workflow.json | 1 + web/i18n/de-DE/workflow.json | 1 + web/i18n/es-ES/workflow.json | 1 + web/i18n/fa-IR/workflow.json | 1 + web/i18n/fr-FR/workflow.json | 1 + web/i18n/hi-IN/workflow.json | 1 + web/i18n/id-ID/workflow.json | 1 + web/i18n/it-IT/workflow.json | 1 + web/i18n/ja-JP/workflow.json | 1 + web/i18n/ko-KR/workflow.json | 1 + web/i18n/nl-NL/workflow.json | 1 + web/i18n/pl-PL/workflow.json | 1 + web/i18n/pt-BR/workflow.json | 1 + web/i18n/ro-RO/workflow.json | 1 + web/i18n/ru-RU/workflow.json | 1 + web/i18n/sl-SI/workflow.json | 1 + web/i18n/th-TH/workflow.json | 1 + web/i18n/tr-TR/workflow.json | 1 + web/i18n/uk-UA/workflow.json | 1 + web/i18n/vi-VN/workflow.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 22 files changed, 22 insertions(+) diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index ab221c869c..181eca8b95 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -120,6 +120,7 @@ "common.branch": "فرع", "common.chooseDSL": "اختر ملف DSL", "common.chooseStartNodeToRun": "اختر عقدة البداية للتشغيل", + "common.clipboardVersionCompatibilityWarning": "تم نسخ هذا المحتوى من إصدار مختلف من تطبيق Dify. قد تكون بعض الأجزاء غير متوافقة.", "common.configure": "تكوين", "common.configureRequired": "التكوين مطلوب", "common.conversationLog": "سجل المحادثة", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index 5f53c485b2..169d107e1b 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -120,6 +120,7 @@ "common.branch": "ZWEIG", "common.chooseDSL": "Wählen Sie eine DSL(yml)-Datei", "common.chooseStartNodeToRun": "Wählen Sie den Startknoten zum Ausführen", + "common.clipboardVersionCompatibilityWarning": "Dieser Inhalt wurde aus einer anderen Dify-App-Version kopiert. Einige Teile sind möglicherweise nicht kompatibel.", "common.configure": "Konfigurieren", "common.configureRequired": "Konfiguration erforderlich", "common.conversationLog": "Konversationsprotokoll", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index 6eef1da198..a73c76df86 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -120,6 +120,7 @@ "common.branch": "RAMA", "common.chooseDSL": "Elegir archivo DSL (yml)", "common.chooseStartNodeToRun": "Elige el nodo de inicio para ejecutar", + "common.clipboardVersionCompatibilityWarning": "Este contenido se copió desde una versión diferente de la aplicación Dify. Es posible que algunas partes no sean compatibles.", "common.configure": "Configurar", "common.configureRequired": "Configuración requerida", "common.conversationLog": "Registro de conversación", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index f2ac339ece..aad77ccef8 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -120,6 +120,7 @@ "common.branch": "شاخه", "common.chooseDSL": "انتخاب فایل DSL (yml)", "common.chooseStartNodeToRun": "گره شروع را برای اجرا انتخاب کنید", + "common.clipboardVersionCompatibilityWarning": "این محتوا از نسخه دیگری از برنامه Dify کپی شده است. ممکن است برخی بخش‌ها ناسازگار باشند.", "common.configure": "پیکربندی", "common.configureRequired": "پیکربندی الزامی است", "common.conversationLog": "لاگ مکالمات", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index 631dc5d05b..5b500b84c4 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -120,6 +120,7 @@ "common.branch": "BRANCHE", "common.chooseDSL": "Choisir le fichier DSL(yml)", "common.chooseStartNodeToRun": "Choisissez le nœud de départ pour lancer", + "common.clipboardVersionCompatibilityWarning": "Ce contenu a été copié depuis une version différente de l'application Dify. Certaines parties peuvent être incompatibles.", "common.configure": "Configurer", "common.configureRequired": "Configuration requise", "common.conversationLog": "Journal de conversation", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index 944b6506bc..9dd1ac0197 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -120,6 +120,7 @@ "common.branch": "शाखा", "common.chooseDSL": "डीएसएल (वाईएमएल) फ़ाइल चुनें", "common.chooseStartNodeToRun": "चलाने के लिए प्रारंभ नोड चुनें", + "common.clipboardVersionCompatibilityWarning": "यह सामग्री Dify ऐप के किसी अलग संस्करण से कॉपी की गई है। इसके कुछ हिस्से असंगत हो सकते हैं।", "common.configure": "कॉन्फ़िगर करें", "common.configureRequired": "कॉन्फ़िगरेशन आवश्यक", "common.conversationLog": "वार्तालाप लॉग", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 8bd900e163..4182dcdc4e 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -120,6 +120,7 @@ "common.branch": "CABANG", "common.chooseDSL": "Pilih file DSL", "common.chooseStartNodeToRun": "Pilih node awal untuk dijalankan", + "common.clipboardVersionCompatibilityWarning": "Konten ini disalin dari versi aplikasi Dify yang berbeda. Beberapa bagian mungkin tidak kompatibel.", "common.configure": "Konfigurasikan", "common.configureRequired": "Konfigurasi yang Diperlukan", "common.conversationLog": "Log Percakapan", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index cb1fcab53b..799ecaf1fa 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -120,6 +120,7 @@ "common.branch": "RAMO", "common.chooseDSL": "Scegli file DSL(yml)", "common.chooseStartNodeToRun": "Scegli il nodo di partenza da eseguire", + "common.clipboardVersionCompatibilityWarning": "Questo contenuto è stato copiato da una versione diversa dell'app Dify. Alcune parti potrebbero non essere compatibili.", "common.configure": "Configura", "common.configureRequired": "Configurazione Richiesta", "common.conversationLog": "Registro conversazioni", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 6dd914e9bd..8c28f89c82 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -120,6 +120,7 @@ "common.branch": "ブランチ", "common.chooseDSL": "DSL(yml) ファイルを選択", "common.chooseStartNodeToRun": "実行する開始ノードを選択", + "common.clipboardVersionCompatibilityWarning": "このコンテンツは別の Dify アプリバージョンからコピーされました。一部が互換性のない可能性があります。", "common.configure": "設定", "common.configureRequired": "設定が必要", "common.conversationLog": "会話ログ", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index 106869f00b..4f3c47992d 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -120,6 +120,7 @@ "common.branch": "브랜치", "common.chooseDSL": "DSL(yml) 파일 선택", "common.chooseStartNodeToRun": "실행할 시작 노드를 선택하세요", + "common.clipboardVersionCompatibilityWarning": "이 콘텐츠는 다른 Dify 앱 버전에서 복사되었습니다. 일부 항목은 호환되지 않을 수 있습니다.", "common.configure": "구성", "common.configureRequired": "구성 필요", "common.conversationLog": "대화 로그", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index 4d9f5adbac..2cc560fb52 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -120,6 +120,7 @@ "common.branch": "BRANCH", "common.chooseDSL": "Choose DSL file", "common.chooseStartNodeToRun": "Choose the start node to run", + "common.clipboardVersionCompatibilityWarning": "Deze inhoud is gekopieerd vanuit een andere Dify-appversie. Sommige onderdelen zijn mogelijk niet compatibel.", "common.configure": "Configure", "common.configureRequired": "Configure Required", "common.conversationLog": "Conversation Log", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 091b1f6ca2..c1baf0400a 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -120,6 +120,7 @@ "common.branch": "GAŁĄŹ", "common.chooseDSL": "Wybierz plik DSL(yml)", "common.chooseStartNodeToRun": "Wybierz węzeł początkowy, aby uruchomić", + "common.clipboardVersionCompatibilityWarning": "Ta zawartość została skopiowana z innej wersji aplikacji Dify. Niektóre elementy mogą być niekompatybilne.", "common.configure": "Skonfiguruj", "common.configureRequired": "Wymagana konfiguracja", "common.conversationLog": "Dziennik rozmów", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index 4ddf62d523..e22d9026d0 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -120,6 +120,7 @@ "common.branch": "RAMIFICAÇÃO", "common.chooseDSL": "Escolha o arquivo DSL(yml)", "common.chooseStartNodeToRun": "Escolha o nó inicial para executar", + "common.clipboardVersionCompatibilityWarning": "Este conteúdo foi copiado de uma versão diferente do aplicativo Dify. Algumas partes podem ser incompatíveis.", "common.configure": "Configurar", "common.configureRequired": "Configuração necessária", "common.conversationLog": "Registro de conversa", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index 507895f9ce..b105f4dea7 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -120,6 +120,7 @@ "common.branch": "RAMURĂ", "common.chooseDSL": "Alegeți fișierul DSL(yml)", "common.chooseStartNodeToRun": "Alegeți nodul de start pentru a rula", + "common.clipboardVersionCompatibilityWarning": "Acest conținut a fost copiat dintr-o altă versiune a aplicației Dify. Unele părți pot fi incompatibile.", "common.configure": "Configurează", "common.configureRequired": "Configurare necesară", "common.conversationLog": "Jurnal conversație", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index a00be27f7b..6e64d0e0d0 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -120,6 +120,7 @@ "common.branch": "ВЕТКА", "common.chooseDSL": "Выберите файл DSL(yml)", "common.chooseStartNodeToRun": "Выберите начальный узел для запуска", + "common.clipboardVersionCompatibilityWarning": "Этот контент был скопирован из другой версии приложения Dify. Некоторые части могут быть несовместимы.", "common.configure": "Настроить", "common.configureRequired": "Требуется настройка", "common.conversationLog": "Журнал разговоров", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index 381d7f866c..64a30fead7 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -120,6 +120,7 @@ "common.branch": "VEJA", "common.chooseDSL": "Izberi DSL datoteko", "common.chooseStartNodeToRun": "Izberite začetno vozlišče za zagon", + "common.clipboardVersionCompatibilityWarning": "Ta vsebina je bila kopirana iz druge različice aplikacije Dify. Nekateri deli morda niso združljivi.", "common.configure": "Konfiguriraj", "common.configureRequired": "Konfigurirajte zahteve", "common.conversationLog": "Pogovor Log", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index d1c767061c..06ea8949fe 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -120,6 +120,7 @@ "common.branch": "กิ่ง", "common.chooseDSL": "เลือกไฟล์ DSL", "common.chooseStartNodeToRun": "เลือกโหนดเริ่มต้นเพื่อรัน", + "common.clipboardVersionCompatibilityWarning": "เนื้อหานี้ถูกคัดลอกจากแอป Dify คนละเวอร์ชัน บางส่วนอาจไม่เข้ากัน", "common.configure": "กําหนดค่า", "common.configureRequired": "กําหนดค่าที่จําเป็น", "common.conversationLog": "บันทึกการสนทนา", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index 51a957518d..ccf657f03c 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -120,6 +120,7 @@ "common.branch": "DAL", "common.chooseDSL": "DSL(yml) dosyasını seçin", "common.chooseStartNodeToRun": "Çalıştırmak için başlangıç düğümünü seçin", + "common.clipboardVersionCompatibilityWarning": "Bu içerik farklı bir Dify uygulama sürümünden kopyalandı. Bazı bölümler uyumsuz olabilir.", "common.configure": "Yapılandır", "common.configureRequired": "Yapılandırma Gerekli", "common.conversationLog": "Konuşma Günlüğü", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 9263a97ece..f967a4d0e7 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -120,6 +120,7 @@ "common.branch": "ГІЛКА", "common.chooseDSL": "Виберіть файл DSL(yml)", "common.chooseStartNodeToRun": "Виберіть початковий вузол для запуску", + "common.clipboardVersionCompatibilityWarning": "Цей вміст скопійовано з іншої версії застосунку Dify. Деякі частини можуть бути несумісними.", "common.configure": "Налаштувати", "common.configureRequired": "Потрібна конфігурація", "common.conversationLog": "Журнал розмов", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 3186805548..27b5ea0a41 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -120,6 +120,7 @@ "common.branch": "NHÁNH", "common.chooseDSL": "Chọn tệp DSL(yml)", "common.chooseStartNodeToRun": "Chọn nút bắt đầu để chạy", + "common.clipboardVersionCompatibilityWarning": "Nội dung này được sao chép từ một phiên bản ứng dụng Dify khác. Một số phần có thể không tương thích.", "common.configure": "Cấu hình", "common.configureRequired": "Yêu cầu cấu hình", "common.conversationLog": "Nhật ký cuộc trò chuyện", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index acda7db2fc..7ca90ead42 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -120,6 +120,7 @@ "common.branch": "分支", "common.chooseDSL": "选择 DSL(yml) 文件", "common.chooseStartNodeToRun": "选择启动节点进行运行", + "common.clipboardVersionCompatibilityWarning": "此内容复制自不同版本的 Dify 应用,部分内容可能不兼容。", "common.configure": "配置", "common.configureRequired": "需要进行配置", "common.conversationLog": "对话记录", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 9d7e06d484..5ae2d9e38d 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -120,6 +120,7 @@ "common.branch": "分支", "common.chooseDSL": "選擇 DSL(yml)檔", "common.chooseStartNodeToRun": "選擇要執行的起始節點", + "common.clipboardVersionCompatibilityWarning": "此內容複製自不同版本的 Dify 應用,部分內容可能不相容。", "common.configure": "配置", "common.configureRequired": "需要進行配置", "common.conversationLog": "對話記錄", From 0b71dd013d37e5b97bfe9cccee5b6e61f28c0e00 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:18:10 +0000 Subject: [PATCH 5/9] [autofix.ci] apply automated fixes --- .../app/apps/advanced_chat/test_app_generator.py | 15 +++++++++------ .../test_generate_task_pipeline_core.py | 8 ++++---- .../app/apps/workflow/test_app_generator_extra.py | 5 +++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index 8faae3661d..e2618d960c 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -150,8 +150,9 @@ class TestAdvancedChatAppGeneratorInternals: "_DummyTraceQueueManager", (TraceQueueManager,), { - "__init__": lambda self, app_id=None, user_id=None: setattr(self, "app_id", app_id) - or setattr(self, "user_id", user_id) + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) }, ) monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.TraceQueueManager", DummyTraceQueueManager) @@ -1124,8 +1125,9 @@ class TestAdvancedChatAppGeneratorInternals: "_DummyTraceQueueManager", (TraceQueueManager,), { - "__init__": lambda self, app_id=None, user_id=None: setattr(self, "app_id", app_id) - or setattr(self, "user_id", user_id) + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) }, ) monkeypatch.setattr( @@ -1202,8 +1204,9 @@ class TestAdvancedChatAppGeneratorInternals: "_DummyTraceQueueManager", (TraceQueueManager,), { - "__init__": lambda self, app_id=None, user_id=None: setattr(self, "app_id", app_id) - or setattr(self, "user_id", user_id) + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) }, ) monkeypatch.setattr( diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index b348ffc33b..67f87710a1 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -240,12 +240,12 @@ class TestAdvancedChatGenerateTaskPipeline: def test_iteration_and_loop_handlers(self): pipeline = _make_pipeline() pipeline._workflow_run_id = "run-id" - pipeline._workflow_response_converter.workflow_iteration_start_to_stream_response = ( - lambda **kwargs: "iter_start" + pipeline._workflow_response_converter.workflow_iteration_start_to_stream_response = lambda **kwargs: ( + "iter_start" ) pipeline._workflow_response_converter.workflow_iteration_next_to_stream_response = lambda **kwargs: "iter_next" - pipeline._workflow_response_converter.workflow_iteration_completed_to_stream_response = ( - lambda **kwargs: "iter_done" + pipeline._workflow_response_converter.workflow_iteration_completed_to_stream_response = lambda **kwargs: ( + "iter_done" ) pipeline._workflow_response_converter.workflow_loop_start_to_stream_response = lambda **kwargs: "loop_start" pipeline._workflow_response_converter.workflow_loop_next_to_stream_response = lambda **kwargs: "loop_next" diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py index 6d6f9272cb..09ad078a70 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py @@ -144,8 +144,9 @@ class TestWorkflowAppGeneratorGenerate: "_DummyTraceQueueManager", (TraceQueueManager,), { - "__init__": lambda self, app_id=None, user_id=None: setattr(self, "app_id", app_id) - or setattr(self, "user_id", user_id) + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) }, ) monkeypatch.setattr( From 8c4461184a9bba9e56c5d7529185044fc004cb11 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 12 Mar 2026 16:01:08 +0800 Subject: [PATCH 6/9] fix CI --- .../workflow/hooks/__tests__/use-panel-interactions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index 23f1abde95..16677ce3f3 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -76,7 +76,7 @@ describe('usePanelInteractions', () => { }) readTextMock.mockResolvedValue(JSON.stringify({ kind: 'dify-workflow-clipboard', - version: 1, + version: '0.6.0', nodes: [clipboardNode], edges: [clipboardEdge], })) From 82f55214637022b32b1dae55ce933c77842171c0 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 12 Mar 2026 16:29:10 +0800 Subject: [PATCH 7/9] fix circle import CI --- api/constants/dsl_version.py | 1 + api/services/app_dsl_service.py | 3 ++- api/services/feature_service.py | 5 ++--- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 api/constants/dsl_version.py diff --git a/api/constants/dsl_version.py b/api/constants/dsl_version.py new file mode 100644 index 0000000000..b0fbe0075c --- /dev/null +++ b/api/constants/dsl_version.py @@ -0,0 +1 @@ +CURRENT_APP_DSL_VERSION = "0.6.0" diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 06f4ccb90e..1f9208d028 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -18,6 +18,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config +from constants.dsl_version import CURRENT_APP_DSL_VERSION from core.helper import ssrf_proxy from core.plugin.entities.plugin import PluginDependency from dify_graph.enums import NodeType @@ -45,7 +46,7 @@ IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:" CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:" IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB -CURRENT_DSL_VERSION = "0.6.0" +CURRENT_DSL_VERSION = CURRENT_APP_DSL_VERSION class ImportMode(StrEnum): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 5659e94768..cdd67a0c59 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -3,6 +3,7 @@ from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field from configs import dify_config +from constants.dsl_version import CURRENT_APP_DSL_VERSION from enums.cloud_plan import CloudPlan from enums.hosted_provider import HostedTrialProvider from services.billing_service import BillingService @@ -224,10 +225,8 @@ class FeatureService: @classmethod def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel: - from services.app_dsl_service import CURRENT_DSL_VERSION - system_features = SystemFeatureModel() - system_features.app_dsl_version = CURRENT_DSL_VERSION + system_features.app_dsl_version = CURRENT_APP_DSL_VERSION cls._fulfill_system_params_from_env(system_features) From 09bbe042c05ba84f82b9a7d720df290ec7091495 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Fri, 13 Mar 2026 16:15:56 +0800 Subject: [PATCH 8/9] fix notification --- .../workflow/hooks/use-nodes-interactions.ts | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 4c369fc066..4b63b6b179 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1824,14 +1824,6 @@ export const useNodesInteractions = () => { const clipboardData = await readWorkflowClipboard(appDslVersion) const hasSystemClipboard = clipboardData.nodes.length > 0 const shouldRunCompatibilityCheck = hasSystemClipboard && clipboardData.isVersionMismatch - if (hasSystemClipboard && clipboardData.isVersionMismatch) { - Toast.notify({ - type: 'warning', - message: t('common.clipboardVersionCompatibilityWarning', { - ns: 'workflow', - }), - }) - } const clipboardElements = hasSystemClipboard ? clipboardData.nodes @@ -1880,12 +1872,36 @@ export const useNodesInteractions = () => { ) } + const compatibleClipboardNodeIds = new Set( + compatibleClipboardElements.map(node => node.id), + ) + const filteredNodeCount = shouldRunCompatibilityCheck + ? validatedClipboardElements.length - compatibleClipboardElements.length + : 0 + const filteredEdgeCount = shouldRunCompatibilityCheck + ? validatedClipboardEdges.filter(edge => + !compatibleClipboardNodeIds.has(edge.source) + || !compatibleClipboardNodeIds.has(edge.target), + ).length + : 0 + + if ( + shouldRunCompatibilityCheck + && (filteredNodeCount > 0 || filteredEdgeCount > 0) + ) { + Toast.notify({ + type: 'warning', + message: t('common.clipboardVersionCompatibilityWarning', { + ns: 'workflow', + }), + }) + } + if (!compatibleClipboardElements.length) return - const clipboardNodeIds = new Set(compatibleClipboardElements.map(node => node.id)) const rootClipboardNodes = compatibleClipboardElements.filter( - node => !node.parentId || !clipboardNodeIds.has(node.parentId), + node => !node.parentId || !compatibleClipboardNodeIds.has(node.parentId), ) const positionReferenceNodes = rootClipboardNodes.length ? rootClipboardNodes From 2e03fd5ade3b177fd6014ca7c50772487ba67fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 13 Mar 2026 16:33:30 +0800 Subject: [PATCH 9/9] Update web/app/components/workflow/hooks/use-nodes-interactions.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- web/app/components/workflow/hooks/use-nodes-interactions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 1347fb37ef..e6b6d87c96 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -2215,7 +2215,7 @@ export const useNodesInteractions = () => { } }) - const sourceEdges = validatedClipboardEdges.length ? validatedClipboardEdges : edges + const sourceEdges = validatedClipboardEdges sourceEdges.forEach((edge) => { const sourceId = idMapping[edge.source]