dify/web/app/components/workflow/run/node.tsx
Jyong c8780dcb7c fix(workflow): stabilize KnowledgeFS retrieval access
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.
2026-08-11 11:51:17 -04:00

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