dify
This commit is contained in:
54
dify/web/app/components/workflow/utils/common.ts
Normal file
54
dify/web/app/components/workflow/utils/common.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const isMac = () => {
|
||||
return navigator.userAgent.toUpperCase().includes('MAC')
|
||||
}
|
||||
|
||||
const specialKeysNameMap: Record<string, string | undefined> = {
|
||||
ctrl: '⌘',
|
||||
alt: '⌥',
|
||||
shift: '⇧',
|
||||
}
|
||||
|
||||
export const getKeyboardKeyNameBySystem = (key: string) => {
|
||||
if (isMac())
|
||||
return specialKeysNameMap[key] || key
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
const specialKeysCodeMap: Record<string, string | undefined> = {
|
||||
ctrl: 'meta',
|
||||
}
|
||||
|
||||
export const getKeyboardKeyCodeBySystem = (key: string) => {
|
||||
if (isMac())
|
||||
return specialKeysCodeMap[key] || key
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
export const isEventTargetInputArea = (target: HTMLElement) => {
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
|
||||
return true
|
||||
|
||||
if (target.contentEditable === 'true')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Format workflow run identifier using finished_at timestamp
|
||||
* @param finishedAt - Unix timestamp in seconds
|
||||
* @param fallbackText - Text to show when finishedAt is not available (default: 'Running')
|
||||
* @returns Formatted string like " (14:30:25)" or " (Running)"
|
||||
*/
|
||||
export const formatWorkflowRunIdentifier = (finishedAt?: number, fallbackText = 'Running'): string => {
|
||||
if (!finishedAt)
|
||||
return ` (${fallbackText})`
|
||||
|
||||
const date = new Date(finishedAt * 1000)
|
||||
const timeStr = date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
return ` (${timeStr})`
|
||||
}
|
||||
37
dify/web/app/components/workflow/utils/data-source.ts
Normal file
37
dify/web/app/components/workflow/utils/data-source.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
InputVar,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import type { DataSourceNodeType } from '../nodes/data-source/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
|
||||
export const getDataSourceCheckParams = (
|
||||
toolData: DataSourceNodeType,
|
||||
dataSourceList: ToolWithProvider[],
|
||||
language: string,
|
||||
) => {
|
||||
const { plugin_id, provider_type, datasource_name } = toolData
|
||||
const isBuiltIn = provider_type === CollectionType.builtIn
|
||||
const currentDataSource = dataSourceList.find(item => item.plugin_id === plugin_id)
|
||||
const currentDataSourceItem = currentDataSource?.tools.find(tool => tool.name === datasource_name)
|
||||
const formSchemas = currentDataSourceItem ? toolParametersToFormSchemas(currentDataSourceItem.parameters) : []
|
||||
|
||||
return {
|
||||
dataSourceInputsSchema: (() => {
|
||||
const formInputs: InputVar[] = []
|
||||
formSchemas.forEach((item: any) => {
|
||||
formInputs.push({
|
||||
label: item.label[language] || item.label.en_US,
|
||||
variable: item.variable,
|
||||
type: item.type,
|
||||
required: item.required,
|
||||
hide: item.hide,
|
||||
})
|
||||
})
|
||||
return formInputs
|
||||
})(),
|
||||
notAuthed: isBuiltIn && !!currentDataSource?.allow_delete && !currentDataSource?.is_authorized,
|
||||
language,
|
||||
}
|
||||
}
|
||||
28
dify/web/app/components/workflow/utils/debug.ts
Normal file
28
dify/web/app/components/workflow/utils/debug.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import { VarInInspectType } from '@/types/workflow'
|
||||
import { VarType } from '../types'
|
||||
|
||||
type OutputToVarInInspectParams = {
|
||||
nodeId: string
|
||||
name: string
|
||||
value: any
|
||||
}
|
||||
export const outputToVarInInspect = ({
|
||||
nodeId,
|
||||
name,
|
||||
value,
|
||||
}: OutputToVarInInspectParams): VarInInspect => {
|
||||
return {
|
||||
id: `${Date.now()}`, // TODO: wait for api
|
||||
type: VarInInspectType.node,
|
||||
name,
|
||||
description: '',
|
||||
selector: [nodeId, name],
|
||||
value_type: VarType.string, // TODO: wait for api or get from node
|
||||
value,
|
||||
edited: false,
|
||||
visible: true,
|
||||
is_truncated: false,
|
||||
full_content: { size_bytes: 0, download_url: '' },
|
||||
}
|
||||
}
|
||||
23
dify/web/app/components/workflow/utils/edge.ts
Normal file
23
dify/web/app/components/workflow/utils/edge.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
NodeRunningStatus,
|
||||
} from '../types'
|
||||
|
||||
export const getEdgeColor = (nodeRunningStatus?: NodeRunningStatus, isFailBranch?: boolean) => {
|
||||
if (nodeRunningStatus === NodeRunningStatus.Succeeded)
|
||||
return 'var(--color-workflow-link-line-success-handle)'
|
||||
|
||||
if (nodeRunningStatus === NodeRunningStatus.Failed)
|
||||
return 'var(--color-workflow-link-line-error-handle)'
|
||||
|
||||
if (nodeRunningStatus === NodeRunningStatus.Exception)
|
||||
return 'var(--color-workflow-link-line-failure-handle)'
|
||||
|
||||
if (nodeRunningStatus === NodeRunningStatus.Running) {
|
||||
if (isFailBranch)
|
||||
return 'var(--color-workflow-link-line-failure-handle)'
|
||||
|
||||
return 'var(--color-workflow-link-line-handle)'
|
||||
}
|
||||
|
||||
return 'var(--color-workflow-link-line-normal)'
|
||||
}
|
||||
529
dify/web/app/components/workflow/utils/elk-layout.ts
Normal file
529
dify/web/app/components/workflow/utils/elk-layout.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import ELK from 'elkjs/lib/elk.bundled.js'
|
||||
import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
BlockEnum,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
NODE_LAYOUT_HORIZONTAL_PADDING,
|
||||
NODE_LAYOUT_VERTICAL_PADDING,
|
||||
} from '@/app/components/workflow/constants'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
|
||||
import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types'
|
||||
|
||||
// Although the file name refers to Dagre, the implementation now relies on ELK's layered algorithm.
|
||||
// Keep the export signatures unchanged to minimise the blast radius while we migrate the layout stack.
|
||||
|
||||
const elk = new ELK()
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 244
|
||||
const DEFAULT_NODE_HEIGHT = 100
|
||||
|
||||
const ROOT_LAYOUT_OPTIONS = {
|
||||
'elk.algorithm': 'layered',
|
||||
'elk.direction': 'RIGHT',
|
||||
|
||||
// === Spacing - Maximum spacing to prevent any overlap ===
|
||||
'elk.layered.spacing.nodeNodeBetweenLayers': '100',
|
||||
'elk.spacing.nodeNode': '80',
|
||||
'elk.spacing.edgeNode': '50',
|
||||
'elk.spacing.edgeEdge': '30',
|
||||
'elk.spacing.edgeLabel': '10',
|
||||
'elk.spacing.portPort': '20',
|
||||
|
||||
// === Port Configuration ===
|
||||
'elk.portConstraints': 'FIXED_ORDER',
|
||||
'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES',
|
||||
'elk.port.side': 'SOUTH',
|
||||
|
||||
// === Node Placement - Best quality ===
|
||||
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
||||
'elk.layered.nodePlacement.favorStraightEdges': 'true',
|
||||
'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5',
|
||||
'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE',
|
||||
|
||||
// === Edge Routing - Maximum quality ===
|
||||
'elk.edgeRouting': 'SPLINES',
|
||||
'elk.layered.edgeRouting.selfLoopPlacement': 'NORTH',
|
||||
'elk.layered.edgeRouting.sloppySplineRouting': 'false',
|
||||
'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE',
|
||||
'elk.layered.edgeRouting.splines.sloppy.layerSpacingFactor': '1.2',
|
||||
|
||||
// === Crossing Minimization - Most aggressive ===
|
||||
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
||||
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
||||
'elk.layered.crossingMinimization.greedySwitchHierarchical.type': 'TWO_SIDED',
|
||||
'elk.layered.crossingMinimization.semiInteractive': 'true',
|
||||
'elk.layered.crossingMinimization.hierarchicalSweepiness': '0.9',
|
||||
|
||||
// === Layering Strategy - Best quality ===
|
||||
'elk.layered.layering.strategy': 'NETWORK_SIMPLEX',
|
||||
'elk.layered.layering.networkSimplex.nodeFlexibility': 'NODE_SIZE',
|
||||
'elk.layered.layering.layerConstraint': 'NONE',
|
||||
'elk.layered.layering.minWidth.upperBoundOnWidth': '4',
|
||||
|
||||
// === Cycle Breaking ===
|
||||
'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST',
|
||||
|
||||
// === Connected Components ===
|
||||
'elk.separateConnectedComponents': 'true',
|
||||
'elk.spacing.componentComponent': '100',
|
||||
|
||||
// === Node Size Constraints ===
|
||||
'elk.nodeSize.constraints': 'NODE_LABELS',
|
||||
'elk.nodeSize.options': 'DEFAULT_MINIMUM_SIZE MINIMUM_SIZE_ACCOUNTS_FOR_PADDING',
|
||||
|
||||
// === Edge Label Placement ===
|
||||
'elk.edgeLabels.placement': 'CENTER',
|
||||
'elk.edgeLabels.inline': 'true',
|
||||
|
||||
// === Compaction ===
|
||||
'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
|
||||
'elk.layered.compaction.postCompaction.constraints': 'EDGE_LENGTH',
|
||||
|
||||
// === High-Quality Mode ===
|
||||
'elk.layered.thoroughness': '10',
|
||||
'elk.layered.wrapping.strategy': 'OFF',
|
||||
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
||||
|
||||
// === Additional Optimizations ===
|
||||
'elk.layered.feedbackEdges': 'true',
|
||||
'elk.layered.mergeEdges': 'false',
|
||||
'elk.layered.mergeHierarchyEdges': 'false',
|
||||
'elk.layered.allowNonFlowPortsToSwitchSides': 'false',
|
||||
'elk.layered.northOrSouthPort': 'false',
|
||||
'elk.partitioning.activate': 'false',
|
||||
'elk.junctionPoints': 'true',
|
||||
|
||||
// === Content Alignment ===
|
||||
'elk.contentAlignment': 'V_TOP H_LEFT',
|
||||
'elk.alignment': 'AUTOMATIC',
|
||||
}
|
||||
|
||||
const CHILD_LAYOUT_OPTIONS = {
|
||||
'elk.algorithm': 'layered',
|
||||
'elk.direction': 'RIGHT',
|
||||
|
||||
// === Spacing - High quality for child nodes ===
|
||||
'elk.layered.spacing.nodeNodeBetweenLayers': '80',
|
||||
'elk.spacing.nodeNode': '60',
|
||||
'elk.spacing.edgeNode': '40',
|
||||
'elk.spacing.edgeEdge': '25',
|
||||
'elk.spacing.edgeLabel': '8',
|
||||
'elk.spacing.portPort': '15',
|
||||
|
||||
// === Node Placement - Best quality ===
|
||||
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
||||
'elk.layered.nodePlacement.favorStraightEdges': 'true',
|
||||
'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5',
|
||||
'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE',
|
||||
|
||||
// === Edge Routing - Maximum quality ===
|
||||
'elk.edgeRouting': 'SPLINES',
|
||||
'elk.layered.edgeRouting.sloppySplineRouting': 'false',
|
||||
'elk.layered.edgeRouting.splines.mode': 'CONSERVATIVE',
|
||||
|
||||
// === Crossing Minimization - Aggressive ===
|
||||
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
||||
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
||||
'elk.layered.crossingMinimization.semiInteractive': 'true',
|
||||
|
||||
// === Layering Strategy ===
|
||||
'elk.layered.layering.strategy': 'NETWORK_SIMPLEX',
|
||||
'elk.layered.layering.networkSimplex.nodeFlexibility': 'NODE_SIZE',
|
||||
|
||||
// === Cycle Breaking ===
|
||||
'elk.layered.cycleBreaking.strategy': 'DEPTH_FIRST',
|
||||
|
||||
// === Node Size ===
|
||||
'elk.nodeSize.constraints': 'NODE_LABELS',
|
||||
|
||||
// === Compaction ===
|
||||
'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
|
||||
|
||||
// === High-Quality Mode ===
|
||||
'elk.layered.thoroughness': '10',
|
||||
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
||||
|
||||
// === Additional Optimizations ===
|
||||
'elk.layered.feedbackEdges': 'true',
|
||||
'elk.layered.mergeEdges': 'false',
|
||||
'elk.junctionPoints': 'true',
|
||||
}
|
||||
|
||||
type LayoutInfo = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
layer?: number
|
||||
}
|
||||
|
||||
type LayoutBounds = {
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
export type LayoutResult = {
|
||||
nodes: Map<string, LayoutInfo>
|
||||
bounds: LayoutBounds
|
||||
}
|
||||
|
||||
// ELK Port definition for native port support
|
||||
type ElkPortShape = {
|
||||
id: string
|
||||
layoutOptions?: LayoutOptions
|
||||
}
|
||||
|
||||
type ElkNodeShape = {
|
||||
id: string
|
||||
width: number
|
||||
height: number
|
||||
ports?: ElkPortShape[]
|
||||
layoutOptions?: LayoutOptions
|
||||
children?: ElkNodeShape[]
|
||||
}
|
||||
|
||||
type ElkEdgeShape = {
|
||||
id: string
|
||||
sources: string[]
|
||||
targets: string[]
|
||||
sourcePort?: string
|
||||
targetPort?: string
|
||||
}
|
||||
|
||||
const toElkNode = (node: Node): ElkNodeShape => ({
|
||||
id: node.id,
|
||||
width: node.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: node.height ?? DEFAULT_NODE_HEIGHT,
|
||||
})
|
||||
|
||||
let edgeCounter = 0
|
||||
const nextEdgeId = () => `elk-edge-${edgeCounter++}`
|
||||
|
||||
const createEdge = (
|
||||
source: string,
|
||||
target: string,
|
||||
sourcePort?: string,
|
||||
targetPort?: string,
|
||||
): ElkEdgeShape => ({
|
||||
id: nextEdgeId(),
|
||||
sources: [source],
|
||||
targets: [target],
|
||||
sourcePort,
|
||||
targetPort,
|
||||
})
|
||||
|
||||
const collectLayout = (graph: ElkNode, predicate: (id: string) => boolean): LayoutResult => {
|
||||
const result = new Map<string, LayoutInfo>()
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
const visit = (node: ElkNode) => {
|
||||
node.children?.forEach((child: ElkNode) => {
|
||||
if (predicate(child.id)) {
|
||||
const x = child.x ?? 0
|
||||
const y = child.y ?? 0
|
||||
const width = child.width ?? DEFAULT_NODE_WIDTH
|
||||
const height = child.height ?? DEFAULT_NODE_HEIGHT
|
||||
const layer = child?.layoutOptions?.['org.eclipse.elk.layered.layerIndex']
|
||||
|
||||
result.set(child.id, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
layer: layer ? Number.parseInt(layer) : undefined,
|
||||
})
|
||||
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x + width)
|
||||
maxY = Math.max(maxY, y + height)
|
||||
}
|
||||
|
||||
if (child.children?.length)
|
||||
visit(child)
|
||||
})
|
||||
}
|
||||
|
||||
visit(graph)
|
||||
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
||||
minX = 0
|
||||
minY = 0
|
||||
maxX = 0
|
||||
maxY = 0
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: result,
|
||||
bounds: {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build If/Else node with ELK native Ports instead of dummy nodes
|
||||
* This is the recommended approach for handling multiple branches
|
||||
*/
|
||||
const buildIfElseWithPorts = (
|
||||
ifElseNode: Node,
|
||||
edges: Edge[],
|
||||
): { node: ElkNodeShape; portMap: Map<string, string> } | null => {
|
||||
const childEdges = edges.filter(edge => edge.source === ifElseNode.id)
|
||||
|
||||
if (childEdges.length <= 1)
|
||||
return null
|
||||
|
||||
// Sort child edges according to case order
|
||||
const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => {
|
||||
const handleA = edgeA.sourceHandle
|
||||
const handleB = edgeB.sourceHandle
|
||||
|
||||
if (handleA && handleB) {
|
||||
const cases = (ifElseNode.data as IfElseNodeType).cases || []
|
||||
const isAElse = handleA === 'false'
|
||||
const isBElse = handleB === 'false'
|
||||
|
||||
if (isAElse)
|
||||
return 1
|
||||
if (isBElse)
|
||||
return -1
|
||||
|
||||
const indexA = cases.findIndex((c: CaseItem) => c.case_id === handleA)
|
||||
const indexB = cases.findIndex((c: CaseItem) => c.case_id === handleB)
|
||||
|
||||
if (indexA !== -1 && indexB !== -1)
|
||||
return indexA - indexB
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
// Create ELK ports for each branch
|
||||
const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({
|
||||
id: `${ifElseNode.id}-port-${edge.sourceHandle || index}`,
|
||||
layoutOptions: {
|
||||
'port.side': 'EAST', // Ports on the right side (matching 'RIGHT' direction)
|
||||
'port.index': String(index),
|
||||
},
|
||||
}))
|
||||
|
||||
// Build port mapping: sourceHandle -> portId
|
||||
const portMap = new Map<string, string>()
|
||||
sortedChildEdges.forEach((edge, index) => {
|
||||
const portId = `${ifElseNode.id}-port-${edge.sourceHandle || index}`
|
||||
portMap.set(edge.id, portId)
|
||||
})
|
||||
|
||||
return {
|
||||
node: {
|
||||
id: ifElseNode.id,
|
||||
width: ifElseNode.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: ifElseNode.height ?? DEFAULT_NODE_HEIGHT,
|
||||
ports,
|
||||
layoutOptions: {
|
||||
'elk.portConstraints': 'FIXED_ORDER',
|
||||
},
|
||||
},
|
||||
portMap,
|
||||
}
|
||||
}
|
||||
|
||||
const normaliseBounds = (layout: LayoutResult): LayoutResult => {
|
||||
const {
|
||||
nodes,
|
||||
bounds,
|
||||
} = layout
|
||||
|
||||
if (nodes.size === 0)
|
||||
return layout
|
||||
|
||||
const offsetX = bounds.minX
|
||||
const offsetY = bounds.minY
|
||||
|
||||
const adjustedNodes = new Map<string, LayoutInfo>()
|
||||
nodes.forEach((info, id) => {
|
||||
adjustedNodes.set(id, {
|
||||
...info,
|
||||
x: info.x - offsetX,
|
||||
y: info.y - offsetY,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: adjustedNodes,
|
||||
bounds: {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: bounds.maxX - offsetX,
|
||||
maxY: bounds.maxY - offsetY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const getLayoutByDagre = async (originNodes: Node[], originEdges: Edge[]): Promise<LayoutResult> => {
|
||||
edgeCounter = 0
|
||||
const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE)
|
||||
const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop))
|
||||
|
||||
const elkNodes: ElkNodeShape[] = []
|
||||
const elkEdges: ElkEdgeShape[] = []
|
||||
|
||||
// Track which edges have been processed for If/Else nodes with ports
|
||||
const edgeToPortMap = new Map<string, string>()
|
||||
|
||||
// Build nodes with ports for If/Else nodes
|
||||
nodes.forEach((node) => {
|
||||
if (node.data.type === BlockEnum.IfElse) {
|
||||
const portsResult = buildIfElseWithPorts(node, edges)
|
||||
if (portsResult) {
|
||||
// Use node with ports
|
||||
elkNodes.push(portsResult.node)
|
||||
// Store port mappings for edges
|
||||
portsResult.portMap.forEach((portId, edgeId) => {
|
||||
edgeToPortMap.set(edgeId, portId)
|
||||
})
|
||||
}
|
||||
else {
|
||||
// No multiple branches, use normal node
|
||||
elkNodes.push(toElkNode(node))
|
||||
}
|
||||
}
|
||||
else {
|
||||
elkNodes.push(toElkNode(node))
|
||||
}
|
||||
})
|
||||
|
||||
// Build edges with port connections
|
||||
edges.forEach((edge) => {
|
||||
const sourcePort = edgeToPortMap.get(edge.id)
|
||||
elkEdges.push(createEdge(edge.source, edge.target, sourcePort))
|
||||
})
|
||||
|
||||
const graph = {
|
||||
id: 'workflow-root',
|
||||
layoutOptions: ROOT_LAYOUT_OPTIONS,
|
||||
children: elkNodes,
|
||||
edges: elkEdges,
|
||||
}
|
||||
|
||||
const layoutedGraph = await elk.layout(graph)
|
||||
// No need to filter dummy nodes anymore, as we're using ports
|
||||
const layout = collectLayout(layoutedGraph, () => true)
|
||||
return normaliseBounds(layout)
|
||||
}
|
||||
|
||||
const normaliseChildLayout = (
|
||||
layout: LayoutResult,
|
||||
nodes: Node[],
|
||||
): LayoutResult => {
|
||||
const result = new Map<string, LayoutInfo>()
|
||||
layout.nodes.forEach((info, id) => {
|
||||
result.set(id, info)
|
||||
})
|
||||
|
||||
// Ensure iteration / loop start nodes do not collapse into the children.
|
||||
const startNode = nodes.find(node =>
|
||||
node.type === CUSTOM_ITERATION_START_NODE
|
||||
|| node.type === CUSTOM_LOOP_START_NODE
|
||||
|| node.data?.type === BlockEnum.LoopStart
|
||||
|| node.data?.type === BlockEnum.IterationStart,
|
||||
)
|
||||
|
||||
if (startNode) {
|
||||
const startLayout = result.get(startNode.id)
|
||||
|
||||
if (startLayout) {
|
||||
const desiredMinX = NODE_LAYOUT_HORIZONTAL_PADDING / 1.5
|
||||
if (startLayout.x > desiredMinX) {
|
||||
const shiftX = startLayout.x - desiredMinX
|
||||
result.forEach((value, key) => {
|
||||
result.set(key, {
|
||||
...value,
|
||||
x: value.x - shiftX,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const desiredMinY = startLayout.y
|
||||
const deltaY = NODE_LAYOUT_VERTICAL_PADDING / 2
|
||||
result.forEach((value, key) => {
|
||||
result.set(key, {
|
||||
...value,
|
||||
y: value.y - desiredMinY + deltaY,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
result.forEach((value) => {
|
||||
minX = Math.min(minX, value.x)
|
||||
minY = Math.min(minY, value.y)
|
||||
maxX = Math.max(maxX, value.x + value.width)
|
||||
maxY = Math.max(maxY, value.y + value.height)
|
||||
})
|
||||
|
||||
if (!Number.isFinite(minX) || !Number.isFinite(minY))
|
||||
return layout
|
||||
|
||||
return normaliseBounds({
|
||||
nodes: result,
|
||||
bounds: {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const getLayoutForChildNodes = async (
|
||||
parentNodeId: string,
|
||||
originNodes: Node[],
|
||||
originEdges: Edge[],
|
||||
): Promise<LayoutResult | null> => {
|
||||
edgeCounter = 0
|
||||
const nodes = cloneDeep(originNodes).filter(node => node.parentId === parentNodeId)
|
||||
if (!nodes.length)
|
||||
return null
|
||||
|
||||
const edges = cloneDeep(originEdges).filter(edge =>
|
||||
(edge.data?.isInIteration && edge.data?.iteration_id === parentNodeId)
|
||||
|| (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId),
|
||||
)
|
||||
|
||||
const elkNodes: ElkNodeShape[] = nodes.map(toElkNode)
|
||||
const elkEdges: ElkEdgeShape[] = edges.map(edge => createEdge(edge.source, edge.target))
|
||||
|
||||
const graph = {
|
||||
id: parentNodeId,
|
||||
layoutOptions: CHILD_LAYOUT_OPTIONS,
|
||||
children: elkNodes,
|
||||
edges: elkEdges,
|
||||
}
|
||||
|
||||
const layoutedGraph = await elk.layout(graph)
|
||||
const layout = collectLayout(layoutedGraph, () => true)
|
||||
return normaliseChildLayout(layout, nodes)
|
||||
}
|
||||
43
dify/web/app/components/workflow/utils/gen-node-meta-data.ts
Normal file
43
dify/web/app/components/workflow/utils/gen-node-meta-data.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import type { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
export type GenNodeMetaDataParams = {
|
||||
classification?: BlockClassificationEnum
|
||||
sort: number
|
||||
type: BlockEnum
|
||||
title?: string
|
||||
author?: string
|
||||
helpLinkUri?: string
|
||||
isRequired?: boolean
|
||||
isUndeletable?: boolean
|
||||
isStart?: boolean
|
||||
isSingleton?: boolean
|
||||
isTypeFixed?: boolean
|
||||
}
|
||||
export const genNodeMetaData = ({
|
||||
classification = BlockClassificationEnum.Default,
|
||||
sort,
|
||||
type,
|
||||
title = '',
|
||||
author = 'Dify',
|
||||
helpLinkUri,
|
||||
isRequired = false,
|
||||
isUndeletable = false,
|
||||
isStart = false,
|
||||
isSingleton = false,
|
||||
isTypeFixed = false,
|
||||
}: GenNodeMetaDataParams) => {
|
||||
return {
|
||||
classification,
|
||||
sort,
|
||||
type,
|
||||
title,
|
||||
author,
|
||||
helpLinkUri: helpLinkUri || type,
|
||||
isRequired,
|
||||
isUndeletable,
|
||||
isStart,
|
||||
isSingleton,
|
||||
isTypeFixed,
|
||||
}
|
||||
}
|
||||
10
dify/web/app/components/workflow/utils/index.ts
Normal file
10
dify/web/app/components/workflow/utils/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './node'
|
||||
export * from './edge'
|
||||
export * from './workflow-init'
|
||||
export * from './elk-layout'
|
||||
export * from './common'
|
||||
export * from './tool'
|
||||
export * from './workflow'
|
||||
export * from './variable'
|
||||
export * from './gen-node-meta-data'
|
||||
export * from './data-source'
|
||||
125
dify/web/app/components/workflow/utils/node-navigation.ts
Normal file
125
dify/web/app/components/workflow/utils/node-navigation.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Node navigation utilities for workflow
|
||||
* This module provides functions for node selection, focusing and scrolling in workflow
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface for node selection event detail
|
||||
*/
|
||||
export type NodeSelectionDetail = {
|
||||
nodeId: string;
|
||||
focus?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a node in the workflow
|
||||
* @param nodeId - The ID of the node to select
|
||||
* @param focus - Whether to focus/scroll to the node
|
||||
*/
|
||||
export function selectWorkflowNode(nodeId: string, focus = false): void {
|
||||
// Create and dispatch a custom event for node selection
|
||||
const event = new CustomEvent('workflow:select-node', {
|
||||
detail: {
|
||||
nodeId,
|
||||
focus,
|
||||
},
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a specific node in the workflow
|
||||
* @param nodeId - The ID of the node to scroll to
|
||||
*/
|
||||
export function scrollToWorkflowNode(nodeId: string): void {
|
||||
// Create and dispatch a custom event for scrolling to node
|
||||
const event = new CustomEvent('workflow:scroll-to-node', {
|
||||
detail: { nodeId },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup node selection event listener
|
||||
* @param handleNodeSelect - Function to handle node selection
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function setupNodeSelectionListener(
|
||||
handleNodeSelect: (nodeId: string) => void,
|
||||
): () => void {
|
||||
// Event handler for node selection
|
||||
const handleNodeSelection = (event: CustomEvent<NodeSelectionDetail>) => {
|
||||
const { nodeId, focus } = event.detail
|
||||
if (nodeId) {
|
||||
// Select the node
|
||||
handleNodeSelect(nodeId)
|
||||
|
||||
// If focus is requested, scroll to the node
|
||||
if (focus) {
|
||||
// Use a small timeout to ensure node selection happens first
|
||||
setTimeout(() => {
|
||||
scrollToWorkflowNode(nodeId)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener(
|
||||
'workflow:select-node',
|
||||
handleNodeSelection as EventListener,
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
'workflow:select-node',
|
||||
handleNodeSelection as EventListener,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scroll to node event listener with ReactFlow
|
||||
* @param nodes - The workflow nodes
|
||||
* @param reactflow - The ReactFlow instance
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function setupScrollToNodeListener(
|
||||
nodes: any[],
|
||||
reactflow: any,
|
||||
): () => void {
|
||||
// Event handler for scrolling to node
|
||||
const handleScrollToNode = (event: CustomEvent<NodeSelectionDetail>) => {
|
||||
const { nodeId } = event.detail
|
||||
if (nodeId) {
|
||||
// Find the target node
|
||||
const node = nodes.find(n => n.id === nodeId)
|
||||
if (node) {
|
||||
// Use ReactFlow's fitView API to scroll to the node
|
||||
const nodePosition = { x: node.position.x, y: node.position.y }
|
||||
|
||||
// Calculate position to place node in top-left area
|
||||
// Move the center point right and down to show node in top-left
|
||||
const targetX = nodePosition.x + window.innerWidth * 0.25
|
||||
const targetY = nodePosition.y + window.innerHeight * 0.25
|
||||
|
||||
reactflow.setCenter(targetX, targetY, { zoom: 1, duration: 800 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener(
|
||||
'workflow:scroll-to-node',
|
||||
handleScrollToNode as EventListener,
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
'workflow:scroll-to-node',
|
||||
handleScrollToNode as EventListener,
|
||||
)
|
||||
}
|
||||
}
|
||||
152
dify/web/app/components/workflow/utils/node.ts
Normal file
152
dify/web/app/components/workflow/utils/node.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Position,
|
||||
} from 'reactflow'
|
||||
import type {
|
||||
Node,
|
||||
} from '../types'
|
||||
import {
|
||||
BlockEnum,
|
||||
} from '../types'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
ITERATION_CHILDREN_Z_INDEX,
|
||||
ITERATION_NODE_Z_INDEX,
|
||||
LOOP_CHILDREN_Z_INDEX,
|
||||
LOOP_NODE_Z_INDEX,
|
||||
} from '../constants'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
import { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants'
|
||||
|
||||
export function generateNewNode({ data, position, id, zIndex, type, ...rest }: Omit<Node, 'id'> & { id?: string }): {
|
||||
newNode: Node
|
||||
newIterationStartNode?: Node
|
||||
newLoopStartNode?: Node
|
||||
} {
|
||||
const newNode = {
|
||||
id: id || `${Date.now()}`,
|
||||
type: type || CUSTOM_NODE,
|
||||
data,
|
||||
position,
|
||||
targetPosition: Position.Left,
|
||||
sourcePosition: Position.Right,
|
||||
zIndex: data.type === BlockEnum.Iteration ? ITERATION_NODE_Z_INDEX : (data.type === BlockEnum.Loop ? LOOP_NODE_Z_INDEX : zIndex),
|
||||
...rest,
|
||||
} as Node
|
||||
|
||||
if (data.type === BlockEnum.Iteration) {
|
||||
const newIterationStartNode = getIterationStartNode(newNode.id);
|
||||
(newNode.data as IterationNodeType).start_node_id = newIterationStartNode.id;
|
||||
(newNode.data as IterationNodeType)._children = [{ nodeId: newIterationStartNode.id, nodeType: BlockEnum.IterationStart }]
|
||||
return {
|
||||
newNode,
|
||||
newIterationStartNode,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === BlockEnum.Loop) {
|
||||
const newLoopStartNode = getLoopStartNode(newNode.id);
|
||||
(newNode.data as LoopNodeType).start_node_id = newLoopStartNode.id;
|
||||
(newNode.data as LoopNodeType)._children = [{ nodeId: newLoopStartNode.id, nodeType: BlockEnum.LoopStart }]
|
||||
return {
|
||||
newNode,
|
||||
newLoopStartNode,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
newNode,
|
||||
}
|
||||
}
|
||||
|
||||
export function getIterationStartNode(iterationId: string): Node {
|
||||
return generateNewNode({
|
||||
id: `${iterationId}start`,
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
data: {
|
||||
title: '',
|
||||
desc: '',
|
||||
type: BlockEnum.IterationStart,
|
||||
isInIteration: true,
|
||||
},
|
||||
position: {
|
||||
x: 24,
|
||||
y: 68,
|
||||
},
|
||||
zIndex: ITERATION_CHILDREN_Z_INDEX,
|
||||
parentId: iterationId,
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
}).newNode
|
||||
}
|
||||
|
||||
export function getLoopStartNode(loopId: string): Node {
|
||||
return generateNewNode({
|
||||
id: `${loopId}start`,
|
||||
type: CUSTOM_LOOP_START_NODE,
|
||||
data: {
|
||||
title: '',
|
||||
desc: '',
|
||||
type: BlockEnum.LoopStart,
|
||||
isInLoop: true,
|
||||
},
|
||||
position: {
|
||||
x: 24,
|
||||
y: 68,
|
||||
},
|
||||
zIndex: LOOP_CHILDREN_Z_INDEX,
|
||||
parentId: loopId,
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
}).newNode
|
||||
}
|
||||
|
||||
export const genNewNodeTitleFromOld = (oldTitle: string) => {
|
||||
const regex = /^(.+?)\s*\((\d+)\)\s*$/
|
||||
const match = oldTitle.match(regex)
|
||||
|
||||
if (match) {
|
||||
const title = match[1]
|
||||
const num = Number.parseInt(match[2], 10)
|
||||
return `${title} (${num + 1})`
|
||||
}
|
||||
else {
|
||||
return `${oldTitle} (1)`
|
||||
}
|
||||
}
|
||||
|
||||
export const getTopLeftNodePosition = (nodes: Node[]) => {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.position.x < minX)
|
||||
minX = node.position.x
|
||||
|
||||
if (node.position.y < minY)
|
||||
minY = node.position.y
|
||||
})
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
}
|
||||
}
|
||||
|
||||
export const getNestedNodePosition = (node: Node, parentNode: Node) => {
|
||||
return {
|
||||
x: node.position.x - parentNode.position.x,
|
||||
y: node.position.y - parentNode.position.y,
|
||||
}
|
||||
}
|
||||
|
||||
export const hasRetryNode = (nodeType?: BlockEnum) => {
|
||||
return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code
|
||||
}
|
||||
|
||||
export const getNodeCustomTypeByNodeDataType = (nodeType: BlockEnum) => {
|
||||
if (nodeType === BlockEnum.LoopEnd)
|
||||
return CUSTOM_SIMPLE_NODE
|
||||
}
|
||||
67
dify/web/app/components/workflow/utils/tool.ts
Normal file
67
dify/web/app/components/workflow/utils/tool.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
InputVar,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import { canFindTool } from '@/utils'
|
||||
import type { StructuredOutput } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
|
||||
export const getToolCheckParams = (
|
||||
toolData: ToolNodeType,
|
||||
buildInTools: ToolWithProvider[],
|
||||
customTools: ToolWithProvider[],
|
||||
workflowTools: ToolWithProvider[],
|
||||
language: string,
|
||||
) => {
|
||||
const { provider_id, provider_type, tool_name } = toolData
|
||||
const isBuiltIn = provider_type === CollectionType.builtIn
|
||||
const currentTools = provider_type === CollectionType.builtIn ? buildInTools : provider_type === CollectionType.custom ? customTools : workflowTools
|
||||
const currCollection = currentTools.find(item => canFindTool(item.id, provider_id))
|
||||
const currTool = currCollection?.tools.find(tool => tool.name === tool_name)
|
||||
const formSchemas = currTool ? toolParametersToFormSchemas(currTool.parameters) : []
|
||||
const toolInputVarSchema = formSchemas.filter(item => item.form === 'llm')
|
||||
const toolSettingSchema = formSchemas.filter(item => item.form !== 'llm')
|
||||
|
||||
return {
|
||||
toolInputsSchema: (() => {
|
||||
const formInputs: InputVar[] = []
|
||||
toolInputVarSchema.forEach((item: any) => {
|
||||
formInputs.push({
|
||||
label: item.label[language] || item.label.en_US,
|
||||
variable: item.variable,
|
||||
type: item.type,
|
||||
required: item.required,
|
||||
})
|
||||
})
|
||||
return formInputs
|
||||
})(),
|
||||
notAuthed: isBuiltIn && !!currCollection?.allow_delete && !currCollection?.is_team_authorization,
|
||||
toolSettingSchema,
|
||||
language,
|
||||
}
|
||||
}
|
||||
|
||||
export const CHUNK_TYPE_MAP = {
|
||||
general_chunks: 'GeneralStructureChunk',
|
||||
parent_child_chunks: 'ParentChildStructureChunk',
|
||||
qa_chunks: 'QAStructureChunk',
|
||||
}
|
||||
|
||||
export const wrapStructuredVarItem = (outputItem: any, matchedSchemaType: string): StructuredOutput => {
|
||||
const dataType = Type.object
|
||||
return {
|
||||
schema: {
|
||||
type: dataType,
|
||||
properties: {
|
||||
[outputItem.name]: {
|
||||
...outputItem.value,
|
||||
schemaType: matchedSchemaType,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
52
dify/web/app/components/workflow/utils/trigger.ts
Normal file
52
dify/web/app/components/workflow/utils/trigger.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import type { PluginTriggerNodeType } from '@/app/components/workflow/nodes/trigger-plugin/types'
|
||||
|
||||
export type TriggerCheckParams = {
|
||||
triggerInputsSchema: Array<{
|
||||
variable: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}>
|
||||
isReadyForCheckValid: boolean
|
||||
}
|
||||
|
||||
export const getTriggerCheckParams = (
|
||||
triggerData: PluginTriggerNodeType,
|
||||
triggerProviders: TriggerWithProvider[] | undefined,
|
||||
language: string,
|
||||
): TriggerCheckParams => {
|
||||
if (!triggerProviders) {
|
||||
return {
|
||||
triggerInputsSchema: [],
|
||||
isReadyForCheckValid: false,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
provider_id,
|
||||
provider_name,
|
||||
event_name,
|
||||
} = triggerData
|
||||
|
||||
const provider = triggerProviders.find(item =>
|
||||
item.name === provider_name
|
||||
|| item.id === provider_id
|
||||
|| (provider_id && item.plugin_id === provider_id),
|
||||
)
|
||||
|
||||
const currentEvent = provider?.events.find(event => event.name === event_name)
|
||||
|
||||
const triggerInputsSchema = (currentEvent?.parameters || []).map((parameter) => {
|
||||
const label = parameter.label?.[language] || parameter.label?.en_US || parameter.name
|
||||
return {
|
||||
variable: parameter.name,
|
||||
label,
|
||||
required: parameter.required,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
triggerInputsSchema,
|
||||
isReadyForCheckValid: true,
|
||||
}
|
||||
}
|
||||
18
dify/web/app/components/workflow/utils/variable.ts
Normal file
18
dify/web/app/components/workflow/utils/variable.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
ValueSelector,
|
||||
} from '../types'
|
||||
import type {
|
||||
BlockEnum,
|
||||
} from '../types'
|
||||
import { hasErrorHandleNode } from '.'
|
||||
|
||||
export const variableTransformer = (v: ValueSelector | string) => {
|
||||
if (typeof v === 'string')
|
||||
return v.replace(/^{{#|#}}$/g, '').split('.')
|
||||
|
||||
return `{{#${v.join('.')}#}}`
|
||||
}
|
||||
|
||||
export const isExceptionVariable = (variable: string, nodeType?: BlockEnum) => {
|
||||
return (variable === 'error_message' || variable === 'error_type') && hasErrorHandleNode(nodeType)
|
||||
}
|
||||
26
dify/web/app/components/workflow/utils/workflow-entry.ts
Normal file
26
dify/web/app/components/workflow/utils/workflow-entry.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { BlockEnum, type Node, isTriggerNode } from '../types'
|
||||
|
||||
/**
|
||||
* Get the workflow entry node
|
||||
* Priority: trigger nodes > start node
|
||||
*/
|
||||
export function getWorkflowEntryNode(nodes: Node[]): Node | undefined {
|
||||
const triggerNode = nodes.find(node => isTriggerNode(node.data.type))
|
||||
if (triggerNode) return triggerNode
|
||||
|
||||
return nodes.find(node => node.data.type === BlockEnum.Start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type is a workflow entry node
|
||||
*/
|
||||
export function isWorkflowEntryNode(nodeType: BlockEnum): boolean {
|
||||
return nodeType === BlockEnum.Start || isTriggerNode(nodeType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workflow is in trigger mode
|
||||
*/
|
||||
export function isTriggerWorkflow(nodes: Node[]): boolean {
|
||||
return nodes.some(node => isTriggerNode(node.data.type))
|
||||
}
|
||||
69
dify/web/app/components/workflow/utils/workflow-init.spec.ts
Normal file
69
dify/web/app/components/workflow/utils/workflow-init.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { preprocessNodesAndEdges } from './workflow-init'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import type {
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
|
||||
|
||||
describe('preprocessNodesAndEdges', () => {
|
||||
it('process nodes without iteration node or loop node should return origin nodes and edges.', () => {
|
||||
const nodes = [
|
||||
{
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result).toEqual({
|
||||
nodes,
|
||||
edges: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('process nodes with iteration node should return nodes with iteration start node', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'iteration',
|
||||
data: {
|
||||
type: BlockEnum.Iteration,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result.nodes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
type: BlockEnum.IterationStart,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('process nodes with iteration node start should return origin', () => {
|
||||
const nodes = [
|
||||
{
|
||||
data: {
|
||||
type: BlockEnum.Iteration,
|
||||
start_node_id: 'iterationStart',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'iterationStart',
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
data: {
|
||||
type: BlockEnum.IterationStart,
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = preprocessNodesAndEdges(nodes as Node[], [])
|
||||
expect(result).toEqual({
|
||||
nodes,
|
||||
edges: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
363
dify/web/app/components/workflow/utils/workflow-init.ts
Normal file
363
dify/web/app/components/workflow/utils/workflow-init.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
getConnectedEdges,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
cloneDeep,
|
||||
} from 'lodash-es'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '../types'
|
||||
import {
|
||||
BlockEnum,
|
||||
ErrorHandleMode,
|
||||
} from '../types'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
DEFAULT_RETRY_INTERVAL,
|
||||
DEFAULT_RETRY_MAX,
|
||||
ITERATION_CHILDREN_Z_INDEX,
|
||||
LOOP_CHILDREN_Z_INDEX,
|
||||
NODE_WIDTH_X_OFFSET,
|
||||
START_INITIAL_POSITION,
|
||||
} from '../constants'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
||||
import type { QuestionClassifierNodeType } from '../nodes/question-classifier/types'
|
||||
import type { IfElseNodeType } from '../nodes/if-else/types'
|
||||
import { branchNameCorrect } from '../nodes/if-else/utils'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import {
|
||||
getIterationStartNode,
|
||||
getLoopStartNode,
|
||||
} from '.'
|
||||
import { correctModelProvider } from '@/utils'
|
||||
|
||||
const WHITE = 'WHITE'
|
||||
const GRAY = 'GRAY'
|
||||
const BLACK = 'BLACK'
|
||||
const isCyclicUtil = (nodeId: string, color: Record<string, string>, adjList: Record<string, string[]>, stack: string[]) => {
|
||||
color[nodeId] = GRAY
|
||||
stack.push(nodeId)
|
||||
|
||||
for (let i = 0; i < adjList[nodeId].length; ++i) {
|
||||
const childId = adjList[nodeId][i]
|
||||
|
||||
if (color[childId] === GRAY) {
|
||||
stack.push(childId)
|
||||
return true
|
||||
}
|
||||
if (color[childId] === WHITE && isCyclicUtil(childId, color, adjList, stack))
|
||||
return true
|
||||
}
|
||||
color[nodeId] = BLACK
|
||||
if (stack.length > 0 && stack[stack.length - 1] === nodeId)
|
||||
stack.pop()
|
||||
return false
|
||||
}
|
||||
|
||||
const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
const adjList: Record<string, string[]> = {}
|
||||
const color: Record<string, string> = {}
|
||||
const stack: string[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
color[node.id] = WHITE
|
||||
adjList[node.id] = []
|
||||
}
|
||||
|
||||
for (const edge of edges)
|
||||
adjList[edge.source]?.push(edge.target)
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (color[nodes[i].id] === WHITE)
|
||||
isCyclicUtil(nodes[i].id, color, adjList, stack)
|
||||
}
|
||||
|
||||
const cycleEdges = []
|
||||
if (stack.length > 0) {
|
||||
const cycleNodes = new Set(stack)
|
||||
for (const edge of edges) {
|
||||
if (cycleNodes.has(edge.source) && cycleNodes.has(edge.target))
|
||||
cycleEdges.push(edge)
|
||||
}
|
||||
}
|
||||
|
||||
return cycleEdges
|
||||
}
|
||||
|
||||
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
|
||||
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
|
||||
|
||||
if (!hasIterationNode && !hasLoopNode) {
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
const nodesMap = nodes.reduce((prev, next) => {
|
||||
prev[next.id] = next
|
||||
return prev
|
||||
}, {} as Record<string, Node>)
|
||||
|
||||
const iterationNodesWithStartNode = []
|
||||
const iterationNodesWithoutStartNode = []
|
||||
const loopNodesWithStartNode = []
|
||||
const loopNodesWithoutStartNode = []
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const currentNode = nodes[i] as Node<IterationNodeType | LoopNodeType>
|
||||
|
||||
if (currentNode.data.type === BlockEnum.Iteration) {
|
||||
if (currentNode.data.start_node_id) {
|
||||
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_ITERATION_START_NODE)
|
||||
iterationNodesWithStartNode.push(currentNode)
|
||||
}
|
||||
else {
|
||||
iterationNodesWithoutStartNode.push(currentNode)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentNode.data.type === BlockEnum.Loop) {
|
||||
if (currentNode.data.start_node_id) {
|
||||
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE)
|
||||
loopNodesWithStartNode.push(currentNode)
|
||||
}
|
||||
else {
|
||||
loopNodesWithoutStartNode.push(currentNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newIterationStartNodesMap = {} as Record<string, Node>
|
||||
const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => {
|
||||
const newNode = getIterationStartNode(iterationNode.id)
|
||||
newNode.id = newNode.id + index
|
||||
newIterationStartNodesMap[iterationNode.id] = newNode
|
||||
return newNode
|
||||
})
|
||||
|
||||
const newLoopStartNodesMap = {} as Record<string, Node>
|
||||
const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map((loopNode, index) => {
|
||||
const newNode = getLoopStartNode(loopNode.id)
|
||||
newNode.id = newNode.id + index
|
||||
newLoopStartNodesMap[loopNode.id] = newNode
|
||||
return newNode
|
||||
})
|
||||
|
||||
const newEdges = [...iterationNodesWithStartNode, ...loopNodesWithStartNode].map((nodeItem) => {
|
||||
const isIteration = nodeItem.data.type === BlockEnum.Iteration
|
||||
const newNode = (isIteration ? newIterationStartNodesMap : newLoopStartNodesMap)[nodeItem.id]
|
||||
const startNode = nodesMap[nodeItem.data.start_node_id]
|
||||
const source = newNode.id
|
||||
const sourceHandle = 'source'
|
||||
const target = startNode.id
|
||||
const targetHandle = 'target'
|
||||
|
||||
const parentNode = nodes.find(node => node.id === startNode.parentId) || null
|
||||
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
|
||||
|
||||
return {
|
||||
id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
|
||||
type: 'custom',
|
||||
source,
|
||||
sourceHandle,
|
||||
target,
|
||||
targetHandle,
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: startNode.data.type,
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? startNode.parentId : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? startNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX,
|
||||
}
|
||||
})
|
||||
nodes.forEach((node) => {
|
||||
if (node.data.type === BlockEnum.Iteration && newIterationStartNodesMap[node.id])
|
||||
(node.data as IterationNodeType).start_node_id = newIterationStartNodesMap[node.id].id
|
||||
|
||||
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id])
|
||||
(node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes],
|
||||
edges: [...edges, ...newEdges],
|
||||
}
|
||||
}
|
||||
|
||||
export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
|
||||
const firstNode = nodes[0]
|
||||
|
||||
if (!firstNode?.position) {
|
||||
nodes.forEach((node, index) => {
|
||||
node.position = {
|
||||
x: START_INITIAL_POSITION.x + index * NODE_WIDTH_X_OFFSET,
|
||||
y: START_INITIAL_POSITION.y,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const iterationOrLoopNodeMap = nodes.reduce((acc, node) => {
|
||||
if (node.parentId) {
|
||||
if (acc[node.parentId])
|
||||
acc[node.parentId].push({ nodeId: node.id, nodeType: node.data.type })
|
||||
else
|
||||
acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }]
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, { nodeId: string; nodeType: BlockEnum }[]>)
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (!node.type)
|
||||
node.type = CUSTOM_NODE
|
||||
|
||||
const connectedEdges = getConnectedEdges([node], edges)
|
||||
node.data._connectedSourceHandleIds = connectedEdges.filter(edge => edge.source === node.id).map(edge => edge.sourceHandle || 'source')
|
||||
node.data._connectedTargetHandleIds = connectedEdges.filter(edge => edge.target === node.id).map(edge => edge.targetHandle || 'target')
|
||||
|
||||
if (node.data.type === BlockEnum.IfElse) {
|
||||
const nodeData = node.data as IfElseNodeType
|
||||
|
||||
if (!nodeData.cases && nodeData.logical_operator && nodeData.conditions) {
|
||||
(node.data as IfElseNodeType).cases = [
|
||||
{
|
||||
case_id: 'true',
|
||||
logical_operator: nodeData.logical_operator,
|
||||
conditions: nodeData.conditions,
|
||||
},
|
||||
]
|
||||
}
|
||||
node.data._targetBranches = branchNameCorrect([
|
||||
...(node.data as IfElseNodeType).cases.map(item => ({ id: item.case_id, name: '' })),
|
||||
{ id: 'false', name: '' },
|
||||
])
|
||||
// delete conditions and logical_operator if cases is not empty
|
||||
if (nodeData.cases.length > 0 && nodeData.conditions && nodeData.logical_operator) {
|
||||
delete nodeData.conditions
|
||||
delete nodeData.logical_operator
|
||||
}
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.QuestionClassifier) {
|
||||
node.data._targetBranches = (node.data as QuestionClassifierNodeType).classes.map((topic) => {
|
||||
return topic
|
||||
})
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.Iteration) {
|
||||
const iterationNodeData = node.data as IterationNodeType
|
||||
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
|
||||
iterationNodeData.is_parallel = iterationNodeData.is_parallel || false
|
||||
iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10
|
||||
iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
|
||||
}
|
||||
|
||||
// TODO: loop error handle mode
|
||||
if (node.data.type === BlockEnum.Loop) {
|
||||
const loopNodeData = node.data as LoopNodeType
|
||||
loopNodeData._children = iterationOrLoopNodeMap[node.id] || []
|
||||
loopNodeData.error_handle_mode = loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
|
||||
}
|
||||
|
||||
// legacy provider handle
|
||||
if (node.data.type === BlockEnum.LLM)
|
||||
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.KnowledgeRetrieval && (node as any).data.multiple_retrieval_config?.reranking_model)
|
||||
(node as any).data.multiple_retrieval_config.reranking_model.provider = correctModelProvider((node as any).data.multiple_retrieval_config?.reranking_model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.QuestionClassifier)
|
||||
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.ParameterExtractor)
|
||||
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.HttpRequest && !node.data.retry_config) {
|
||||
node.data.retry_config = {
|
||||
retry_enabled: true,
|
||||
max_retries: DEFAULT_RETRY_MAX,
|
||||
retry_interval: DEFAULT_RETRY_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.Tool && !(node as Node<ToolNodeType>).data.version && !(node as Node<ToolNodeType>).data.tool_node_version) {
|
||||
(node as Node<ToolNodeType>).data.tool_node_version = '2'
|
||||
|
||||
const toolConfigurations = (node as Node<ToolNodeType>).data.tool_configurations
|
||||
if (toolConfigurations && Object.keys(toolConfigurations).length > 0) {
|
||||
const newValues = { ...toolConfigurations }
|
||||
Object.keys(toolConfigurations).forEach((key) => {
|
||||
if (typeof toolConfigurations[key] !== 'object' || toolConfigurations[key] === null) {
|
||||
newValues[key] = {
|
||||
type: 'constant',
|
||||
value: toolConfigurations[key],
|
||||
}
|
||||
}
|
||||
});
|
||||
(node as Node<ToolNodeType>).data.tool_configurations = newValues
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
export const initialEdges = (originEdges: Edge[], originNodes: Node[]) => {
|
||||
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
|
||||
let selectedNode: Node | null = null
|
||||
const nodesMap = nodes.reduce((acc, node) => {
|
||||
acc[node.id] = node
|
||||
|
||||
if (node.data?.selected)
|
||||
selectedNode = node
|
||||
|
||||
return acc
|
||||
}, {} as Record<string, Node>)
|
||||
|
||||
const cycleEdges = getCycleEdges(nodes, edges)
|
||||
return edges.filter((edge) => {
|
||||
return !cycleEdges.find(cycEdge => cycEdge.source === edge.source && cycEdge.target === edge.target)
|
||||
}).map((edge) => {
|
||||
edge.type = 'custom'
|
||||
|
||||
if (!edge.sourceHandle)
|
||||
edge.sourceHandle = 'source'
|
||||
|
||||
if (!edge.targetHandle)
|
||||
edge.targetHandle = 'target'
|
||||
|
||||
if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
sourceType: nodesMap[edge.source].data.type!,
|
||||
} as any
|
||||
}
|
||||
|
||||
if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
targetType: nodesMap[edge.target].data.type!,
|
||||
} as any
|
||||
}
|
||||
|
||||
if (selectedNode) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
_connectedNodeIsSelected: edge.source === selectedNode.id || edge.target === selectedNode.id,
|
||||
} as any
|
||||
}
|
||||
|
||||
return edge
|
||||
})
|
||||
}
|
||||
187
dify/web/app/components/workflow/utils/workflow.ts
Normal file
187
dify/web/app/components/workflow/utils/workflow.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
getOutgoers,
|
||||
} from 'reactflow'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import {
|
||||
uniqBy,
|
||||
} from 'lodash-es'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '../types'
|
||||
import {
|
||||
BlockEnum,
|
||||
} from '../types'
|
||||
|
||||
export const canRunBySingle = (nodeType: BlockEnum, isChildNode: boolean) => {
|
||||
// child node means in iteration or loop. Set value to iteration(or loop) may cause variable not exit problem in backend.
|
||||
if(isChildNode && nodeType === BlockEnum.Assigner)
|
||||
return false
|
||||
return nodeType === BlockEnum.LLM
|
||||
|| nodeType === BlockEnum.KnowledgeRetrieval
|
||||
|| nodeType === BlockEnum.Code
|
||||
|| nodeType === BlockEnum.TemplateTransform
|
||||
|| nodeType === BlockEnum.QuestionClassifier
|
||||
|| nodeType === BlockEnum.HttpRequest
|
||||
|| nodeType === BlockEnum.Tool
|
||||
|| nodeType === BlockEnum.ParameterExtractor
|
||||
|| nodeType === BlockEnum.Iteration
|
||||
|| nodeType === BlockEnum.Agent
|
||||
|| nodeType === BlockEnum.DocExtractor
|
||||
|| nodeType === BlockEnum.Loop
|
||||
|| nodeType === BlockEnum.Start
|
||||
|| nodeType === BlockEnum.IfElse
|
||||
|| nodeType === BlockEnum.VariableAggregator
|
||||
|| nodeType === BlockEnum.Assigner
|
||||
|| nodeType === BlockEnum.DataSource
|
||||
|| nodeType === BlockEnum.TriggerSchedule
|
||||
|| nodeType === BlockEnum.TriggerWebhook
|
||||
|| nodeType === BlockEnum.TriggerPlugin
|
||||
}
|
||||
|
||||
export const isSupportCustomRunForm = (nodeType: BlockEnum) => {
|
||||
return nodeType === BlockEnum.DataSource
|
||||
}
|
||||
|
||||
type ConnectedSourceOrTargetNodesChange = {
|
||||
type: string
|
||||
edge: Edge
|
||||
}[]
|
||||
export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSourceOrTargetNodesChange, nodes: Node[]) => {
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = {} as Record<string, any>
|
||||
|
||||
changes.forEach((change) => {
|
||||
const {
|
||||
edge,
|
||||
type,
|
||||
} = change
|
||||
const sourceNode = nodes.find(node => node.id === edge.source)!
|
||||
if (sourceNode) {
|
||||
nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id] = nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id] || {
|
||||
_connectedSourceHandleIds: [...(sourceNode?.data._connectedSourceHandleIds || [])],
|
||||
_connectedTargetHandleIds: [...(sourceNode?.data._connectedTargetHandleIds || [])],
|
||||
}
|
||||
}
|
||||
|
||||
const targetNode = nodes.find(node => node.id === edge.target)!
|
||||
if (targetNode) {
|
||||
nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id] = nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id] || {
|
||||
_connectedSourceHandleIds: [...(targetNode?.data._connectedSourceHandleIds || [])],
|
||||
_connectedTargetHandleIds: [...(targetNode?.data._connectedTargetHandleIds || [])],
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceNode) {
|
||||
if (type === 'remove') {
|
||||
const index = nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.findIndex((handleId: string) => handleId === edge.sourceHandle)
|
||||
nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.splice(index, 1)
|
||||
}
|
||||
|
||||
if (type === 'add')
|
||||
nodesConnectedSourceOrTargetHandleIdsMap[sourceNode.id]._connectedSourceHandleIds.push(edge.sourceHandle || 'source')
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
if (type === 'remove') {
|
||||
const index = nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.findIndex((handleId: string) => handleId === edge.targetHandle)
|
||||
nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.splice(index, 1)
|
||||
}
|
||||
|
||||
if (type === 'add')
|
||||
nodesConnectedSourceOrTargetHandleIdsMap[targetNode.id]._connectedTargetHandleIds.push(edge.targetHandle || 'target')
|
||||
}
|
||||
})
|
||||
|
||||
return nodesConnectedSourceOrTargetHandleIdsMap
|
||||
}
|
||||
|
||||
export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
|
||||
// Find all start nodes (Start and Trigger nodes)
|
||||
const startNodes = nodes.filter(node =>
|
||||
node.data.type === BlockEnum.Start
|
||||
|| node.data.type === BlockEnum.TriggerSchedule
|
||||
|| node.data.type === BlockEnum.TriggerWebhook
|
||||
|| node.data.type === BlockEnum.TriggerPlugin,
|
||||
)
|
||||
|
||||
if (startNodes.length === 0) {
|
||||
return {
|
||||
validNodes: [],
|
||||
maxDepth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const list: Node[] = []
|
||||
let maxDepth = 0
|
||||
|
||||
const traverse = (root: Node, depth: number) => {
|
||||
// Add the current node to the list
|
||||
list.push(root)
|
||||
|
||||
if (depth > maxDepth)
|
||||
maxDepth = depth
|
||||
|
||||
const outgoers = getOutgoers(root, nodes, edges)
|
||||
|
||||
if (outgoers.length) {
|
||||
outgoers.forEach((outgoer) => {
|
||||
// Only traverse if we haven't processed this node yet (avoid cycles)
|
||||
if (!list.find(n => n.id === outgoer.id)) {
|
||||
if (outgoer.data.type === BlockEnum.Iteration)
|
||||
list.push(...nodes.filter(node => node.parentId === outgoer.id))
|
||||
if (outgoer.data.type === BlockEnum.Loop)
|
||||
list.push(...nodes.filter(node => node.parentId === outgoer.id))
|
||||
|
||||
traverse(outgoer, depth + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Leaf node - add iteration/loop children if any
|
||||
if (root.data.type === BlockEnum.Iteration)
|
||||
list.push(...nodes.filter(node => node.parentId === root.id))
|
||||
if (root.data.type === BlockEnum.Loop)
|
||||
list.push(...nodes.filter(node => node.parentId === root.id))
|
||||
}
|
||||
}
|
||||
|
||||
// Start traversal from all start nodes
|
||||
startNodes.forEach((startNode) => {
|
||||
if (!list.find(n => n.id === startNode.id))
|
||||
traverse(startNode, 1)
|
||||
})
|
||||
|
||||
return {
|
||||
validNodes: uniqBy(list, 'id'),
|
||||
maxDepth,
|
||||
}
|
||||
}
|
||||
|
||||
export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => {
|
||||
const idMap = nodes.reduce((acc, node) => {
|
||||
acc[node.id] = uuid4()
|
||||
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
const newNodes = nodes.map((node) => {
|
||||
return {
|
||||
...node,
|
||||
id: idMap[node.id],
|
||||
}
|
||||
})
|
||||
|
||||
const newEdges = edges.map((edge) => {
|
||||
return {
|
||||
...edge,
|
||||
source: idMap[edge.source],
|
||||
target: idMap[edge.target],
|
||||
}
|
||||
})
|
||||
|
||||
return [newNodes, newEdges] as [Node[], Edge[]]
|
||||
}
|
||||
|
||||
export const hasErrorHandleNode = (nodeType?: BlockEnum) => {
|
||||
return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code
|
||||
}
|
||||
Reference in New Issue
Block a user