This commit is contained in:
非法操作 2026-03-24 21:29:44 +08:00 committed by GitHub
commit 02dcd9fccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 970 additions and 215 deletions

View File

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

View File

@ -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):

View File

@ -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)

View File

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

View File

@ -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<typeof vi.fn>
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 } },

View File

@ -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<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))
}
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<string, string> = {}
const pastedNodesMap: Record<string, Node> = {}
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<string, string> = {}
const pastedNodesMap: Record<string, Node> = {}
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<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: 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<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: 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<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: 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)

View File

@ -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({

View File

@ -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', () => {

View File

@ -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<WorkflowSliceShape> = 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,

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[]
}
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<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 './data-source'
export * from './edge'

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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",

View File

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

View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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",

View File

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

View File

@ -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üğü",

View File

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

View File

@ -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",

View File

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

View File

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

View File

@ -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,