feat(workflow): add node position indicator and prevent event propagation

Add node position indicator to workflow run panel when not in publish mode. Also prevent event propagation when clicking the position indicator to avoid unintended panel toggling.
pull/21659/head
Mminamiyama 11 months ago
parent cea6522122
commit 091c480547

@ -36,7 +36,8 @@ const NodePosition = ({
> >
<div <div
className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover' className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
onClick={() => { onClick={(e) => {
e.stopPropagation()
setViewport({ setViewport({
x: (clientWidth - 400 - nodeWidth * zoom) / 2 - nodePosition.x * zoom, x: (clientWidth - 400 - nodeWidth * zoom) / 2 - nodePosition.x * zoom,
y: (clientHeight - nodeHeight * zoom) / 2 - nodePosition.y * zoom, y: (clientHeight - nodeHeight * zoom) / 2 - nodePosition.y * zoom,

@ -1,267 +1,293 @@
'use client' 'use client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { FC } from 'react' import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { import {
RiAlertFill, RiAlertFill,
RiArrowRightSLine, RiArrowRightSLine,
RiCheckboxCircleFill, RiCheckboxCircleFill,
RiErrorWarningLine, RiErrorWarningLine,
RiLoader2Line, RiLoader2Line,
} from '@remixicon/react' } from '@remixicon/react'
import BlockIcon from '../block-icon' import BlockIcon from '../block-icon'
import { BlockEnum } from '../types' import { BlockEnum } from '../types'
import { RetryLogTrigger } from './retry-log' import { RetryLogTrigger } from './retry-log'
import { IterationLogTrigger } from './iteration-log' import { IterationLogTrigger } from './iteration-log'
import { LoopLogTrigger } from './loop-log' import { LoopLogTrigger } from './loop-log'
import { AgentLogTrigger } from './agent-log' import { AgentLogTrigger } from './agent-log'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import StatusContainer from '@/app/components/workflow/run/status-container' import StatusContainer from '@/app/components/workflow/run/status-container'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { import type {
AgentLogItemWithChildren, AgentLogItemWithChildren,
IterationDurationMap, IterationDurationMap,
LoopDurationMap, LoopDurationMap,
LoopVariableMap, LoopVariableMap,
NodeTracing, NodeTracing,
} from '@/types/workflow' } from '@/types/workflow'
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip' import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
import { hasRetryNode } from '@/app/components/workflow/utils' import { hasRetryNode } from '@/app/components/workflow/utils'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import NodePosition from '@/app/components/workflow/nodes/_base/components/node-position'
type Props = { import type { XYPosition } from 'reactflow'
className?: string import { WorkflowHistoryStoreContext } from '@/app/components/workflow/workflow-history-store'
nodeInfo: NodeTracing
allExecutions?: NodeTracing[] type Props = {
inMessage?: boolean className?: string
hideInfo?: boolean nodeInfo: NodeTracing
hideProcessDetail?: boolean allExecutions?: NodeTracing[]
onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void inMessage?: boolean
onShowLoopDetail?: (detail: NodeTracing[][], loopDurationMap: LoopDurationMap, loopVariableMap: LoopVariableMap) => void hideInfo?: boolean
onShowRetryDetail?: (detail: NodeTracing[]) => void hideProcessDetail?: boolean
onShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void
notShowIterationNav?: boolean onShowLoopDetail?: (detail: NodeTracing[][], loopDurationMap: LoopDurationMap, loopVariableMap: LoopVariableMap) => void
notShowLoopNav?: boolean onShowRetryDetail?: (detail: NodeTracing[]) => void
} onShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void
notShowIterationNav?: boolean
const NodePanel: FC<Props> = ({ notShowLoopNav?: boolean
className, }
nodeInfo,
allExecutions, const NodePanel: FC<Props> = ({
inMessage = false, className,
hideInfo = false, nodeInfo,
hideProcessDetail, allExecutions,
onShowIterationDetail, inMessage = false,
onShowLoopDetail, hideInfo = false,
onShowRetryDetail, hideProcessDetail,
onShowAgentOrToolLog, onShowIterationDetail,
notShowIterationNav, onShowLoopDetail,
notShowLoopNav, onShowRetryDetail,
}) => { onShowAgentOrToolLog,
const [collapseState, doSetCollapseState] = useState<boolean>(true) notShowIterationNav,
const setCollapseState = useCallback((state: boolean) => { notShowLoopNav,
if (hideProcessDetail) }) => {
return const { store } = useContext(WorkflowHistoryStoreContext)
doSetCollapseState(state) let inPublishMode = true
}, [hideProcessDetail]) let hasNode = false
const { t } = useTranslation() let nodePosition: XYPosition = { x: 0, y: 0 }
const docLink = useDocLink() let nodeWidth = 0
let nodeHeight = 0
const getTime = (time: number) => {
if (time < 1) if (store) {
return `${(time * 1000).toFixed(3)} ms` inPublishMode = false
if (time > 60) const nodes = store.getState().nodes
return `${Number.parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s` const currentNodeIndex = nodes.findIndex(node => node.id === nodeInfo.node_id)
return `${time.toFixed(3)} s` const currentNode = nodes[currentNodeIndex]
} nodePosition = currentNode?.position ?? { x: -1, y: -1 }
nodeWidth = currentNode?.width ?? -1
const getTokenCount = (tokens: number) => { nodeHeight = currentNode?.height ?? -1
if (tokens < 1000) hasNode = !!currentNode
return tokens }
if (tokens >= 1000 && tokens < 1000000)
return `${Number.parseFloat((tokens / 1000).toFixed(3))}K` const [collapseState, doSetCollapseState] = useState<boolean>(true)
if (tokens >= 1000000) const setCollapseState = useCallback((state: boolean) => {
return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M` if (hideProcessDetail)
} return
doSetCollapseState(state)
useEffect(() => { }, [hideProcessDetail])
setCollapseState(!nodeInfo.expand) const { t } = useTranslation()
}, [nodeInfo.expand, setCollapseState]) const docLink = useDocLink()
const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration && !!nodeInfo.details?.length const getTime = (time: number) => {
const isLoopNode = nodeInfo.node_type === BlockEnum.Loop && !!nodeInfo.details?.length if (time < 1)
const isRetryNode = hasRetryNode(nodeInfo.node_type) && !!nodeInfo.retryDetail?.length return `${(time * 1000).toFixed(3)} ms`
const isAgentNode = nodeInfo.node_type === BlockEnum.Agent && !!nodeInfo.agentLog?.length if (time > 60)
const isToolNode = nodeInfo.node_type === BlockEnum.Tool && !!nodeInfo.agentLog?.length return `${Number.parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s`
return `${time.toFixed(3)} s`
const inputsTitle = useMemo(() => { }
let text = t('workflow.common.input')
if (nodeInfo.node_type === BlockEnum.Loop) const getTokenCount = (tokens: number) => {
text = t('workflow.nodes.loop.initialLoopVariables') if (tokens < 1000)
return text.toLocaleUpperCase() return tokens
}, [nodeInfo.node_type, t]) if (tokens >= 1000 && tokens < 1000000)
const processDataTitle = t('workflow.common.processData').toLocaleUpperCase() return `${Number.parseFloat((tokens / 1000).toFixed(3))}K`
const outputTitle = useMemo(() => { if (tokens >= 1000000)
let text = t('workflow.common.output') return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M`
if (nodeInfo.node_type === BlockEnum.Loop) }
text = t('workflow.nodes.loop.finalLoopVariables')
return text.toLocaleUpperCase() useEffect(() => {
}, [nodeInfo.node_type, t]) setCollapseState(!nodeInfo.expand)
}, [nodeInfo.expand, setCollapseState])
return (
<div className={cn('px-2 py-1', className)}> const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration && !!nodeInfo.details?.length
<div className='group rounded-[10px] border border-components-panel-border bg-background-default shadow-xs transition-all hover:shadow-md'> const isLoopNode = nodeInfo.node_type === BlockEnum.Loop && !!nodeInfo.details?.length
<div const isRetryNode = hasRetryNode(nodeInfo.node_type) && !!nodeInfo.retryDetail?.length
className={cn( const isAgentNode = nodeInfo.node_type === BlockEnum.Agent && !!nodeInfo.agentLog?.length
'flex cursor-pointer items-center pl-1 pr-3', const isToolNode = nodeInfo.node_type === BlockEnum.Tool && !!nodeInfo.agentLog?.length
hideInfo ? 'py-2 pl-2' : 'py-1.5',
!collapseState && (hideInfo ? '!pb-1' : '!pb-1.5'), const inputsTitle = useMemo(() => {
)} let text = t('workflow.common.input')
onClick={() => setCollapseState(!collapseState)} if (nodeInfo.node_type === BlockEnum.Loop)
> text = t('workflow.nodes.loop.initialLoopVariables')
{!hideProcessDetail && ( return text.toLocaleUpperCase()
<RiArrowRightSLine }, [nodeInfo.node_type, t])
className={cn( const processDataTitle = t('workflow.common.processData').toLocaleUpperCase()
'mr-1 h-4 w-4 shrink-0 text-text-quaternary transition-all group-hover:text-text-tertiary', const outputTitle = useMemo(() => {
!collapseState && 'rotate-90', let text = t('workflow.common.output')
)} if (nodeInfo.node_type === BlockEnum.Loop)
/> text = t('workflow.nodes.loop.finalLoopVariables')
)} return text.toLocaleUpperCase()
<BlockIcon size={inMessage ? 'xs' : 'sm'} className={cn('mr-2 shrink-0', inMessage && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} /> }, [nodeInfo.node_type, t])
<Tooltip
popupContent={ return (
<div className='max-w-xs'>{nodeInfo.title}</div> <div className={cn('px-2 py-1', className)}>
} <div className='group rounded-[10px] border border-components-panel-border bg-background-default shadow-xs transition-all hover:shadow-md'>
> <div
<div className={cn( className={cn(
'system-xs-semibold-uppercase grow truncate text-text-secondary', 'flex cursor-pointer items-center pl-1 pr-3',
hideInfo && '!text-xs', hideInfo ? 'py-2 pl-2' : 'py-1.5',
)}>{nodeInfo.title}</div> !collapseState && (hideInfo ? '!pb-1' : '!pb-1.5'),
</Tooltip> )}
{nodeInfo.status !== 'running' && !hideInfo && ( onClick={() => setCollapseState(!collapseState)}
<div className='system-xs-regular shrink-0 text-text-tertiary'>{nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''}{`${getTime(nodeInfo.elapsed_time || 0)}`}</div> >
)} {!hideProcessDetail && (
{nodeInfo.status === 'succeeded' && ( <RiArrowRightSLine
<RiCheckboxCircleFill className='ml-2 h-3.5 w-3.5 shrink-0 text-text-success' /> className={cn(
)} 'mr-1 h-4 w-4 shrink-0 text-text-quaternary transition-all group-hover:text-text-tertiary',
{nodeInfo.status === 'failed' && ( !collapseState && 'rotate-90',
<RiErrorWarningLine className='ml-2 h-3.5 w-3.5 shrink-0 text-text-warning' /> )}
)} />
{nodeInfo.status === 'stopped' && ( )}
<RiAlertFill className={cn('ml-2 h-4 w-4 shrink-0 text-text-warning-secondary', inMessage && 'h-3.5 w-3.5')} /> <BlockIcon size={inMessage ? 'xs' : 'sm'} className={cn('mr-2 shrink-0', inMessage && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
)} <Tooltip
{nodeInfo.status === 'exception' && ( popupContent={
<RiAlertFill className={cn('ml-2 h-4 w-4 shrink-0 text-text-warning-secondary', inMessage && 'h-3.5 w-3.5')} /> <div className='max-w-xs'>{nodeInfo.title}</div>
)} }
{nodeInfo.status === 'running' && ( >
<div className='flex shrink-0 items-center text-[13px] font-medium leading-[16px] text-text-accent'> <div className={cn(
<span className='mr-2 text-xs font-normal'>Running</span> 'system-xs-semibold-uppercase grow truncate text-text-secondary',
<RiLoader2Line className='h-3.5 w-3.5 animate-spin' /> hideInfo && '!text-xs',
</div> )}>{nodeInfo.title}</div>
)} </Tooltip>
</div> {nodeInfo.status !== 'running' && !hideInfo && (
{!collapseState && !hideProcessDetail && ( <div className='system-xs-regular shrink-0 text-text-tertiary'>{nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''}{`${getTime(nodeInfo.elapsed_time || 0)}`}</div>
<div className='px-1 pb-1'> )}
{/* The nav to the iteration detail */} {nodeInfo.status === 'succeeded' && (
{isIterationNode && !notShowIterationNav && onShowIterationDetail && ( <RiCheckboxCircleFill className='ml-2 h-3.5 w-3.5 shrink-0 text-text-success' />
<IterationLogTrigger )}
nodeInfo={nodeInfo} {nodeInfo.status === 'failed' && (
allExecutions={allExecutions} <RiErrorWarningLine className='ml-2 h-3.5 w-3.5 shrink-0 text-text-warning' />
onShowIterationResultList={onShowIterationDetail} )}
/> {nodeInfo.status === 'stopped' && (
)} <RiAlertFill className={cn('ml-2 h-4 w-4 shrink-0 text-text-warning-secondary', inMessage && 'h-3.5 w-3.5')} />
{/* The nav to the Loop detail */} )}
{isLoopNode && !notShowLoopNav && onShowLoopDetail && ( {nodeInfo.status === 'exception' && (
<LoopLogTrigger <RiAlertFill className={cn('ml-2 h-4 w-4 shrink-0 text-text-warning-secondary', inMessage && 'h-3.5 w-3.5')} />
nodeInfo={nodeInfo} )}
allExecutions={allExecutions} {nodeInfo.status === 'running' && (
onShowLoopResultList={onShowLoopDetail} <div className='flex shrink-0 items-center text-[13px] font-medium leading-[16px] text-text-accent'>
/> <span className='mr-2 text-xs font-normal'>Running</span>
)} <RiLoader2Line className='h-3.5 w-3.5 animate-spin' />
{isRetryNode && onShowRetryDetail && ( </div>
<RetryLogTrigger )}
nodeInfo={nodeInfo} {!inPublishMode && (
onShowRetryResultList={onShowRetryDetail} <div className='ml-1' style={{ pointerEvents: hasNode ? 'auto' : 'none', opacity: hasNode ? 1 : 0.5 }}>
/> <NodePosition nodePosition={nodePosition} nodeWidth={nodeWidth} nodeHeight={nodeHeight}></NodePosition>
)} </div>
{ )}
(isAgentNode || isToolNode) && onShowAgentOrToolLog && ( </div>
<AgentLogTrigger {!collapseState && !hideProcessDetail && (
nodeInfo={nodeInfo} <div className='px-1 pb-1'>
onShowAgentOrToolLog={onShowAgentOrToolLog} {/* The nav to the iteration detail */}
/> {isIterationNode && !notShowIterationNav && onShowIterationDetail && (
) <IterationLogTrigger
} nodeInfo={nodeInfo}
<div className={cn('mb-1', hideInfo && '!px-2 !py-0.5')}> allExecutions={allExecutions}
{(nodeInfo.status === 'stopped') && ( onShowIterationResultList={onShowIterationDetail}
<StatusContainer status='stopped'> />
{t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })} )}
</StatusContainer> {/* The nav to the Loop detail */}
)} {isLoopNode && !notShowLoopNav && onShowLoopDetail && (
{(nodeInfo.status === 'exception') && ( <LoopLogTrigger
<StatusContainer status='stopped'> nodeInfo={nodeInfo}
{nodeInfo.error} allExecutions={allExecutions}
<a onShowLoopResultList={onShowLoopDetail}
href={docLink('/guides/workflow/error-handling/error-type')} />
target='_blank' )}
className='text-text-accent' {isRetryNode && onShowRetryDetail && (
> <RetryLogTrigger
{t('workflow.common.learnMore')} nodeInfo={nodeInfo}
</a> onShowRetryResultList={onShowRetryDetail}
</StatusContainer> />
)} )}
{nodeInfo.status === 'failed' && ( {
<StatusContainer status='failed'> (isAgentNode || isToolNode) && onShowAgentOrToolLog && (
{nodeInfo.error} <AgentLogTrigger
</StatusContainer> nodeInfo={nodeInfo}
)} onShowAgentOrToolLog={onShowAgentOrToolLog}
{nodeInfo.status === 'retry' && ( />
<StatusContainer status='failed'> )
{nodeInfo.error} }
</StatusContainer> <div className={cn('mb-1', hideInfo && '!px-2 !py-0.5')}>
)} {(nodeInfo.status === 'stopped') && (
</div> <StatusContainer status='stopped'>
{nodeInfo.inputs && ( {t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })}
<div className={cn('mb-1')}> </StatusContainer>
<CodeEditor )}
readOnly {(nodeInfo.status === 'exception') && (
title={<div>{inputsTitle}</div>} <StatusContainer status='stopped'>
language={CodeLanguage.json} {nodeInfo.error}
value={nodeInfo.inputs} <a
isJSONStringifyBeauty href={docLink('/guides/workflow/error-handling/error-type')}
/> target='_blank'
</div> className='text-text-accent'
)} >
{nodeInfo.process_data && ( {t('workflow.common.learnMore')}
<div className={cn('mb-1')}> </a>
<CodeEditor </StatusContainer>
readOnly )}
title={<div>{processDataTitle}</div>} {nodeInfo.status === 'failed' && (
language={CodeLanguage.json} <StatusContainer status='failed'>
value={nodeInfo.process_data} {nodeInfo.error}
isJSONStringifyBeauty </StatusContainer>
/> )}
</div> {nodeInfo.status === 'retry' && (
)} <StatusContainer status='failed'>
{nodeInfo.outputs && ( {nodeInfo.error}
<div> </StatusContainer>
<CodeEditor )}
readOnly </div>
title={<div>{outputTitle}</div>} {nodeInfo.inputs && (
language={CodeLanguage.json} <div className={cn('mb-1')}>
value={nodeInfo.outputs} <CodeEditor
isJSONStringifyBeauty readOnly
tip={<ErrorHandleTip type={nodeInfo.execution_metadata?.error_strategy} />} title={<div>{inputsTitle}</div>}
/> language={CodeLanguage.json}
</div> value={nodeInfo.inputs}
)} isJSONStringifyBeauty
</div> />
)} </div>
</div> )}
</div> {nodeInfo.process_data && (
) <div className={cn('mb-1')}>
} <CodeEditor
readOnly
export default NodePanel title={<div>{processDataTitle}</div>}
language={CodeLanguage.json}
value={nodeInfo.process_data}
isJSONStringifyBeauty
/>
</div>
)}
{nodeInfo.outputs && (
<div>
<CodeEditor
readOnly
title={<div>{outputTitle}</div>}
language={CodeLanguage.json}
value={nodeInfo.outputs}
isJSONStringifyBeauty
tip={<ErrorHandleTip type={nodeInfo.execution_metadata?.error_strategy} />}
/>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default NodePanel

Loading…
Cancel
Save