feat: Parallel Execution of Nodes in Workflows (#8192)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Co-authored-by: Yi <yxiaoisme@gmail.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
takatost
2024-09-10 15:23:16 +08:00
committed by GitHub
parent 5da0182800
commit dabfd74622
156 changed files with 11158 additions and 5605 deletions

View File

@@ -63,26 +63,22 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
const formatNodeList = useCallback((list: NodeTracing[]) => {
const allItems = list.reverse()
const result: NodeTracing[] = []
let iterationIndex = 0
allItems.forEach((item) => {
const { node_type, execution_metadata } = item
if (node_type !== BlockEnum.Iteration) {
const isInIteration = !!execution_metadata?.iteration_id
if (isInIteration) {
const iterationDetails = result[result.length - 1].details!
const currentIterationIndex = execution_metadata?.iteration_index
const isIterationFirstNode = iterationIndex !== currentIterationIndex || iterationDetails.length === 0
const iterationNode = result.find(node => node.node_id === execution_metadata?.iteration_id)
const iterationDetails = iterationNode?.details
const currentIterationIndex = execution_metadata?.iteration_index ?? 0
if (isIterationFirstNode) {
iterationDetails!.push([item])
iterationIndex = currentIterationIndex!
if (Array.isArray(iterationDetails)) {
if (iterationDetails.length === 0 || !iterationDetails[currentIterationIndex])
iterationDetails[currentIterationIndex] = [item]
else
iterationDetails[currentIterationIndex].push(item)
}
else {
iterationDetails[iterationDetails.length - 1].push(item)
}
return
}
// not in iteration
@@ -90,7 +86,6 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
return
}
result.push({
...item,
details: [],

View File

@@ -1,10 +1,14 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import {
RiArrowRightSLine,
RiCloseLine,
} from '@remixicon/react'
import { ArrowNarrowLeft } from '../../base/icons/src/vender/line/arrows'
import NodePanel from './node'
import TracingPanel from './tracing-panel'
import { Iteration } from '@/app/components/base/icons/src/vender/workflow'
import cn from '@/utils/classnames'
import type { NodeTracing } from '@/types/workflow'
const i18nPrefix = 'workflow.singleRun'
@@ -23,43 +27,67 @@ const IterationResultPanel: FC<Props> = ({
noWrap,
}) => {
const { t } = useTranslation()
const [expandedIterations, setExpandedIterations] = useState<Record<number, boolean>>([])
const toggleIteration = useCallback((index: number) => {
setExpandedIterations(prev => ({
...prev,
[index]: !prev[index],
}))
}, [])
const main = (
<>
<div className={cn(!noWrap && 'shrink-0 ', 'pl-4 pr-3 pt-3')}>
<div className={cn(!noWrap && 'shrink-0 ', 'px-4 pt-3')}>
<div className='shrink-0 flex justify-between items-center h-8'>
<div className='text-base font-semibold text-gray-900 truncate'>
<div className='system-xl-semibold text-text-primary truncate'>
{t(`${i18nPrefix}.testRunIteration`)}
</div>
<div className='ml-2 shrink-0 p-1 cursor-pointer' onClick={onHide}>
<RiCloseLine className='w-4 h-4 text-gray-500 ' />
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
</div>
</div>
<div className='flex items-center py-2 space-x-1 text-primary-600 cursor-pointer' onClick={onBack}>
<div className='flex items-center py-2 space-x-1 text-text-accent-secondary cursor-pointer' onClick={onBack}>
<ArrowNarrowLeft className='w-4 h-4' />
<div className='leading-[18px] text-[13px] font-medium'>{t(`${i18nPrefix}.back`)}</div>
<div className='system-sm-medium'>{t(`${i18nPrefix}.back`)}</div>
</div>
</div>
{/* List */}
<div className={cn(!noWrap ? 'h-0 grow' : 'max-h-full', 'overflow-y-auto px-4 pb-4 bg-gray-50')}>
<div className={cn(!noWrap ? 'flex-grow overflow-auto' : 'max-h-full', 'p-2 bg-components-panel-bg')}>
{list.map((iteration, index) => (
<div key={index} className={cn('my-4', index === 0 && 'mt-2')}>
<div className='flex items-center'>
<div className='shrink-0 leading-[18px] text-xs font-semibold text-gray-500 uppercase'>{t(`${i18nPrefix}.iteration`)} {index + 1}</div>
<div
className='ml-3 grow w-0 h-px'
style={{ background: 'linear-gradient(to right, #F3F4F6, rgba(243, 244, 246, 0))' }}
></div>
<div key={index} className={cn('mb-1 overflow-hidden rounded-xl bg-background-section-burn border-none')}>
<div
className={cn(
'flex items-center justify-between w-full px-3 cursor-pointer',
expandedIterations[index] ? 'pt-3 pb-2' : 'py-3',
'rounded-xl text-left',
)}
onClick={() => toggleIteration(index)}
>
<div className={cn('flex items-center gap-2 flex-grow')}>
<div className='flex items-center justify-center w-4 h-4 rounded-[5px] border-divider-subtle bg-util-colors-cyan-cyan-500 flex-shrink-0'>
<Iteration className='w-3 h-3 text-text-primary-on-surface' />
</div>
<span className='system-sm-semibold-uppercase text-text-primary flex-grow'>
{t(`${i18nPrefix}.iteration`)} {index + 1}
</span>
<RiArrowRightSLine className={cn(
'w-4 h-4 text-text-tertiary transition-transform duration-200 flex-shrink-0',
expandedIterations[index] && 'transform rotate-90',
)} />
</div>
</div>
<div className='mt-0.5 space-y-1'>
{iteration.map(node => (
<NodePanel
key={node.id}
className='!px-0 !py-0'
nodeInfo={node}
notShowIterationNav
/>
))}
{expandedIterations[index] && <div
className="flex-grow h-px bg-divider-subtle"
></div>}
<div className={cn(
'overflow-hidden transition-all duration-200',
expandedIterations[index] ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0',
)}>
<TracingPanel
list={iteration}
className='bg-background-section-burn'
/>
</div>
</div>
))}

View File

@@ -4,15 +4,17 @@ import type { FC } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
RiArrowRightSLine,
RiCheckboxCircleLine,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiLoader2Line,
} from '@remixicon/react'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import Split from '../nodes/_base/components/split'
import { Iteration } from '@/app/components/base/icons/src/vender/workflow'
import cn from '@/utils/classnames'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import Button from '@/app/components/base/button'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import type { NodeTracing } from '@/types/workflow'
@@ -61,31 +63,38 @@ const NodePanel: FC<Props> = ({
return `${parseFloat((tokens / 1000000).toFixed(3))}M`
}
const getCount = (iteration_curr_length: number | undefined, iteration_length: number) => {
if ((iteration_curr_length && iteration_curr_length < iteration_length) || !iteration_length)
return iteration_curr_length
return iteration_length
}
useEffect(() => {
setCollapseState(!nodeInfo.expand)
}, [nodeInfo.expand, setCollapseState])
const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration
const handleOnShowIterationDetail = (e: React.MouseEvent<HTMLDivElement>) => {
const handleOnShowIterationDetail = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onShowIterationDetail?.(nodeInfo.details || [])
}
return (
<div className={cn('px-4 py-1', className, hideInfo && '!p-0')}>
<div className={cn('group transition-all bg-white border border-gray-100 rounded-2xl shadow-xs hover:shadow-md', hideInfo && '!rounded-lg')}>
<div className={cn('px-2 py-1', className)}>
<div className='group transition-all bg-background-default border border-components-panel-border rounded-[10px] shadows-shadow-xs hover:shadow-md'>
<div
className={cn(
'flex items-center pl-[6px] pr-3 cursor-pointer',
hideInfo ? 'py-2' : 'py-3',
!collapseState && (hideInfo ? '!pb-1' : '!pb-2'),
'flex items-center pl-1 pr-3 cursor-pointer',
hideInfo ? 'py-2' : 'py-1.5',
!collapseState && (hideInfo ? '!pb-1' : '!pb-1.5'),
)}
onClick={() => setCollapseState(!collapseState)}
>
{!hideProcessDetail && (
<RiArrowRightSLine
className={cn(
'shrink-0 w-3 h-3 mr-1 text-gray-400 transition-all group-hover:text-gray-500',
'shrink-0 w-4 h-4 mr-1 text-text-quaternary transition-all group-hover:text-text-tertiary',
!collapseState && 'rotate-90',
)}
/>
@@ -93,23 +102,23 @@ const NodePanel: FC<Props> = ({
<BlockIcon size={hideInfo ? 'xs' : 'sm'} className={cn('shrink-0 mr-2', hideInfo && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
<div className={cn(
'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate',
'grow text-text-secondary system-xs-semibold-uppercase truncate',
hideInfo && '!text-xs',
)} title={nodeInfo.title}>{nodeInfo.title}</div>
{nodeInfo.status !== 'running' && !hideInfo && (
<div className='shrink-0 text-gray-500 text-xs leading-[18px]'>{`${getTime(nodeInfo.elapsed_time || 0)} · ${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens`}</div>
<div className='shrink-0 text-text-tertiary system-xs-regular'>{nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''}{`${getTime(nodeInfo.elapsed_time || 0)}`}</div>
)}
{nodeInfo.status === 'succeeded' && (
<RiCheckboxCircleLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#12B76A]' />
<RiCheckboxCircleFill className='shrink-0 ml-2 w-3.5 h-3.5 text-text-success' />
)}
{nodeInfo.status === 'failed' && (
<RiErrorWarningLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F04438]' />
<RiErrorWarningLine className='shrink-0 ml-2 w-3.5 h-3.5 text-text-warning' />
)}
{nodeInfo.status === 'stopped' && (
<AlertTriangle className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F79009]' />
)}
{nodeInfo.status === 'running' && (
<div className='shrink-0 flex items-center text-primary-600 text-[13px] leading-[16px] font-medium'>
<div className='shrink-0 flex items-center text-text-accent text-[13px] leading-[16px] font-medium'>
<span className='mr-2 text-xs font-normal'>Running</span>
<RiLoader2Line className='w-3.5 h-3.5 animate-spin' />
</div>
@@ -120,25 +129,27 @@ const NodePanel: FC<Props> = ({
{/* The nav to the iteration detail */}
{isIterationNode && !notShowIterationNav && (
<div className='mt-2 mb-1 !px-2'>
<div
className='flex items-center h-[34px] justify-between px-3 bg-gray-100 border-[0.5px] border-gray-200 rounded-lg cursor-pointer'
onClick={handleOnShowIterationDetail}>
<div className='leading-[18px] text-[13px] font-medium text-gray-700'>{t('workflow.nodes.iteration.iteration', { count: nodeInfo.metadata?.iterator_length || nodeInfo.details?.length })}</div>
<Button
className='flex items-center w-full self-stretch gap-2 px-3 py-2 bg-components-button-tertiary-bg-hover hover:bg-components-button-tertiary-bg-hover rounded-lg cursor-pointer border-none'
onClick={handleOnShowIterationDetail}
>
<Iteration className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
<div className='flex-1 text-left system-sm-medium text-components-button-tertiary-text'>{t('workflow.nodes.iteration.iteration', { count: getCount(nodeInfo.details?.length, nodeInfo.metadata?.iterator_length) })}</div>
{justShowIterationNavArrow
? (
<RiArrowRightSLine className='w-3.5 h-3.5 text-gray-500' />
<RiArrowRightSLine className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
)
: (
<div className='flex items-center space-x-1 text-[#155EEF]'>
<div className='text-[13px] font-normal '>{t('workflow.common.viewDetailInTracingPanel')}</div>
<RiArrowRightSLine className='w-3.5 h-3.5' />
<RiArrowRightSLine className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
</div>
)}
</div>
</Button>
<Split className='mt-2' />
</div>
)}
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<div className={cn('px-[10px]', hideInfo && '!px-2 !py-0.5')}>
{nodeInfo.status === 'stopped' && (
<div className='px-3 py-[10px] bg-[#fffaeb] rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] text-xs leading-[18px] text-[#dc6803] shadow-xs'>{t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })}</div>
)}

View File

@@ -1,24 +1,266 @@
'use client'
import type { FC } from 'react'
import
React,
{
useCallback,
useState,
} from 'react'
import cn from 'classnames'
import {
RiArrowDownSLine,
RiMenu4Line,
} from '@remixicon/react'
import NodePanel from './node'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
type TracingPanelProps = {
list: NodeTracing[]
onShowIterationDetail: (detail: NodeTracing[][]) => void
onShowIterationDetail?: (detail: NodeTracing[][]) => void
className?: string
hideNodeInfo?: boolean
hideNodeProcessDetail?: boolean
}
const TracingPanel: FC<TracingPanelProps> = ({ list, onShowIterationDetail }) => {
type TracingNodeProps = {
id: string
uniqueId: string
isParallel: boolean
data: NodeTracing | null
children: TracingNodeProps[]
parallelTitle?: string
branchTitle?: string
hideNodeInfo?: boolean
hideNodeProcessDetail?: boolean
}
function buildLogTree(nodes: NodeTracing[]): TracingNodeProps[] {
const rootNodes: TracingNodeProps[] = []
const parallelStacks: { [key: string]: TracingNodeProps } = {}
const levelCounts: { [key: string]: number } = {}
const parallelChildCounts: { [key: string]: Set<string> } = {}
let uniqueIdCounter = 0
const getUniqueId = () => {
uniqueIdCounter++
return `unique-${uniqueIdCounter}`
}
const getParallelTitle = (parentId: string | null): string => {
const levelKey = parentId || 'root'
if (!levelCounts[levelKey])
levelCounts[levelKey] = 0
levelCounts[levelKey]++
const parentTitle = parentId ? parallelStacks[parentId]?.parallelTitle : ''
const levelNumber = parentTitle ? parseInt(parentTitle.split('-')[1]) + 1 : 1
const letter = parallelChildCounts[levelKey]?.size > 1 ? String.fromCharCode(64 + levelCounts[levelKey]) : ''
return `PARALLEL-${levelNumber}${letter}`
}
const getBranchTitle = (parentId: string | null, branchNum: number): string => {
const levelKey = parentId || 'root'
const parentTitle = parentId ? parallelStacks[parentId]?.parallelTitle : ''
const levelNumber = parentTitle ? parseInt(parentTitle.split('-')[1]) + 1 : 1
const letter = parallelChildCounts[levelKey]?.size > 1 ? String.fromCharCode(64 + levelCounts[levelKey]) : ''
const branchLetter = String.fromCharCode(64 + branchNum)
return `BRANCH-${levelNumber}${letter}-${branchLetter}`
}
// Count parallel children (for figuring out if we need to use letters)
for (const node of nodes) {
const parent_parallel_id = node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null
const parallel_id = node.parallel_id ?? node.execution_metadata?.parallel_id ?? null
if (parallel_id) {
const parentKey = parent_parallel_id || 'root'
if (!parallelChildCounts[parentKey])
parallelChildCounts[parentKey] = new Set()
parallelChildCounts[parentKey].add(parallel_id)
}
}
for (const node of nodes) {
const parallel_id = node.parallel_id ?? node.execution_metadata?.parallel_id ?? null
const parent_parallel_id = node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null
const parallel_start_node_id = node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null
const parent_parallel_start_node_id = node.parent_parallel_start_node_id ?? node.execution_metadata?.parent_parallel_start_node_id ?? null
if (!parallel_id || node.node_type === BlockEnum.End) {
rootNodes.push({
id: node.id,
uniqueId: getUniqueId(),
isParallel: false,
data: node,
children: [],
})
}
else {
if (!parallelStacks[parallel_id]) {
const newParallelGroup: TracingNodeProps = {
id: parallel_id,
uniqueId: getUniqueId(),
isParallel: true,
data: null,
children: [],
parallelTitle: '',
}
parallelStacks[parallel_id] = newParallelGroup
if (parent_parallel_id && parallelStacks[parent_parallel_id]) {
const sameBranchIndex = parallelStacks[parent_parallel_id].children.findLastIndex(c =>
c.data?.execution_metadata?.parallel_start_node_id === parent_parallel_start_node_id || c.data?.parallel_start_node_id === parent_parallel_start_node_id,
)
parallelStacks[parent_parallel_id].children.splice(sameBranchIndex + 1, 0, newParallelGroup)
newParallelGroup.parallelTitle = getParallelTitle(parent_parallel_id)
}
else {
newParallelGroup.parallelTitle = getParallelTitle(parent_parallel_id)
rootNodes.push(newParallelGroup)
}
}
const branchTitle = parallel_start_node_id === node.node_id ? getBranchTitle(parent_parallel_id, parallelStacks[parallel_id].children.length + 1) : ''
if (branchTitle) {
parallelStacks[parallel_id].children.push({
id: node.id,
uniqueId: getUniqueId(),
isParallel: false,
data: node,
children: [],
branchTitle,
})
}
else {
let sameBranchIndex = parallelStacks[parallel_id].children.findLastIndex(c =>
c.data?.execution_metadata?.parallel_start_node_id === parallel_start_node_id || c.data?.parallel_start_node_id === parallel_start_node_id,
)
if (parallelStacks[parallel_id].children[sameBranchIndex + 1]?.isParallel)
sameBranchIndex++
parallelStacks[parallel_id].children.splice(sameBranchIndex + 1, 0, {
id: node.id,
uniqueId: getUniqueId(),
isParallel: false,
data: node,
children: [],
branchTitle,
})
}
}
}
return rootNodes
}
const TracingPanel: FC<TracingPanelProps> = ({
list,
onShowIterationDetail,
className,
hideNodeInfo = false,
hideNodeProcessDetail = false,
}) => {
const treeNodes = buildLogTree(list)
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set())
const [hoveredParallel, setHoveredParallel] = useState<string | null>(null)
const toggleCollapse = (id: string) => {
setCollapsedNodes((prev) => {
const newSet = new Set(prev)
if (newSet.has(id))
newSet.delete(id)
else
newSet.add(id)
return newSet
})
}
const handleParallelMouseEnter = useCallback((id: string) => {
setHoveredParallel(id)
}, [])
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)
}
}, [])
const renderNode = (node: TracingNodeProps) => {
if (node.isParallel) {
const isCollapsed = collapsedNodes.has(node.id)
const isHovered = hoveredParallel === node.id
return (
<div
key={node.uniqueId}
className="ml-4 mb-2 relative"
data-parallel-id={node.id}
onMouseEnter={() => handleParallelMouseEnter(node.id)}
onMouseLeave={handleParallelMouseLeave}
>
<div className="flex items-center mb-1">
<button
onClick={() => toggleCollapse(node.id)}
className={cn(
'mr-2 transition-colors',
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 ? <RiArrowDownSLine className="w-3 h-3" /> : <RiMenu4Line className="w-3 h-3 text-text-tertiary" />}
</button>
<div className="system-xs-semibold-uppercase text-text-secondary flex items-center">
<span>{node.parallelTitle}</span>
</div>
<div
className="mx-2 flex-grow h-px bg-divider-subtle"
style={{ background: 'linear-gradient(to right, rgba(16, 24, 40, 0.08), rgba(255, 255, 255, 0)' }}
></div>
</div>
<div className={`pl-2 relative ${isCollapsed ? 'hidden' : ''}`}>
<div className={cn(
'absolute top-0 bottom-0 left-[5px] w-[2px]',
isHovered ? 'bg-text-accent-secondary' : 'bg-divider-subtle',
)}></div>
{node.children.map(renderNode)}
</div>
</div>
)
}
else {
const isHovered = hoveredParallel === node.id
return (
<div key={node.uniqueId}>
<div className={cn('pl-4 -mb-1.5 system-2xs-medium-uppercase', isHovered ? 'text-text-tertiary' : 'text-text-quaternary')}>
{node.branchTitle}
</div>
<NodePanel
nodeInfo={node.data!}
onShowIterationDetail={onShowIterationDetail}
justShowIterationNavArrow={true}
hideInfo={hideNodeInfo}
hideProcessDetail={hideNodeProcessDetail}
/>
</div>
)
}
}
return (
<div className='bg-gray-50 py-2'>
{list.map(node => (
<NodePanel
key={node.id}
nodeInfo={node}
onShowIterationDetail={onShowIterationDetail}
justShowIterationNavArrow
/>
))}
<div className={cn(className || 'bg-components-panel-bg', 'py-2')}>
{treeNodes.map(renderNode)}
</div>
)
}