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 68cb3438ca..734e7a3ce2 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 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 ImportMode(StrEnum): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index f38e1762d1..8b5ccd8fc2 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 @@ -224,6 +226,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/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 5236beda60..15f1a6b360 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-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index 517af513b9..ba2fd4c88c 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/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index cd35d2310f..23d0c8f778 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -22,6 +22,8 @@ import { useReactFlow, useStoreApi, } from 'reactflow' +import Toast from '@/app/components/base/toast' +import { useGlobalPublicStore } from '@/context/global-public-context' import { CUSTOM_EDGE, ITERATION_CHILDREN_Z_INDEX, @@ -47,6 +49,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' @@ -73,8 +81,49 @@ 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 appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version) const store = useStoreApi() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() @@ -448,13 +497,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 } @@ -776,9 +823,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) @@ -854,7 +899,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] @@ -1580,9 +1625,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) { @@ -1654,71 +1697,150 @@ 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, appDslVersion).catch(() => {}) }, - [getNodesReadOnly, store, workflowStore], + [getNodesReadOnly, workflowStore, store, 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 { getNodes, setNodes, edges, setEdges } = store.getState() @@ -1726,55 +1848,208 @@ 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, + let compatibleClipboardElements = validatedClipboardElements.filter((node) => { + if (node.type === CUSTOM_NOTE_NODE) + return true + + 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.notify({ + type: 'warning', + message: t('common.clipboardVersionCompatibilityWarning', { + ns: 'workflow', + }), }) - 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 { 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 (!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 selectedNode = nodes.find(node => node.selected) + + rootClipboardNodes.forEach((nodeToPaste, index) => { + const nodeDefaultValue = getNodeDefaultValueForPaste(nodeToPaste) + 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: { + ...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: genNewNodeTitleFromOld(sourceTitle), + }, + 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: genNewNodeTitleFromOld(childSourceTitle), + 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( @@ -1784,24 +2059,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: genNewNodeTitleFromOld(childSourceTitle), + 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( @@ -1811,127 +2159,126 @@ 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 = 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] + 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, - nodesMetaDataMap, + getNodeDefaultValueForPaste, + appDslVersion, ]) const handleNodesDuplicate = useCallback( @@ -2074,9 +2421,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..eff636e20f 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -1,12 +1,20 @@ 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(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 +26,7 @@ export const usePanelInteractions = () => { left: e.clientX - x, }, }) - }, [workflowStore]) + }, [workflowStore, appDslVersion]) const handlePaneContextmenuCancel = useCallback(() => { 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 df0288ac09..20fd2cb3b9 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..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..db3e92c54a --- /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[] +} + +export type WorkflowClipboardData = { + nodes: Node[] + edges: Edge[] +} + +export 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 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/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/en-US/workflow.json b/web/i18n/en-US/workflow.json index e5049069d6..6dc6270acd 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", 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 f136363fb8..bf43d1c6b4 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 a9437ec3df..3e4e0f56ee 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 37ffa13b42..a280757678 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": "對話記錄", 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,