mirror of
https://github.com/langgenius/dify.git
synced 2026-09-02 14:25:35 +08:00
Keep the workflow editor mounted after history updates, expose an independent Workflow Access control, and map KnowledgeFS admission failures to actionable localized errors. Add frontend and backend regression coverage for KF-BUG-020 and KF-BUG-021.
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
'use client'
|
|
import type { FC } from 'react'
|
|
import type {
|
|
AgentLogItemWithChildren,
|
|
IterationDurationMap,
|
|
LoopDurationMap,
|
|
LoopVariableMap,
|
|
NodeTracing,
|
|
} from '@/types/workflow'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
|
import {
|
|
RiAlertFill,
|
|
RiArrowRightSLine,
|
|
RiCheckboxCircleFill,
|
|
RiErrorWarningFill,
|
|
RiLoader2Line,
|
|
RiPauseCircleFill,
|
|
} from '@remixicon/react'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
|
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
|
|
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
|
import { localizeKnowledgeRetrievalV2Error } from '@/app/components/workflow/nodes/knowledge-retrieval-v2/error-message'
|
|
import StatusContainer from '@/app/components/workflow/run/status-container'
|
|
import { hasRetryNode } from '@/app/components/workflow/utils'
|
|
import { useDocLink } from '@/context/i18n'
|
|
import BlockIcon from '../block-icon'
|
|
import { BlockEnum } from '../types'
|
|
import LargeDataAlert from '../variable-inspect/large-data-alert'
|
|
import { AgentLogTrigger } from './agent-log/agent-log-trigger'
|
|
import { IterationLogTrigger } from './iteration-log'
|
|
import { LoopLogTrigger } from './loop-log'
|
|
import { RetryLogTrigger } from './retry-log'
|
|
|
|
type Props = {
|
|
readonly className?: string
|
|
readonly nodeInfo: NodeTracing
|
|
readonly allExecutions?: NodeTracing[]
|
|
readonly inMessage?: boolean
|
|
readonly hideInfo?: boolean
|
|
readonly hideProcessDetail?: boolean
|
|
readonly onShowIterationDetail?: (
|
|
detail: NodeTracing[][],
|
|
iterDurationMap: IterationDurationMap,
|
|
) => void
|
|
readonly onShowLoopDetail?: (
|
|
detail: NodeTracing[][],
|
|
loopDurationMap: LoopDurationMap,
|
|
loopVariableMap: LoopVariableMap,
|
|
) => void
|
|
readonly onShowRetryDetail?: (detail: NodeTracing[]) => void
|
|
readonly onShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void
|
|
readonly notShowIterationNav?: boolean
|
|
readonly notShowLoopNav?: boolean
|
|
}
|
|
|
|
const NodePanel: FC<Props> = ({
|
|
className,
|
|
nodeInfo,
|
|
allExecutions,
|
|
inMessage = false,
|
|
hideInfo = false,
|
|
hideProcessDetail,
|
|
onShowIterationDetail,
|
|
onShowLoopDetail,
|
|
onShowRetryDetail,
|
|
onShowAgentOrToolLog,
|
|
notShowIterationNav,
|
|
notShowLoopNav,
|
|
}) => {
|
|
const [collapseState, doSetCollapseState] = useState<boolean>(true)
|
|
const setCollapseState = useCallback(
|
|
(state: boolean) => {
|
|
if (hideProcessDetail) return
|
|
doSetCollapseState(state)
|
|
},
|
|
[hideProcessDetail],
|
|
)
|
|
const { t } = useTranslation()
|
|
const docLink = useDocLink()
|
|
|
|
const getTime = (time: number) => {
|
|
if (time < 1) return `${(time * 1000).toFixed(3)} ms`
|
|
if (time > 60) return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s`
|
|
return `${time.toFixed(3)} s`
|
|
}
|
|
|
|
const getTokenCount = (tokens: number) => {
|
|
if (tokens < 1000) return tokens
|
|
if (tokens >= 1000 && tokens < 1000000)
|
|
return `${Number.parseFloat((tokens / 1000).toFixed(3))}K`
|
|
if (tokens >= 1000000) return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M`
|
|
}
|
|
|
|
useEffect(() => {
|
|
setCollapseState(!nodeInfo.expand)
|
|
}, [nodeInfo.expand, setCollapseState])
|
|
|
|
const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration && !!nodeInfo.details?.length
|
|
const isLoopNode = nodeInfo.node_type === BlockEnum.Loop && !!nodeInfo.details?.length
|
|
const isRetryNode = hasRetryNode(nodeInfo.node_type) && !!nodeInfo.retryDetail?.length
|
|
const isAgentNode = nodeInfo.node_type === BlockEnum.Agent && !!nodeInfo.agentLog?.length
|
|
const isToolNode = nodeInfo.node_type === BlockEnum.Tool && !!nodeInfo.agentLog?.length
|
|
const localizedError = localizeKnowledgeRetrievalV2Error(nodeInfo.error, (key) =>
|
|
t(($) => $[key], { ns: 'workflow' }),
|
|
)
|
|
|
|
const inputsTitle = useMemo(() => {
|
|
let text = t(($) => $['common.input'], { ns: 'workflow' })
|
|
if (nodeInfo.node_type === BlockEnum.Loop)
|
|
text = t(($) => $['nodes.loop.initialLoopVariables'], { ns: 'workflow' })
|
|
return text.toLocaleUpperCase()
|
|
}, [nodeInfo.node_type, t])
|
|
const processDataTitle = t(($) => $['common.processData'], { ns: 'workflow' }).toLocaleUpperCase()
|
|
const outputTitle = useMemo(() => {
|
|
let text = t(($) => $['common.output'], { ns: 'workflow' })
|
|
if (nodeInfo.node_type === BlockEnum.Loop)
|
|
text = t(($) => $['nodes.loop.finalLoopVariables'], { ns: 'workflow' })
|
|
return text.toLocaleUpperCase()
|
|
}, [nodeInfo.node_type, t])
|
|
|
|
return (
|
|
<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
|
|
className={cn(
|
|
'flex cursor-pointer items-center pr-3 pl-1',
|
|
hideInfo ? 'py-2 pl-2' : 'py-1.5',
|
|
!collapseState && (hideInfo ? 'pb-1!' : 'pb-1.5!'),
|
|
)}
|
|
onClick={() => setCollapseState(!collapseState)}
|
|
>
|
|
{!hideProcessDetail && (
|
|
<RiArrowRightSLine
|
|
className={cn(
|
|
'mr-1 size-4 shrink-0 text-text-quaternary transition-all group-hover:text-text-tertiary',
|
|
!collapseState && 'rotate-90',
|
|
)}
|
|
/>
|
|
)}
|
|
<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>
|
|
<TooltipTrigger
|
|
render={
|
|
<div
|
|
className={cn(
|
|
'min-w-0 grow truncate system-xs-semibold-uppercase text-text-secondary',
|
|
hideInfo && 'text-xs!',
|
|
)}
|
|
>
|
|
{nodeInfo.title}
|
|
</div>
|
|
}
|
|
/>
|
|
<TooltipContent>
|
|
<div className="max-w-xs">{nodeInfo.title}</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
{!['running', 'paused'].includes(nodeInfo.status) && !hideInfo && (
|
|
<div className="shrink-0 system-xs-regular text-text-tertiary">
|
|
{nodeInfo.execution_metadata?.total_tokens
|
|
? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · `
|
|
: ''}
|
|
{`${getTime(nodeInfo.elapsed_time || 0)}`}
|
|
</div>
|
|
)}
|
|
{nodeInfo.status === 'succeeded' && (
|
|
<RiCheckboxCircleFill className="ml-2 size-3.5 shrink-0 text-text-success" />
|
|
)}
|
|
{nodeInfo.status === 'failed' && (
|
|
<RiErrorWarningFill className="ml-2 size-3.5 shrink-0 text-text-destructive" />
|
|
)}
|
|
{nodeInfo.status === 'stopped' && (
|
|
<RiAlertFill
|
|
className={cn(
|
|
'ml-2 size-4 shrink-0 text-text-warning-secondary',
|
|
inMessage && 'size-3.5',
|
|
)}
|
|
/>
|
|
)}
|
|
{nodeInfo.status === 'paused' && (
|
|
<RiPauseCircleFill
|
|
className={cn(
|
|
'ml-2 size-4 shrink-0 text-text-warning-secondary',
|
|
inMessage && 'size-3.5',
|
|
)}
|
|
/>
|
|
)}
|
|
{nodeInfo.status === 'exception' && (
|
|
<RiAlertFill
|
|
className={cn(
|
|
'ml-2 size-4 shrink-0 text-text-warning-secondary',
|
|
inMessage && 'size-3.5',
|
|
)}
|
|
/>
|
|
)}
|
|
{nodeInfo.status === 'running' && (
|
|
<div className="flex shrink-0 items-center text-[13px] leading-4 font-medium text-text-accent">
|
|
<span className="mr-2 text-xs font-normal">Running</span>
|
|
<RiLoader2Line className="size-3.5 animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{!collapseState && !hideProcessDetail && (
|
|
<div className="px-1 pb-1">
|
|
{/* The nav to the iteration detail */}
|
|
{isIterationNode && !notShowIterationNav && onShowIterationDetail && (
|
|
<IterationLogTrigger
|
|
nodeInfo={nodeInfo}
|
|
allExecutions={allExecutions}
|
|
onShowIterationResultList={onShowIterationDetail}
|
|
/>
|
|
)}
|
|
{/* The nav to the Loop detail */}
|
|
{isLoopNode && !notShowLoopNav && onShowLoopDetail && (
|
|
<LoopLogTrigger
|
|
nodeInfo={nodeInfo}
|
|
allExecutions={allExecutions}
|
|
onShowLoopResultList={onShowLoopDetail}
|
|
/>
|
|
)}
|
|
{isRetryNode && onShowRetryDetail && (
|
|
<RetryLogTrigger nodeInfo={nodeInfo} onShowRetryResultList={onShowRetryDetail} />
|
|
)}
|
|
{(isAgentNode || isToolNode) && onShowAgentOrToolLog && (
|
|
<AgentLogTrigger nodeInfo={nodeInfo} onShowAgentOrToolLog={onShowAgentOrToolLog} />
|
|
)}
|
|
<div className={cn('mb-1', hideInfo && 'px-2! py-0.5!')}>
|
|
{nodeInfo.status === 'stopped' && (
|
|
<StatusContainer status="stopped">
|
|
{t(($) => $['tracing.stopBy'], {
|
|
ns: 'workflow',
|
|
user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A',
|
|
})}
|
|
</StatusContainer>
|
|
)}
|
|
{nodeInfo.status === 'exception' && (
|
|
<StatusContainer status="stopped">
|
|
{localizedError}
|
|
<a
|
|
href={docLink('/use-dify/debug/error-type')}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-text-accent"
|
|
>
|
|
{t(($) => $['common.learnMore'], { ns: 'workflow' })}
|
|
</a>
|
|
</StatusContainer>
|
|
)}
|
|
{nodeInfo.status === 'failed' && (
|
|
<StatusContainer status="failed">{localizedError}</StatusContainer>
|
|
)}
|
|
{nodeInfo.status === 'retry' && (
|
|
<StatusContainer status="failed">{localizedError}</StatusContainer>
|
|
)}
|
|
{nodeInfo.status === 'paused' && (
|
|
<StatusContainer status="paused">
|
|
<div className="system-xs-regular text-text-warning">
|
|
{t(($) => $['nodes.humanInput.log.reasonContent'], { ns: 'workflow' })}
|
|
</div>
|
|
</StatusContainer>
|
|
)}
|
|
</div>
|
|
{nodeInfo.inputs && (
|
|
<div className={cn('mb-1')}>
|
|
<CodeEditor
|
|
readOnly
|
|
title={<div>{inputsTitle}</div>}
|
|
language={CodeLanguage.json}
|
|
value={nodeInfo.inputs}
|
|
isJSONStringifyBeauty
|
|
footer={
|
|
nodeInfo.inputs_truncated && (
|
|
<LargeDataAlert textHasNoExport className="mx-1 mt-2 mb-1 h-7" />
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{nodeInfo.process_data && (
|
|
<div className={cn('mb-1')}>
|
|
<CodeEditor
|
|
readOnly
|
|
showFileList
|
|
title={<div>{processDataTitle}</div>}
|
|
language={CodeLanguage.json}
|
|
value={nodeInfo.process_data}
|
|
isJSONStringifyBeauty
|
|
/>
|
|
</div>
|
|
)}
|
|
{nodeInfo.outputs && (
|
|
<div>
|
|
<CodeEditor
|
|
readOnly
|
|
showFileList
|
|
title={<div>{outputTitle}</div>}
|
|
language={CodeLanguage.json}
|
|
value={nodeInfo.outputs}
|
|
isJSONStringifyBeauty
|
|
tip={<ErrorHandleTip type={nodeInfo.execution_metadata?.error_strategy} />}
|
|
footer={
|
|
nodeInfo.outputs_truncated && (
|
|
<LargeDataAlert
|
|
textHasNoExport
|
|
downloadUrl={nodeInfo.outputs_full_content?.download_url}
|
|
className="mx-1 mt-2 mb-1 h-7"
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default NodePanel
|