mirror of https://github.com/langgenius/dify.git
Merge d14ab709e4 into e3c1112b15
This commit is contained in:
commit
02dcd9fccb
|
|
@ -0,0 +1 @@
|
|||
CURRENT_APP_DSL_VERSION = "0.6.0"
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ describe('EmbeddedChatbot Header', () => {
|
|||
}
|
||||
|
||||
const defaultSystemFeatures: SystemFeatures = {
|
||||
app_dsl_version: '',
|
||||
trial_models: [],
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './clipboard'
|
||||
export * from './common'
|
||||
export * from './data-source'
|
||||
export * from './edge'
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "فرع",
|
||||
"common.chooseDSL": "اختر ملف DSL",
|
||||
"common.chooseStartNodeToRun": "اختر عقدة البداية للتشغيل",
|
||||
"common.clipboardVersionCompatibilityWarning": "تم نسخ هذا المحتوى من إصدار مختلف من تطبيق Dify. قد تكون بعض الأجزاء غير متوافقة.",
|
||||
"common.configure": "تكوين",
|
||||
"common.configureRequired": "التكوين مطلوب",
|
||||
"common.conversationLog": "سجل المحادثة",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "شاخه",
|
||||
"common.chooseDSL": "انتخاب فایل DSL (yml)",
|
||||
"common.chooseStartNodeToRun": "گره شروع را برای اجرا انتخاب کنید",
|
||||
"common.clipboardVersionCompatibilityWarning": "این محتوا از نسخه دیگری از برنامه Dify کپی شده است. ممکن است برخی بخشها ناسازگار باشند.",
|
||||
"common.configure": "پیکربندی",
|
||||
"common.configureRequired": "پیکربندی الزامی است",
|
||||
"common.conversationLog": "لاگ مکالمات",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "शाखा",
|
||||
"common.chooseDSL": "डीएसएल (वाईएमएल) फ़ाइल चुनें",
|
||||
"common.chooseStartNodeToRun": "चलाने के लिए प्रारंभ नोड चुनें",
|
||||
"common.clipboardVersionCompatibilityWarning": "यह सामग्री Dify ऐप के किसी अलग संस्करण से कॉपी की गई है। इसके कुछ हिस्से असंगत हो सकते हैं।",
|
||||
"common.configure": "कॉन्फ़िगर करें",
|
||||
"common.configureRequired": "कॉन्फ़िगरेशन आवश्यक",
|
||||
"common.conversationLog": "वार्तालाप लॉग",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "ブランチ",
|
||||
"common.chooseDSL": "DSL(yml) ファイルを選択",
|
||||
"common.chooseStartNodeToRun": "実行する開始ノードを選択",
|
||||
"common.clipboardVersionCompatibilityWarning": "このコンテンツは別の Dify アプリバージョンからコピーされました。一部が互換性のない可能性があります。",
|
||||
"common.configure": "設定",
|
||||
"common.configureRequired": "設定が必要",
|
||||
"common.conversationLog": "会話ログ",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "브랜치",
|
||||
"common.chooseDSL": "DSL(yml) 파일 선택",
|
||||
"common.chooseStartNodeToRun": "실행할 시작 노드를 선택하세요",
|
||||
"common.clipboardVersionCompatibilityWarning": "이 콘텐츠는 다른 Dify 앱 버전에서 복사되었습니다. 일부 항목은 호환되지 않을 수 있습니다.",
|
||||
"common.configure": "구성",
|
||||
"common.configureRequired": "구성 필요",
|
||||
"common.conversationLog": "대화 로그",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "ВЕТКА",
|
||||
"common.chooseDSL": "Выберите файл DSL(yml)",
|
||||
"common.chooseStartNodeToRun": "Выберите начальный узел для запуска",
|
||||
"common.clipboardVersionCompatibilityWarning": "Этот контент был скопирован из другой версии приложения Dify. Некоторые части могут быть несовместимы.",
|
||||
"common.configure": "Настроить",
|
||||
"common.configureRequired": "Требуется настройка",
|
||||
"common.conversationLog": "Журнал разговоров",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "กิ่ง",
|
||||
"common.chooseDSL": "เลือกไฟล์ DSL",
|
||||
"common.chooseStartNodeToRun": "เลือกโหนดเริ่มต้นเพื่อรัน",
|
||||
"common.clipboardVersionCompatibilityWarning": "เนื้อหานี้ถูกคัดลอกจากแอป Dify คนละเวอร์ชัน บางส่วนอาจไม่เข้ากัน",
|
||||
"common.configure": "กําหนดค่า",
|
||||
"common.configureRequired": "กําหนดค่าที่จําเป็น",
|
||||
"common.conversationLog": "บันทึกการสนทนา",
|
||||
|
|
|
|||
|
|
@ -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üğü",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "ГІЛКА",
|
||||
"common.chooseDSL": "Виберіть файл DSL(yml)",
|
||||
"common.chooseStartNodeToRun": "Виберіть початковий вузол для запуску",
|
||||
"common.clipboardVersionCompatibilityWarning": "Цей вміст скопійовано з іншої версії застосунку Dify. Деякі частини можуть бути несумісними.",
|
||||
"common.configure": "Налаштувати",
|
||||
"common.configureRequired": "Потрібна конфігурація",
|
||||
"common.conversationLog": "Журнал розмов",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "分支",
|
||||
"common.chooseDSL": "选择 DSL(yml) 文件",
|
||||
"common.chooseStartNodeToRun": "选择启动节点进行运行",
|
||||
"common.clipboardVersionCompatibilityWarning": "此内容复制自不同版本的 Dify 应用,部分内容可能不兼容。",
|
||||
"common.configure": "配置",
|
||||
"common.configureRequired": "需要进行配置",
|
||||
"common.conversationLog": "对话记录",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"common.branch": "分支",
|
||||
"common.chooseDSL": "選擇 DSL(yml)檔",
|
||||
"common.chooseStartNodeToRun": "選擇要執行的起始節點",
|
||||
"common.clipboardVersionCompatibilityWarning": "此內容複製自不同版本的 Dify 應用,部分內容可能不相容。",
|
||||
"common.configure": "配置",
|
||||
"common.configureRequired": "需要進行配置",
|
||||
"common.conversationLog": "對話記錄",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue