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 78806927bc..97aaea3395 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -17,6 +17,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 core.trigger.constants import ( @@ -50,7 +51,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 Import(BaseModel): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index d0d3fbd66b..e18eb096c9 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 @@ -157,6 +158,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 @@ -225,6 +227,7 @@ class FeatureService: @classmethod def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel: system_features = SystemFeatureModel() + system_features.app_dsl_version = CURRENT_APP_DSL_VERSION cls._fulfill_system_params_from_env(system_features) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 477391e2de..fbeb1d6dcf 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -6206,7 +6206,7 @@ }, "web/app/components/workflow/utils/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 9 + "count": 10 } }, "web/app/components/workflow/utils/node-navigation.ts": { 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 51aa8a9b0c..50ba15ae61 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 @@ -48,6 +48,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/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index 4117c85abd..41d1fb39d9 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -11,6 +11,14 @@ const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) const mockUndo = vi.hoisted(() => vi.fn()) const mockRedo = vi.hoisted(() => vi.fn()) +const mockHandleNodeIterationChildrenCopy = vi.hoisted(() => vi.fn(() => ({ + copyChildren: [], + newIdMapping: {}, +}))) +const mockHandleNodeLoopChildrenCopy = vi.hoisted(() => vi.fn(() => ({ + copyChildren: [], + newIdMapping: {}, +}))) const runtimeNodesMetaDataMap = vi.hoisted(() => ({ value: {} as Record, })) @@ -72,14 +80,14 @@ vi.mock('../use-inspect-vars-crud', () => ({ vi.mock('../../nodes/iteration/use-interactions', () => ({ useNodeIterationInteractions: () => ({ handleNodeIterationChildDrag: () => ({ restrictPosition: {} }), - handleNodeIterationChildrenCopy: vi.fn(), + handleNodeIterationChildrenCopy: mockHandleNodeIterationChildrenCopy, }), })) vi.mock('../../nodes/loop/use-interactions', () => ({ useNodeLoopInteractions: () => ({ handleNodeLoopChildDrag: () => ({ restrictPosition: {} }), - handleNodeLoopChildrenCopy: vi.fn(), + handleNodeLoopChildrenCopy: mockHandleNodeLoopChildrenCopy, }), })) @@ -673,4 +681,316 @@ describe('useNodesInteractions', () => { expect(rfState.setNodes).toHaveBeenCalled() }) + + // Paste title handling should preserve original names until the destination canvas conflicts. + describe('paste title handling', () => { + beforeEach(() => { + runtimeNodesMetaDataMap.value = { + [BlockEnum.Code]: { + defaultValue: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + }, + metaData: { + isSingleton: false, + }, + }, + } + }) + + it('preserves the original title when the destination canvas has no conflict', async () => { + currentNodes = [ + createNode({ + id: 'existing-node', + data: { + type: BlockEnum.Code, + title: 'Existing', + desc: '', + }, + }), + ] + currentEdges = [] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + store.setState({ + clipboardElements: [ + createNode({ + id: 'clipboard-node', + data: { + type: BlockEnum.Code, + title: 'Clipboard', + desc: '', + }, + }), + ] as never, + clipboardEdges: [] as never, + mousePosition: { + pageX: 60, + pageY: 80, + } as never, + }) + + await act(async () => { + await result.current.handleNodesPaste() + }) + + const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] + const newNode = pastedNodes.find(node => node.id !== 'existing-node') + + expect(newNode?.data.title).toBe('Clipboard') + }) + + it('renames pasted nodes only when the destination canvas already uses the title', async () => { + currentNodes = [ + createNode({ + id: 'existing-node', + data: { + type: BlockEnum.Code, + title: 'Clipboard', + desc: '', + }, + }), + createNode({ + id: 'existing-node-2', + data: { + type: BlockEnum.Code, + title: 'Clipboard (1)', + desc: '', + }, + }), + ] + currentEdges = [] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + store.setState({ + clipboardElements: [ + createNode({ + id: 'clipboard-node', + data: { + type: BlockEnum.Code, + title: 'Clipboard', + desc: '', + }, + }), + ] as never, + clipboardEdges: [] as never, + mousePosition: { + pageX: 60, + pageY: 80, + } as never, + }) + + await act(async () => { + await result.current.handleNodesPaste() + }) + + const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] + const newNode = pastedNodes.find(node => !currentNodes.some(existingNode => existingNode.id === node.id)) + + expect(newNode?.data.title).toBe('Clipboard (2)') + }) + }) + + // A copied container can still be selected on the source canvas during same-canvas paste. + describe('container paste target detection', () => { + beforeEach(() => { + runtimeNodesMetaDataMap.value = { + [BlockEnum.Iteration]: { + defaultValue: { + type: BlockEnum.Iteration, + title: 'Iteration', + desc: '', + _children: [], + }, + metaData: { + isSingleton: false, + }, + }, + [BlockEnum.Loop]: { + defaultValue: { + type: BlockEnum.Loop, + title: 'Loop', + desc: '', + _children: [], + }, + metaData: { + isSingleton: false, + }, + }, + } + }) + + it.each([ + [BlockEnum.Iteration, 'iteration-source'], + [BlockEnum.Loop, 'loop-source'], + ])('pastes a copied %s as a top-level node when the source container remains selected', async (containerType, nodeId) => { + currentNodes = [ + createNode({ + id: nodeId, + position: { x: 20, y: 20 }, + selected: true, + data: { + type: containerType, + title: containerType === BlockEnum.Iteration ? 'Iteration' : 'Loop', + desc: '', + _children: [], + }, + }), + ] + currentEdges = [] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + store.setState({ + clipboardElements: [ + createNode({ + id: nodeId, + position: { x: 120, y: 120 }, + data: { + type: containerType, + title: containerType === BlockEnum.Iteration ? 'Iteration' : 'Loop', + desc: '', + _children: [], + }, + }), + ] as never, + clipboardEdges: [] as never, + mousePosition: { + pageX: 60, + pageY: 80, + } as never, + }) + + await act(async () => { + await result.current.handleNodesPaste() + }) + + const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] + const newContainer = pastedNodes.find(node => node.id !== nodeId && node.data.type === containerType) + + expect(newContainer).toBeDefined() + expect(newContainer?.parentId).toBeUndefined() + expect(newContainer?.data.isInIteration).toBeFalsy() + expect(newContainer?.data.isInLoop).toBeFalsy() + }) + }) + + // Nested container paste restrictions should stay aligned with available block filtering. + describe('nested container paste restrictions', () => { + const disallowedNestedPasteNodeTypes = [ + BlockEnum.End, + BlockEnum.Iteration, + BlockEnum.Loop, + BlockEnum.DataSource, + BlockEnum.KnowledgeBase, + BlockEnum.HumanInput, + ] + + const createNodeMeta = (type: BlockEnum) => ({ + defaultValue: { + type, + title: `${type} node`, + desc: '', + }, + metaData: { + isSingleton: false, + }, + }) + + const runDisallowedPasteScenario = async (containerType: BlockEnum.Iteration | BlockEnum.Loop, nodeType: BlockEnum) => { + runtimeNodesMetaDataMap.value = { + [nodeType]: createNodeMeta(nodeType), + } + + const containerId = `${containerType}-container` + currentNodes = [ + createNode({ + id: containerId, + position: { x: 20, y: 20 }, + selected: true, + data: { + type: containerType, + title: containerType === BlockEnum.Iteration ? 'Iteration' : 'Loop', + desc: '', + _children: [], + }, + }), + ] + currentEdges = [] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = currentEdges as unknown as typeof rfState.edges + + const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: currentEdges, + }, + }) + + store.setState({ + clipboardElements: [ + createNode({ + id: `${nodeType}-clipboard-node`, + position: { x: 100, y: 100 }, + data: { + type: nodeType, + title: `${nodeType} clipboard node`, + desc: '', + }, + }), + ] as never, + clipboardEdges: [] as never, + mousePosition: { + pageX: 60, + pageY: 80, + } as never, + }) + + await act(async () => { + await result.current.handleNodesPaste() + }) + + const pastedNodes = rfState.setNodes.mock.calls.at(-1)?.[0] as Node[] + + expect(pastedNodes).toHaveLength(1) + expect(pastedNodes[0]?.id).toBe(containerId) + expect(pastedNodes[0]?.data._children).toEqual([]) + expect(pastedNodes.some(node => node.data.type === nodeType && node.parentId === containerId)).toBe(false) + } + + it.each(disallowedNestedPasteNodeTypes)( + 'should not paste %s into an iteration container', + async (nodeType) => { + await runDisallowedPasteScenario(BlockEnum.Iteration, nodeType) + }, + ) + + it('should not paste human-input into a loop container', async () => { + await runDisallowedPasteScenario(BlockEnum.Loop, BlockEnum.HumanInput) + }) + }) }) 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 83c21fcb6a..e6efea2870 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({ @@ -65,6 +76,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: '0.6.0', + 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/__tests__/use-shortcuts.spec.ts b/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts index b3c63ff519..1769533221 100644 --- a/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-shortcuts.spec.ts @@ -11,10 +11,18 @@ type KeyPressRegistration = { } } +type ReactFlowNodeMock = { + id: string + data: { + _isBundled?: boolean + } +} + const keyPressRegistrations = vi.hoisted(() => []) const mockZoomTo = vi.hoisted(() => vi.fn()) const mockGetZoom = vi.hoisted(() => vi.fn(() => 1)) const mockFitView = vi.hoisted(() => vi.fn()) +const mockGetNodes = vi.hoisted(() => vi.fn<() => ReactFlowNodeMock[]>(() => [])) const mockHandleNodesDelete = vi.hoisted(() => vi.fn()) const mockHandleEdgeDelete = vi.hoisted(() => vi.fn()) const mockHandleNodesCopy = vi.hoisted(() => vi.fn()) @@ -41,6 +49,7 @@ vi.mock('reactflow', () => ({ zoomTo: mockZoomTo, getZoom: mockGetZoom, fitView: mockFitView, + getNodes: mockGetNodes, }), })) @@ -84,6 +93,14 @@ const createKeyboardEvent = (target: HTMLElement = document.body) => ({ target, }) as unknown as KeyboardEvent +const createSelectionMock = (commonAncestorContainer: Node): Selection => ({ + isCollapsed: false, + rangeCount: 1, + getRangeAt: () => ({ + commonAncestorContainer, + } as unknown as Range), +} as unknown as Selection) + const findRegistration = (matcher: (registration: KeyPressRegistration) => boolean) => { const registration = keyPressRegistrations.find(matcher) expect(registration).toBeDefined() @@ -94,6 +111,7 @@ describe('useShortcuts', () => { beforeEach(() => { keyPressRegistrations.length = 0 vi.clearAllMocks() + mockGetNodes.mockReturnValue([]) }) it('deletes selected nodes and edges only outside editable inputs', () => { @@ -141,6 +159,35 @@ describe('useShortcuts', () => { expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(4) }) + it('copies bundled nodes even when an incidental text selection exists outside the workflow canvas', () => { + const getSelectionSpy = vi.spyOn(document, 'getSelection') + const textContainer = document.createElement('div') + const selectedText = document.createElement('span') + selectedText.textContent = 'Selected browser text' + textContainer.appendChild(selectedText) + + getSelectionSpy.mockReturnValue(createSelectionMock(selectedText)) + mockGetNodes.mockReturnValue([ + { + id: 'bundled-node', + data: { + _isBundled: true, + }, + }, + ]) + + renderWorkflowHook(() => useShortcuts()) + + const copyShortcut = findRegistration(registration => registration.keyFilter === 'ctrl.c' || registration.keyFilter === 'meta.c') + const event = createKeyboardEvent() + copyShortcut.handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1) + + getSelectionSpy.mockRestore() + }) + it('dims on shift down, undims on shift up, and responds to zen toggle events', () => { const { unmount } = renderWorkflowHook(() => useShortcuts()) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 27b13bb42c..a29c88e9cb 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -13,6 +13,7 @@ import type { LoopNodeType } from '../nodes/loop/types' import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types' import type { Edge, Node, OnNodeAdd } from '../types' import type { RAGPipelineVariables } from '@/models/pipeline' +import { toast } from '@langgenius/dify-ui/toast' import { produce } from 'immer' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -21,6 +22,7 @@ import { getOutgoers, useReactFlow, } from 'reactflow' +import { useGlobalPublicStore } from '@/context/global-public-context' import { collaborationManager } from '../collaboration/core/collaboration-manager' import { CUSTOM_EDGE, @@ -48,6 +50,12 @@ import { getNodeCustomTypeByNodeDataType, getNodesConnectedSourceOrTargetHandleIdsMap, getTopLeftNodePosition, + isClipboardEdgeStructurallyValid, + isClipboardNodeStructurallyValid, + isClipboardValueCompatibleWithDefault, + readWorkflowClipboard, + sanitizeClipboardValueByDefault, + writeWorkflowClipboard, } from '../utils' import { useWorkflowHistoryStore } from '../workflow-history-store' import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url' @@ -75,8 +83,62 @@ 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)) +} + +const getUniquePastedNodeTitle = ( + sourceTitle: string, + reservedTitles: Set, +) => { + let titleCandidate = sourceTitle + + while (reservedTitles.has(titleCandidate)) + titleCandidate = genNewNodeTitleFromOld(titleCandidate) + + reservedTitles.add(titleCandidate) + return titleCandidate +} + export const useNodesInteractions = () => { const { t } = useTranslation() + const appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version) const collaborativeWorkflow = useCollaborativeWorkflow() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() @@ -445,13 +507,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 } @@ -769,9 +829,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) @@ -849,7 +907,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] @@ -1577,9 +1635,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) { @@ -1649,131 +1705,378 @@ 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 { nodes } = collaborativeWorkflow.getState() + const { setClipboardData } = workflowStore.getState() + const { nodes, edges } = collaborativeWorkflow.getState() + 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]?.metaData - if (!metaData) - return false - 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]?.metaData - if (!metaData) - return false - 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, appDslVersion).catch(() => {}) }, - [getNodesReadOnly, collaborativeWorkflow, workflowStore], + [getNodesReadOnly, workflowStore, collaborativeWorkflow, isNodeCopyable, appDslVersion], ) - 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(appDslVersion) + const hasSystemClipboard = clipboardData.nodes.length > 0 + const shouldRunCompatibilityCheck = hasSystemClipboard && clipboardData.isVersionMismatch + + const clipboardElements = hasSystemClipboard + ? clipboardData.nodes + : storeClipboardElements + const clipboardEdges = hasSystemClipboard + ? clipboardData.edges + : storeClipboardEdges + + if (hasSystemClipboard) + setClipboardData(clipboardData) + + const validatedClipboardElements = clipboardElements.filter(isClipboardNodeStructurallyValid) + const validatedClipboardEdges = clipboardEdges.filter(isClipboardEdgeStructurallyValid) + + if (!validatedClipboardElements.length) + return const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState() + const reservedNodeTitles = new Set( + nodes + .map(node => node.data.title) + .filter((title): title is string => typeof title === 'string'), + ) const nodesToPaste: Node[] = [] const edgesToPaste: Edge[] = [] - 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 nodeDefaultValue = nodeToPaste.type !== CUSTOM_NOTE_NODE - ? nodesMetaDataMap?.[nodeType]?.defaultValue - : undefined + let compatibleClipboardElements = validatedClipboardElements.filter((node) => { + if (node.type === CUSTOM_NOTE_NODE) + return true - const { newNode, newIterationStartNode, newLoopStartNode } - = generateNewNode({ - type: nodeToPaste.type, - data: { - ...(nodeDefaultValue || {}), - ...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, + const nodeDefaultValue = getNodeDefaultValueForPaste(node) + if (!nodeDefaultValue) + return false + + if ( + shouldRunCompatibilityCheck + && !isClipboardValueCompatibleWithDefault(nodeDefaultValue, node.data) + ) { + return false + } + + return true + }) + + if (shouldRunCompatibilityCheck) { + compatibleClipboardElements = pruneClipboardNodesWithFilteredAncestors( + validatedClipboardElements, + compatibleClipboardElements, + ) + } + + 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.warning(t('common.clipboardVersionCompatibilityWarning', { + ns: 'workflow', + })) + } + + if (!compatibleClipboardElements.length) + return + + const rootClipboardNodes = compatibleClipboardElements.filter( + node => !node.parentId || !compatibleClipboardNodeIds.has(node.parentId), + ) + const positionReferenceNodes = rootClipboardNodes.length + ? rootClipboardNodes + : compatibleClipboardElements + 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 selectedNodes = nodes.filter(node => node.selected) + // Keep this list aligned with availableBlocksFilter(inContainer) + // in use-available-blocks.ts. + const commonNestedDisallowPasteNodes = [ + BlockEnum.End, + BlockEnum.Iteration, + BlockEnum.Loop, + BlockEnum.DataSource, + BlockEnum.KnowledgeBase, + BlockEnum.HumanInput, + ] + // Same-canvas copy keeps the source container selected, so only treat a + // selected container as the paste target when it is not part of the clipboard. + const selectedContainerNode = selectedNodes.length === 1 + && (selectedNodes[0]?.data.type === BlockEnum.Iteration || selectedNodes[0]?.data.type === BlockEnum.Loop) + && !compatibleClipboardNodeIds.has(selectedNodes[0].id) + ? selectedNodes[0] + : undefined + + rootClipboardNodes.forEach((nodeToPaste, index) => { + const nodeDefaultValue = getNodeDefaultValueForPaste(nodeToPaste) + if (nodeToPaste.type !== CUSTOM_NOTE_NODE && !nodeDefaultValue) + return + + if (selectedContainerNode && commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) + 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: { + ...mergedData, + type: nodeToPaste.data.type, + desc: sourceDesc, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + isInIteration: false, + iteration_id: undefined, + isInLoop: false, + loop_id: undefined, + title: getUniquePastedNodeTitle(sourceTitle, reservedNodeTitles), + }, + 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 = compatibleClipboardElements.find( + n => + n.parentId === nodeToPaste.id + && n.type === CUSTOM_ITERATION_START_NODE, + ) + if (oldIterationStartNodeInClipboard && newIterationStartNode) + idMapping[oldIterationStartNodeInClipboard.id] = newIterationStartNode.id + + const copiedIterationChildren = compatibleClipboardElements.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 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: { + ...mergedChildData, + desc: childSourceDesc, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + title: getUniquePastedNodeTitle(childSourceTitle, reservedNodeTitles), + 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 +2086,97 @@ 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 = compatibleClipboardElements.find( + n => + n.parentId === nodeToPaste.id + && n.type === CUSTOM_LOOP_START_NODE, + ) + if (oldLoopStartNodeInClipboard && newLoopStartNode) + idMapping[oldLoopStartNodeInClipboard.id] = newLoopStartNode.id + + const copiedLoopChildren = compatibleClipboardElements.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 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: { + ...mergedChildData, + desc: childSourceDesc, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + _dimmed: false, + title: getUniquePastedNodeTitle(childSourceTitle, reservedNodeTitles), + 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,148 +2186,124 @@ 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 { - // Paste a single regular node. Loop/Iteration nodes are handled above. - const selectedNode = nodes.find(node => node.selected) - let pastedToNestedBlock = false - - if (selectedNode) { - // Keep this list aligned with availableBlocksFilter(inContainer) - // in use-available-blocks.ts. - const commonNestedDisallowPasteNodes = [ - BlockEnum.End, - BlockEnum.Iteration, - BlockEnum.Loop, - BlockEnum.DataSource, - BlockEnum.KnowledgeBase, - BlockEnum.HumanInput, - ] - - if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) - return - - // If a Loop/Iteration container is selected, paste into it as a child. - 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, - } - // Rebase position into the selected container coordinate system. - newNode.position = getNestedNodePosition(newNode, selectedNode) - - // Mirror native add behavior by appending parent._children. - parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type }) - - pastedToNestedBlock = true - } - } - - // Clear loop/iteration metadata when pasting outside nested blocks (fixes #29835) - // This ensures nodes copied from inside Loop/Iteration are properly independent - // when pasted outside - if (!pastedToNestedBlock) { - newNode.data.isInLoop = false - newNode.data.loop_id = undefined - newNode.data.isInIteration = false - newNode.data.iteration_id = undefined - newNode.parentId = undefined - } - } - - 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 (selectedContainerNode) { + if (selectedContainerNode.data.type === BlockEnum.Iteration || selectedContainerNode.data.type === BlockEnum.Loop) { + const isIteration = selectedContainerNode.data.type === BlockEnum.Iteration - setNodes(newNodes) - setEdges([...edges, ...edgesToPaste]) - saveStateToHistory(WorkflowHistoryEvent.NodePaste, { - nodeId: nodesToPaste?.[0]?.id, + newNode.data.isInIteration = isIteration + newNode.data.iteration_id = isIteration ? selectedContainerNode.id : undefined + newNode.data.isInLoop = !isIteration + newNode.data.loop_id = !isIteration ? selectedContainerNode.id : undefined + + newNode.parentId = selectedContainerNode.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, selectedContainerNode) + parentChildrenToAppend.push({ + parentId: selectedContainerNode.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 = validatedClipboardEdges + + sourceEdges.forEach((edge) => { + const sourceId = idMapping[edge.source] + const targetId = idMapping[edge.target] + + if (sourceId && targetId) { + const sourceNode = pastedNodesMap[sourceId] + const targetNode = pastedNodesMap[targetId] + if (!sourceNode || !targetNode) + return + + 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 || {}), + sourceType: sourceNode.data.type, + targetType: targetNode.data.type, + 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, collaborativeWorkflow, reactflow, + t, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy, - nodesMetaDataMap, + getNodeDefaultValueForPaste, + appDslVersion, ]) const handleNodesDuplicate = useCallback( @@ -2091,9 +2443,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 469a7abdee..37120068d4 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -1,12 +1,23 @@ 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() + // Sync the latest system clipboard into the workflow store before opening + // the pane menu because "Paste here" is disabled when no compatible node + // copy exists, including cross-app copies written outside this tab. + void readWorkflowClipboard(appDslVersion).then(({ nodes, edges }) => { + if (nodes.length) + workflowStore.getState().setClipboardData({ nodes, edges }) + }) + const container = document.querySelector('#workflow-container') const { x, y } = container!.getBoundingClientRect() workflowStore.setState({ @@ -18,7 +29,7 @@ export const usePanelInteractions = () => { left: e.clientX - x, }, }) - }, [workflowStore]) + }, [workflowStore, appDslVersion]) const handlePaneContextmenuCancel = useCallback(() => { workflowStore.setState({ diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 8ba7fa4602..e4100908ff 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -46,6 +46,7 @@ export const useShortcuts = (): void => { zoomTo, getZoom, fitView, + getNodes, } = useReactFlow() // Zoom out to a minimum of 0.25 for shortcut @@ -67,9 +68,14 @@ export const useShortcuts = (): void => { }, []) const shouldHandleCopy = useCallback(() => { + // Box selection can leave incidental DOM text selection behind while the + // workflow selection itself lives on node.data._isBundled. + if (getNodes().some(node => node.data._isBundled)) + return true + const selection = document.getSelection() - return !selection || selection.isCollapsed - }, []) + return !selection || selection.isCollapsed || !selection.rangeCount + }, [getNodes]) useKeyPress(['delete', 'backspace'], (e) => { if (shouldHandleShortcut(e)) { 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 6edc3dc316..6cebc4e23c 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 c3b41f06a2..58e3debc63 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, @@ -34,7 +35,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 @@ -75,7 +79,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..ccb3f426d4 --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/clipboard.spec.ts @@ -0,0 +1,101 @@ +import { createEdge, createNode } from '../../__tests__/fixtures' +import { + parseWorkflowClipboardText, + readWorkflowClipboard, + stringifyWorkflowClipboardData, + writeWorkflowClipboard, +} from '../clipboard' + +describe('workflow clipboard storage', () => { + const currentVersion = '0.6.0' + 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(currentVersion)).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 }, currentVersion) + readTextMock.mockResolvedValue(serialized) + + await writeWorkflowClipboard({ nodes, edges }, currentVersion) + expect(writeTextMock).toHaveBeenCalledWith(serialized) + await expect(readWorkflowClipboard(currentVersion)).resolves.toEqual({ + nodes, + edges, + sourceVersion: currentVersion, + 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: '0.5.0', + nodes, + edges, + })) + + await expect(readWorkflowClipboard(currentVersion)).resolves.toEqual({ + nodes, + edges, + sourceVersion: '0.5.0', + isVersionMismatch: true, + }) + }) + + it('should return empty clipboard data for invalid JSON', () => { + expect(parseWorkflowClipboardText('{invalid-json', currentVersion)).toEqual({ + nodes: [], + edges: [], + isVersionMismatch: false, + }) + }) + + it('should return empty clipboard data for invalid structure', () => { + expect(parseWorkflowClipboardText(JSON.stringify({ + kind: 'unknown', + version: 1, + nodes: [], + edges: [], + }), currentVersion)).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(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 new file mode 100644 index 0000000000..246d3351f3 --- /dev/null +++ b/web/app/components/workflow/utils/clipboard.ts @@ -0,0 +1,205 @@ +import type { Edge, Node } from '../types' + +const WORKFLOW_CLIPBOARD_KIND = 'dify-workflow-clipboard' + +type WorkflowClipboardPayload = { + kind: string + version: string + nodes: Node[] + edges: Edge[] +} + +type WorkflowClipboardData = { + nodes: Node[] + edges: Edge[] +} + +type WorkflowClipboardReadResult = WorkflowClipboardData & { + sourceVersion?: string + 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) +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, + currentClipboardVersion: string, +): WorkflowClipboardReadResult => { + if (!text) + return emptyClipboardReadResult + + try { + const parsed = JSON.parse(text) as Partial + if ( + parsed.kind !== WORKFLOW_CLIPBOARD_KIND + || typeof parsed.version !== 'string' + || !isNodeArray(parsed.nodes) + || !isEdgeArray(parsed.edges) + ) { + return emptyClipboardReadResult + } + + const sourceVersion = parsed.version + + const validatedNodes = parsed.nodes.filter(isClipboardNodeStructurallyValid) + const validatedEdges = parsed.edges.filter(isClipboardEdgeStructurallyValid) + + return { + nodes: validatedNodes, + edges: validatedEdges, + sourceVersion, + isVersionMismatch: sourceVersion !== currentClipboardVersion, + } + } + catch { + return emptyClipboardReadResult + } +} + +export const stringifyWorkflowClipboardData = ( + payload: WorkflowClipboardData, + currentClipboardVersion: string, +): string => { + const data: WorkflowClipboardPayload = { + kind: WORKFLOW_CLIPBOARD_KIND, + version: currentClipboardVersion, + nodes: payload.nodes, + edges: payload.edges, + } + + return JSON.stringify(data) +} + +export const writeWorkflowClipboard = async ( + payload: WorkflowClipboardData, + currentClipboardVersion: string, +): Promise => { + const text = stringifyWorkflowClipboardData(payload, currentClipboardVersion) + await navigator.clipboard.writeText(text) +} + +export const readWorkflowClipboard = async ( + currentClipboardVersion: string, +): Promise => { + try { + const text = await navigator.clipboard.readText() + return parseWorkflowClipboardText(text, currentClipboardVersion) + } + catch { + return emptyClipboardReadResult + } +} diff --git a/web/app/components/workflow/utils/index.ts b/web/app/components/workflow/utils/index.ts index 641deec695..5098549528 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/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index 6b9d2b2f8c..1e17bdd5ac 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -151,6 +151,7 @@ "common.branch": "فرع", "common.chooseDSL": "اختر ملف DSL", "common.chooseStartNodeToRun": "اختر عقدة البداية للتشغيل", + "common.clipboardVersionCompatibilityWarning": "تم نسخ هذا المحتوى من إصدار مختلف من تطبيق Dify. قد تكون بعض الأجزاء غير متوافقة.", "common.commentMode": "وضع التعليقات", "common.configure": "تكوين", "common.configureRequired": "التكوين مطلوب", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index 20a142431c..f354824321 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -151,6 +151,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.commentMode": "Kommentar-Modus", "common.configure": "Konfigurieren", "common.configureRequired": "Konfiguration erforderlich", diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index aaadce8814..15b165d108 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -151,6 +151,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.commentMode": "Comment Mode", "common.configure": "Configure", "common.configureRequired": "Configure Required", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index 2b7ca99452..e77ca46200 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -151,6 +151,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.commentMode": "Modo de comentarios", "common.configure": "Configurar", "common.configureRequired": "Configuración requerida", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index 7914b861ed..36c2a37f4b 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -151,6 +151,7 @@ "common.branch": "شاخه", "common.chooseDSL": "انتخاب فایل DSL (yml)", "common.chooseStartNodeToRun": "گره شروع را برای اجرا انتخاب کنید", + "common.clipboardVersionCompatibilityWarning": "این محتوا از نسخه دیگری از برنامه Dify کپی شده است. ممکن است برخی بخش‌ها ناسازگار باشند.", "common.commentMode": "حالت نظرات", "common.configure": "پیکربندی", "common.configureRequired": "پیکربندی الزامی است", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index d4fbd8f27a..e84ceb2343 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -151,6 +151,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.commentMode": "Mode commentaires", "common.configure": "Configurer", "common.configureRequired": "Configuration requise", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index 5c3b6eb1f9..026034c812 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -151,6 +151,7 @@ "common.branch": "शाखा", "common.chooseDSL": "डीएसएल (वाईएमएल) फ़ाइल चुनें", "common.chooseStartNodeToRun": "चलाने के लिए प्रारंभ नोड चुनें", + "common.clipboardVersionCompatibilityWarning": "यह सामग्री Dify ऐप के किसी अलग संस्करण से कॉपी की गई है। इसके कुछ हिस्से असंगत हो सकते हैं।", "common.commentMode": "टिप्पणी मोड", "common.configure": "कॉन्फ़िगर करें", "common.configureRequired": "कॉन्फ़िगरेशन आवश्यक", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 376227eedf..c604539e0a 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -151,6 +151,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.commentMode": "Mode komentar", "common.configure": "Konfigurasikan", "common.configureRequired": "Konfigurasi yang Diperlukan", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index 7bd102f745..f34cb96155 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -151,6 +151,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.commentMode": "Modalità commenti", "common.configure": "Configura", "common.configureRequired": "Configurazione Richiesta", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 9564abc43d..ce94735a4a 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -151,6 +151,7 @@ "common.branch": "ブランチ", "common.chooseDSL": "DSL(yml) ファイルを選択", "common.chooseStartNodeToRun": "実行する開始ノードを選択", + "common.clipboardVersionCompatibilityWarning": "このコンテンツは別の Dify アプリバージョンからコピーされました。一部が互換性のない可能性があります。", "common.commentMode": "コメントモード", "common.configure": "設定", "common.configureRequired": "設定が必要", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index 7dfeda4573..92911fad82 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -151,6 +151,7 @@ "common.branch": "브랜치", "common.chooseDSL": "DSL(yml) 파일 선택", "common.chooseStartNodeToRun": "실행할 시작 노드를 선택하세요", + "common.clipboardVersionCompatibilityWarning": "이 콘텐츠는 다른 Dify 앱 버전에서 복사되었습니다. 일부 항목은 호환되지 않을 수 있습니다.", "common.commentMode": "댓글 모드", "common.configure": "구성", "common.configureRequired": "구성 필요", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index 852415b419..b21e057c92 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -151,6 +151,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.commentMode": "Opmerkingsmodus", "common.configure": "Configure", "common.configureRequired": "Configure Required", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 996681f50b..914127be33 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -151,6 +151,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.commentMode": "Tryb komentarzy", "common.configure": "Skonfiguruj", "common.configureRequired": "Wymagana konfiguracja", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index 74af4a8e62..58fbe2be2e 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -151,6 +151,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.commentMode": "Modo de comentários", "common.configure": "Configurar", "common.configureRequired": "Configuração necessária", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index 6c02f5fc3c..7ef8244fd3 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -151,6 +151,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.commentMode": "Mod comentarii", "common.configure": "Configurează", "common.configureRequired": "Configurare necesară", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 87153206a9..4474db83b0 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -151,6 +151,7 @@ "common.branch": "ВЕТКА", "common.chooseDSL": "Выберите файл DSL(yml)", "common.chooseStartNodeToRun": "Выберите начальный узел для запуска", + "common.clipboardVersionCompatibilityWarning": "Этот контент был скопирован из другой версии приложения Dify. Некоторые части могут быть несовместимы.", "common.commentMode": "Режим комментариев", "common.configure": "Настроить", "common.configureRequired": "Требуется настройка", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index 987d439f9a..2ce35d8093 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -151,6 +151,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.commentMode": "Način komentarjev", "common.configure": "Konfiguriraj", "common.configureRequired": "Konfigurirajte zahteve", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index 424f510d4a..e4e1f8b0a8 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -151,6 +151,7 @@ "common.branch": "กิ่ง", "common.chooseDSL": "เลือกไฟล์ DSL", "common.chooseStartNodeToRun": "เลือกโหนดเริ่มต้นเพื่อรัน", + "common.clipboardVersionCompatibilityWarning": "เนื้อหานี้ถูกคัดลอกจากแอป Dify คนละเวอร์ชัน บางส่วนอาจไม่เข้ากัน", "common.commentMode": "โหมดความคิดเห็น", "common.configure": "กําหนดค่า", "common.configureRequired": "กําหนดค่าที่จําเป็น", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index ce43144e5c..3e4b379d9b 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -151,6 +151,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.commentMode": "Yorum modu", "common.configure": "Yapılandır", "common.configureRequired": "Yapılandırma Gerekli", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 76cd3eddee..92c20acf73 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -151,6 +151,7 @@ "common.branch": "ГІЛКА", "common.chooseDSL": "Виберіть файл DSL(yml)", "common.chooseStartNodeToRun": "Виберіть початковий вузол для запуску", + "common.clipboardVersionCompatibilityWarning": "Цей вміст скопійовано з іншої версії застосунку Dify. Деякі частини можуть бути несумісними.", "common.commentMode": "Режим коментарів", "common.configure": "Налаштувати", "common.configureRequired": "Потрібна конфігурація", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 7750f0efb6..74dbbbd6dd 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -151,6 +151,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.commentMode": "Chế độ bình luận", "common.configure": "Cấu hình", "common.configureRequired": "Yêu cầu cấu hình", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 2b6f4bcbe7..fc2aa1c880 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -151,6 +151,7 @@ "common.branch": "分支", "common.chooseDSL": "选择 DSL(yml) 文件", "common.chooseStartNodeToRun": "选择启动节点进行运行", + "common.clipboardVersionCompatibilityWarning": "此内容复制自不同版本的 Dify 应用,部分内容可能不兼容。", "common.commentMode": "评论模式", "common.configure": "配置", "common.configureRequired": "需要进行配置", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 6b8ad06650..ef81f091f0 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -151,6 +151,7 @@ "common.branch": "分支", "common.chooseDSL": "選擇 DSL(yml)檔", "common.chooseStartNodeToRun": "選擇要執行的起始節點", + "common.clipboardVersionCompatibilityWarning": "此內容複製自不同版本的 Dify 應用,部分內容可能不相容。", "common.commentMode": "評論模式", "common.configure": "配置", "common.configureRequired": "需要進行配置", diff --git a/web/types/feature.ts b/web/types/feature.ts index 5abfb1e1a0..635221f2be 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 @@ -68,6 +69,7 @@ export type SystemFeatures = { } export const defaultSystemFeatures: SystemFeatures = { + app_dsl_version: '', trial_models: [], plugin_installation_permission: { plugin_installation_scope: InstallationScope.ALL,