mirror of
https://github.com/langgenius/dify.git
synced 2026-03-31 13:06:52 +00:00
Compare commits
1 Commits
feat/evalu
...
refactor/i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee98705ecc |
@@ -486,6 +486,242 @@ describe('getLayoutByELK', () => {
|
||||
expect(hiNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should build ports for QuestionClassifier sorted by classes order', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'qc-1',
|
||||
data: {
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: '',
|
||||
desc: '',
|
||||
classes: [{ id: 'cls-a', name: 'A' }, { id: 'cls-b', name: 'B' }, { id: 'cls-c', name: 'C' }],
|
||||
},
|
||||
}),
|
||||
makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'z', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e-c', source: 'qc-1', target: 'z', sourceHandle: 'cls-c' }),
|
||||
makeWorkflowEdge({ id: 'e-a', source: 'qc-1', target: 'x', sourceHandle: 'cls-a' }),
|
||||
makeWorkflowEdge({ id: 'e-b', source: 'qc-1', target: 'y', sourceHandle: 'cls-b' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')!
|
||||
const portIds = qcNode.ports!.map((p: { id: string }) => p.id)
|
||||
expect(portIds).toEqual([
|
||||
'qc-1-out-cls-a',
|
||||
'qc-1-out-cls-b',
|
||||
'qc-1-out-cls-c',
|
||||
])
|
||||
})
|
||||
|
||||
it('should build ports for QuestionClassifier with single class', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'qc-1',
|
||||
data: {
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: '',
|
||||
desc: '',
|
||||
classes: [{ id: 'cls-only', name: 'Only' }],
|
||||
},
|
||||
}),
|
||||
makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'x', sourceHandle: 'cls-only' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')!
|
||||
expect(qcNode.ports).toHaveLength(1)
|
||||
expect(qcNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST')
|
||||
})
|
||||
|
||||
it('should only create output (EAST) ports, not input (WEST) ports', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.End, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'a', target: 'b' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'b', target: 'c' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
layoutCallArgs!.children!.forEach((child: ElkChild) => {
|
||||
if (child.ports) {
|
||||
child.ports.forEach((port) => {
|
||||
expect(port.layoutOptions!['elk.port.side']).toBe('EAST')
|
||||
})
|
||||
}
|
||||
})
|
||||
const endNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'c')!
|
||||
expect(endNode.ports).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should order children array by DFS following port order', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: {
|
||||
type: BlockEnum.IfElse,
|
||||
title: '',
|
||||
desc: '',
|
||||
cases: [{ case_id: 'case-a', logical_operator: 'and', conditions: [] }],
|
||||
},
|
||||
}),
|
||||
makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'branch-a', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'branch-else', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'start', target: 'if-1' }),
|
||||
makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'branch-else', sourceHandle: 'false' }),
|
||||
makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'branch-a', sourceHandle: 'case-a' }),
|
||||
makeWorkflowEdge({ source: 'branch-a', target: 'end' }),
|
||||
makeWorkflowEdge({ source: 'branch-else', target: 'end' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id)
|
||||
// DFS from start: start → if-1 → branch-a (case-a first) → end → branch-else
|
||||
const idxA = childIds.indexOf('branch-a')
|
||||
const idxElse = childIds.indexOf('branch-else')
|
||||
expect(idxA).toBeLessThan(idxElse)
|
||||
})
|
||||
|
||||
it('should order children by DFS across nested branching nodes', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({
|
||||
id: 'qc-1',
|
||||
data: {
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: '',
|
||||
desc: '',
|
||||
classes: [{ id: 'c1', name: 'C1' }, { id: 'c2', name: 'C2' }],
|
||||
},
|
||||
}),
|
||||
makeWorkflowNode({ id: 'upper', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'lower', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'start', target: 'qc-1' }),
|
||||
makeWorkflowEdge({ id: 'e-c2', source: 'qc-1', target: 'lower', sourceHandle: 'c2' }),
|
||||
makeWorkflowEdge({ id: 'e-c1', source: 'qc-1', target: 'upper', sourceHandle: 'c1' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id)
|
||||
// DFS: start → qc-1 → upper (c1 first) → lower (c2 second)
|
||||
expect(childIds.indexOf('upper')).toBeLessThan(childIds.indexOf('lower'))
|
||||
})
|
||||
|
||||
it('should handle QuestionClassifier with no classes property', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'qc-1', data: { type: BlockEnum.QuestionClassifier, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'b', sourceHandle: 'cls-1' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'qc-1', target: 'c', sourceHandle: 'cls-2' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')!
|
||||
expect(qcNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle QuestionClassifier edges where handle not found in classes', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'qc-1',
|
||||
data: { type: BlockEnum.QuestionClassifier, title: '', desc: '', classes: [{ id: 'known', name: 'K' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'b', sourceHandle: 'unknown-1' }),
|
||||
makeWorkflowEdge({ id: 'e2', source: 'qc-1', target: 'c', sourceHandle: 'unknown-2' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')!
|
||||
expect(qcNode.ports).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should include disconnected nodes in the layout', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'connected', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'isolated', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'start', target: 'connected' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id)
|
||||
expect(childIds).toContain('isolated')
|
||||
expect(childIds).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should build edges in DFS order matching port order', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-a' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'start', target: 'if-1' }),
|
||||
makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'b', sourceHandle: 'false' }),
|
||||
makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'a', sourceHandle: 'case-a' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }>
|
||||
const ifEdges = elkEdges.filter(e => e.sources[0] === 'if-1')
|
||||
expect(ifEdges[0].targets[0]).toBe('a')
|
||||
expect(ifEdges[1].targets[0]).toBe('b')
|
||||
})
|
||||
|
||||
it('should keep edges for components where every node has an incoming edge', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({
|
||||
id: 'if-1',
|
||||
data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-a' }] },
|
||||
}),
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'a', sourceHandle: 'case-a' }),
|
||||
makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'b', sourceHandle: 'false' }),
|
||||
makeWorkflowEdge({ id: 'e-back', source: 'a', target: 'if-1' }),
|
||||
]
|
||||
|
||||
await getLayoutByELK(nodes, edges)
|
||||
|
||||
const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }>
|
||||
expect(elkEdges).toHaveLength(3)
|
||||
expect(elkEdges).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ sources: ['if-1'], targets: ['a'] }),
|
||||
expect.objectContaining({ sources: ['if-1'], targets: ['b'] }),
|
||||
expect.objectContaining({ sources: ['a'], targets: ['if-1'] }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should filter loop internal edges', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
|
||||
@@ -650,6 +886,45 @@ describe('getLayoutForChildNodes', () => {
|
||||
expect(result!.nodes.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should build ports and DFS-order for branching nodes inside iteration', async () => {
|
||||
const nodes = [
|
||||
makeWorkflowNode({ id: 'parent', data: { type: BlockEnum.Iteration, title: '', desc: '' } }),
|
||||
makeWorkflowNode({
|
||||
id: 'iter-start',
|
||||
type: CUSTOM_ITERATION_START_NODE,
|
||||
parentId: 'parent',
|
||||
data: { type: BlockEnum.IterationStart, title: '', desc: '' },
|
||||
}),
|
||||
makeWorkflowNode({
|
||||
id: 'qc-child',
|
||||
parentId: 'parent',
|
||||
data: {
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: '',
|
||||
desc: '',
|
||||
classes: [{ id: 'cls-1', name: 'C1' }, { id: 'cls-2', name: 'C2' }],
|
||||
},
|
||||
}),
|
||||
makeWorkflowNode({ id: 'upper', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
makeWorkflowNode({ id: 'lower', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }),
|
||||
]
|
||||
const edges = [
|
||||
makeWorkflowEdge({ source: 'iter-start', target: 'qc-child', data: { isInIteration: true, iteration_id: 'parent' } }),
|
||||
makeWorkflowEdge({ id: 'e-c2', source: 'qc-child', target: 'lower', sourceHandle: 'cls-2', data: { isInIteration: true, iteration_id: 'parent' } }),
|
||||
makeWorkflowEdge({ id: 'e-c1', source: 'qc-child', target: 'upper', sourceHandle: 'cls-1', data: { isInIteration: true, iteration_id: 'parent' } }),
|
||||
]
|
||||
|
||||
await getLayoutForChildNodes('parent', nodes, edges)
|
||||
|
||||
const qcElk = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-child')!
|
||||
expect(qcElk.ports).toHaveLength(2)
|
||||
expect(qcElk.ports![0].id).toContain('cls-1')
|
||||
expect(qcElk.ports![1].id).toContain('cls-2')
|
||||
|
||||
const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id)
|
||||
expect(childIds.indexOf('upper')).toBeLessThan(childIds.indexOf('lower'))
|
||||
})
|
||||
|
||||
it('should return original layout when bounds are not finite', async () => {
|
||||
mockReturnOverride = (graph: ElkGraph) => ({
|
||||
...graph,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api'
|
||||
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types'
|
||||
import type { QuestionClassifierNodeType, Topic } from '@/app/components/workflow/nodes/question-classifier/types'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
@@ -37,13 +38,13 @@ const ROOT_LAYOUT_OPTIONS = {
|
||||
|
||||
// === Port Configuration ===
|
||||
'elk.portConstraints': 'FIXED_ORDER',
|
||||
'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES',
|
||||
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
||||
'elk.layered.crossingMinimization.forceNodeModelOrder': 'true',
|
||||
|
||||
// === Node Placement - Best quality ===
|
||||
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
||||
// === Node Placement - Balanced centering ===
|
||||
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
||||
'elk.layered.nodePlacement.favorStraightEdges': 'true',
|
||||
'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5',
|
||||
'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE',
|
||||
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
||||
|
||||
// === Edge Routing - Maximum quality ===
|
||||
'elk.edgeRouting': 'SPLINES',
|
||||
@@ -56,7 +57,7 @@ const ROOT_LAYOUT_OPTIONS = {
|
||||
'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.semiInteractive': 'false',
|
||||
'elk.layered.crossingMinimization.hierarchicalSweepiness': '0.9',
|
||||
|
||||
// === Layering Strategy - Best quality ===
|
||||
@@ -115,11 +116,15 @@ const CHILD_LAYOUT_OPTIONS = {
|
||||
'elk.spacing.edgeLabel': '8',
|
||||
'elk.spacing.portPort': '15',
|
||||
|
||||
// === Node Placement - Best quality ===
|
||||
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
||||
// === Port Configuration ===
|
||||
'elk.portConstraints': 'FIXED_ORDER',
|
||||
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
||||
'elk.layered.crossingMinimization.forceNodeModelOrder': 'true',
|
||||
|
||||
// === Node Placement - Balanced centering ===
|
||||
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
||||
'elk.layered.nodePlacement.favorStraightEdges': 'true',
|
||||
'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5',
|
||||
'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE',
|
||||
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
||||
|
||||
// === Edge Routing - Maximum quality ===
|
||||
'elk.edgeRouting': 'SPLINES',
|
||||
@@ -129,7 +134,7 @@ const CHILD_LAYOUT_OPTIONS = {
|
||||
// === Crossing Minimization - Aggressive ===
|
||||
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
||||
'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED',
|
||||
'elk.layered.crossingMinimization.semiInteractive': 'true',
|
||||
'elk.layered.crossingMinimization.semiInteractive': 'false',
|
||||
|
||||
// === Layering Strategy ===
|
||||
'elk.layered.layering.strategy': 'NETWORK_SIMPLEX',
|
||||
@@ -197,12 +202,6 @@ type ElkEdgeShape = {
|
||||
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++}`
|
||||
|
||||
@@ -297,6 +296,24 @@ const sortIfElseOutEdges = (ifElseNode: Node, outEdges: Edge[]): Edge[] => {
|
||||
})
|
||||
}
|
||||
|
||||
const sortQuestionClassifierOutEdges = (classifierNode: Node, outEdges: Edge[]): Edge[] => {
|
||||
return [...outEdges].sort((edgeA, edgeB) => {
|
||||
const handleA = edgeA.sourceHandle
|
||||
const handleB = edgeB.sourceHandle
|
||||
|
||||
if (handleA && handleB) {
|
||||
const classes = (classifierNode.data as QuestionClassifierNodeType).classes || []
|
||||
const indexA = classes.findIndex((t: Topic) => t.id === handleA)
|
||||
const indexB = classes.findIndex((t: Topic) => t.id === handleB)
|
||||
|
||||
if (indexA !== -1 && indexB !== -1)
|
||||
return indexA - indexB
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] => {
|
||||
return [...outEdges].sort((edgeA, edgeB) => {
|
||||
const handleA = edgeA.sourceHandle
|
||||
@@ -352,63 +369,45 @@ const normaliseBounds = (layout: LayoutResult): LayoutResult => {
|
||||
}
|
||||
}
|
||||
|
||||
export const getLayoutByELK = 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))
|
||||
|
||||
/**
|
||||
* Build ELK nodes with output ports (sorted for branching types)
|
||||
* and edges ordered by a DFS traversal that follows port order.
|
||||
*/
|
||||
const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => {
|
||||
const outEdgesByNode = new Map<string, Edge[]>()
|
||||
const inEdgesByNode = new Map<string, Edge[]>()
|
||||
edges.forEach((edge) => {
|
||||
if (!outEdgesByNode.has(edge.source))
|
||||
outEdgesByNode.set(edge.source, [])
|
||||
outEdgesByNode.get(edge.source)!.push(edge)
|
||||
if (!inEdgesByNode.has(edge.target))
|
||||
inEdgesByNode.set(edge.target, [])
|
||||
inEdgesByNode.get(edge.target)!.push(edge)
|
||||
})
|
||||
|
||||
const elkNodes: ElkNodeShape[] = []
|
||||
const elkEdges: ElkEdgeShape[] = []
|
||||
const sourcePortMap = new Map<string, string>()
|
||||
const targetPortMap = new Map<string, string>()
|
||||
const sortedOutEdgesByNode = new Map<string, Edge[]>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const inEdges = inEdgesByNode.get(node.id) || []
|
||||
let outEdges = outEdgesByNode.get(node.id) || []
|
||||
|
||||
if (node.data.type === BlockEnum.IfElse)
|
||||
outEdges = sortIfElseOutEdges(node, outEdges)
|
||||
else if (node.data.type === BlockEnum.QuestionClassifier)
|
||||
outEdges = sortQuestionClassifierOutEdges(node, outEdges)
|
||||
else if (node.data.type === BlockEnum.HumanInput)
|
||||
outEdges = sortHumanInputOutEdges(node, outEdges)
|
||||
|
||||
sortedOutEdgesByNode.set(node.id, outEdges)
|
||||
|
||||
const ports: ElkPortShape[] = []
|
||||
|
||||
inEdges.forEach((edge, index) => {
|
||||
const portId = `${node.id}-in-${index}`
|
||||
ports.push({
|
||||
id: portId,
|
||||
layoutOptions: {
|
||||
'elk.port.side': 'WEST',
|
||||
'elk.port.index': String(index),
|
||||
},
|
||||
})
|
||||
targetPortMap.set(edge.id, portId)
|
||||
})
|
||||
|
||||
outEdges.forEach((edge, index) => {
|
||||
const ports: ElkPortShape[] = outEdges.map((edge, index) => {
|
||||
const portId = `${node.id}-out-${edge.sourceHandle || index}`
|
||||
ports.push({
|
||||
sourcePortMap.set(edge.id, portId)
|
||||
return {
|
||||
id: portId,
|
||||
layoutOptions: {
|
||||
'elk.port.side': 'EAST',
|
||||
'elk.port.index': String(index),
|
||||
},
|
||||
})
|
||||
sourcePortMap.set(edge.id, portId)
|
||||
}
|
||||
})
|
||||
|
||||
elkNodes.push({
|
||||
@@ -422,19 +421,51 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]):
|
||||
})
|
||||
})
|
||||
|
||||
// Build edges in sorted per-node order so PREFER_EDGES aligns with port order
|
||||
nodes.forEach((node) => {
|
||||
const outEdges = sortedOutEdgesByNode.get(node.id) || []
|
||||
// DFS in port order to determine the definitive vertical ordering of nodes.
|
||||
// forceNodeModelOrder makes ELK respect the children-array order within each layer.
|
||||
const nodeIdSet = new Set(nodes.map(n => n.id))
|
||||
const visited = new Set<string>()
|
||||
const orderedIds: string[] = []
|
||||
|
||||
const dfs = (id: string) => {
|
||||
if (visited.has(id) || !nodeIdSet.has(id))
|
||||
return
|
||||
visited.add(id)
|
||||
orderedIds.push(id)
|
||||
const outEdges = sortedOutEdgesByNode.get(id) || []
|
||||
outEdges.forEach(e => dfs(e.target))
|
||||
}
|
||||
|
||||
nodes.forEach((n) => {
|
||||
if (!edges.some(e => e.target === n.id))
|
||||
dfs(n.id)
|
||||
})
|
||||
nodes.forEach(n => dfs(n.id))
|
||||
|
||||
const nodeOrder = new Map(orderedIds.map((id, i) => [id, i]))
|
||||
elkNodes.sort((a, b) => (nodeOrder.get(a.id) ?? 0) - (nodeOrder.get(b.id) ?? 0))
|
||||
|
||||
orderedIds.forEach((id) => {
|
||||
const outEdges = sortedOutEdgesByNode.get(id) || []
|
||||
outEdges.forEach((edge) => {
|
||||
elkEdges.push(createEdge(
|
||||
edge.source,
|
||||
edge.target,
|
||||
sourcePortMap.get(edge.id),
|
||||
targetPortMap.get(edge.id),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
return { elkNodes, elkEdges }
|
||||
}
|
||||
|
||||
export const getLayoutByELK = 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, elkEdges } = buildPortAwareGraph(nodes, edges)
|
||||
|
||||
const graph = {
|
||||
id: 'workflow-root',
|
||||
layoutOptions: ROOT_LAYOUT_OPTIONS,
|
||||
@@ -443,7 +474,6 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]):
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -532,8 +562,7 @@ export const getLayoutForChildNodes = async (
|
||||
|| (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 { elkNodes, elkEdges } = buildPortAwareGraph(nodes, edges)
|
||||
|
||||
const graph = {
|
||||
id: parentNodeId,
|
||||
|
||||
Reference in New Issue
Block a user