diff --git a/web/app/components/workflow/run/__tests__/hooks.spec.ts b/web/app/components/workflow/run/__tests__/hooks.spec.ts
new file mode 100644
index 0000000000..d6eefbcd3e
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/hooks.spec.ts
@@ -0,0 +1,127 @@
+import type {
+ AgentLogItemWithChildren,
+ IterationDurationMap,
+ LoopDurationMap,
+ LoopVariableMap,
+ NodeTracing,
+} from '@/types/workflow'
+import { act, renderHook } from '@testing-library/react'
+import { BlockEnum } from '../../types'
+import { useLogs } from '../hooks'
+
+const createNodeTracing = (id: string): NodeTracing => ({
+ id,
+ index: 0,
+ predecessor_node_id: '',
+ node_id: id,
+ node_type: BlockEnum.Tool,
+ title: id,
+ inputs: {},
+ inputs_truncated: false,
+ process_data: {},
+ process_data_truncated: false,
+ outputs_truncated: false,
+ status: 'succeeded',
+ elapsed_time: 1,
+ metadata: {
+ iterator_length: 0,
+ iterator_index: 0,
+ loop_length: 0,
+ loop_index: 0,
+ },
+ created_at: 0,
+ created_by: {
+ id: 'user-1',
+ name: 'User',
+ email: 'user@example.com',
+ },
+ finished_at: 1,
+})
+
+const createAgentLog = (id: string, children: AgentLogItemWithChildren[] = []): AgentLogItemWithChildren => ({
+ node_execution_id: `execution-${id}`,
+ node_id: `node-${id}`,
+ parent_id: undefined,
+ label: id,
+ status: 'success',
+ data: {},
+ metadata: {},
+ message_id: id,
+ children,
+})
+
+describe('useLogs', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should manage retry, iteration, and loop detail panels', () => {
+ const { result } = renderHook(() => useLogs())
+ const retryDetail = [createNodeTracing('retry-node')]
+ const iterationDetail = [[createNodeTracing('iteration-node')]]
+ const loopDetail = [[createNodeTracing('loop-node')]]
+ const iterationDurationMap: IterationDurationMap = { 'iteration-node': 2 }
+ const loopDurationMap: LoopDurationMap = { 'loop-node': 3 }
+ const loopVariableMap: LoopVariableMap = { 'loop-node': { item: 'value' } }
+
+ expect(result.current.showSpecialResultPanel).toBe(false)
+
+ act(() => {
+ result.current.handleShowRetryResultList(retryDetail)
+ })
+
+ expect(result.current.showRetryDetail).toBe(true)
+ expect(result.current.retryResultList).toEqual(retryDetail)
+ expect(result.current.showSpecialResultPanel).toBe(true)
+
+ act(() => {
+ result.current.setShowRetryDetailFalse()
+ result.current.handleShowIterationResultList(iterationDetail, iterationDurationMap)
+ result.current.handleShowLoopResultList(loopDetail, loopDurationMap, loopVariableMap)
+ })
+
+ expect(result.current.showRetryDetail).toBe(false)
+ expect(result.current.showIteratingDetail).toBe(true)
+ expect(result.current.iterationResultList).toEqual(iterationDetail)
+ expect(result.current.iterationResultDurationMap).toEqual(iterationDurationMap)
+ expect(result.current.showLoopingDetail).toBe(true)
+ expect(result.current.loopResultList).toEqual(loopDetail)
+ expect(result.current.loopResultDurationMap).toEqual(loopDurationMap)
+ expect(result.current.loopResultVariableMap).toEqual(loopVariableMap)
+ })
+
+ it('should push, trim, and clear agent/tool log navigation state', () => {
+ const { result } = renderHook(() => useLogs())
+ const childLog = createAgentLog('child-log')
+ const rootLog = createAgentLog('root-log', [childLog])
+ const siblingLog = createAgentLog('sibling-log')
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(rootLog)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
+ expect(result.current.agentOrToolLogListMap).toEqual({
+ 'root-log': [childLog],
+ })
+ expect(result.current.showSpecialResultPanel).toBe(true)
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(siblingLog)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([rootLog, siblingLog])
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(rootLog)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
+
+ act(() => {
+ result.current.handleShowAgentOrToolLog(undefined)
+ })
+
+ expect(result.current.agentOrToolLogItemStack).toEqual([])
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/result-panel.spec.tsx b/web/app/components/workflow/run/__tests__/result-panel.spec.tsx
new file mode 100644
index 0000000000..ea8606d74e
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/result-panel.spec.tsx
@@ -0,0 +1,356 @@
+import type { ReactNode } from 'react'
+import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { BlockEnum, NodeRunningStatus } from '../../types'
+import ResultPanel from '../result-panel'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockCodeEditor = vi.hoisted(() => vi.fn())
+const mockLargeDataAlert = vi.hoisted(() => vi.fn())
+const mockStatusPanel = vi.hoisted(() => vi.fn())
+const mockMetaData = vi.hoisted(() => vi.fn())
+const mockErrorHandleTip = vi.hoisted(() => vi.fn())
+const mockIterationLogTrigger = vi.hoisted(() => vi.fn())
+const mockLoopLogTrigger = vi.hoisted(() => vi.fn())
+const mockRetryLogTrigger = vi.hoisted(() => vi.fn())
+const mockAgentLogTrigger = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
+ __esModule: true,
+ default: (props: {
+ title: ReactNode
+ value: unknown
+ footer?: ReactNode
+ tip?: ReactNode
+ }) => {
+ mockCodeEditor(props)
+ return (
+
+ {props.title}
+ {typeof props.value === 'string' ? props.value : JSON.stringify(props.value)}
+ {props.tip}
+ {props.footer}
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip', () => ({
+ __esModule: true,
+ default: ({ type }: { type?: string }) => {
+ mockErrorHandleTip(type)
+ return
{type}
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/iteration-log', () => ({
+ IterationLogTrigger: (props: {
+ onShowIterationResultList: (detail: unknown, durationMap: unknown) => void
+ nodeInfo: { details?: unknown, iterDurationMap?: unknown }
+ }) => {
+ mockIterationLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/loop-log', () => ({
+ LoopLogTrigger: (props: {
+ onShowLoopResultList: (detail: unknown, durationMap: unknown) => void
+ nodeInfo: { details?: unknown, loopDurationMap?: unknown }
+ }) => {
+ mockLoopLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/retry-log', () => ({
+ RetryLogTrigger: (props: {
+ onShowRetryResultList: (detail: unknown) => void
+ nodeInfo: { retryDetail?: unknown }
+ }) => {
+ mockRetryLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/agent-log', () => ({
+ AgentLogTrigger: (props: {
+ onShowAgentOrToolLog: (detail: unknown) => void
+ nodeInfo: { agentLog?: unknown }
+ }) => {
+ mockAgentLogTrigger(props)
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/variable-inspect/large-data-alert', () => ({
+ __esModule: true,
+ default: (props: { downloadUrl?: string }) => {
+ mockLargeDataAlert(props)
+ return
{props.downloadUrl ?? 'no-download'}
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/meta', () => ({
+ __esModule: true,
+ default: (props: Record
) => {
+ mockMetaData(props)
+ return {JSON.stringify(props)}
+ },
+}))
+
+vi.mock('@/app/components/workflow/run/status', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ mockStatusPanel(props)
+ return {JSON.stringify(props)}
+ },
+}))
+
+const createNodeInfo = (overrides: Partial = {}): NodeTracing => ({
+ id: 'trace-node-1',
+ index: 0,
+ predecessor_node_id: '',
+ node_id: 'node-1',
+ node_type: BlockEnum.Code,
+ title: 'Code',
+ inputs: {},
+ inputs_truncated: false,
+ process_data: {},
+ process_data_truncated: false,
+ outputs_truncated: false,
+ status: NodeRunningStatus.Succeeded,
+ elapsed_time: 0,
+ metadata: {
+ iterator_length: 0,
+ iterator_index: 0,
+ loop_length: 0,
+ loop_index: 0,
+ },
+ created_at: 0,
+ created_by: {
+ id: 'user-1',
+ name: 'User',
+ email: 'user@example.com',
+ },
+ finished_at: 1,
+ details: undefined,
+ retryDetail: undefined,
+ agentLog: undefined,
+ iterDurationMap: undefined,
+ loopDurationMap: undefined,
+ ...overrides,
+})
+
+const createLogDetail = (id: string): NodeTracing => createNodeInfo({
+ id: `trace-${id}`,
+ node_id: id,
+ title: id,
+})
+
+const createAgentLog = (label: string): AgentLogItemWithChildren => ({
+ node_execution_id: `execution-${label}`,
+ message_id: `message-${label}`,
+ node_id: `node-${label}`,
+ parent_id: undefined,
+ label,
+ status: 'success',
+ data: {},
+ metadata: {},
+ children: [],
+})
+
+describe('ResultPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ })
+
+ it('should render status, editors, alerts, error strategy tip, and metadata', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('status-panel')).toBeInTheDocument()
+ expect(screen.getByText('COMMON.INPUT')).toBeInTheDocument()
+ expect(screen.getByText('COMMON.PROCESSDATA')).toBeInTheDocument()
+ expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
+ expect(screen.getAllByTestId('code-editor')).toHaveLength(3)
+ expect(screen.getAllByTestId('large-data-alert')).toHaveLength(3)
+ expect(screen.getByTestId('error-handle-tip')).toHaveTextContent('continue-on-error')
+ expect(screen.getByTestId('meta-data')).toBeInTheDocument()
+ expect(mockStatusPanel).toHaveBeenCalledWith(expect.objectContaining({
+ status: NodeRunningStatus.Succeeded,
+ time: 2.5,
+ tokens: 42,
+ error: 'boom',
+ exceptionCounts: 1,
+ isListening: true,
+ workflowRunId: 'run-1',
+ }))
+ expect(mockMetaData).toHaveBeenCalledWith(expect.objectContaining({
+ status: NodeRunningStatus.Succeeded,
+ executor: 'Alice',
+ startTime: 1710000000,
+ time: 2.5,
+ tokens: 42,
+ steps: 3,
+ showSteps: true,
+ }))
+ expect(mockLargeDataAlert).toHaveBeenLastCalledWith(expect.objectContaining({
+ downloadUrl: 'https://example.com/output.json',
+ }))
+ })
+
+ it('should render and invoke iteration and loop triggers only when their handlers are provided', () => {
+ const handleShowIterationResultList = vi.fn()
+ const handleShowLoopResultList = vi.fn()
+ const details = [[createLogDetail('iter-1')]]
+
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'iteration-trigger' }))
+ expect(handleShowIterationResultList).toHaveBeenCalledWith(details, { 0: 3 })
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'loop-trigger' }))
+ expect(handleShowLoopResultList).toHaveBeenCalledWith(details, { 0: 5 })
+ })
+
+ it('should render retry and agent/tool triggers when the node shape supports them', () => {
+ const onShowRetryDetail = vi.fn()
+ const handleShowAgentOrToolLog = vi.fn()
+ const retryDetail = [createLogDetail('retry-1')]
+ const agentLog = [createAgentLog('tool-call')]
+
+ const { rerender } = render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'retry-trigger' }))
+ expect(onShowRetryDetail).toHaveBeenCalledWith(retryDetail)
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
+ expect(handleShowAgentOrToolLog).toHaveBeenCalledWith(agentLog)
+
+ rerender(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
+ expect(handleShowAgentOrToolLog).toHaveBeenLastCalledWith(agentLog)
+ })
+
+ it('should still render the output editor while the node is running even without outputs', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx b/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx
new file mode 100644
index 0000000000..f5445f5f9f
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/tracing-panel.spec.tsx
@@ -0,0 +1,199 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { getHoveredParallelId } from '../get-hovered-parallel-id'
+import TracingPanel from '../tracing-panel'
+
+const mockUseTranslation = vi.hoisted(() => vi.fn())
+const mockFormatNodeList = vi.hoisted(() => vi.fn())
+const mockUseLogs = vi.hoisted(() => vi.fn())
+const mockNodePanel = vi.hoisted(() => vi.fn())
+const mockSpecialResultPanel = vi.hoisted(() => vi.fn())
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => mockUseTranslation(),
+}))
+
+vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatNodeList(...args),
+}))
+
+vi.mock('../hooks', () => ({
+ useLogs: () => mockUseLogs(),
+}))
+
+vi.mock('../node', () => ({
+ __esModule: true,
+ default: (props: {
+ nodeInfo: { id: string }
+ }) => {
+ mockNodePanel(props)
+ return {props.nodeInfo.id}
+ },
+}))
+
+vi.mock('../special-result-panel', () => ({
+ __esModule: true,
+ default: (props: Record) => {
+ mockSpecialResultPanel(props)
+ return special
+ },
+}))
+
+describe('TracingPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseTranslation.mockReturnValue({
+ t: (key: string) => key,
+ })
+ mockUseLogs.mockReturnValue({
+ showSpecialResultPanel: false,
+ showRetryDetail: false,
+ setShowRetryDetailFalse: vi.fn(),
+ retryResultList: [],
+ handleShowRetryResultList: vi.fn(),
+ showIteratingDetail: false,
+ setShowIteratingDetailFalse: vi.fn(),
+ iterationResultList: [],
+ iterationResultDurationMap: {},
+ handleShowIterationResultList: vi.fn(),
+ showLoopingDetail: false,
+ setShowLoopingDetailFalse: vi.fn(),
+ loopResultList: [],
+ loopResultDurationMap: {},
+ loopResultVariableMap: {},
+ handleShowLoopResultList: vi.fn(),
+ agentOrToolLogItemStack: [],
+ agentOrToolLogListMap: {},
+ handleShowAgentOrToolLog: vi.fn(),
+ })
+ })
+
+ it('should render formatted nodes, preserve branch labels, and collapse parallel groups', () => {
+ mockFormatNodeList.mockReturnValue([
+ {
+ id: 'parallel-1',
+ parallelDetail: {
+ isParallelStartNode: true,
+ parallelTitle: 'Parallel Group',
+ children: [{
+ id: 'child-1',
+ title: 'Child Node',
+ parallelDetail: {
+ branchTitle: 'Branch A',
+ },
+ }],
+ },
+ },
+ {
+ id: 'node-2',
+ title: 'Standalone Node',
+ parallelDetail: {
+ branchTitle: 'Branch B',
+ },
+ },
+ ])
+
+ const parentClick = vi.fn()
+ const { container } = render(
+
+
+
,
+ )
+
+ expect(screen.getByText('Parallel Group')).toBeInTheDocument()
+ expect(screen.getByText('Branch A')).toBeInTheDocument()
+ expect(screen.getByText('Branch B')).toBeInTheDocument()
+ expect(screen.getByTestId('node-child-1')).toBeInTheDocument()
+ expect(screen.getByTestId('node-node-2')).toBeInTheDocument()
+
+ fireEvent.click(container.querySelector('.py-2') as HTMLElement)
+ expect(parentClick).not.toHaveBeenCalled()
+
+ const hoverTarget = screen.getByText('Parallel Group').closest('[data-parallel-id="parallel-1"]') as HTMLElement
+ const nestedParallelTarget = document.createElement('div')
+ nestedParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
+ const unrelatedTarget = document.createElement('div')
+ document.body.appendChild(nestedParallelTarget)
+ document.body.appendChild(unrelatedTarget)
+
+ fireEvent.mouseEnter(hoverTarget)
+ const sameParallelOut = new MouseEvent('mouseout', { bubbles: true })
+ Object.defineProperty(sameParallelOut, 'relatedTarget', { value: nestedParallelTarget })
+ hoverTarget.dispatchEvent(sameParallelOut)
+
+ const differentTargetOut = new MouseEvent('mouseout', { bubbles: true })
+ Object.defineProperty(differentTargetOut, 'relatedTarget', { value: unrelatedTarget })
+ hoverTarget.dispatchEvent(differentTargetOut)
+
+ fireEvent.mouseLeave(hoverTarget)
+
+ fireEvent.click(screen.getAllByRole('button')[0])
+ expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).toHaveClass('hidden')
+ fireEvent.click(screen.getAllByRole('button')[0])
+ expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).not.toHaveClass('hidden')
+ expect(mockNodePanel).toHaveBeenCalledWith(expect.objectContaining({
+ hideInfo: true,
+ hideProcessDetail: true,
+ }))
+
+ nestedParallelTarget.remove()
+ unrelatedTarget.remove()
+ })
+
+ it('should switch to the special result panel when the log state requests it', () => {
+ mockUseLogs.mockReturnValue({
+ showSpecialResultPanel: true,
+ showRetryDetail: true,
+ setShowRetryDetailFalse: vi.fn(),
+ retryResultList: [{ id: 'retry-1' }],
+ handleShowRetryResultList: vi.fn(),
+ showIteratingDetail: true,
+ setShowIteratingDetailFalse: vi.fn(),
+ iterationResultList: [[{ id: 'iter-1' }]],
+ iterationResultDurationMap: { 0: 1 },
+ handleShowIterationResultList: vi.fn(),
+ showLoopingDetail: true,
+ setShowLoopingDetailFalse: vi.fn(),
+ loopResultList: [[{ id: 'loop-1' }]],
+ loopResultDurationMap: { 0: 2 },
+ loopResultVariableMap: { 0: {} },
+ handleShowLoopResultList: vi.fn(),
+ agentOrToolLogItemStack: [{ id: 'agent-1' }],
+ agentOrToolLogListMap: { agent: [] },
+ handleShowAgentOrToolLog: vi.fn(),
+ })
+
+ render()
+
+ expect(screen.getByTestId('special-result-panel')).toBeInTheDocument()
+ expect(mockSpecialResultPanel).toHaveBeenCalledWith(expect.objectContaining({
+ showRetryDetail: true,
+ retryResultList: [{ id: 'retry-1' }],
+ showIteratingDetail: true,
+ showLoopingDetail: true,
+ agentOrToolLogItemStack: [{ id: 'agent-1' }],
+ }))
+ })
+
+ it('should resolve hovered parallel ids from related targets', () => {
+ const sameParallelTarget = document.createElement('div')
+ sameParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
+ document.body.appendChild(sameParallelTarget)
+
+ const nestedChild = document.createElement('span')
+ sameParallelTarget.appendChild(nestedChild)
+
+ const unrelatedTarget = document.createElement('div')
+
+ expect(getHoveredParallelId(nestedChild)).toBe('parallel-1')
+ expect(getHoveredParallelId(unrelatedTarget)).toBeNull()
+ expect(getHoveredParallelId(null)).toBeNull()
+
+ sameParallelTarget.remove()
+ })
+})
diff --git a/web/app/components/workflow/run/get-hovered-parallel-id.ts b/web/app/components/workflow/run/get-hovered-parallel-id.ts
new file mode 100644
index 0000000000..cd369d5eb1
--- /dev/null
+++ b/web/app/components/workflow/run/get-hovered-parallel-id.ts
@@ -0,0 +1,10 @@
+export const getHoveredParallelId = (relatedTarget: EventTarget | null) => {
+ const element = relatedTarget as Element | null
+ if (element && 'closest' in element) {
+ const closestParallel = element.closest('[data-parallel-id]')
+ if (closestParallel)
+ return closestParallel.getAttribute('data-parallel-id')
+ }
+
+ return null
+}
diff --git a/web/app/components/workflow/run/tracing-panel.tsx b/web/app/components/workflow/run/tracing-panel.tsx
index e4926aebbc..b748eec351 100644
--- a/web/app/components/workflow/run/tracing-panel.tsx
+++ b/web/app/components/workflow/run/tracing-panel.tsx
@@ -1,10 +1,6 @@
'use client'
import type { FC } from 'react'
import type { NodeTracing } from '@/types/workflow'
-import {
- RiArrowDownSLine,
- RiMenu4Line,
-} from '@remixicon/react'
import * as React from 'react'
import {
useCallback,
@@ -14,6 +10,7 @@ import {
import { useTranslation } from 'react-i18next'
import formatNodeList from '@/app/components/workflow/run/utils/format-log'
import { cn } from '@/utils/classnames'
+import { getHoveredParallelId } from './get-hovered-parallel-id'
import { useLogs } from './hooks'
import NodePanel from './node'
import SpecialResultPanel from './special-result-panel'
@@ -54,18 +51,7 @@ const TracingPanel: FC = ({
}, [])
const handleParallelMouseLeave = useCallback((e: React.MouseEvent) => {
- const relatedTarget = e.relatedTarget as Element | null
- if (relatedTarget && 'closest' in relatedTarget) {
- const closestParallel = relatedTarget.closest('[data-parallel-id]')
- if (closestParallel)
- setHoveredParallel(closestParallel.getAttribute('data-parallel-id'))
-
- else
- setHoveredParallel(null)
- }
- else {
- setHoveredParallel(null)
- }
+ setHoveredParallel(getHoveredParallelId(e.relatedTarget))
}, [])
const {
@@ -130,7 +116,9 @@ const TracingPanel: FC = ({
isHovered ? 'rounded border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' : 'text-text-secondary hover:text-text-primary',
)}
>
- {isHovered ? : }
+ {isHovered
+ ?
+ : }
{parallelDetail.parallelTitle}
diff --git a/web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts b/web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts
new file mode 100644
index 0000000000..53acbb7218
--- /dev/null
+++ b/web/app/components/workflow/run/utils/format-log/__tests__/index.spec.ts
@@ -0,0 +1,199 @@
+import type { NodeTracing } from '@/types/workflow'
+import { BlockEnum } from '@/app/components/workflow/types'
+
+import formatToTracingNodeList from '../index'
+
+const mockFormatAgentNode = vi.hoisted(() => vi.fn())
+const mockFormatHumanInputNode = vi.hoisted(() => vi.fn())
+const mockFormatRetryNode = vi.hoisted(() => vi.fn())
+const mockAddChildrenToLoopNode = vi.hoisted(() => vi.fn())
+const mockAddChildrenToIterationNode = vi.hoisted(() => vi.fn())
+const mockFormatParallelNode = vi.hoisted(() => vi.fn())
+
+vi.mock('../agent', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatAgentNode(...args),
+}))
+
+vi.mock('../human-input', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatHumanInputNode(...args),
+}))
+
+vi.mock('../retry', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatRetryNode(...args),
+}))
+
+vi.mock('../loop', () => ({
+ addChildrenToLoopNode: (...args: unknown[]) => mockAddChildrenToLoopNode(...args),
+}))
+
+vi.mock('../iteration', () => ({
+ addChildrenToIterationNode: (...args: unknown[]) => mockAddChildrenToIterationNode(...args),
+}))
+
+vi.mock('../parallel', () => ({
+ __esModule: true,
+ default: (...args: unknown[]) => mockFormatParallelNode(...args),
+}))
+
+const createTrace = (overrides: Partial
= {}): NodeTracing => ({
+ id: overrides.id ?? overrides.node_id ?? 'node-1',
+ index: overrides.index ?? 0,
+ predecessor_node_id: '',
+ node_id: overrides.node_id ?? 'node-1',
+ node_type: overrides.node_type ?? BlockEnum.Tool,
+ title: overrides.title ?? 'Node',
+ inputs: {},
+ inputs_truncated: false,
+ process_data: {},
+ process_data_truncated: false,
+ outputs_truncated: false,
+ status: overrides.status ?? 'succeeded',
+ error: overrides.error,
+ elapsed_time: 1,
+ execution_metadata: overrides.execution_metadata ?? {
+ total_tokens: 0,
+ total_price: 0,
+ currency: 'USD',
+ },
+ metadata: {
+ iterator_length: 0,
+ iterator_index: 0,
+ loop_length: 0,
+ loop_index: 0,
+ },
+ created_at: 0,
+ created_by: {
+ id: 'user-1',
+ name: 'User',
+ email: 'user@example.com',
+ },
+ finished_at: 1,
+})
+
+const createExecutionMetadata = (overrides: Partial> = {}): NonNullable => ({
+ total_tokens: 0,
+ total_price: 0,
+ currency: 'USD',
+ ...overrides,
+})
+
+describe('formatToTracingNodeList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFormatAgentNode.mockImplementation((list: NodeTracing[]) => list)
+ mockFormatHumanInputNode.mockImplementation((list: NodeTracing[]) => list)
+ mockFormatRetryNode.mockImplementation((list: NodeTracing[]) => list)
+ mockAddChildrenToLoopNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
+ ...item,
+ loopChildren: children.map(child => child.node_id),
+ details: [[{ id: 'loop-detail-row' }]],
+ }))
+ mockAddChildrenToIterationNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
+ ...item,
+ iterationChildren: children.map(child => child.node_id),
+ details: [[{ id: 'iteration-detail-row' }]],
+ }))
+ mockFormatParallelNode.mockImplementation((list: unknown[]) =>
+ list.map(item => ({
+ ...(item as Record),
+ parallelFormatted: true,
+ })))
+ })
+
+ it('should sort the input by index and run the formatter pipeline in order', () => {
+ const t = vi.fn((key: string) => key)
+ const traces = [
+ createTrace({ id: 'b', node_id: 'b', title: 'B', index: 2 }),
+ createTrace({ id: 'a', node_id: 'a', title: 'A', index: 0 }),
+ createTrace({ id: 'c', node_id: 'c', title: 'C', index: 1 }),
+ ]
+
+ const result = formatToTracingNodeList(traces, t)
+
+ expect(mockFormatAgentNode).toHaveBeenCalledWith([
+ expect.objectContaining({ node_id: 'a' }),
+ expect.objectContaining({ node_id: 'c' }),
+ expect.objectContaining({ node_id: 'b' }),
+ ])
+ expect(mockFormatHumanInputNode).toHaveBeenCalledWith(mockFormatAgentNode.mock.results[0].value)
+ expect(mockFormatRetryNode).toHaveBeenCalledWith(mockFormatHumanInputNode.mock.results[0].value)
+ expect(mockFormatParallelNode).toHaveBeenLastCalledWith(expect.any(Array), t)
+ expect(result).toEqual([
+ expect.objectContaining({ node_id: 'a', parallelFormatted: true }),
+ expect.objectContaining({ node_id: 'c', parallelFormatted: true }),
+ expect.objectContaining({ node_id: 'b', parallelFormatted: true }),
+ ])
+ })
+
+ it('should collapse loop and iteration children into parent nodes and propagate child failures', () => {
+ const t = vi.fn((key: string) => key)
+ const loopParent = createTrace({
+ id: 'loop-parent',
+ node_id: 'loop-parent',
+ node_type: BlockEnum.Loop,
+ index: 0,
+ })
+ const loopChild = createTrace({
+ id: 'loop-child',
+ node_id: 'loop-child',
+ index: 1,
+ status: 'failed',
+ error: 'loop child failed',
+ execution_metadata: createExecutionMetadata({ loop_id: 'loop-parent' }),
+ })
+ const iterationParent = createTrace({
+ id: 'iteration-parent',
+ node_id: 'iteration-parent',
+ node_type: BlockEnum.Iteration,
+ index: 2,
+ })
+ const iterationChild = createTrace({
+ id: 'iteration-child',
+ node_id: 'iteration-child',
+ index: 3,
+ status: 'failed',
+ error: 'iteration child failed',
+ execution_metadata: createExecutionMetadata({ iteration_id: 'iteration-parent' }),
+ })
+
+ const result = formatToTracingNodeList([
+ loopParent,
+ loopChild,
+ iterationParent,
+ iterationChild,
+ ], t)
+
+ expect(mockAddChildrenToLoopNode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ node_id: 'loop-parent',
+ status: 'failed',
+ error: 'loop child failed',
+ }),
+ [expect.objectContaining({ node_id: 'loop-child' })],
+ )
+ expect(mockAddChildrenToIterationNode).toHaveBeenCalledWith(
+ expect.objectContaining({
+ node_id: 'iteration-parent',
+ status: 'failed',
+ error: 'iteration child failed',
+ }),
+ [expect.objectContaining({ node_id: 'iteration-child' })],
+ )
+ expect(mockFormatParallelNode).toHaveBeenCalledTimes(3)
+ expect(result).toEqual([
+ expect.objectContaining({
+ node_id: 'loop-parent',
+ loopChildren: ['loop-child'],
+ parallelFormatted: true,
+ }),
+ expect.objectContaining({
+ node_id: 'iteration-parent',
+ iterationChildren: ['iteration-child'],
+ parallelFormatted: true,
+ }),
+ ])
+ })
+})
diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx
index 70e4de0973..3273ea7e71 100644
--- a/web/app/components/workflow/selection-contextmenu.tsx
+++ b/web/app/components/workflow/selection-contextmenu.tsx
@@ -1,5 +1,5 @@
-import type { FC, ReactElement } from 'react'
-import type { I18nKeysByPrefix } from '@/types/i18n'
+import type { ComponentType } from 'react'
+import type { Node } from './types'
import {
RiAlignBottom,
RiAlignCenter,
@@ -8,413 +8,350 @@ import {
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
-import { useClickAway } from 'ahooks'
import { produce } from 'immer'
import {
memo,
useCallback,
useEffect,
useMemo,
- useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
-import { useStore as useReactFlowStore } from 'reactflow'
-import { shallow } from 'zustand/shallow'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
-import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
+import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuGroup,
+ ContextMenuGroupLabel,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from '@/app/components/base/ui/context-menu'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
-import ShortcutsName from './shortcuts-name'
import { useStore, useWorkflowStore } from './store'
-enum AlignType {
- Left = 'left',
- Center = 'center',
- Right = 'right',
- Top = 'top',
- Middle = 'middle',
- Bottom = 'bottom',
- DistributeHorizontal = 'distributeHorizontal',
- DistributeVertical = 'distributeVertical',
+const AlignType = {
+ Bottom: 'bottom',
+ Center: 'center',
+ DistributeHorizontal: 'distributeHorizontal',
+ DistributeVertical: 'distributeVertical',
+ Left: 'left',
+ Middle: 'middle',
+ Right: 'right',
+ Top: 'top',
+} as const
+
+type AlignTypeValue = (typeof AlignType)[keyof typeof AlignType]
+
+type SelectionMenuPosition = {
+ left: number
+ top: number
}
-type AlignButtonConfig = {
- type: AlignType
- icon: ReactElement
- labelKey: I18nKeysByPrefix<'workflow', 'operator.'>
+type ContainerRect = Pick
+
+type AlignBounds = {
+ minX: number
+ maxX: number
+ minY: number
+ maxY: number
}
-type AlignButtonProps = {
- config: AlignButtonConfig
- label: string
- onClick: (type: AlignType) => void
- position?: 'top' | 'bottom' | 'left' | 'right'
+type MenuItem = {
+ alignType: AlignTypeValue
+ icon: ComponentType<{ className?: string }>
+ iconClassName?: string
+ translationKey: string
}
-const AlignButton: FC = ({ config, label, onClick, position = 'bottom' }) => {
- return (
-
- onClick(config.type)}
- >
- {config.icon}
-
- {label}
-
- )
+type MenuSection = {
+ titleKey: string
+ items: MenuItem[]
}
-const ALIGN_BUTTONS: AlignButtonConfig[] = [
- { type: AlignType.Left, icon: , labelKey: 'alignLeft' },
- { type: AlignType.Center, icon: , labelKey: 'alignCenter' },
- { type: AlignType.Right, icon: , labelKey: 'alignRight' },
- { type: AlignType.DistributeHorizontal, icon: , labelKey: 'distributeHorizontal' },
- { type: AlignType.Top, icon: , labelKey: 'alignTop' },
- { type: AlignType.Middle, icon: , labelKey: 'alignMiddle' },
- { type: AlignType.Bottom, icon: , labelKey: 'alignBottom' },
- { type: AlignType.DistributeVertical, icon: , labelKey: 'distributeVertical' },
+const MENU_WIDTH = 240
+const MENU_HEIGHT = 380
+
+const menuSections: MenuSection[] = [
+ {
+ titleKey: 'operator.vertical',
+ items: [
+ { alignType: AlignType.Top, icon: RiAlignTop, translationKey: 'operator.alignTop' },
+ { alignType: AlignType.Middle, icon: RiAlignCenter, iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
+ { alignType: AlignType.Bottom, icon: RiAlignBottom, translationKey: 'operator.alignBottom' },
+ { alignType: AlignType.DistributeVertical, icon: RiAlignJustify, iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
+ ],
+ },
+ {
+ titleKey: 'operator.horizontal',
+ items: [
+ { alignType: AlignType.Left, icon: RiAlignLeft, translationKey: 'operator.alignLeft' },
+ { alignType: AlignType.Center, icon: RiAlignCenter, translationKey: 'operator.alignCenter' },
+ { alignType: AlignType.Right, icon: RiAlignRight, translationKey: 'operator.alignRight' },
+ { alignType: AlignType.DistributeHorizontal, icon: RiAlignJustify, translationKey: 'operator.distributeHorizontal' },
+ ],
+ },
]
+const getMenuPosition = (
+ selectionMenu: SelectionMenuPosition | undefined,
+ containerRect?: ContainerRect | null,
+) => {
+ if (!selectionMenu)
+ return { left: 0, top: 0 }
+
+ let { left, top } = selectionMenu
+
+ if (containerRect) {
+ if (left + MENU_WIDTH > containerRect.width)
+ left = left - MENU_WIDTH
+
+ if (top + MENU_HEIGHT > containerRect.height)
+ top = top - MENU_HEIGHT
+
+ left = Math.max(0, left)
+ top = Math.max(0, top)
+ }
+
+ return { left, top }
+}
+
+const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
+ const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
+ const childNodeIds = new Set()
+
+ nodes.forEach((node) => {
+ if (!node.data._children?.length || !selectedNodeIds.has(node.id))
+ return
+
+ node.data._children.forEach((child) => {
+ childNodeIds.add(child.nodeId)
+ })
+ })
+
+ return nodes.filter(node => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id))
+}
+
+const getAlignBounds = (nodes: Node[]): AlignBounds | null => {
+ const validNodes = nodes.filter(node => node.width && node.height)
+ if (validNodes.length <= 1)
+ return null
+
+ return validNodes.reduce((bounds, node) => {
+ const width = node.width!
+ const height = node.height!
+
+ return {
+ minX: Math.min(bounds.minX, node.position.x),
+ maxX: Math.max(bounds.maxX, node.position.x + width),
+ minY: Math.min(bounds.minY, node.position.y),
+ maxY: Math.max(bounds.maxY, node.position.y + height),
+ }
+ }, {
+ minX: Number.MAX_SAFE_INTEGER,
+ maxX: Number.MIN_SAFE_INTEGER,
+ minY: Number.MAX_SAFE_INTEGER,
+ maxY: Number.MIN_SAFE_INTEGER,
+ })
+}
+
+const alignNodePosition = (
+ currentNode: Node,
+ nodeToAlign: Node,
+ alignType: AlignTypeValue,
+ bounds: AlignBounds,
+) => {
+ const width = nodeToAlign.width ?? 0
+ const height = nodeToAlign.height ?? 0
+
+ switch (alignType) {
+ case AlignType.Left:
+ currentNode.position.x = bounds.minX
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.x = bounds.minX
+ break
+ case AlignType.Center: {
+ const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 - width / 2
+ currentNode.position.x = centerX
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.x = centerX
+ break
+ }
+ case AlignType.Right: {
+ const rightX = bounds.maxX - width
+ currentNode.position.x = rightX
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.x = rightX
+ break
+ }
+ case AlignType.Top:
+ currentNode.position.y = bounds.minY
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.y = bounds.minY
+ break
+ case AlignType.Middle: {
+ const middleY = bounds.minY + (bounds.maxY - bounds.minY) / 2 - height / 2
+ currentNode.position.y = middleY
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.y = middleY
+ break
+ }
+ case AlignType.Bottom: {
+ const bottomY = Math.round(bounds.maxY - height)
+ currentNode.position.y = bottomY
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.y = bottomY
+ break
+ }
+ }
+}
+
+const distributeNodes = (
+ nodesToAlign: Node[],
+ nodes: Node[],
+ alignType: AlignTypeValue,
+) => {
+ const isHorizontal = alignType === AlignType.DistributeHorizontal
+ const sortedNodes = [...nodesToAlign].sort((a, b) =>
+ isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y)
+
+ if (sortedNodes.length < 3)
+ return null
+
+ const firstNode = sortedNodes[0]
+ const lastNode = sortedNodes[sortedNodes.length - 1]
+
+ const totalGap = isHorizontal
+ ? lastNode.position.x + (lastNode.width || 0) - firstNode.position.x
+ : lastNode.position.y + (lastNode.height || 0) - firstNode.position.y
+
+ const fixedSpace = sortedNodes.reduce((sum, node) =>
+ sum + (isHorizontal ? (node.width || 0) : (node.height || 0)), 0)
+
+ const spacing = (totalGap - fixedSpace) / (sortedNodes.length - 1)
+ if (spacing <= 0)
+ return null
+
+ return produce(nodes, (draft) => {
+ let currentPosition = isHorizontal
+ ? firstNode.position.x + (firstNode.width || 0)
+ : firstNode.position.y + (firstNode.height || 0)
+
+ for (let index = 1; index < sortedNodes.length - 1; index++) {
+ const nodeToAlign = sortedNodes[index]
+ const currentNode = draft.find(node => node.id === nodeToAlign.id)
+ if (!currentNode)
+ continue
+
+ if (isHorizontal) {
+ const nextX = currentPosition + spacing
+ currentNode.position.x = nextX
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.x = nextX
+ currentPosition = nextX + (nodeToAlign.width || 0)
+ }
+ else {
+ const nextY = currentPosition + spacing
+ currentNode.position.y = nextY
+ if (currentNode.positionAbsolute)
+ currentNode.positionAbsolute.y = nextY
+ currentPosition = nextY + (nodeToAlign.height || 0)
+ }
+ }
+ })
+}
+
const SelectionContextmenu = () => {
const { t } = useTranslation()
- const ref = useRef(null)
const { getNodesReadOnly, nodesReadOnly } = useNodesReadOnly()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const {
handleNodesCopy,
- handleNodesDuplicate,
handleNodesDelete,
+ handleNodesDuplicate,
} = useNodesInteractions()
const selectionMenu = useStore(s => s.selectionMenu)
-
- // Access React Flow methods
+ const store = useStoreApi()
const workflowStore = useWorkflowStore()
- const collaborativeWorkflow = useCollaborativeWorkflow()
-
- const selectedNodeIds = useReactFlowStore((state) => {
- const ids = state.getNodes().filter(node => node.selected).map(node => node.id)
- ids.sort()
- return ids
- }, shallow)
-
+ const selectedNodes = useReactFlowStore(state =>
+ state.getNodes().filter(node => node.selected),
+ )
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
- const menuRef = useRef(null)
-
const menuPosition = useMemo(() => {
- if (!selectionMenu)
- return { left: 0, top: 0 }
-
- let left = selectionMenu.left
- let top = selectionMenu.top
-
const container = document.querySelector('#workflow-container')
- if (container) {
- const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect()
-
- const menuWidth = 244
-
- const estimatedMenuHeight = 203
-
- if (left + menuWidth > containerWidth)
- left = left - menuWidth
-
- if (top + estimatedMenuHeight > containerHeight)
- top = top - estimatedMenuHeight
-
- left = Math.max(0, left)
- top = Math.max(0, top)
- }
-
- return { left, top }
+ return getMenuPosition(selectionMenu, container?.getBoundingClientRect())
}, [selectionMenu])
- useClickAway(() => {
- handleSelectionContextmenuCancel()
- }, ref)
-
useEffect(() => {
- if (selectionMenu && selectedNodeIds.length <= 1)
+ if (selectionMenu && selectedNodes.length <= 1)
handleSelectionContextmenuCancel()
- }, [selectionMenu, selectedNodeIds.length, handleSelectionContextmenuCancel])
+ }, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
- // Handle align nodes logic
- const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
- const width = nodeToAlign.width
- const height = nodeToAlign.height
-
- // Calculate new positions based on alignment type
- switch (alignType) {
- case AlignType.Left:
- // For left alignment, align left edge of each node to minX
- currentNode.position.x = minX
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.x = minX
- break
-
- case AlignType.Center: {
- // For center alignment, center each node horizontally in the selection bounds
- const centerX = minX + (maxX - minX) / 2 - width / 2
- currentNode.position.x = centerX
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.x = centerX
- break
- }
-
- case AlignType.Right: {
- // For right alignment, align right edge of each node to maxX
- const rightX = maxX - width
- currentNode.position.x = rightX
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.x = rightX
- break
- }
-
- case AlignType.Top: {
- // For top alignment, align top edge of each node to minY
- currentNode.position.y = minY
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.y = minY
- break
- }
-
- case AlignType.Middle: {
- // For middle alignment, center each node vertically in the selection bounds
- const middleY = minY + (maxY - minY) / 2 - height / 2
- currentNode.position.y = middleY
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.y = middleY
- break
- }
-
- case AlignType.Bottom: {
- // For bottom alignment, align bottom edge of each node to maxY
- const newY = Math.round(maxY - height)
- currentNode.position.y = newY
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.y = newY
- break
- }
- }
- }, [])
-
- // Handle distribute nodes logic
- const handleDistributeNodes = useCallback((nodesToAlign: any[], nodes: any[], alignType: AlignType) => {
- // Sort nodes appropriately
- const sortedNodes = [...nodesToAlign].sort((a, b) => {
- if (alignType === AlignType.DistributeHorizontal) {
- // Sort by left position for horizontal distribution
- return a.position.x - b.position.x
- }
- else {
- // Sort by top position for vertical distribution
- return a.position.y - b.position.y
- }
- })
-
- if (sortedNodes.length < 3)
- return null // Need at least 3 nodes for distribution
-
- let totalGap = 0
- let fixedSpace = 0
-
- if (alignType === AlignType.DistributeHorizontal) {
- // Fixed positions - first node's left edge and last node's right edge
- const firstNodeLeft = sortedNodes[0].position.x
- const lastNodeRight = sortedNodes[sortedNodes.length - 1].position.x + (sortedNodes[sortedNodes.length - 1].width || 0)
-
- // Total available space
- totalGap = lastNodeRight - firstNodeLeft
-
- // Space occupied by nodes themselves
- fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.width || 0), 0)
- }
- else {
- // Fixed positions - first node's top edge and last node's bottom edge
- const firstNodeTop = sortedNodes[0].position.y
- const lastNodeBottom = sortedNodes[sortedNodes.length - 1].position.y + (sortedNodes[sortedNodes.length - 1].height || 0)
-
- // Total available space
- totalGap = lastNodeBottom - firstNodeTop
-
- // Space occupied by nodes themselves
- fixedSpace = sortedNodes.reduce((sum, node) => sum + (node.height || 0), 0)
- }
-
- // Available space for gaps
- const availableSpace = totalGap - fixedSpace
-
- // Calculate even spacing between node edges
- const spacing = availableSpace / (sortedNodes.length - 1)
-
- if (spacing <= 0)
- return null // Nodes are overlapping, can't distribute evenly
-
- return produce(nodes, (draft) => {
- // Keep first node fixed, position others with even gaps
- let currentPosition
-
- if (alignType === AlignType.DistributeHorizontal) {
- // Start from first node's right edge
- currentPosition = sortedNodes[0].position.x + (sortedNodes[0].width || 0)
- }
- else {
- // Start from first node's bottom edge
- currentPosition = sortedNodes[0].position.y + (sortedNodes[0].height || 0)
- }
-
- // Skip first node (index 0), it stays in place
- for (let i = 1; i < sortedNodes.length - 1; i++) {
- const nodeToAlign = sortedNodes[i]
- const currentNode = draft.find(n => n.id === nodeToAlign.id)
- if (!currentNode)
- continue
-
- if (alignType === AlignType.DistributeHorizontal) {
- // Position = previous right edge + spacing
- const newX: number = currentPosition + spacing
- currentNode.position.x = newX
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.x = newX
-
- // Update for next iteration - current node's right edge
- currentPosition = newX + (nodeToAlign.width || 0)
- }
- else {
- // Position = previous bottom edge + spacing
- const newY: number = currentPosition + spacing
- currentNode.position.y = newY
- if (currentNode.positionAbsolute)
- currentNode.positionAbsolute.y = newY
-
- // Update for next iteration - current node's bottom edge
- currentPosition = newY + (nodeToAlign.height || 0)
- }
- }
- })
- }, [])
-
- const handleAlignNodes = useCallback((alignType: AlignType) => {
- if (getNodesReadOnly() || selectedNodeIds.length <= 1) {
+ const handleAlignNodes = useCallback((alignType: AlignTypeValue) => {
+ if (getNodesReadOnly() || selectedNodes.length <= 1) {
handleSelectionContextmenuCancel()
return
}
- // Disable node animation state - same as handleNodeDragStart
workflowStore.setState({ nodeAnimation: false })
- // Get all current nodes
- const { nodes, setNodes } = collaborativeWorkflow.getState()
-
- // Find container nodes and their children
- // Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
- // when the container is selected. This prevents child nodes from being moved outside their containers.
- const childNodeIds = new Set()
-
- nodes.forEach((node) => {
- // Check if this is a container node (Iteration or Loop)
- if (node.data._children && node.data._children.length > 0) {
- // If container node is selected, add its children to the exclusion set
- if (selectedNodeIds.includes(node.id)) {
- // Add all its children to the childNodeIds set
- node.data._children.forEach((child: { nodeId: string, nodeType: string }) => {
- childNodeIds.add(child.nodeId)
- })
- }
- }
- })
-
- // Filter out child nodes from the alignment operation
- // Only align nodes that are selected AND are not children of container nodes
- // This ensures container nodes can be aligned while their children stay in the same relative position
- const nodesToAlign = nodes.filter(node =>
- selectedNodeIds.includes(node.id) && !childNodeIds.has(node.id))
+ const nodes = store.getState().getNodes()
+ const nodesToAlign = getAlignableNodes(nodes, selectedNodes)
if (nodesToAlign.length <= 1) {
handleSelectionContextmenuCancel()
return
}
- // Calculate node boundaries for alignment
- let minX = Number.MAX_SAFE_INTEGER
- let maxX = Number.MIN_SAFE_INTEGER
- let minY = Number.MAX_SAFE_INTEGER
- let maxY = Number.MIN_SAFE_INTEGER
+ const bounds = getAlignBounds(nodesToAlign)
+ if (!bounds) {
+ handleSelectionContextmenuCancel()
+ return
+ }
- // Calculate boundaries of selected nodes
- const validNodes = nodesToAlign.filter(node => node.width && node.height)
- validNodes.forEach((node) => {
- const width = node.width!
- const height = node.height!
- minX = Math.min(minX, node.position.x)
- maxX = Math.max(maxX, node.position.x + width)
- minY = Math.min(minY, node.position.y)
- maxY = Math.max(maxY, node.position.y + height)
- })
-
- // Handle distribute nodes logic
if (alignType === AlignType.DistributeHorizontal || alignType === AlignType.DistributeVertical) {
- const distributeNodes = handleDistributeNodes(nodesToAlign, nodes, alignType)
- if (distributeNodes) {
- // Apply node distribution updates
- setNodes(distributeNodes)
+ const distributedNodes = distributeNodes(nodesToAlign, nodes, alignType)
+ if (distributedNodes) {
+ store.getState().setNodes(distributedNodes)
handleSelectionContextmenuCancel()
- // Clear guide lines
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()
- // Sync workflow draft
handleSyncWorkflowDraft()
-
- // Save to history
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
-
- return // End function execution
+ return
}
}
const newNodes = produce(nodes, (draft) => {
- // Iterate through all selected nodes
const validNodesToAlign = nodesToAlign.filter(node => node.width && node.height)
validNodesToAlign.forEach((nodeToAlign) => {
- // Find the corresponding node in draft - consistent with handleNodeDrag
const currentNode = draft.find(n => n.id === nodeToAlign.id)
if (!currentNode)
return
- // Use the extracted alignment function
- handleAlignNode(currentNode, nodeToAlign, alignType, minX, maxX, minY, maxY)
+ alignNodePosition(currentNode, nodeToAlign, alignType, bounds)
})
})
- // Apply node position updates - consistent with handleNodeDrag and handleNodeDragStop
try {
- // Directly use setNodes to update nodes - consistent with handleNodeDrag
- setNodes(newNodes)
-
- // Close popup
+ store.getState().setNodes(newNodes)
handleSelectionContextmenuCancel()
-
- // Clear guide lines - consistent with handleNodeDragStop
const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState()
setHelpLineHorizontal()
setHelpLineVertical()
-
- // Sync workflow draft - consistent with handleNodeDragStop
handleSyncWorkflowDraft()
-
- // Save to history - consistent with handleNodeDragStop
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
}
catch (err) {
console.error('Failed to update nodes:', err)
}
- }, [collaborativeWorkflow, workflowStore, selectedNodeIds, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
+ }, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel])
if (!selectionMenu)
return null
@@ -422,64 +359,77 @@ const SelectionContextmenu = () => {
return (
-
- {!nodesReadOnly && (
- <>
-
-
{
+ if (!open)
+ handleSelectionContextmenuCancel()
+ }}
+ >
+
+
+
+
+ {!nodesReadOnly && (
+
+ {
handleNodesCopy()
handleSelectionContextmenuCancel()
}}
>
{t('common.copy', { ns: 'workflow' })}
-
-
-
+ {
handleNodesDuplicate()
handleSelectionContextmenuCancel()
}}
>
{t('common.duplicate', { ns: 'workflow' })}
-
-
-
-
-
-
+ {
handleNodesDelete()
handleSelectionContextmenuCancel()
}}
>
{t('operation.delete', { ns: 'common' })}
-
-
-
-
- >
- )}
-
- {ALIGN_BUTTONS.map(config => (
-
+
+
+ )}
+ {menuSections.map((section, sectionIndex) => (
+
+ {(sectionIndex > 0 || !nodesReadOnly) && }
+
+ {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
+
+ {section.items.map((item) => {
+ const Icon = item.icon
+ return (
+ handleAlignNodes(item.alignType)}
+ >
+
+ {t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
+
+ )
+ })}
+
))}
-
-
+
+
)
}
diff --git a/web/app/components/workflow/update-dsl-modal.helpers.ts b/web/app/components/workflow/update-dsl-modal.helpers.ts
new file mode 100644
index 0000000000..a86be7266f
--- /dev/null
+++ b/web/app/components/workflow/update-dsl-modal.helpers.ts
@@ -0,0 +1,111 @@
+import type { CommonNodeType, Node } from './types'
+import { load as yamlLoad } from 'js-yaml'
+import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
+import { DSLImportStatus } from '@/models/app'
+import { AppModeEnum } from '@/types/app'
+import { BlockEnum, SupportUploadFileTypes } from './types'
+
+type ParsedDSL = {
+ workflow?: {
+ graph?: {
+ nodes?: Array>
+ }
+ }
+}
+
+type WorkflowFileUploadFeatures = {
+ enabled?: boolean
+ allowed_file_types?: SupportUploadFileTypes[]
+ allowed_file_extensions?: string[]
+ allowed_file_upload_methods?: string[]
+ number_limits?: number
+ image?: {
+ enabled?: boolean
+ number_limits?: number
+ transfer_methods?: string[]
+ }
+}
+
+type WorkflowFeatures = {
+ file_upload?: WorkflowFileUploadFeatures
+ opening_statement?: string
+ suggested_questions?: string[]
+ suggested_questions_after_answer?: { enabled: boolean }
+ speech_to_text?: { enabled: boolean }
+ text_to_speech?: { enabled: boolean }
+ retriever_resource?: { enabled: boolean }
+ sensitive_word_avoidance?: { enabled: boolean }
+}
+
+type ImportNotificationPayload = {
+ type: 'success' | 'warning'
+ message: string
+ children?: string
+}
+
+export const getInvalidNodeTypes = (mode?: AppModeEnum) => {
+ if (mode === AppModeEnum.ADVANCED_CHAT) {
+ return [
+ BlockEnum.End,
+ BlockEnum.TriggerWebhook,
+ BlockEnum.TriggerSchedule,
+ BlockEnum.TriggerPlugin,
+ ]
+ }
+
+ return [BlockEnum.Answer]
+}
+
+export const validateDSLContent = (content: string, mode?: AppModeEnum) => {
+ try {
+ const data = yamlLoad(content) as ParsedDSL
+ const nodes = data?.workflow?.graph?.nodes ?? []
+ const invalidNodes = getInvalidNodeTypes(mode)
+ return !nodes.some((node: Node) => invalidNodes.includes(node?.data?.type))
+ }
+ catch {
+ return false
+ }
+}
+
+export const isImportCompleted = (status: DSLImportStatus) => {
+ return status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS
+}
+
+export const getImportNotificationPayload = (status: DSLImportStatus, t: (key: string, options?: Record) => string): ImportNotificationPayload => {
+ return {
+ type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
+ message: t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }),
+ children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS
+ ? t('common.importWarningDetails', { ns: 'workflow' })
+ : undefined,
+ }
+}
+
+export const normalizeWorkflowFeatures = (features?: WorkflowFeatures) => {
+ const resolvedFeatures = features ?? {}
+ return {
+ file: {
+ image: {
+ enabled: !!resolvedFeatures.file_upload?.image?.enabled,
+ number_limits: resolvedFeatures.file_upload?.image?.number_limits || 3,
+ transfer_methods: resolvedFeatures.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
+ },
+ enabled: !!(resolvedFeatures.file_upload?.enabled || resolvedFeatures.file_upload?.image?.enabled),
+ allowed_file_types: resolvedFeatures.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
+ allowed_file_extensions: resolvedFeatures.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
+ allowed_file_upload_methods: resolvedFeatures.file_upload?.allowed_file_upload_methods || resolvedFeatures.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
+ number_limits: resolvedFeatures.file_upload?.number_limits || resolvedFeatures.file_upload?.image?.number_limits || 3,
+ },
+ opening: {
+ enabled: !!resolvedFeatures.opening_statement,
+ opening_statement: resolvedFeatures.opening_statement,
+ suggested_questions: resolvedFeatures.suggested_questions,
+ },
+ suggested: resolvedFeatures.suggested_questions_after_answer || { enabled: false },
+ speech2text: resolvedFeatures.speech_to_text || { enabled: false },
+ text2speech: resolvedFeatures.text_to_speech || { enabled: false },
+ citation: resolvedFeatures.retriever_resource || { enabled: false },
+ moderation: resolvedFeatures.sensitive_word_avoidance || { enabled: false },
+ }
+}
diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx
index dec5a39284..c0ab08ad99 100644
--- a/web/app/components/workflow/update-dsl-modal.tsx
+++ b/web/app/components/workflow/update-dsl-modal.tsx
@@ -1,16 +1,11 @@
'use client'
import type { MouseEventHandler } from 'react'
-import type {
- CommonNodeType,
- Node,
-} from './types'
import {
RiAlertFill,
RiCloseLine,
RiFileDownloadLine,
} from '@remixicon/react'
-import { load as yamlLoad } from 'js-yaml'
import {
memo,
useCallback,
@@ -34,10 +29,14 @@ import {
importDSLConfirm,
} from '@/service/apps'
import { fetchWorkflowDraft } from '@/service/workflow'
-import { AppModeEnum } from '@/types/app'
-import { collaborationManager } from './collaboration/core/collaboration-manager'
import { WORKFLOW_DATA_UPDATE } from './constants'
-import { BlockEnum } from './types'
+import {
+ getImportNotificationPayload,
+ isImportCompleted,
+ normalizeWorkflowFeatures,
+ validateDSLContent,
+} from './update-dsl-modal.helpers'
+import { collaborationManager } from './collaboration/core/collaboration-manager'
import {
initialEdges,
initialNodes,
@@ -93,15 +92,13 @@ const UpdateDSLModal = ({
} = await fetchWorkflowDraft(`/apps/${app_id}/workflows/draft`)
const { nodes, edges, viewport } = graph
- const draftFeatures = features ?? {}
-
eventEmitter?.emit({
type: WORKFLOW_DATA_UPDATE,
payload: {
nodes: initialNodes(nodes, edges),
edges: initialEdges(edges, nodes),
viewport,
- features: draftFeatures,
+ features: normalizeWorkflowFeatures(features),
hash,
conversation_variables: conversation_variables || [],
environment_variables: environment_variables || [],
@@ -109,76 +106,63 @@ const UpdateDSLModal = ({
} as any)
}, [eventEmitter])
- const validateDSLContent = (content: string): boolean => {
- try {
- const data = yamlLoad(content) as any
- const nodes = data?.workflow?.graph?.nodes ?? []
- const invalidNodes: BlockEnum[] = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
- ? [
- BlockEnum.End,
- BlockEnum.TriggerWebhook,
- BlockEnum.TriggerSchedule,
- BlockEnum.TriggerPlugin,
- ]
- : [BlockEnum.Answer]
- const hasInvalidNode = nodes.some((node: Node) => {
- const nodeType = node?.data?.type
- return nodeType !== undefined && invalidNodes.includes(nodeType)
- })
- if (hasInvalidNode) {
- toast.error(t('common.importFailure', { ns: 'workflow' }))
- return false
- }
- return true
- }
- catch {
- toast.error(t('common.importFailure', { ns: 'workflow' }))
- return false
- }
- }
-
const isCreatingRef = useRef(false)
+ const handleCompletedImport = useCallback(async (status: DSLImportStatus, appId?: string) => {
+ if (!appId) {
+ toast.error(t('common.importFailure', { ns: 'workflow' }))
+ return
+ }
+
+ await handleWorkflowUpdate(appId)
+ collaborationManager.emitWorkflowUpdate(appId)
+ onImport?.()
+ const payload = getImportNotificationPayload(status, t)
+ toast[payload.type](payload.message, payload.children ? { description: payload.children } : undefined)
+ await handleCheckPluginDependencies(appId)
+ setLoading(false)
+ onCancel()
+ }, [handleCheckPluginDependencies, handleWorkflowUpdate, onCancel, onImport, t])
+
+ const handlePendingImport = useCallback((id: string, importedVersion?: string | null, currentVersion?: string | null) => {
+ setShow(false)
+ setTimeout(() => {
+ setShowErrorModal(true)
+ }, 300)
+ setVersions({
+ importedVersion: importedVersion ?? '',
+ systemVersion: currentVersion ?? '',
+ })
+ setImportId(id)
+ }, [])
+
const handleImport: MouseEventHandler = useCallback(async () => {
if (isCreatingRef.current)
return
isCreatingRef.current = true
- if (!currentFile)
+ if (!currentFile) {
+ isCreatingRef.current = false
return
+ }
try {
- if (appDetail && fileContent && validateDSLContent(fileContent)) {
+ if (appDetail && fileContent && validateDSLContent(fileContent, appDetail.mode)) {
setLoading(true)
const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, app_id: appDetail.id })
const { id, status, app_id, imported_dsl_version, current_dsl_version } = response
- if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
- if (!app_id) {
- toast.error(t('common.importFailure', { ns: 'workflow' }))
- return
- }
- handleWorkflowUpdate(app_id)
- if (onImport)
- onImport()
- toast(t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }), { type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('common.importWarningDetails', { ns: 'workflow' }) })
- await handleCheckPluginDependencies(app_id)
- setLoading(false)
- onCancel()
+ if (isImportCompleted(status)) {
+ await handleCompletedImport(status, app_id)
}
else if (status === DSLImportStatus.PENDING) {
- setShow(false)
- setTimeout(() => {
- setShowErrorModal(true)
- }, 300)
- setVersions({
- importedVersion: imported_dsl_version ?? '',
- systemVersion: current_dsl_version ?? '',
- })
- setImportId(id)
+ handlePendingImport(id, imported_dsl_version, current_dsl_version)
}
else {
setLoading(false)
toast.error(t('common.importFailure', { ns: 'workflow' }))
}
}
+ else if (fileContent) {
+ toast.error(t('common.importFailure', { ns: 'workflow' }))
+ }
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
@@ -186,7 +170,7 @@ const UpdateDSLModal = ({
toast.error(t('common.importFailure', { ns: 'workflow' }))
}
isCreatingRef.current = false
- }, [currentFile, fileContent, onCancel, t, appDetail, onImport, handleWorkflowUpdate, handleCheckPluginDependencies])
+ }, [currentFile, fileContent, t, appDetail, handleCompletedImport, handlePendingImport])
const onUpdateDSLConfirm: MouseEventHandler = async () => {
try {
@@ -198,20 +182,8 @@ const UpdateDSLModal = ({
const { status, app_id } = response
- if (status === DSLImportStatus.COMPLETED) {
- if (!app_id) {
- toast.error(t('common.importFailure', { ns: 'workflow' }))
- return
- }
- handleWorkflowUpdate(app_id)
- // Notify other collaboration clients about the workflow update
- collaborationManager.emitWorkflowUpdate(app_id)
- await handleCheckPluginDependencies(app_id)
- if (onImport)
- onImport()
- toast.success(t('common.importSuccess', { ns: 'workflow' }))
- setLoading(false)
- onCancel()
+ if (isImportCompleted(status)) {
+ await handleCompletedImport(status, app_id)
}
else if (status === DSLImportStatus.FAILED) {
setLoading(false)
diff --git a/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx
new file mode 100644
index 0000000000..1960576605
--- /dev/null
+++ b/web/app/components/workflow/variable-inspect/__tests__/value-content-sections.spec.tsx
@@ -0,0 +1,143 @@
+import type { FileUploadConfigResponse } from '@/models/common'
+import type { VarInInspect } from '@/types/workflow'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ToastContext } from '@/app/components/base/toast/context'
+import { VarType } from '@/app/components/workflow/types'
+import { VarInInspectType } from '@/types/workflow'
+import {
+ BoolArraySection,
+ ErrorMessages,
+ FileEditorSection,
+ JsonEditorSection,
+ TextEditorSection,
+} from '../value-content-sections'
+
+vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', () => ({
+ default: ({ schema, onUpdate }: { schema: string, onUpdate: (value: string) => void }) => (
+