feat: copy nodes cross apps (#33273)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
非法操作
2026-04-17 18:02:26 +08:00
committed by GitHub
parent 96122692cb
commit f980d5d3be
40 changed files with 1384 additions and 248 deletions

View File

@@ -0,0 +1 @@
CURRENT_APP_DSL_VERSION = "0.6.0"

View File

@@ -17,6 +17,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from configs import dify_config from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from core.helper import ssrf_proxy from core.helper import ssrf_proxy
from core.plugin.entities.plugin import PluginDependency from core.plugin.entities.plugin import PluginDependency
from core.trigger.constants import ( 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:" CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
CURRENT_DSL_VERSION = "0.6.0" CURRENT_DSL_VERSION = CURRENT_APP_DSL_VERSION
class Import(BaseModel): class Import(BaseModel):

View File

@@ -3,6 +3,7 @@ from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from configs import dify_config from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from enums.cloud_plan import CloudPlan from enums.cloud_plan import CloudPlan
from enums.hosted_provider import HostedTrialProvider from enums.hosted_provider import HostedTrialProvider
from services.billing_service import BillingService from services.billing_service import BillingService
@@ -157,6 +158,7 @@ class PluginManagerModel(BaseModel):
class SystemFeatureModel(BaseModel): class SystemFeatureModel(BaseModel):
app_dsl_version: str = ""
sso_enforced_for_signin: bool = False sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = "" sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False enable_marketplace: bool = False
@@ -225,6 +227,7 @@ class FeatureService:
@classmethod @classmethod
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel: def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
system_features = SystemFeatureModel() system_features = SystemFeatureModel()
system_features.app_dsl_version = CURRENT_APP_DSL_VERSION
cls._fulfill_system_params_from_env(system_features) cls._fulfill_system_params_from_env(system_features)

View File

@@ -6206,7 +6206,7 @@
}, },
"web/app/components/workflow/utils/index.ts": { "web/app/components/workflow/utils/index.ts": {
"no-barrel-files/no-barrel-files": { "no-barrel-files/no-barrel-files": {
"count": 9 "count": 10
} }
}, },
"web/app/components/workflow/utils/node-navigation.ts": { "web/app/components/workflow/utils/node-navigation.ts": {

View File

@@ -48,6 +48,7 @@ describe('EmbeddedChatbot Header', () => {
} }
const defaultSystemFeatures: SystemFeatures = { const defaultSystemFeatures: SystemFeatures = {
app_dsl_version: '',
trial_models: [], trial_models: [],
plugin_installation_permission: { plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL, plugin_installation_scope: InstallationScope.ALL,

View File

@@ -11,6 +11,14 @@ const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
const mockUndo = vi.hoisted(() => vi.fn()) const mockUndo = vi.hoisted(() => vi.fn())
const mockRedo = 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(() => ({ const runtimeNodesMetaDataMap = vi.hoisted(() => ({
value: {} as Record<string, unknown>, value: {} as Record<string, unknown>,
})) }))
@@ -72,14 +80,14 @@ vi.mock('../use-inspect-vars-crud', () => ({
vi.mock('../../nodes/iteration/use-interactions', () => ({ vi.mock('../../nodes/iteration/use-interactions', () => ({
useNodeIterationInteractions: () => ({ useNodeIterationInteractions: () => ({
handleNodeIterationChildDrag: () => ({ restrictPosition: {} }), handleNodeIterationChildDrag: () => ({ restrictPosition: {} }),
handleNodeIterationChildrenCopy: vi.fn(), handleNodeIterationChildrenCopy: mockHandleNodeIterationChildrenCopy,
}), }),
})) }))
vi.mock('../../nodes/loop/use-interactions', () => ({ vi.mock('../../nodes/loop/use-interactions', () => ({
useNodeLoopInteractions: () => ({ useNodeLoopInteractions: () => ({
handleNodeLoopChildDrag: () => ({ restrictPosition: {} }), handleNodeLoopChildDrag: () => ({ restrictPosition: {} }),
handleNodeLoopChildrenCopy: vi.fn(), handleNodeLoopChildrenCopy: mockHandleNodeLoopChildrenCopy,
}), }),
})) }))
@@ -673,4 +681,316 @@ describe('useNodesInteractions', () => {
expect(rfState.setNodes).toHaveBeenCalled() 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)
})
})
}) })

View File

@@ -1,11 +1,22 @@
import type * as React from 'react' 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 { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { usePanelInteractions } from '../use-panel-interactions' import { usePanelInteractions } from '../use-panel-interactions'
describe('usePanelInteractions', () => { describe('usePanelInteractions', () => {
let container: HTMLDivElement let container: HTMLDivElement
let readTextMock: ReturnType<typeof vi.fn>
beforeEach(() => { beforeEach(() => {
readTextMock = vi.fn().mockResolvedValue('')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: readTextMock,
},
})
container = document.createElement('div') container = document.createElement('div')
container.id = 'workflow-container' container.id = 'workflow-container'
container.getBoundingClientRect = vi.fn().mockReturnValue({ container.getBoundingClientRect = vi.fn().mockReturnValue({
@@ -65,6 +76,34 @@ describe('usePanelInteractions', () => {
}).toThrow() }).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', () => { it('handlePaneContextmenuCancel should clear panelMenu', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: { panelMenu: { top: 10, left: 20 } }, initialStoreState: { panelMenu: { top: 10, left: 20 } },

View File

@@ -11,10 +11,18 @@ type KeyPressRegistration = {
} }
} }
type ReactFlowNodeMock = {
id: string
data: {
_isBundled?: boolean
}
}
const keyPressRegistrations = vi.hoisted<KeyPressRegistration[]>(() => []) const keyPressRegistrations = vi.hoisted<KeyPressRegistration[]>(() => [])
const mockZoomTo = vi.hoisted(() => vi.fn()) const mockZoomTo = vi.hoisted(() => vi.fn())
const mockGetZoom = vi.hoisted(() => vi.fn(() => 1)) const mockGetZoom = vi.hoisted(() => vi.fn(() => 1))
const mockFitView = vi.hoisted(() => vi.fn()) const mockFitView = vi.hoisted(() => vi.fn())
const mockGetNodes = vi.hoisted(() => vi.fn<() => ReactFlowNodeMock[]>(() => []))
const mockHandleNodesDelete = vi.hoisted(() => vi.fn()) const mockHandleNodesDelete = vi.hoisted(() => vi.fn())
const mockHandleEdgeDelete = vi.hoisted(() => vi.fn()) const mockHandleEdgeDelete = vi.hoisted(() => vi.fn())
const mockHandleNodesCopy = vi.hoisted(() => vi.fn()) const mockHandleNodesCopy = vi.hoisted(() => vi.fn())
@@ -41,6 +49,7 @@ vi.mock('reactflow', () => ({
zoomTo: mockZoomTo, zoomTo: mockZoomTo,
getZoom: mockGetZoom, getZoom: mockGetZoom,
fitView: mockFitView, fitView: mockFitView,
getNodes: mockGetNodes,
}), }),
})) }))
@@ -84,6 +93,14 @@ const createKeyboardEvent = (target: HTMLElement = document.body) => ({
target, target,
}) as unknown as KeyboardEvent }) 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 findRegistration = (matcher: (registration: KeyPressRegistration) => boolean) => {
const registration = keyPressRegistrations.find(matcher) const registration = keyPressRegistrations.find(matcher)
expect(registration).toBeDefined() expect(registration).toBeDefined()
@@ -94,6 +111,7 @@ describe('useShortcuts', () => {
beforeEach(() => { beforeEach(() => {
keyPressRegistrations.length = 0 keyPressRegistrations.length = 0
vi.clearAllMocks() vi.clearAllMocks()
mockGetNodes.mockReturnValue([])
}) })
it('deletes selected nodes and edges only outside editable inputs', () => { it('deletes selected nodes and edges only outside editable inputs', () => {
@@ -141,6 +159,35 @@ describe('useShortcuts', () => {
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(4) 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', () => { it('dims on shift down, undims on shift up, and responds to zen toggle events', () => {
const { unmount } = renderWorkflowHook(() => useShortcuts()) const { unmount } = renderWorkflowHook(() => useShortcuts())

View File

@@ -13,6 +13,7 @@ import type { LoopNodeType } from '../nodes/loop/types'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types' import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
import type { Edge, Node, OnNodeAdd } from '../types' import type { Edge, Node, OnNodeAdd } from '../types'
import type { RAGPipelineVariables } from '@/models/pipeline' import type { RAGPipelineVariables } from '@/models/pipeline'
import { toast } from '@langgenius/dify-ui/toast'
import { produce } from 'immer' import { produce } from 'immer'
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -21,6 +22,7 @@ import {
getOutgoers, getOutgoers,
useReactFlow, useReactFlow,
} from 'reactflow' } from 'reactflow'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { collaborationManager } from '../collaboration/core/collaboration-manager' import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { import {
CUSTOM_EDGE, CUSTOM_EDGE,
@@ -48,6 +50,12 @@ import {
getNodeCustomTypeByNodeDataType, getNodeCustomTypeByNodeDataType,
getNodesConnectedSourceOrTargetHandleIdsMap, getNodesConnectedSourceOrTargetHandleIdsMap,
getTopLeftNodePosition, getTopLeftNodePosition,
isClipboardEdgeStructurallyValid,
isClipboardNodeStructurallyValid,
isClipboardValueCompatibleWithDefault,
readWorkflowClipboard,
sanitizeClipboardValueByDefault,
writeWorkflowClipboard,
} from '../utils' } from '../utils'
import { useWorkflowHistoryStore } from '../workflow-history-store' import { useWorkflowHistoryStore } from '../workflow-history-store'
import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url' 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 y: 21, // Adjusted based on visual testing feedback
} as const } 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<string, string[]>()
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<string>,
) => {
let titleCandidate = sourceTitle
while (reservedTitles.has(titleCandidate))
titleCandidate = genNewNodeTitleFromOld(titleCandidate)
reservedTitles.add(titleCandidate)
return titleCandidate
}
export const useNodesInteractions = () => { export const useNodesInteractions = () => {
const { t } = useTranslation() const { t } = useTranslation()
const appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version)
const collaborativeWorkflow = useCollaborativeWorkflow() const collaborativeWorkflow = useCollaborativeWorkflow()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const reactflow = useReactFlow() const reactflow = useReactFlow()
@@ -445,13 +507,11 @@ export const useNodesInteractions = () => {
} }
if ( if (
edges.find( edges.some(edge =>
edge =>
edge.source === source edge.source === source
&& edge.sourceHandle === sourceHandle && edge.sourceHandle === sourceHandle
&& edge.target === target && edge.target === target
&& edge.targetHandle === targetHandle, && edge.targetHandle === targetHandle)
)
) { ) {
return return
} }
@@ -769,9 +829,7 @@ export const useNodesInteractions = () => {
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
return draft.filter( return draft.filter(
edge => edge =>
!connectedEdges.find( !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id),
connectedEdge => connectedEdge.id === edge.id,
),
) )
}) })
setEdges(newEdges) setEdges(newEdges)
@@ -849,7 +907,7 @@ export const useNodesInteractions = () => {
const outgoers = getOutgoers(prevNode!, nodes, edges).sort( const outgoers = getOutgoers(prevNode!, nodes, edges).sort(
(a, b) => a.position.y - b.position.y, (a, b) => a.position.y - b.position.y,
) )
const lastOutgoer = outgoers[outgoers.length - 1] const lastOutgoer = outgoers.at(-1)
newNode.data._connectedTargetHandleIds newNode.data._connectedTargetHandleIds
= nodeType === BlockEnum.DataSource ? [] : [targetHandle] = nodeType === BlockEnum.DataSource ? [] : [targetHandle]
@@ -1577,9 +1635,7 @@ export const useNodesInteractions = () => {
setNodes(newNodes) setNodes(newNodes)
const remainingEdges = edges.filter( const remainingEdges = edges.filter(
edge => edge =>
!connectedEdges.find( !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id),
connectedEdge => connectedEdge.id === edge.id,
),
) )
setEdges([...remainingEdges, ...reconnectedEdges]) setEdges([...remainingEdges, ...reconnectedEdges])
if (nodeType === BlockEnum.TriggerWebhook) { if (nodeType === BlockEnum.TriggerWebhook) {
@@ -1649,81 +1705,216 @@ export const useNodesInteractions = () => {
[workflowStore, handleNodeSelect], [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( const handleNodesCopy = useCallback(
(nodeId?: string) => { (nodeId?: string) => {
if (getNodesReadOnly()) if (getNodesReadOnly())
return return
const { setClipboardElements } = workflowStore.getState() const { setClipboardData } = workflowStore.getState()
const { nodes, edges } = collaborativeWorkflow.getState()
const { nodes } = collaborativeWorkflow.getState() let nodesToCopy: Node[] = []
if (nodeId) { if (nodeId) {
// If nodeId is provided, copy that specific node const nodeToCopy = nodes.find(node => node.id === nodeId && isNodeCopyable(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,
)
if (nodeToCopy) if (nodeToCopy)
setClipboardElements([nodeToCopy]) nodesToCopy = [nodeToCopy]
} }
else { else {
// If no nodeId is provided, fall back to the current behavior
const bundledNodes = nodes.filter((node) => { const bundledNodes = nodes.filter((node) => {
if (!node.data._isBundled) if (!node.data._isBundled)
return false return false
if (!isNodeCopyable(node))
return false
if (node.type === CUSTOM_NOTE_NODE) if (node.type === CUSTOM_NOTE_NODE)
return true 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 return !node.data.isInIteration && !node.data.isInLoop
}) })
if (bundledNodes.length) { if (bundledNodes.length) {
setClipboardElements(bundledNodes) nodesToCopy = bundledNodes
return
} }
else {
const selectedNode = nodes.find((node) => { const selectedNodes = nodes.filter(
if (!node.data.selected) node => node.data.selected && 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
return !metaData.isSingleton
})
if (selectedNode)
setClipboardElements([selectedNode])
}
},
[getNodesReadOnly, collaborativeWorkflow, workflowStore],
) )
const handleNodesPaste = useCallback(() => { 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, workflowStore, collaborativeWorkflow, isNodeCopyable, appDslVersion],
)
const handleNodesPaste = useCallback(async () => {
if (getNodesReadOnly()) if (getNodesReadOnly())
return 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 { 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 nodesToPaste: Node[] = []
const edgesToPaste: Edge[] = [] const edgesToPaste: Edge[] = []
if (clipboardElements.length) { let compatibleClipboardElements = validatedClipboardElements.filter((node) => {
const { x, y } = getTopLeftNodePosition(clipboardElements) 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.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 { screenToFlowPosition } = reactflow
const currentPosition = screenToFlowPosition({ const currentPosition = screenToFlowPosition({
x: mousePosition.pageX, x: mousePosition.pageX,
@@ -1734,96 +1925,7 @@ export const useNodesInteractions = () => {
let idMapping: Record<string, string> = {} let idMapping: Record<string, string> = {}
const pastedNodesMap: Record<string, Node> = {} const pastedNodesMap: Record<string, Node> = {}
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = [] const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
clipboardElements.forEach((nodeToPaste, index) => { const selectedNodes = nodes.filter(node => node.selected)
const nodeType = nodeToPaste.data.type
const nodeDefaultValue = nodeToPaste.type !== CUSTOM_NOTE_NODE
? nodesMetaDataMap?.[nodeType]?.defaultValue
: undefined
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,
})
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
const oldIterationStartNode = nodes.find(
n =>
n.parentId === nodeToPaste.id
&& n.type === CUSTOM_ITERATION_START_NODE,
)
idMapping[oldIterationStartNode!.id] = newIterationStartNode!.id
const { copyChildren, newIdMapping }
= handleNodeIterationChildrenCopy(
nodeToPaste.id,
newNode.id,
idMapping,
)
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
const oldLoopStartNode = nodes.find(
n =>
n.parentId === nodeToPaste.id
&& n.type === CUSTOM_LOOP_START_NODE,
)
idMapping[oldLoopStartNode!.id] = newLoopStartNode!.id
const { copyChildren, newIdMapping }
= handleNodeLoopChildrenCopy(
nodeToPaste.id,
newNode.id,
idMapping,
)
newChildren = copyChildren
idMapping = newIdMapping
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) // Keep this list aligned with availableBlocksFilter(inContainer)
// in use-available-blocks.ts. // in use-available-blocks.ts.
const commonNestedDisallowPasteNodes = [ const commonNestedDisallowPasteNodes = [
@@ -1834,44 +1936,288 @@ export const useNodesInteractions = () => {
BlockEnum.KnowledgeBase, BlockEnum.KnowledgeBase,
BlockEnum.HumanInput, 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
if (commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type)) rootClipboardNodes.forEach((nodeToPaste, index) => {
const nodeDefaultValue = getNodeDefaultValueForPaste(nodeToPaste)
if (nodeToPaste.type !== CUSTOM_NOTE_NODE && !nodeDefaultValue)
return return
// If a Loop/Iteration container is selected, paste into it as a child. if (selectedContainerNode && commonNestedDisallowPasteNodes.includes(nodeToPaste.data.type))
if (selectedNode.data.type === BlockEnum.Iteration || selectedNode.data.type === BlockEnum.Loop) { return
const isIteration = selectedNode.data.type === BlockEnum.Iteration
const mergedData = shouldRunCompatibilityCheck
? sanitizeClipboardValueByDefault(nodeDefaultValue ?? {}, nodeToPaste.data) as Record<string, unknown>
: {
...(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<string, unknown>
: {
...(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)
})
}
else {
const oldIterationStartNode = nodes.find(
n =>
n.parentId === nodeToPaste.id
&& n.type === CUSTOM_ITERATION_START_NODE,
)
if (oldIterationStartNode && newIterationStartNode)
idMapping[oldIterationStartNode.id] = newIterationStartNode.id
const { copyChildren, newIdMapping }
= handleNodeIterationChildrenCopy(
nodeToPaste.id,
newNode.id,
idMapping,
)
newChildren = copyChildren
idMapping = newIdMapping
}
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<string, unknown>
: {
...(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,
)
if (oldLoopStartNode && newLoopStartNode)
idMapping[oldLoopStartNode.id] = newLoopStartNode.id
const { copyChildren, newIdMapping }
= handleNodeLoopChildrenCopy(
nodeToPaste.id,
newNode.id,
idMapping,
)
newChildren = copyChildren
idMapping = newIdMapping
}
newChildren.forEach((child) => {
newNode.data._children?.push({
nodeId: child.id,
nodeType: child.data.type,
})
})
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
newNode.data.isInIteration = isIteration newNode.data.isInIteration = isIteration
newNode.data.iteration_id = isIteration ? selectedNode.id : undefined newNode.data.iteration_id = isIteration ? selectedContainerNode.id : undefined
newNode.data.isInLoop = !isIteration newNode.data.isInLoop = !isIteration
newNode.data.loop_id = !isIteration ? selectedNode.id : undefined newNode.data.loop_id = !isIteration ? selectedContainerNode.id : undefined
newNode.parentId = selectedNode.id newNode.parentId = selectedContainerNode.id
newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX newNode.zIndex = isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX
newNode.positionAbsolute = { newNode.positionAbsolute = {
x: newNode.position.x, x: newNode.position.x,
y: newNode.position.y, y: newNode.position.y,
} }
// Rebase position into the selected container coordinate system. newNode.position = getNestedNodePosition(newNode, selectedContainerNode)
newNode.position = getNestedNodePosition(newNode, selectedNode) parentChildrenToAppend.push({
parentId: selectedContainerNode.id,
// Mirror native add behavior by appending parent._children. childId: newNode.id,
parentChildrenToAppend.push({ parentId: selectedNode.id, childId: newNode.id, childType: newNode.data.type }) 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
} }
} }
@@ -1887,15 +2233,19 @@ export const useNodesInteractions = () => {
} }
}) })
// Rebuild edges where both endpoints are part of the pasted set. const sourceEdges = validatedClipboardEdges
edges.forEach((edge) => {
sourceEdges.forEach((edge) => {
const sourceId = idMapping[edge.source] const sourceId = idMapping[edge.source]
const targetId = idMapping[edge.target] const targetId = idMapping[edge.target]
if (sourceId && targetId) { if (sourceId && targetId) {
const sourceNode = pastedNodesMap[sourceId] const sourceNode = pastedNodesMap[sourceId]
const targetNode = pastedNodesMap[targetId] const targetNode = pastedNodesMap[targetId]
const parentNode = sourceNode?.parentId && sourceNode.parentId === targetNode?.parentId if (!sourceNode || !targetNode)
return
const parentNode = sourceNode.parentId && sourceNode.parentId === targetNode.parentId
? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId) ? pastedNodesMap[sourceNode.parentId] ?? nodes.find(n => n.id === sourceNode.parentId)
: null : null
const isInIteration = parentNode?.data.type === BlockEnum.Iteration const isInIteration = parentNode?.data.type === BlockEnum.Iteration
@@ -1906,7 +2256,9 @@ export const useNodesInteractions = () => {
source: sourceId, source: sourceId,
target: targetId, target: targetId,
data: { data: {
...edge.data, ...(edge.data || {}),
sourceType: sourceNode.data.type,
targetType: targetNode.data.type,
isInIteration, isInIteration,
iteration_id: isInIteration ? parentNode?.id : undefined, iteration_id: isInIteration ? parentNode?.id : undefined,
isInLoop, isInLoop,
@@ -1928,9 +2280,8 @@ export const useNodesInteractions = () => {
const newNodes = produce(nodes, (draft: Node[]) => { const newNodes = produce(nodes, (draft: Node[]) => {
parentChildrenToAppend.forEach(({ parentId, childId, childType }) => { parentChildrenToAppend.forEach(({ parentId, childId, childType }) => {
const p = draft.find(n => n.id === parentId) const p = draft.find(n => n.id === parentId)
if (p) { if (p)
p.data._children?.push({ nodeId: childId, nodeType: childType }) p.data._children?.push({ nodeId: childId, nodeType: childType })
}
}) })
draft.push(...nodesToPaste) draft.push(...nodesToPaste)
}) })
@@ -1941,17 +2292,18 @@ export const useNodesInteractions = () => {
nodeId: nodesToPaste?.[0]?.id, nodeId: nodesToPaste?.[0]?.id,
}) })
handleSyncWorkflowDraft() handleSyncWorkflowDraft()
}
}, [ }, [
getNodesReadOnly, getNodesReadOnly,
workflowStore, workflowStore,
collaborativeWorkflow, collaborativeWorkflow,
reactflow, reactflow,
t,
saveStateToHistory, saveStateToHistory,
handleSyncWorkflowDraft, handleSyncWorkflowDraft,
handleNodeIterationChildrenCopy, handleNodeIterationChildrenCopy,
handleNodeLoopChildrenCopy, handleNodeLoopChildrenCopy,
nodesMetaDataMap, getNodeDefaultValueForPaste,
appDslVersion,
]) ])
const handleNodesDuplicate = useCallback( const handleNodesDuplicate = useCallback(
@@ -2091,9 +2443,7 @@ export const useNodesInteractions = () => {
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
return draft.filter( return draft.filter(
edge => edge =>
!connectedEdges.find( !connectedEdges.some(connectedEdge => connectedEdge.id === edge.id),
connectedEdge => connectedEdge.id === edge.id,
),
) )
}) })
setEdges(newEdges) setEdges(newEdges)

View File

@@ -1,12 +1,23 @@
import type { MouseEvent } from 'react' import type { MouseEvent } from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkflowStore } from '../store' import { useWorkflowStore } from '../store'
import { readWorkflowClipboard } from '../utils'
export const usePanelInteractions = () => { export const usePanelInteractions = () => {
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version)
const handlePaneContextMenu = useCallback((e: MouseEvent) => { const handlePaneContextMenu = useCallback((e: MouseEvent) => {
e.preventDefault() 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 container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect() const { x, y } = container!.getBoundingClientRect()
workflowStore.setState({ workflowStore.setState({
@@ -18,7 +29,7 @@ export const usePanelInteractions = () => {
left: e.clientX - x, left: e.clientX - x,
}, },
}) })
}, [workflowStore]) }, [workflowStore, appDslVersion])
const handlePaneContextmenuCancel = useCallback(() => { const handlePaneContextmenuCancel = useCallback(() => {
workflowStore.setState({ workflowStore.setState({

View File

@@ -46,6 +46,7 @@ export const useShortcuts = (): void => {
zoomTo, zoomTo,
getZoom, getZoom,
fitView, fitView,
getNodes,
} = useReactFlow() } = useReactFlow()
// Zoom out to a minimum of 0.25 for shortcut // Zoom out to a minimum of 0.25 for shortcut
@@ -67,9 +68,14 @@ export const useShortcuts = (): void => {
}, []) }, [])
const shouldHandleCopy = useCallback(() => { 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() const selection = document.getSelection()
return !selection || selection.isCollapsed return !selection || selection.isCollapsed || !selection.rangeCount
}, []) }, [getNodes])
useKeyPress(['delete', 'backspace'], (e) => { useKeyPress(['delete', 'backspace'], (e) => {
if (shouldHandleShortcut(e)) { if (shouldHandleShortcut(e)) {

View File

@@ -1,6 +1,7 @@
import type { Shape, SliceFromInjection } from '../workflow' import type { Shape, SliceFromInjection } from '../workflow'
import { renderHook } from '@testing-library/react' import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { createTestWorkflowStore, renderWorkflowHook } from '../../__tests__/workflow-test-env' import { createTestWorkflowStore, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow' import { createWorkflowStore, useStore, useWorkflowStore } from '../workflow'
@@ -51,6 +52,7 @@ describe('createWorkflowStore', () => {
['listeningTriggerNodeIds', 'setListeningTriggerNodeIds', ['n1', 'n2']], ['listeningTriggerNodeIds', 'setListeningTriggerNodeIds', ['n1', 'n2']],
['listeningTriggerIsAll', 'setListeningTriggerIsAll', true], ['listeningTriggerIsAll', 'setListeningTriggerIsAll', true],
['clipboardElements', 'setClipboardElements', []], ['clipboardElements', 'setClipboardElements', []],
['clipboardEdges', 'setClipboardEdges', []],
['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }], ['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }],
['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }], ['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }],
['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }], ['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }],
@@ -68,6 +70,17 @@ describe('createWorkflowStore', () => {
expect(store.getState().controlMode).toBe('pointer') expect(store.getState().controlMode).toBe('pointer')
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-operation-mode', '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', () => { describe('Node Slice Setters', () => {

View File

@@ -1,5 +1,6 @@
import type { StateCreator } from 'zustand' import type { StateCreator } from 'zustand'
import type { import type {
Edge,
Node, Node,
TriggerNodeType, TriggerNodeType,
WorkflowRunningData, WorkflowRunningData,
@@ -34,7 +35,10 @@ export type WorkflowSliceShape = {
listeningTriggerIsAll: boolean listeningTriggerIsAll: boolean
setListeningTriggerIsAll: (isAll: boolean) => void setListeningTriggerIsAll: (isAll: boolean) => void
clipboardElements: Node[] clipboardElements: Node[]
clipboardEdges: Edge[]
setClipboardElements: (clipboardElements: Node[]) => void 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 } selection: null | { x1: number, y1: number, x2: number, y2: number }
setSelection: (selection: WorkflowSliceShape['selection']) => void setSelection: (selection: WorkflowSliceShape['selection']) => void
bundleNodeSize: { width: number, height: number } | null bundleNodeSize: { width: number, height: number } | null
@@ -75,7 +79,15 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
listeningTriggerIsAll: false, listeningTriggerIsAll: false,
setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })), setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })),
clipboardElements: [], clipboardElements: [],
clipboardEdges: [],
setClipboardElements: clipboardElements => set(() => ({ clipboardElements })), setClipboardElements: clipboardElements => set(() => ({ clipboardElements })),
setClipboardEdges: clipboardEdges => set(() => ({ clipboardEdges })),
setClipboardData: ({ nodes, edges }) => {
set(() => ({
clipboardElements: nodes,
clipboardEdges: edges,
}))
},
selection: null, selection: null,
setSelection: selection => set(() => ({ selection })), setSelection: selection => set(() => ({ selection })),
bundleNodeSize: null, bundleNodeSize: null,

View File

@@ -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<string>>()
const writeTextMock = vi.fn<(text: string) => Promise<void>>()
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,
})
})
})

View File

@@ -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<string, unknown> =>
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<string, unknown> = {}
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<WorkflowClipboardPayload>
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<void> => {
const text = stringifyWorkflowClipboardData(payload, currentClipboardVersion)
await navigator.clipboard.writeText(text)
}
export const readWorkflowClipboard = async (
currentClipboardVersion: string,
): Promise<WorkflowClipboardReadResult> => {
try {
const text = await navigator.clipboard.readText()
return parseWorkflowClipboardText(text, currentClipboardVersion)
}
catch {
return emptyClipboardReadResult
}
}

View File

@@ -1,3 +1,4 @@
export * from './clipboard'
export * from './common' export * from './common'
export * from './data-source' export * from './data-source'
export * from './edge' export * from './edge'

View File

@@ -151,6 +151,7 @@
"common.branch": "فرع", "common.branch": "فرع",
"common.chooseDSL": "اختر ملف DSL", "common.chooseDSL": "اختر ملف DSL",
"common.chooseStartNodeToRun": "اختر عقدة البداية للتشغيل", "common.chooseStartNodeToRun": "اختر عقدة البداية للتشغيل",
"common.clipboardVersionCompatibilityWarning": "تم نسخ هذا المحتوى من إصدار مختلف من تطبيق Dify. قد تكون بعض الأجزاء غير متوافقة.",
"common.commentMode": "وضع التعليقات", "common.commentMode": "وضع التعليقات",
"common.configure": "تكوين", "common.configure": "تكوين",
"common.configureRequired": "التكوين مطلوب", "common.configureRequired": "التكوين مطلوب",

View File

@@ -151,6 +151,7 @@
"common.branch": "ZWEIG", "common.branch": "ZWEIG",
"common.chooseDSL": "Wählen Sie eine DSL(yml)-Datei", "common.chooseDSL": "Wählen Sie eine DSL(yml)-Datei",
"common.chooseStartNodeToRun": "Wählen Sie den Startknoten zum Ausführen", "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.commentMode": "Kommentar-Modus",
"common.configure": "Konfigurieren", "common.configure": "Konfigurieren",
"common.configureRequired": "Konfiguration erforderlich", "common.configureRequired": "Konfiguration erforderlich",

View File

@@ -151,6 +151,7 @@
"common.branch": "BRANCH", "common.branch": "BRANCH",
"common.chooseDSL": "Choose DSL file", "common.chooseDSL": "Choose DSL file",
"common.chooseStartNodeToRun": "Choose the start node to run", "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.commentMode": "Comment Mode",
"common.configure": "Configure", "common.configure": "Configure",
"common.configureRequired": "Configure Required", "common.configureRequired": "Configure Required",

View File

@@ -151,6 +151,7 @@
"common.branch": "RAMA", "common.branch": "RAMA",
"common.chooseDSL": "Elegir archivo DSL (yml)", "common.chooseDSL": "Elegir archivo DSL (yml)",
"common.chooseStartNodeToRun": "Elige el nodo de inicio para ejecutar", "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.commentMode": "Modo de comentarios",
"common.configure": "Configurar", "common.configure": "Configurar",
"common.configureRequired": "Configuración requerida", "common.configureRequired": "Configuración requerida",

View File

@@ -151,6 +151,7 @@
"common.branch": "شاخه", "common.branch": "شاخه",
"common.chooseDSL": "انتخاب فایل DSL (yml)", "common.chooseDSL": "انتخاب فایل DSL (yml)",
"common.chooseStartNodeToRun": "گره شروع را برای اجرا انتخاب کنید", "common.chooseStartNodeToRun": "گره شروع را برای اجرا انتخاب کنید",
"common.clipboardVersionCompatibilityWarning": "این محتوا از نسخه دیگری از برنامه Dify کپی شده است. ممکن است برخی بخش‌ها ناسازگار باشند.",
"common.commentMode": "حالت نظرات", "common.commentMode": "حالت نظرات",
"common.configure": "پیکربندی", "common.configure": "پیکربندی",
"common.configureRequired": "پیکربندی الزامی است", "common.configureRequired": "پیکربندی الزامی است",

View File

@@ -151,6 +151,7 @@
"common.branch": "BRANCHE", "common.branch": "BRANCHE",
"common.chooseDSL": "Choisir le fichier DSL(yml)", "common.chooseDSL": "Choisir le fichier DSL(yml)",
"common.chooseStartNodeToRun": "Choisissez le nœud de départ pour lancer", "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.commentMode": "Mode commentaires",
"common.configure": "Configurer", "common.configure": "Configurer",
"common.configureRequired": "Configuration requise", "common.configureRequired": "Configuration requise",

View File

@@ -151,6 +151,7 @@
"common.branch": "शाखा", "common.branch": "शाखा",
"common.chooseDSL": "डीएसएल (वाईएमएल) फ़ाइल चुनें", "common.chooseDSL": "डीएसएल (वाईएमएल) फ़ाइल चुनें",
"common.chooseStartNodeToRun": "चलाने के लिए प्रारंभ नोड चुनें", "common.chooseStartNodeToRun": "चलाने के लिए प्रारंभ नोड चुनें",
"common.clipboardVersionCompatibilityWarning": "यह सामग्री Dify ऐप के किसी अलग संस्करण से कॉपी की गई है। इसके कुछ हिस्से असंगत हो सकते हैं।",
"common.commentMode": "टिप्पणी मोड", "common.commentMode": "टिप्पणी मोड",
"common.configure": "कॉन्फ़िगर करें", "common.configure": "कॉन्फ़िगर करें",
"common.configureRequired": "कॉन्फ़िगरेशन आवश्यक", "common.configureRequired": "कॉन्फ़िगरेशन आवश्यक",

View File

@@ -151,6 +151,7 @@
"common.branch": "CABANG", "common.branch": "CABANG",
"common.chooseDSL": "Pilih file DSL", "common.chooseDSL": "Pilih file DSL",
"common.chooseStartNodeToRun": "Pilih node awal untuk dijalankan", "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.commentMode": "Mode komentar",
"common.configure": "Konfigurasikan", "common.configure": "Konfigurasikan",
"common.configureRequired": "Konfigurasi yang Diperlukan", "common.configureRequired": "Konfigurasi yang Diperlukan",

View File

@@ -151,6 +151,7 @@
"common.branch": "RAMO", "common.branch": "RAMO",
"common.chooseDSL": "Scegli file DSL(yml)", "common.chooseDSL": "Scegli file DSL(yml)",
"common.chooseStartNodeToRun": "Scegli il nodo di partenza da eseguire", "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.commentMode": "Modalità commenti",
"common.configure": "Configura", "common.configure": "Configura",
"common.configureRequired": "Configurazione Richiesta", "common.configureRequired": "Configurazione Richiesta",

View File

@@ -151,6 +151,7 @@
"common.branch": "ブランチ", "common.branch": "ブランチ",
"common.chooseDSL": "DSL(yml) ファイルを選択", "common.chooseDSL": "DSL(yml) ファイルを選択",
"common.chooseStartNodeToRun": "実行する開始ノードを選択", "common.chooseStartNodeToRun": "実行する開始ノードを選択",
"common.clipboardVersionCompatibilityWarning": "このコンテンツは別の Dify アプリバージョンからコピーされました。一部が互換性のない可能性があります。",
"common.commentMode": "コメントモード", "common.commentMode": "コメントモード",
"common.configure": "設定", "common.configure": "設定",
"common.configureRequired": "設定が必要", "common.configureRequired": "設定が必要",

View File

@@ -151,6 +151,7 @@
"common.branch": "브랜치", "common.branch": "브랜치",
"common.chooseDSL": "DSL(yml) 파일 선택", "common.chooseDSL": "DSL(yml) 파일 선택",
"common.chooseStartNodeToRun": "실행할 시작 노드를 선택하세요", "common.chooseStartNodeToRun": "실행할 시작 노드를 선택하세요",
"common.clipboardVersionCompatibilityWarning": "이 콘텐츠는 다른 Dify 앱 버전에서 복사되었습니다. 일부 항목은 호환되지 않을 수 있습니다.",
"common.commentMode": "댓글 모드", "common.commentMode": "댓글 모드",
"common.configure": "구성", "common.configure": "구성",
"common.configureRequired": "구성 필요", "common.configureRequired": "구성 필요",

View File

@@ -151,6 +151,7 @@
"common.branch": "BRANCH", "common.branch": "BRANCH",
"common.chooseDSL": "Choose DSL file", "common.chooseDSL": "Choose DSL file",
"common.chooseStartNodeToRun": "Choose the start node to run", "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.commentMode": "Opmerkingsmodus",
"common.configure": "Configure", "common.configure": "Configure",
"common.configureRequired": "Configure Required", "common.configureRequired": "Configure Required",

View File

@@ -151,6 +151,7 @@
"common.branch": "GAŁĄŹ", "common.branch": "GAŁĄŹ",
"common.chooseDSL": "Wybierz plik DSL(yml)", "common.chooseDSL": "Wybierz plik DSL(yml)",
"common.chooseStartNodeToRun": "Wybierz węzeł początkowy, aby uruchomić", "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.commentMode": "Tryb komentarzy",
"common.configure": "Skonfiguruj", "common.configure": "Skonfiguruj",
"common.configureRequired": "Wymagana konfiguracja", "common.configureRequired": "Wymagana konfiguracja",

View File

@@ -151,6 +151,7 @@
"common.branch": "RAMIFICAÇÃO", "common.branch": "RAMIFICAÇÃO",
"common.chooseDSL": "Escolha o arquivo DSL(yml)", "common.chooseDSL": "Escolha o arquivo DSL(yml)",
"common.chooseStartNodeToRun": "Escolha o nó inicial para executar", "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.commentMode": "Modo de comentários",
"common.configure": "Configurar", "common.configure": "Configurar",
"common.configureRequired": "Configuração necessária", "common.configureRequired": "Configuração necessária",

View File

@@ -151,6 +151,7 @@
"common.branch": "RAMURĂ", "common.branch": "RAMURĂ",
"common.chooseDSL": "Alegeți fișierul DSL(yml)", "common.chooseDSL": "Alegeți fișierul DSL(yml)",
"common.chooseStartNodeToRun": "Alegeți nodul de start pentru a rula", "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.commentMode": "Mod comentarii",
"common.configure": "Configurează", "common.configure": "Configurează",
"common.configureRequired": "Configurare necesară", "common.configureRequired": "Configurare necesară",

View File

@@ -151,6 +151,7 @@
"common.branch": "ВЕТКА", "common.branch": "ВЕТКА",
"common.chooseDSL": "Выберите файл DSL(yml)", "common.chooseDSL": "Выберите файл DSL(yml)",
"common.chooseStartNodeToRun": "Выберите начальный узел для запуска", "common.chooseStartNodeToRun": "Выберите начальный узел для запуска",
"common.clipboardVersionCompatibilityWarning": "Этот контент был скопирован из другой версии приложения Dify. Некоторые части могут быть несовместимы.",
"common.commentMode": "Режим комментариев", "common.commentMode": "Режим комментариев",
"common.configure": "Настроить", "common.configure": "Настроить",
"common.configureRequired": "Требуется настройка", "common.configureRequired": "Требуется настройка",

View File

@@ -151,6 +151,7 @@
"common.branch": "VEJA", "common.branch": "VEJA",
"common.chooseDSL": "Izberi DSL datoteko", "common.chooseDSL": "Izberi DSL datoteko",
"common.chooseStartNodeToRun": "Izberite začetno vozlišče za zagon", "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.commentMode": "Način komentarjev",
"common.configure": "Konfiguriraj", "common.configure": "Konfiguriraj",
"common.configureRequired": "Konfigurirajte zahteve", "common.configureRequired": "Konfigurirajte zahteve",

View File

@@ -151,6 +151,7 @@
"common.branch": "กิ่ง", "common.branch": "กิ่ง",
"common.chooseDSL": "เลือกไฟล์ DSL", "common.chooseDSL": "เลือกไฟล์ DSL",
"common.chooseStartNodeToRun": "เลือกโหนดเริ่มต้นเพื่อรัน", "common.chooseStartNodeToRun": "เลือกโหนดเริ่มต้นเพื่อรัน",
"common.clipboardVersionCompatibilityWarning": "เนื้อหานี้ถูกคัดลอกจากแอป Dify คนละเวอร์ชัน บางส่วนอาจไม่เข้ากัน",
"common.commentMode": "โหมดความคิดเห็น", "common.commentMode": "โหมดความคิดเห็น",
"common.configure": "กําหนดค่า", "common.configure": "กําหนดค่า",
"common.configureRequired": "กําหนดค่าที่จําเป็น", "common.configureRequired": "กําหนดค่าที่จําเป็น",

View File

@@ -151,6 +151,7 @@
"common.branch": "DAL", "common.branch": "DAL",
"common.chooseDSL": "DSL(yml) dosyasını seçin", "common.chooseDSL": "DSL(yml) dosyasını seçin",
"common.chooseStartNodeToRun": "Çalıştırmak için başlangıç düğümü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.commentMode": "Yorum modu",
"common.configure": "Yapılandır", "common.configure": "Yapılandır",
"common.configureRequired": "Yapılandırma Gerekli", "common.configureRequired": "Yapılandırma Gerekli",

View File

@@ -151,6 +151,7 @@
"common.branch": "ГІЛКА", "common.branch": "ГІЛКА",
"common.chooseDSL": "Виберіть файл DSL(yml)", "common.chooseDSL": "Виберіть файл DSL(yml)",
"common.chooseStartNodeToRun": "Виберіть початковий вузол для запуску", "common.chooseStartNodeToRun": "Виберіть початковий вузол для запуску",
"common.clipboardVersionCompatibilityWarning": "Цей вміст скопійовано з іншої версії застосунку Dify. Деякі частини можуть бути несумісними.",
"common.commentMode": "Режим коментарів", "common.commentMode": "Режим коментарів",
"common.configure": "Налаштувати", "common.configure": "Налаштувати",
"common.configureRequired": "Потрібна конфігурація", "common.configureRequired": "Потрібна конфігурація",

View File

@@ -151,6 +151,7 @@
"common.branch": "NHÁNH", "common.branch": "NHÁNH",
"common.chooseDSL": "Chọn tệp DSL(yml)", "common.chooseDSL": "Chọn tệp DSL(yml)",
"common.chooseStartNodeToRun": "Chọn nút bắt đầu để chạy", "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.commentMode": "Chế độ bình luận",
"common.configure": "Cấu hình", "common.configure": "Cấu hình",
"common.configureRequired": "Yêu cầu cấu hình", "common.configureRequired": "Yêu cầu cấu hình",

View File

@@ -151,6 +151,7 @@
"common.branch": "分支", "common.branch": "分支",
"common.chooseDSL": "选择 DSL(yml) 文件", "common.chooseDSL": "选择 DSL(yml) 文件",
"common.chooseStartNodeToRun": "选择启动节点进行运行", "common.chooseStartNodeToRun": "选择启动节点进行运行",
"common.clipboardVersionCompatibilityWarning": "此内容复制自不同版本的 Dify 应用,部分内容可能不兼容。",
"common.commentMode": "评论模式", "common.commentMode": "评论模式",
"common.configure": "配置", "common.configure": "配置",
"common.configureRequired": "需要进行配置", "common.configureRequired": "需要进行配置",

View File

@@ -151,6 +151,7 @@
"common.branch": "分支", "common.branch": "分支",
"common.chooseDSL": "選擇 DSLyml檔", "common.chooseDSL": "選擇 DSLyml檔",
"common.chooseStartNodeToRun": "選擇要執行的起始節點", "common.chooseStartNodeToRun": "選擇要執行的起始節點",
"common.clipboardVersionCompatibilityWarning": "此內容複製自不同版本的 Dify 應用,部分內容可能不相容。",
"common.commentMode": "評論模式", "common.commentMode": "評論模式",
"common.configure": "配置", "common.configure": "配置",
"common.configureRequired": "需要進行配置", "common.configureRequired": "需要進行配置",

View File

@@ -28,6 +28,7 @@ type License = {
} }
export type SystemFeatures = { export type SystemFeatures = {
app_dsl_version: string
trial_models: ModelProviderQuotaGetPaid[] trial_models: ModelProviderQuotaGetPaid[]
plugin_installation_permission: { plugin_installation_permission: {
plugin_installation_scope: InstallationScope plugin_installation_scope: InstallationScope
@@ -68,6 +69,7 @@ export type SystemFeatures = {
} }
export const defaultSystemFeatures: SystemFeatures = { export const defaultSystemFeatures: SystemFeatures = {
app_dsl_version: '',
trial_models: [], trial_models: [],
plugin_installation_permission: { plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL, plugin_installation_scope: InstallationScope.ALL,