mirror of
https://github.com/langgenius/dify.git
synced 2026-04-15 09:57:03 +08:00
fix(web): assign in-progress tracing items to latest loop/iteration record (#34661)
Co-authored-by: Blackoutta <37723456+Blackoutta@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
7f4bf19186
commit
5d4d60bb95
@ -0,0 +1,126 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LoopVariableMap, NodeTracing } from '@/types/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import LoopResultPanel from '../loop-result-panel'
|
||||
|
||||
const mockCodeEditor = vi.hoisted(() => vi.fn())
|
||||
const mockTracingPanel = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { title: ReactNode, value: unknown }) => {
|
||||
mockCodeEditor(props)
|
||||
return (
|
||||
<section data-testid="code-editor">
|
||||
<div>{props.title}</div>
|
||||
<div>{JSON.stringify(props.value)}</div>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
|
||||
__esModule: true,
|
||||
default: (props: { list: NodeTracing[], className?: string }) => {
|
||||
mockTracingPanel(props)
|
||||
return <div data-testid="tracing-panel">{props.list.length}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const createNodeTracing = (id: string, executionMetadata?: NonNullable<NodeTracing['execution_metadata']>): NodeTracing => ({
|
||||
id,
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: `node-${id}`,
|
||||
node_type: BlockEnum.Code,
|
||||
title: `Node ${id}`,
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
error: '',
|
||||
elapsed_time: 0,
|
||||
execution_metadata: executionMetadata,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Tester',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
})
|
||||
|
||||
describe('LoopResultPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Loop variables should be resolved by the actual run key, not the rendered row position.
|
||||
describe('Loop Variable Resolution', () => {
|
||||
it('should read loop variables by the actual loop index when rows are compacted', () => {
|
||||
const loopVariableMap: LoopVariableMap = {
|
||||
2: { item: 'alpha' },
|
||||
}
|
||||
|
||||
render(
|
||||
<LoopResultPanel
|
||||
list={[[
|
||||
createNodeTracing('loop-2-step-1', {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
loop_index: 2,
|
||||
}),
|
||||
]]}
|
||||
onBack={vi.fn()}
|
||||
loopVariableMap={loopVariableMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
|
||||
|
||||
expect(screen.getByTestId('code-editor')).toHaveTextContent('{"item":"alpha"}')
|
||||
expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: loopVariableMap[2],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should read loop variables by parallel run id when available', () => {
|
||||
const loopVariableMap: LoopVariableMap = {
|
||||
'parallel-1': { item: 'beta' },
|
||||
}
|
||||
|
||||
render(
|
||||
<LoopResultPanel
|
||||
list={[[
|
||||
createNodeTracing('parallel-step-1', {
|
||||
total_tokens: 0,
|
||||
total_price: 0,
|
||||
currency: 'USD',
|
||||
parallel_mode_run_id: 'parallel-1',
|
||||
}),
|
||||
]]}
|
||||
onBack={vi.fn()}
|
||||
loopVariableMap={loopVariableMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.singleRun.loop 1'))
|
||||
|
||||
expect(screen.getByTestId('code-editor')).toHaveTextContent('{"item":"beta"}')
|
||||
expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: loopVariableMap['parallel-1'],
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -19,6 +19,18 @@ import { cn } from '@/utils/classnames'
|
||||
|
||||
const i18nPrefix = 'singleRun'
|
||||
|
||||
const getLoopRunKey = (loop: NodeTracing[], fallbackIndex: number) => {
|
||||
const executionMetadata = loop[0]?.execution_metadata
|
||||
|
||||
if (executionMetadata?.parallel_mode_run_id !== undefined)
|
||||
return executionMetadata.parallel_mode_run_id
|
||||
|
||||
if (executionMetadata?.loop_index !== undefined)
|
||||
return String(executionMetadata.loop_index)
|
||||
|
||||
return String(fallbackIndex)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
list: NodeTracing[][]
|
||||
onBack: () => void
|
||||
@ -42,10 +54,8 @@ const LoopResultPanel: FC<Props> = ({
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const countLoopDuration = (loop: NodeTracing[], loopDurationMap: LoopDurationMap): string => {
|
||||
const loopRunIndex = loop[0]?.execution_metadata?.loop_index as number
|
||||
const loopRunId = loop[0]?.execution_metadata?.parallel_mode_run_id
|
||||
const loopItem = loopDurationMap[loopRunId || loopRunIndex]
|
||||
const countLoopDuration = (loop: NodeTracing[], index: number, loopDurationMap: LoopDurationMap): string => {
|
||||
const loopItem = loopDurationMap[getLoopRunKey(loop, index)]
|
||||
const duration = loopItem
|
||||
return `${(duration && duration > 0.01) ? duration.toFixed(2) : 0.01}s`
|
||||
}
|
||||
@ -59,13 +69,13 @@ const LoopResultPanel: FC<Props> = ({
|
||||
return <RiErrorWarningLine className="h-4 w-4 text-text-destructive" />
|
||||
|
||||
if (isRunning)
|
||||
return <RiLoader2Line className="h-3.5 w-3.5 animate-spin text-primary-600" />
|
||||
return <RiLoader2Line className="text-primary-600 h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDurationMap && (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{countLoopDuration(loop, loopDurationMap)}
|
||||
{countLoopDuration(loop, index, loopDurationMap)}
|
||||
</div>
|
||||
)}
|
||||
<RiArrowRightSLine
|
||||
@ -98,7 +108,7 @@ const LoopResultPanel: FC<Props> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center justify-between px-3',
|
||||
expandedLoops[index] ? 'pb-2 pt-3' : 'py-3',
|
||||
expandedLoops[index] ? 'pt-3 pb-2' : 'py-3',
|
||||
'rounded-xl text-left',
|
||||
)}
|
||||
onClick={() => toggleLoop(index)}
|
||||
@ -107,7 +117,7 @@ const LoopResultPanel: FC<Props> = ({
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center rounded-[5px] border-divider-subtle bg-util-colors-cyan-cyan-500">
|
||||
<Loop className="h-3 w-3 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<span className="system-sm-semibold-uppercase grow text-text-primary">
|
||||
<span className="grow system-sm-semibold-uppercase text-text-primary">
|
||||
{t(`${i18nPrefix}.loop`, { ns: 'workflow' })}
|
||||
{' '}
|
||||
{index + 1}
|
||||
@ -129,14 +139,14 @@ const LoopResultPanel: FC<Props> = ({
|
||||
)}
|
||||
>
|
||||
{
|
||||
loopVariableMap?.[index] && (
|
||||
loopVariableMap?.[getLoopRunKey(loop, index)] && (
|
||||
<div className="p-2 pb-0">
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>{t('nodes.loop.loopVariables', { ns: 'workflow' }).toLocaleUpperCase()}</div>}
|
||||
language={CodeLanguage.json}
|
||||
height={112}
|
||||
value={loopVariableMap[index]}
|
||||
value={loopVariableMap[getLoopRunKey(loop, index)]}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import format from '..'
|
||||
import format, { addChildrenToIterationNode } from '..'
|
||||
import graphToLogStruct from '../../graph-to-log-struct'
|
||||
|
||||
describe('iteration', () => {
|
||||
@ -9,15 +9,48 @@ describe('iteration', () => {
|
||||
it('result should have no nodes in iteration node', () => {
|
||||
expect(result.find(item => !!item.execution_metadata?.iteration_id)).toBeUndefined()
|
||||
})
|
||||
// test('iteration should put nodes in details', () => {
|
||||
// expect(result).toEqual([
|
||||
// startNode,
|
||||
// {
|
||||
// ...iterationNode,
|
||||
// details: [
|
||||
// [iterations[0], iterations[1]],
|
||||
// ],
|
||||
// },
|
||||
// ])
|
||||
// })
|
||||
|
||||
it('should place the first child of a new iteration at a new record when its index is missing', () => {
|
||||
const parent = { node_id: 'iter1', node_type: 'iteration', execution_metadata: {} } as unknown as NodeTracing
|
||||
const child0 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 0 } } as unknown as NodeTracing
|
||||
const streaming = { node_id: 'code', execution_metadata: { iteration_id: 'iter1' } } as unknown as NodeTracing
|
||||
|
||||
const result = addChildrenToIterationNode(parent, [child0, streaming])
|
||||
expect(result.details![0]).toEqual([child0])
|
||||
expect(result.details![1]).toEqual([streaming])
|
||||
})
|
||||
|
||||
it('should keep missing iteration_index items in the current record when the node has not restarted', () => {
|
||||
const parent = {
|
||||
node_id: 'iter1',
|
||||
node_type: 'iteration',
|
||||
execution_metadata: {
|
||||
iteration_duration_map: { 0: 1.2, 1: 0.4 },
|
||||
},
|
||||
} as unknown as NodeTracing
|
||||
const child0 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 0 } } as unknown as NodeTracing
|
||||
const child1 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 1 } } as unknown as NodeTracing
|
||||
const streaming = { node_id: 'tool', execution_metadata: { iteration_id: 'iter1' } } as unknown as NodeTracing
|
||||
|
||||
const result = addChildrenToIterationNode(parent, [child0, child1, streaming])
|
||||
expect(result.details![0]).toEqual([child0])
|
||||
expect(result.details![1]).toEqual([child1, streaming])
|
||||
})
|
||||
|
||||
it('should not jump to the latest iteration when an earlier item is missing iteration_index', () => {
|
||||
const parent = {
|
||||
node_id: 'iter1',
|
||||
node_type: 'iteration',
|
||||
execution_metadata: {
|
||||
iteration_duration_map: { 0: 1.2, 1: 0.4 },
|
||||
},
|
||||
} as unknown as NodeTracing
|
||||
const code0 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 0 } } as unknown as NodeTracing
|
||||
const tool = { node_id: 'tool', execution_metadata: { iteration_id: 'iter1' } } as unknown as NodeTracing
|
||||
const code1 = { node_id: 'code', execution_metadata: { iteration_id: 'iter1', iteration_index: 1 } } as unknown as NodeTracing
|
||||
|
||||
const result = addChildrenToIterationNode(parent, [code0, tool, code1])
|
||||
expect(result.details![0]).toEqual([code0, tool])
|
||||
expect(result.details![1]).toEqual([code1])
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,15 +4,31 @@ import formatParallelNode from '../parallel'
|
||||
|
||||
export function addChildrenToIterationNode(iterationNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing {
|
||||
const details: NodeTracing[][] = []
|
||||
childrenNodes.forEach((item, index) => {
|
||||
let lastResolvedIndex = -1
|
||||
|
||||
childrenNodes.forEach((item) => {
|
||||
if (!item.execution_metadata)
|
||||
return
|
||||
const { iteration_index = 0 } = item.execution_metadata
|
||||
const runIndex: number = iteration_index !== undefined ? iteration_index : index
|
||||
const { iteration_index } = item.execution_metadata
|
||||
let runIndex: number
|
||||
|
||||
if (iteration_index !== undefined) {
|
||||
runIndex = iteration_index
|
||||
}
|
||||
else if (lastResolvedIndex >= 0) {
|
||||
const currentGroup = details[lastResolvedIndex] || []
|
||||
const seenSameNodeInCurrentGroup = currentGroup.some(node => node.node_id === item.node_id)
|
||||
runIndex = seenSameNodeInCurrentGroup ? lastResolvedIndex + 1 : lastResolvedIndex
|
||||
}
|
||||
else {
|
||||
runIndex = 0
|
||||
}
|
||||
|
||||
if (!details[runIndex])
|
||||
details[runIndex] = []
|
||||
|
||||
details[runIndex].push(item)
|
||||
lastResolvedIndex = runIndex
|
||||
})
|
||||
return {
|
||||
...iterationNode,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import format from '..'
|
||||
import format, { addChildrenToLoopNode } from '..'
|
||||
import graphToLogStruct from '../../graph-to-log-struct'
|
||||
|
||||
describe('loop', () => {
|
||||
@ -21,4 +21,48 @@ describe('loop', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should place the first child of a new loop run at a new record when its index is missing', () => {
|
||||
const parent = { node_id: 'loop1', node_type: 'loop', execution_metadata: {} } as unknown as NodeTracing
|
||||
const child0 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 0 } } as unknown as NodeTracing
|
||||
const streaming = { node_id: 'code', execution_metadata: { loop_id: 'loop1' } } as unknown as NodeTracing
|
||||
|
||||
const result = addChildrenToLoopNode(parent, [child0, streaming])
|
||||
expect(result.details![0]).toEqual([child0])
|
||||
expect(result.details![1]).toEqual([streaming])
|
||||
})
|
||||
|
||||
it('should keep missing loop_index items in the current record when the node has not restarted', () => {
|
||||
const parent = {
|
||||
node_id: 'loop1',
|
||||
node_type: 'loop',
|
||||
execution_metadata: {
|
||||
loop_duration_map: { 0: 1.2, 1: 0.4 },
|
||||
},
|
||||
} as unknown as NodeTracing
|
||||
const child0 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 0 } } as unknown as NodeTracing
|
||||
const child1 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 1 } } as unknown as NodeTracing
|
||||
const streaming = { node_id: 'tool', execution_metadata: { loop_id: 'loop1' } } as unknown as NodeTracing
|
||||
|
||||
const result = addChildrenToLoopNode(parent, [child0, child1, streaming])
|
||||
expect(result.details![0]).toEqual([child0])
|
||||
expect(result.details![1]).toEqual([child1, streaming])
|
||||
})
|
||||
|
||||
it('should not jump to the latest loop when an earlier item is missing loop_index', () => {
|
||||
const parent = {
|
||||
node_id: 'loop1',
|
||||
node_type: 'loop',
|
||||
execution_metadata: {
|
||||
loop_duration_map: { 0: 1.2, 1: 0.4 },
|
||||
},
|
||||
} as unknown as NodeTracing
|
||||
const code0 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 0 } } as unknown as NodeTracing
|
||||
const tool = { node_id: 'tool', execution_metadata: { loop_id: 'loop1' } } as unknown as NodeTracing
|
||||
const code1 = { node_id: 'code', execution_metadata: { loop_id: 'loop1', loop_index: 1 } } as unknown as NodeTracing
|
||||
|
||||
const result = addChildrenToLoopNode(parent, [code0, tool, code1])
|
||||
expect(result.details![0]).toEqual([code0, tool])
|
||||
expect(result.details![1]).toEqual([code1])
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,20 +3,49 @@ import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import formatParallelNode from '../parallel'
|
||||
|
||||
export function addChildrenToLoopNode(loopNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing {
|
||||
const details: NodeTracing[][] = []
|
||||
const detailsByKey = new Map<string, NodeTracing[]>()
|
||||
let lastResolvedIndex = -1
|
||||
const order: string[] = []
|
||||
|
||||
const ensureGroup = (key: string) => {
|
||||
const group = detailsByKey.get(key)
|
||||
if (group)
|
||||
return group
|
||||
|
||||
const newGroup: NodeTracing[] = []
|
||||
detailsByKey.set(key, newGroup)
|
||||
order.push(key)
|
||||
return newGroup
|
||||
}
|
||||
|
||||
childrenNodes.forEach((item) => {
|
||||
if (!item.execution_metadata)
|
||||
return
|
||||
const { parallel_mode_run_id, loop_index = 0 } = item.execution_metadata
|
||||
const runIndex: number = (parallel_mode_run_id || loop_index) as number
|
||||
if (!details[runIndex])
|
||||
details[runIndex] = []
|
||||
const { parallel_mode_run_id, loop_index } = item.execution_metadata
|
||||
let runIndex: number | string
|
||||
|
||||
details[runIndex].push(item)
|
||||
if (parallel_mode_run_id !== undefined) {
|
||||
runIndex = parallel_mode_run_id
|
||||
}
|
||||
else if (loop_index !== undefined) {
|
||||
runIndex = loop_index
|
||||
}
|
||||
else if (lastResolvedIndex >= 0) {
|
||||
const currentGroup = detailsByKey.get(String(lastResolvedIndex)) || []
|
||||
const seenSameNodeInCurrentGroup = currentGroup.some(node => node.node_id === item.node_id)
|
||||
runIndex = seenSameNodeInCurrentGroup ? lastResolvedIndex + 1 : lastResolvedIndex
|
||||
}
|
||||
else {
|
||||
runIndex = 0
|
||||
}
|
||||
|
||||
ensureGroup(String(runIndex)).push(item)
|
||||
if (typeof runIndex === 'number')
|
||||
lastResolvedIndex = runIndex
|
||||
})
|
||||
return {
|
||||
...loopNode,
|
||||
details,
|
||||
details: order.map(key => detailsByKey.get(key) || []),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11073,11 +11073,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/run/loop-log/loop-result-panel.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/workflow/run/loop-result-panel.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 4
|
||||
|
||||
Loading…
Reference in New Issue
Block a user