feat: workflow continue on error (#11474)

This commit is contained in:
zxhlyh 2024-12-11 14:21:38 +08:00 committed by GitHub
parent 86dfdcb8ec
commit bec5451f12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1481 additions and 282 deletions

View File

@ -26,6 +26,7 @@ import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { Line3 } from '@/app/components/base/icons/src/public/common' import { Line3 } from '@/app/components/base/icons/src/public/common'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
type WorkflowVariableBlockComponentProps = { type WorkflowVariableBlockComponentProps = {
nodeKey: string nodeKey: string
@ -53,6 +54,7 @@ const WorkflowVariableBlockComponent = ({
const node = localWorkflowNodesMap![variables[0]] const node = localWorkflowNodesMap![variables[0]]
const isEnv = isENV(variables) const isEnv = isENV(variables)
const isChatVar = isConversationVar(variables) const isChatVar = isConversationVar(variables)
const isException = isExceptionVariable(varName, node?.type)
useEffect(() => { useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode])) if (!editor.hasNodes([WorkflowVariableBlockNode]))
@ -98,10 +100,10 @@ const WorkflowVariableBlockComponent = ({
</div> </div>
)} )}
<div className='flex items-center text-primary-600'> <div className='flex items-center text-primary-600'>
{!isEnv && !isChatVar && <Variable02 className='shrink-0 w-3.5 h-3.5' />} {!isEnv && !isChatVar && <Variable02 className={cn('shrink-0 w-3.5 h-3.5', isException && 'text-text-warning')} />}
{isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />} {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div className={cn('shrink-0 ml-0.5 text-xs font-medium truncate', (isEnv || isChatVar) && 'text-gray-900')} title={varName}>{varName}</div> <div className={cn('shrink-0 ml-0.5 text-xs font-medium truncate', (isEnv || isChatVar) && 'text-gray-900', isException && 'text-text-warning')} title={varName}>{varName}</div>
{ {
!node && !isEnv && !isChatVar && ( !node && !isEnv && !isChatVar && (
<RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' /> <RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' />

View File

@ -0,0 +1,53 @@
type CustomEdgeLinearGradientRenderProps = {
id: string
startColor: string
stopColor: string
position: {
x1: number
x2: number
y1: number
y2: number
}
}
const CustomEdgeLinearGradientRender = ({
id,
startColor,
stopColor,
position,
}: CustomEdgeLinearGradientRenderProps) => {
const {
x1,
x2,
y1,
y2,
} = position
return (
<defs>
<linearGradient
id={id}
gradientUnits='userSpaceOnUse'
x1={x1}
y1={y1}
x2={x2}
y2={y2}
>
<stop
offset='0%'
style={{
stopColor: startColor,
stopOpacity: 1,
}}
/>
<stop
offset='100%'
style={{
stopColor,
stopOpacity: 1,
}}
/>
</linearGradient>
</defs>
)
}
export default CustomEdgeLinearGradientRender

View File

@ -1,6 +1,7 @@
import { import {
memo, memo,
useCallback, useCallback,
useMemo,
useState, useState,
} from 'react' } from 'react'
import { intersection } from 'lodash-es' import { intersection } from 'lodash-es'
@ -20,8 +21,12 @@ import type {
Edge, Edge,
OnSelectBlock, OnSelectBlock,
} from './types' } from './types'
import { NodeRunningStatus } from './types'
import { getEdgeColor } from './utils'
import { ITERATION_CHILDREN_Z_INDEX } from './constants' import { ITERATION_CHILDREN_Z_INDEX } from './constants'
import CustomEdgeLinearGradientRender from './custom-edge-linear-gradient-render'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
const CustomEdge = ({ const CustomEdge = ({
id, id,
@ -53,6 +58,26 @@ const CustomEdge = ({
const { handleNodeAdd } = useNodesInteractions() const { handleNodeAdd } = useNodesInteractions()
const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration) const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration)
const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration) const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration)
const {
_sourceRunningStatus,
_targetRunningStatus,
} = data
const linearGradientId = useMemo(() => {
if (
(
_sourceRunningStatus === NodeRunningStatus.Succeeded
|| _sourceRunningStatus === NodeRunningStatus.Failed
|| _sourceRunningStatus === NodeRunningStatus.Exception
) && (
_targetRunningStatus === NodeRunningStatus.Succeeded
|| _targetRunningStatus === NodeRunningStatus.Failed
|| _targetRunningStatus === NodeRunningStatus.Exception
|| _targetRunningStatus === NodeRunningStatus.Running
)
)
return id
}, [_sourceRunningStatus, _targetRunningStatus, id])
const handleOpenChange = useCallback((v: boolean) => { const handleOpenChange = useCallback((v: boolean) => {
setOpen(v) setOpen(v)
@ -73,14 +98,43 @@ const CustomEdge = ({
) )
}, [handleNodeAdd, source, sourceHandleId, target, targetHandleId]) }, [handleNodeAdd, source, sourceHandleId, target, targetHandleId])
const stroke = useMemo(() => {
if (selected)
return getEdgeColor(NodeRunningStatus.Running)
if (linearGradientId)
return `url(#${linearGradientId})`
if (data?._connectedNodeIsHovering)
return getEdgeColor(NodeRunningStatus.Running, sourceHandleId === ErrorHandleTypeEnum.failBranch)
return getEdgeColor()
}, [data._connectedNodeIsHovering, linearGradientId, selected, sourceHandleId])
return ( return (
<> <>
{
linearGradientId && (
<CustomEdgeLinearGradientRender
id={linearGradientId}
startColor={getEdgeColor(_sourceRunningStatus)}
stopColor={getEdgeColor(_targetRunningStatus)}
position={{
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
}}
/>
)
}
<BaseEdge <BaseEdge
id={id} id={id}
path={edgePath} path={edgePath}
style={{ style={{
stroke: (selected || data?._connectedNodeIsHovering || data?._run) ? '#2970FF' : '#D0D5DD', stroke,
strokeWidth: 2, strokeWidth: 2,
opacity: data._waitingRun ? 0.7 : 1,
}} }}
/> />
<EdgeLabelRenderer> <EdgeLabelRenderer>
@ -95,6 +149,7 @@ const CustomEdge = ({
position: 'absolute', position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all', pointerEvents: 'all',
opacity: data._waitingRun ? 0.7 : 1,
}} }}
> >
<BlockSelector <BlockSelector

View File

@ -63,25 +63,29 @@ export const useEdgesInteractions = () => {
edges, edges,
setEdges, setEdges,
} = store.getState() } = store.getState()
const currentEdgeIndex = edges.findIndex(edge => edge.source === nodeId && edge.sourceHandle === branchId) const edgeWillBeDeleted = edges.filter(edge => edge.source === nodeId && edge.sourceHandle === branchId)
if (currentEdgeIndex < 0) if (!edgeWillBeDeleted.length)
return return
const currentEdge = edges[currentEdgeIndex] const nodes = getNodes()
const newNodes = produce(getNodes(), (draft: Node[]) => { const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
const sourceNode = draft.find(node => node.id === currentEdge.source) edgeWillBeDeleted.map(edge => ({ type: 'remove', edge })),
const targetNode = draft.find(node => node.id === currentEdge.target) nodes,
)
if (sourceNode) const newNodes = produce(nodes, (draft: Node[]) => {
sourceNode.data._connectedSourceHandleIds = sourceNode.data._connectedSourceHandleIds?.filter(handleId => handleId !== currentEdge.sourceHandle) draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
if (targetNode) node.data = {
targetNode.data._connectedTargetHandleIds = targetNode.data._connectedTargetHandleIds?.filter(handleId => handleId !== currentEdge.targetHandle) ...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
}) })
setNodes(newNodes) setNodes(newNodes)
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
draft.splice(currentEdgeIndex, 1) return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id))
}) })
setEdges(newEdges) setEdges(newEdges)
handleSyncWorkflowDraft() handleSyncWorkflowDraft()
@ -155,7 +159,9 @@ export const useEdgesInteractions = () => {
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => { draft.forEach((edge) => {
edge.data._run = false edge.data._sourceRunningStatus = undefined
edge.data._targetRunningStatus = undefined
edge.data._waitingRun = false
}) })
}) })
setEdges(newEdges) setEdges(newEdges)

View File

@ -1033,6 +1033,7 @@ export const useNodesInteractions = () => {
const newNodes = produce(nodes, (draft) => { const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => { draft.forEach((node) => {
node.data._runningStatus = undefined node.data._runningStatus = undefined
node.data._waitingRun = false
}) })
}) })
setNodes(newNodes) setNodes(newNodes)

View File

@ -1,6 +1,5 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { import {
getIncomers,
useReactFlow, useReactFlow,
useStoreApi, useStoreApi,
} from 'reactflow' } from 'reactflow'
@ -9,8 +8,8 @@ import { v4 as uuidV4 } from 'uuid'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useWorkflowStore } from '../store' import { useWorkflowStore } from '../store'
import { useNodesSyncDraft } from '../hooks' import { useNodesSyncDraft } from '../hooks'
import type { Node } from '../types'
import { import {
BlockEnum,
NodeRunningStatus, NodeRunningStatus,
WorkflowRunningStatus, WorkflowRunningStatus,
} from '../types' } from '../types'
@ -28,6 +27,7 @@ import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player
import { import {
getFilesInLogs, getFilesInLogs,
} from '@/app/components/base/file-uploader/utils' } from '@/app/components/base/file-uploader/utils'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
export const useWorkflowRun = () => { export const useWorkflowRun = () => {
const store = useStoreApi() const store = useStoreApi()
@ -174,6 +174,8 @@ export const useWorkflowRun = () => {
setIterParallelLogMap, setIterParallelLogMap,
} = workflowStore.getState() } = workflowStore.getState()
const { const {
getNodes,
setNodes,
edges, edges,
setEdges, setEdges,
} = store.getState() } = store.getState()
@ -186,12 +188,20 @@ export const useWorkflowRun = () => {
status: WorkflowRunningStatus.Running, status: WorkflowRunningStatus.Running,
} }
})) }))
const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
node.data._waitingRun = true
})
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => { draft.forEach((edge) => {
edge.data = { edge.data = {
...edge.data, ...edge.data,
_run: false, _sourceRunningStatus: undefined,
_targetRunningStatus: undefined,
_waitingRun: true,
} }
}) })
}) })
@ -311,13 +321,27 @@ export const useWorkflowRun = () => {
} }
const newNodes = produce(nodes, (draft) => { const newNodes = produce(nodes, (draft) => {
draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running
draft[currentNodeIndex].data._waitingRun = false
}) })
setNodes(newNodes) setNodes(newNodes)
const incomeNodesId = getIncomers({ id: data.node_id } as Node, newNodes, edges).filter(node => node.data._runningStatus === NodeRunningStatus.Succeeded).map(node => node.id)
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => { const incomeEdges = draft.filter((edge) => {
if (edge.target === data.node_id && incomeNodesId.includes(edge.source)) return edge.target === data.node_id
edge.data = { ...edge.data, _run: true } as any })
incomeEdges.forEach((edge) => {
const incomeNode = nodes.find(node => node.id === edge.source)!
if (
(!incomeNode.data._runningBranchId && edge.sourceHandle === 'source')
|| (incomeNode.data._runningBranchId && edge.sourceHandle === incomeNode.data._runningBranchId)
) {
edge.data = {
...edge.data,
_sourceRunningStatus: incomeNode.data._runningStatus,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: false,
}
}
}) })
}) })
setEdges(newEdges) setEdges(newEdges)
@ -336,6 +360,8 @@ export const useWorkflowRun = () => {
const { const {
getNodes, getNodes,
setNodes, setNodes,
edges,
setEdges,
} = store.getState() } = store.getState()
const nodes = getNodes() const nodes = getNodes()
const nodeParentId = nodes.find(node => node.id === data.node_id)!.parentId const nodeParentId = nodes.find(node => node.id === data.node_id)!.parentId
@ -423,8 +449,31 @@ export const useWorkflowRun = () => {
const newNodes = produce(nodes, (draft) => { const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)! const currentNode = draft.find(node => node.id === data.node_id)!
currentNode.data._runningStatus = data.status as any currentNode.data._runningStatus = data.status as any
if (data.status === NodeRunningStatus.Exception) {
if (data.execution_metadata.error_strategy === ErrorHandleTypeEnum.failBranch)
currentNode.data._runningBranchId = ErrorHandleTypeEnum.failBranch
}
else {
if (data.node_type === BlockEnum.IfElse)
currentNode.data._runningBranchId = data?.outputs?.selected_case_id
if (data.node_type === BlockEnum.QuestionClassifier)
currentNode.data._runningBranchId = data?.outputs?.class_id
}
}) })
setNodes(newNodes) setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
const incomeEdges = draft.filter((edge) => {
return edge.target === data.node_id
})
incomeEdges.forEach((edge) => {
edge.data = {
...edge.data,
_targetRunningStatus: data.status as any,
}
})
})
setEdges(newEdges)
prevNodeId = data.node_id prevNodeId = data.node_id
} }
@ -474,13 +523,20 @@ export const useWorkflowRun = () => {
const newNodes = produce(nodes, (draft) => { const newNodes = produce(nodes, (draft) => {
draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running
draft[currentNodeIndex].data._iterationLength = data.metadata.iterator_length draft[currentNodeIndex].data._iterationLength = data.metadata.iterator_length
draft[currentNodeIndex].data._waitingRun = false
}) })
setNodes(newNodes) setNodes(newNodes)
const newEdges = produce(edges, (draft) => { const newEdges = produce(edges, (draft) => {
const edge = draft.find(edge => edge.target === data.node_id && edge.source === prevNodeId) const incomeEdges = draft.filter(edge => edge.target === data.node_id)
if (edge) incomeEdges.forEach((edge) => {
edge.data = { ...edge.data, _run: true } as any edge.data = {
...edge.data,
_sourceRunningStatus: nodes.find(node => node.id === edge.source)!.data._runningStatus,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: false,
}
})
}) })
setEdges(newEdges) setEdges(newEdges)

View File

@ -59,7 +59,7 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const isFinished = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed const isFinished = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed || runningStatus === NodeRunningStatus.Exception
const isRunning = runningStatus === NodeRunningStatus.Running const isRunning = runningStatus === NodeRunningStatus.Running
const isFileLoaded = (() => { const isFileLoaded = (() => {
// system files // system files

View File

@ -0,0 +1,26 @@
import Collapse from '.'
type FieldCollapseProps = {
title: string
children: JSX.Element
}
const FieldCollapse = ({
title,
children,
}: FieldCollapseProps) => {
return (
<div className='py-4'>
<Collapse
trigger={
<div className='flex items-center h-6 system-sm-semibold-uppercase text-text-secondary cursor-pointer'>{title}</div>
}
>
<div className='px-4'>
{children}
</div>
</Collapse>
</div>
)
}
export default FieldCollapse

View File

@ -0,0 +1,56 @@
import { useState } from 'react'
import { RiArrowDropRightLine } from '@remixicon/react'
import cn from '@/utils/classnames'
export { default as FieldCollapse } from './field-collapse'
type CollapseProps = {
disabled?: boolean
trigger: JSX.Element
children: JSX.Element
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
}
const Collapse = ({
disabled,
trigger,
children,
collapsed,
onCollapse,
}: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
return (
<>
<div
className='flex items-center'
onClick={() => {
if (!disabled) {
setCollapsedLocal(!collapsedMerged)
onCollapse?.(!collapsedMerged)
}
}}
>
<div className='shrink-0 w-4 h-4'>
{
!disabled && (
<RiArrowDropRightLine
className={cn(
'w-4 h-4 text-text-tertiary',
!collapsedMerged && 'transform rotate-90',
)}
/>
)
}
</div>
{trigger}
</div>
{
!collapsedMerged && children
}
</>
)
}
export default Collapse

View File

@ -33,6 +33,7 @@ type Props = {
}[] }[]
showFileList?: boolean showFileList?: boolean
showCodeGenerator?: boolean showCodeGenerator?: boolean
tip?: JSX.Element
} }
const Base: FC<Props> = ({ const Base: FC<Props> = ({
@ -49,6 +50,7 @@ const Base: FC<Props> = ({
fileList = [], fileList = [],
showFileList, showFileList,
showCodeGenerator = false, showCodeGenerator = false,
tip,
}) => { }) => {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const { const {
@ -100,6 +102,7 @@ const Base: FC<Props> = ({
</div> </div>
</div> </div>
</div> </div>
{tip && <div className='px-1 py-0.5'>{tip}</div>}
<PromptEditorHeightResizeWrap <PromptEditorHeightResizeWrap
height={isExpand ? editorExpandHeight : editorContentHeight} height={isExpand ? editorExpandHeight : editorContentHeight}
minHeight={editorContentMinHeight} minHeight={editorContentMinHeight}

View File

@ -34,6 +34,7 @@ export type Props = {
onGenerated?: (value: string) => void onGenerated?: (value: string) => void
showCodeGenerator?: boolean showCodeGenerator?: boolean
className?: string className?: string
tip?: JSX.Element
} }
export const languageMap = { export const languageMap = {
@ -69,6 +70,7 @@ const CodeEditor: FC<Props> = ({
onGenerated, onGenerated,
showCodeGenerator = false, showCodeGenerator = false,
className, className,
tip,
}) => { }) => {
const [isFocus, setIsFocus] = React.useState(false) const [isFocus, setIsFocus] = React.useState(false)
const [isMounted, setIsMounted] = React.useState(false) const [isMounted, setIsMounted] = React.useState(false)
@ -211,6 +213,7 @@ const CodeEditor: FC<Props> = ({
fileList={fileList as any} fileList={fileList as any}
showFileList={showFileList} showFileList={showFileList}
showCodeGenerator={showCodeGenerator} showCodeGenerator={showCodeGenerator}
tip={tip}
> >
{main} {main}
</Base> </Base>

View File

@ -0,0 +1,89 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import type { DefaultValueForm } from './types'
import Input from '@/app/components/base/input'
import { VarType } from '@/app/components/workflow/types'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
type DefaultValueProps = {
forms: DefaultValueForm[]
onFormChange: (form: DefaultValueForm) => void
}
const DefaultValue = ({
forms,
onFormChange,
}: DefaultValueProps) => {
const { t } = useTranslation()
const getFormChangeHandler = useCallback(({ key, type }: DefaultValueForm) => {
return (payload: any) => {
let value
if (type === VarType.string || type === VarType.number)
value = payload.target.value
if (type === VarType.array || type === VarType.arrayNumber || type === VarType.arrayString || type === VarType.arrayObject || type === VarType.arrayFile || type === VarType.object)
value = payload
onFormChange({ key, type, value })
}
}, [onFormChange])
return (
<div className='px-4 pt-2'>
<div className='mb-2 body-xs-regular text-text-tertiary'>
{t('workflow.nodes.common.errorHandle.defaultValue.desc')}
&nbsp;
<a
href='https://docs.dify.ai/guides/workflow/error-handling'
target='_blank'
className='text-text-accent'
>
{t('workflow.common.learnMore')}
</a>
</div>
<div className='space-y-1'>
{
forms.map((form, index) => {
return (
<div
key={index}
className='py-1'
>
<div className='flex items-center mb-1'>
<div className='mr-1 system-sm-medium text-text-primary'>{form.key}</div>
<div className='system-xs-regular text-text-tertiary'>{form.type}</div>
</div>
{
(form.type === VarType.string || form.type === VarType.number) && (
<Input
type={form.type}
value={form.value || (form.type === VarType.string ? '' : 0)}
onChange={getFormChangeHandler({ key: form.key, type: form.type })}
/>
)
}
{
(
form.type === VarType.array
|| form.type === VarType.arrayNumber
|| form.type === VarType.arrayString
|| form.type === VarType.arrayObject
|| form.type === VarType.object
) && (
<CodeEditor
language={CodeLanguage.json}
value={form.value}
onChange={getFormChangeHandler({ key: form.key, type: form.type })}
/>
)
}
</div>
)
})
}
</div>
</div>
)
}
export default DefaultValue

View File

@ -0,0 +1,67 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useUpdateNodeInternals } from 'reactflow'
import { NodeSourceHandle } from '../node-handle'
import { ErrorHandleTypeEnum } from './types'
import type { Node } from '@/app/components/workflow/types'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type ErrorHandleOnNodeProps = Pick<Node, 'id' | 'data'>
const ErrorHandleOnNode = ({
id,
data,
}: ErrorHandleOnNodeProps) => {
const { t } = useTranslation()
const { error_strategy } = data
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => {
if (error_strategy === ErrorHandleTypeEnum.failBranch)
updateNodeInternals(id)
}, [error_strategy, id, updateNodeInternals])
if (!error_strategy)
return null
return (
<div className='relative pt-1 pb-2 px-3'>
<div className={cn(
'relative flex items-center justify-between px-[5px] h-6 bg-workflow-block-parma-bg rounded-md',
data._runningStatus === NodeRunningStatus.Exception && 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover',
)}>
<div className='system-xs-medium-uppercase text-text-tertiary'>
{t('workflow.common.onFailure')}
</div>
<div className={cn(
'system-xs-medium text-text-secondary',
data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning',
)}>
{
error_strategy === ErrorHandleTypeEnum.defaultValue && (
t('workflow.nodes.common.errorHandle.defaultValue.output')
)
}
{
error_strategy === ErrorHandleTypeEnum.failBranch && (
t('workflow.nodes.common.errorHandle.failBranch.title')
)
}
</div>
{
error_strategy === ErrorHandleTypeEnum.failBranch && (
<NodeSourceHandle
id={id}
data={data}
handleId={ErrorHandleTypeEnum.failBranch}
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2 after:!bg-workflow-link-line-failure-button-bg'
nodeSelectorClassName='!bg-workflow-link-line-failure-button-bg'
/>
)
}
</div>
</div>
)
}
export default ErrorHandleOnNode

View File

@ -0,0 +1,90 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Collapse from '../collapse'
import { ErrorHandleTypeEnum } from './types'
import ErrorHandleTypeSelector from './error-handle-type-selector'
import FailBranchCard from './fail-branch-card'
import DefaultValue from './default-value'
import {
useDefaultValue,
useErrorHandle,
} from './hooks'
import type { DefaultValueForm } from './types'
import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import Tooltip from '@/app/components/base/tooltip'
type ErrorHandleProps = Pick<Node, 'id' | 'data'>
const ErrorHandle = ({
id,
data,
}: ErrorHandleProps) => {
const { t } = useTranslation()
const { error_strategy, default_value } = data
const {
collapsed,
setCollapsed,
handleErrorHandleTypeChange,
} = useErrorHandle(id, data)
const { handleFormChange } = useDefaultValue(id)
const getHandleErrorHandleTypeChange = useCallback((data: CommonNodeType) => {
return (value: ErrorHandleTypeEnum) => {
handleErrorHandleTypeChange(value, data)
}
}, [handleErrorHandleTypeChange])
const getHandleFormChange = useCallback((data: CommonNodeType) => {
return (v: DefaultValueForm) => {
handleFormChange(v, data)
}
}, [handleFormChange])
return (
<>
<Split />
<div className='py-4'>
<Collapse
disabled={!error_strategy}
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={
<div className='grow flex items-center justify-between pr-4'>
<div className='flex items-center'>
<div className='mr-0.5 system-sm-semibold-uppercase text-text-secondary'>
{t('workflow.nodes.common.errorHandle.title')}
</div>
<Tooltip popupContent={t('workflow.nodes.common.errorHandle.tip')} />
</div>
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div>
}
>
<>
{
error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && (
<FailBranchCard />
)
}
{
error_strategy === ErrorHandleTypeEnum.defaultValue && !collapsed && !!default_value?.length && (
<DefaultValue
forms={default_value}
onFormChange={getHandleFormChange(data)}
/>
)
}
</>
</Collapse>
</div>
</>
)
}
export default ErrorHandle

View File

@ -0,0 +1,43 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { RiAlertFill } from '@remixicon/react'
import { ErrorHandleTypeEnum } from './types'
type ErrorHandleTipProps = {
type?: ErrorHandleTypeEnum
}
const ErrorHandleTip = ({
type,
}: ErrorHandleTipProps) => {
const { t } = useTranslation()
const text = useMemo(() => {
if (type === ErrorHandleTypeEnum.failBranch)
return t('workflow.nodes.common.errorHandle.failBranch.inLog')
if (type === ErrorHandleTypeEnum.defaultValue)
return t('workflow.nodes.common.errorHandle.defaultValue.inLog')
}, [])
if (!type)
return null
return (
<div
className='relative flex p-2 pr-[52px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xs'
>
<div
className='absolute inset-0 opacity-40 rounded-lg'
style={{
background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)',
}}
></div>
<RiAlertFill className='shrink-0 mr-1 w-4 h-4 text-text-warning-secondary' />
<div className='grow system-xs-medium text-text-primary'>
{text}
</div>
</div>
)
}
export default ErrorHandleTip

View File

@ -0,0 +1,95 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { ErrorHandleTypeEnum } from './types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
type ErrorHandleTypeSelectorProps = {
value: ErrorHandleTypeEnum
onSelected: (value: ErrorHandleTypeEnum) => void
}
const ErrorHandleTypeSelector = ({
value,
onSelected,
}: ErrorHandleTypeSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
value: ErrorHandleTypeEnum.none,
label: t('workflow.nodes.common.errorHandle.none.title'),
description: t('workflow.nodes.common.errorHandle.none.desc'),
},
{
value: ErrorHandleTypeEnum.defaultValue,
label: t('workflow.nodes.common.errorHandle.defaultValue.title'),
description: t('workflow.nodes.common.errorHandle.defaultValue.desc'),
},
{
value: ErrorHandleTypeEnum.failBranch,
label: t('workflow.nodes.common.errorHandle.failBranch.title'),
description: t('workflow.nodes.common.errorHandle.failBranch.desc'),
},
]
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
setOpen(v => !v)
}}>
<Button
size='small'
>
{selectedOption?.label}
<RiArrowDownSLine className='w-3.5 h-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<div className='p-1 w-[280px] border-[0.5px] border-components-panel-border rounded-xl bg-components-panel-bg-blur shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex p-2 pr-3 rounded-lg hover:bg-state-base-hover cursor-pointer'
onClick={(e) => {
e.stopPropagation()
onSelected(option.value)
setOpen(false)
}}
>
<div className='mr-1 w-4 shrink-0'>
{
value === option.value && (
<RiCheckLine className='w-4 h-4 text-text-accent' />
)
}
</div>
<div className='grow'>
<div className='mb-0.5 system-sm-semibold text-text-secondary'>{option.label}</div>
<div className='system-xs-regular text-text-tertiary'>{option.description}</div>
</div>
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ErrorHandleTypeSelector

View File

@ -0,0 +1,32 @@
import { RiMindMap } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
const FailBranchCard = () => {
const { t } = useTranslation()
return (
<div className='pt-2 px-4'>
<div className='p-4 rounded-[10px] bg-workflow-process-bg'>
<div className='flex items-center justify-center mb-2 w-8 h-8 rounded-[10px] border-[0.5px] bg-components-card-bg shadow-lg'>
<RiMindMap className='w-5 h-5 text-text-tertiary' />
</div>
<div className='mb-1 system-sm-medium text-text-secondary'>
{t('workflow.nodes.common.errorHandle.failBranch.customize')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('workflow.nodes.common.errorHandle.failBranch.customizeTip')}
&nbsp;
<a
href='https://docs.dify.ai/guides/workflow/error-handling'
target='_blank'
className='text-text-accent'
>
{t('workflow.common.learnMore')}
</a>
</div>
</div>
</div>
)
}
export default FailBranchCard

View File

@ -0,0 +1,123 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import { ErrorHandleTypeEnum } from './types'
import type { DefaultValueForm } from './types'
import { getDefaultValue } from './utils'
import type {
CommonNodeType,
} from '@/app/components/workflow/types'
import {
useEdgesInteractions,
useNodeDataUpdate,
} from '@/app/components/workflow/hooks'
export const useDefaultValue = (
id: string,
) => {
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const handleFormChange = useCallback((
{
key,
value,
type,
}: DefaultValueForm,
data: CommonNodeType,
) => {
const default_value = data.default_value || []
const index = default_value.findIndex(form => form.key === key)
if (index > -1) {
const newDefaultValue = [...default_value]
newDefaultValue[index].value = value
handleNodeDataUpdateWithSyncDraft({
id,
data: {
default_value: newDefaultValue,
},
})
return
}
handleNodeDataUpdateWithSyncDraft({
id,
data: {
default_value: [
...default_value,
{
key,
value,
type,
},
],
},
})
}, [handleNodeDataUpdateWithSyncDraft, id])
return {
handleFormChange,
}
}
export const useErrorHandle = (
id: string,
data: CommonNodeType,
) => {
const initCollapsed = useMemo(() => {
if (data.error_strategy === ErrorHandleTypeEnum.none)
return true
return false
}, [data.error_strategy])
const [collapsed, setCollapsed] = useState(initCollapsed)
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const { handleEdgeDeleteByDeleteBranch } = useEdgesInteractions()
const handleErrorHandleTypeChange = useCallback((value: ErrorHandleTypeEnum, data: CommonNodeType) => {
if (data.error_strategy === value)
return
if (value === ErrorHandleTypeEnum.none) {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
error_strategy: undefined,
default_value: undefined,
},
})
setCollapsed(true)
handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch)
}
if (value === ErrorHandleTypeEnum.failBranch) {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
error_strategy: value,
default_value: undefined,
},
})
setCollapsed(false)
}
if (value === ErrorHandleTypeEnum.defaultValue) {
handleNodeDataUpdateWithSyncDraft({
id,
data: {
error_strategy: value,
default_value: getDefaultValue(data),
},
})
setCollapsed(false)
handleEdgeDeleteByDeleteBranch(id, ErrorHandleTypeEnum.failBranch)
}
}, [id, handleNodeDataUpdateWithSyncDraft, handleEdgeDeleteByDeleteBranch])
return {
collapsed,
setCollapsed,
handleErrorHandleTypeChange,
}
}

View File

@ -0,0 +1,13 @@
import type { VarType } from '@/app/components/workflow/types'
export enum ErrorHandleTypeEnum {
none = 'none',
failBranch = 'fail-branch',
defaultValue = 'default-value',
}
export type DefaultValueForm = {
key: string
type: VarType
value?: any
}

View File

@ -0,0 +1,83 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
import {
BlockEnum,
VarType,
} from '@/app/components/workflow/types'
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
const getDefaultValueByType = (type: VarType) => {
if (type === VarType.string)
return ''
if (type === VarType.number)
return 0
if (type === VarType.object)
return '{}'
if (type === VarType.arrayObject || type === VarType.arrayString || type === VarType.arrayNumber || type === VarType.arrayFile)
return '[]'
return ''
}
export const getDefaultValue = (data: CommonNodeType) => {
const { type } = data
if (type === BlockEnum.LLM) {
return [{
key: 'text',
type: VarType.string,
value: getDefaultValueByType(VarType.string),
}]
}
if (type === BlockEnum.HttpRequest) {
return [
{
key: 'body',
type: VarType.string,
value: getDefaultValueByType(VarType.string),
},
{
key: 'status_code',
type: VarType.number,
value: getDefaultValueByType(VarType.number),
},
{
key: 'headers',
type: VarType.object,
value: getDefaultValueByType(VarType.object),
},
]
}
if (type === BlockEnum.Tool) {
return [
{
key: 'text',
type: VarType.string,
value: getDefaultValueByType(VarType.string),
},
{
key: 'json',
type: VarType.arrayObject,
value: getDefaultValueByType(VarType.arrayObject),
},
]
}
if (type === BlockEnum.Code) {
const { outputs } = data as CodeNodeType
return Object.keys(outputs).map((key) => {
return {
key,
type: outputs[key].type,
value: getDefaultValueByType(outputs[key].type),
}
})
}
return []
}

View File

@ -1,6 +1,7 @@
import { import {
memo, memo,
useCallback, useCallback,
useMemo,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -24,12 +25,14 @@ type AddProps = {
nodeData: CommonNodeType nodeData: CommonNodeType
sourceHandle: string sourceHandle: string
isParallel?: boolean isParallel?: boolean
isFailBranch?: boolean
} }
const Add = ({ const Add = ({
nodeId, nodeId,
nodeData, nodeData,
sourceHandle, sourceHandle,
isParallel, isParallel,
isFailBranch,
}: AddProps) => { }: AddProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -58,6 +61,15 @@ const Add = ({
setOpen(newOpen) setOpen(newOpen)
}, [checkParallelLimit, nodeId, sourceHandle]) }, [checkParallelLimit, nodeId, sourceHandle])
const tip = useMemo(() => {
if (isFailBranch)
return t('workflow.common.addFailureBranch')
if (isParallel)
return t('workflow.common.addParallelNode')
return t('workflow.panel.selectNextStep')
}, [isFailBranch, isParallel, t])
const renderTrigger = useCallback((open: boolean) => { const renderTrigger = useCallback((open: boolean) => {
return ( return (
<div <div
@ -72,15 +84,11 @@ const Add = ({
<RiAddLine className='w-3 h-3' /> <RiAddLine className='w-3 h-3' />
</div> </div>
<div className='flex items-center uppercase'> <div className='flex items-center uppercase'>
{ {tip}
isParallel
? t('workflow.common.addParallelNode')
: t('workflow.panel.selectNextStep')
}
</div> </div>
</div> </div>
) )
}, [t, nodesReadOnly, isParallel]) }, [nodesReadOnly, tip])
return ( return (
<BlockSelector <BlockSelector

View File

@ -4,6 +4,7 @@ import type {
CommonNodeType, CommonNodeType,
Node, Node,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type ContainerProps = { type ContainerProps = {
nodeId: string nodeId: string
@ -11,6 +12,7 @@ type ContainerProps = {
sourceHandle: string sourceHandle: string
nextNodes: Node[] nextNodes: Node[]
branchName?: string branchName?: string
isFailBranch?: boolean
} }
const Container = ({ const Container = ({
@ -19,13 +21,20 @@ const Container = ({
sourceHandle, sourceHandle,
nextNodes, nextNodes,
branchName, branchName,
isFailBranch,
}: ContainerProps) => { }: ContainerProps) => {
return ( return (
<div className='p-0.5 space-y-0.5 rounded-[10px] bg-background-section-burn'> <div className={cn(
'p-0.5 space-y-0.5 rounded-[10px] bg-background-section-burn',
isFailBranch && 'border-[0.5px] border-state-warning-hover-alt bg-state-warning-hover',
)}>
{ {
branchName && ( branchName && (
<div <div
className='flex items-center px-2 system-2xs-semibold-uppercase text-text-tertiary truncate' className={cn(
'flex items-center px-2 system-2xs-semibold-uppercase text-text-tertiary truncate',
isFailBranch && 'text-text-warning',
)}
title={branchName} title={branchName}
> >
{branchName} {branchName}
@ -44,6 +53,7 @@ const Container = ({
} }
<Add <Add
isParallel={!!nextNodes.length} isParallel={!!nextNodes.length}
isFailBranch={isFailBranch}
nodeId={nodeId} nodeId={nodeId}
nodeData={nodeData} nodeData={nodeData}
sourceHandle={sourceHandle} sourceHandle={sourceHandle}

View File

@ -14,6 +14,8 @@ import type {
import { BlockEnum } from '../../../../types' import { BlockEnum } from '../../../../types'
import Line from './line' import Line from './line'
import Container from './container' import Container from './container'
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
type NextStepProps = { type NextStepProps = {
selectedNode: Node selectedNode: Node
@ -28,25 +30,54 @@ const NextStep = ({
const branches = useMemo(() => { const branches = useMemo(() => {
return data._targetBranches || [] return data._targetBranches || []
}, [data]) }, [data])
const nodeWithBranches = data.type === BlockEnum.IfElse || data.type === BlockEnum.QuestionClassifier
const edges = useEdges() const edges = useEdges()
const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges) const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges)
const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id) const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id)
const branchesOutgoers = useMemo(() => { const list = useMemo(() => {
if (!branches?.length) let items = []
return [] if (branches?.length) {
items = branches.map((branch, index) => {
return branches.map((branch) => {
const connected = connectedEdges.filter(edge => edge.sourceHandle === branch.id) const connected = connectedEdges.filter(edge => edge.sourceHandle === branch.id)
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!) const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
return { return {
branch, branch: {
...branch,
name: data.type === BlockEnum.QuestionClassifier ? `${t('workflow.nodes.questionClassifiers.class')} ${index + 1}` : branch.name,
},
nextNodes, nextNodes,
} }
}) })
}, [branches, connectedEdges, outgoers]) }
else {
const connected = connectedEdges.filter(edge => edge.sourceHandle === 'source')
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
items = [{
branch: {
id: '',
name: '',
},
nextNodes,
}]
if (data.error_strategy === ErrorHandleTypeEnum.failBranch && hasErrorHandleNode(data.type)) {
const connected = connectedEdges.filter(edge => edge.sourceHandle === ErrorHandleTypeEnum.failBranch)
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
items.push({
branch: {
id: ErrorHandleTypeEnum.failBranch,
name: t('workflow.common.onFailure'),
},
nextNodes,
})
}
}
return items
}, [branches, connectedEdges, data.error_strategy, data.type, outgoers, t])
return ( return (
<div className='flex py-1'> <div className='flex py-1'>
@ -57,34 +88,23 @@ const NextStep = ({
/> />
</div> </div>
<Line <Line
list={nodeWithBranches ? branchesOutgoers.map(item => item.nextNodes.length + 1) : [1]} list={list.length ? list.map(item => item.nextNodes.length + 1) : [1]}
/> />
<div className='grow space-y-2'> <div className='grow space-y-2'>
{ {
!nodeWithBranches && ( list.map((item, index) => {
<Container
nodeId={selectedNode!.id}
nodeData={selectedNode!.data}
sourceHandle='source'
nextNodes={outgoers}
/>
)
}
{
nodeWithBranches && (
branchesOutgoers.map((item, index) => {
return ( return (
<Container <Container
key={item.branch.id} key={index}
nodeId={selectedNode!.id} nodeId={selectedNode!.id}
nodeData={selectedNode!.data} nodeData={selectedNode!.data}
sourceHandle={item.branch.id} sourceHandle={item.branch.id}
nextNodes={item.nextNodes} nextNodes={item.nextNodes}
branchName={item.branch.name || `${t('workflow.nodes.questionClassifiers.class')} ${index + 1}`} branchName={item.branch.name}
isFailBranch={item.branch.id === ErrorHandleTypeEnum.failBranch}
/> />
) )
}) })
)
} }
</div> </div>
</div> </div>

View File

@ -10,7 +10,10 @@ import {
Position, Position,
} from 'reactflow' } from 'reactflow'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BlockEnum } from '../../../types' import {
BlockEnum,
NodeRunningStatus,
} from '../../../types'
import type { Node } from '../../../types' import type { Node } from '../../../types'
import BlockSelector from '../../../block-selector' import BlockSelector from '../../../block-selector'
import type { ToolDefaultValue } from '../../../block-selector/types' import type { ToolDefaultValue } from '../../../block-selector/types'
@ -24,11 +27,13 @@ import {
import { import {
useStore, useStore,
} from '../../../store' } from '../../../store'
import cn from '@/utils/classnames'
type NodeHandleProps = { type NodeHandleProps = {
handleId: string handleId: string
handleClassName?: string handleClassName?: string
nodeSelectorClassName?: string nodeSelectorClassName?: string
showExceptionStatus?: boolean
} & Pick<Node, 'id' | 'data'> } & Pick<Node, 'id' | 'data'>
export const NodeTargetHandle = memo(({ export const NodeTargetHandle = memo(({
@ -72,14 +77,17 @@ export const NodeTargetHandle = memo(({
id={handleId} id={handleId}
type='target' type='target'
position={Position.Left} position={Position.Left}
className={` className={cn(
!w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none z-[1] '!w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none z-[1]',
after:absolute after:w-0.5 after:h-2 after:left-1.5 after:top-1 after:bg-primary-500 'after:absolute after:w-0.5 after:h-2 after:left-1.5 after:top-1 after:bg-workflow-link-line-handle',
hover:scale-125 transition-all 'hover:scale-125 transition-all',
${!connected && 'after:opacity-0'} data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle',
${data.type === BlockEnum.Start && 'opacity-0'} data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle',
${handleClassName} data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle',
`} !connected && 'after:opacity-0',
data.type === BlockEnum.Start && 'opacity-0',
handleClassName,
)}
isConnectable={isConnectable} isConnectable={isConnectable}
onClick={handleHandleClick} onClick={handleHandleClick}
> >
@ -114,6 +122,7 @@ export const NodeSourceHandle = memo(({
handleId, handleId,
handleClassName, handleClassName,
nodeSelectorClassName, nodeSelectorClassName,
showExceptionStatus,
}: NodeHandleProps) => { }: NodeHandleProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const notInitialWorkflow = useStore(s => s.notInitialWorkflow) const notInitialWorkflow = useStore(s => s.notInitialWorkflow)
@ -157,13 +166,16 @@ export const NodeSourceHandle = memo(({
id={handleId} id={handleId}
type='source' type='source'
position={Position.Right} position={Position.Right}
className={` className={cn(
group/handle !w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none z-[1] 'group/handle !w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none z-[1]',
after:absolute after:w-0.5 after:h-2 after:right-1.5 after:top-1 after:bg-primary-500 'after:absolute after:w-0.5 after:h-2 after:right-1.5 after:top-1 after:bg-workflow-link-line-handle',
hover:scale-125 transition-all 'hover:scale-125 transition-all',
${!connected && 'after:opacity-0'} data._runningStatus === NodeRunningStatus.Succeeded && 'after:bg-workflow-link-line-success-handle',
${handleClassName} data._runningStatus === NodeRunningStatus.Failed && 'after:bg-workflow-link-line-error-handle',
`} showExceptionStatus && data._runningStatus === NodeRunningStatus.Exception && 'after:bg-workflow-link-line-failure-handle',
!connected && 'after:opacity-0',
handleClassName,
)}
isConnectable={isConnectable} isConnectable={isConnectable}
onClick={handleHandleClick} onClick={handleHandleClick}
> >

View File

@ -2,11 +2,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks' import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import cn from '@/utils/classnames'
type Props = { type Props = {
className?: string className?: string
@ -15,28 +11,14 @@ type Props = {
} }
const OutputVars: FC<Props> = ({ const OutputVars: FC<Props> = ({
className,
title, title,
children, children,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isFold, {
toggle: toggleFold,
}] = useBoolean(true)
return ( return (
<div> <FieldCollapse title={title || t('workflow.nodes.common.outputVars')}>
<div
onClick={toggleFold}
className={cn(className, 'flex justify-between system-sm-semibold-uppercase text-text-secondary cursor-pointer')}>
<div>{title || t('workflow.nodes.common.outputVars')}</div>
<RiArrowDownSLine className='w-4 h-4 text-text-tertiary transform transition-transform' style={{ transform: isFold ? 'rotate(-90deg)' : 'rotate(0deg)' }} />
</div>
{!isFold && (
<div className='mt-2 space-y-1'>
{children} {children}
</div> </FieldCollapse>
)}
</div>
) )
} }
type VarItemProps = { type VarItemProps = {

View File

@ -17,6 +17,7 @@ import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others
import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { getNodeInfoById, isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { isExceptionVariable } from '@/app/components/workflow/utils'
type VariableTagProps = { type VariableTagProps = {
valueSelector: ValueSelector valueSelector: ValueSelector
@ -45,6 +46,7 @@ const VariableTag = ({
const isValid = Boolean(node) || isEnv || isChatVar const isValid = Boolean(node) || isEnv || isChatVar
const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.') const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.')
const isException = isExceptionVariable(variableName, node?.data.type)
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@ -67,12 +69,12 @@ const VariableTag = ({
</> </>
)} )}
<Line3 className='shrink-0 mx-0.5' /> <Line3 className='shrink-0 mx-0.5' />
<Variable02 className='shrink-0 mr-0.5 w-3.5 h-3.5 text-text-accent' /> <Variable02 className={cn('shrink-0 mr-0.5 w-3.5 h-3.5 text-text-accent', isException && 'text-text-warning')} />
</>)} </>)}
{isEnv && <Env className='shrink-0 mr-0.5 w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='shrink-0 mr-0.5 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />} {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div <div
className={cn('truncate ml-0.5 text-text-accent font-medium', (isEnv || isChatVar) && 'text-text-secondary')} className={cn('truncate ml-0.5 text-text-accent font-medium', (isEnv || isChatVar) && 'text-text-secondary', isException && 'text-text-warning')}
title={variableName} title={variableName}
> >
{variableName} {variableName}

View File

@ -315,6 +315,24 @@ const formatItem = (
} }
} }
const { error_strategy } = data
if (error_strategy) {
res.vars = [
...res.vars,
{
variable: 'error_message',
type: VarType.string,
isException: true,
},
{
variable: 'error_type',
type: VarType.string,
isException: true,
},
]
}
const selector = [id] const selector = [id]
res.vars = res.vars.filter((v) => { res.vars = res.vars.filter((v) => {
const isCurrentMatched = filterVar(v, (() => { const isCurrentMatched = filterVar(v, (() => {

View File

@ -36,6 +36,7 @@ import TypeSelector from '@/app/components/workflow/nodes/_base/components/selec
import AddButton from '@/app/components/base/button/add-button' import AddButton from '@/app/components/base/button/add-button'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
const TRIGGER_DEFAULT_WIDTH = 227 const TRIGGER_DEFAULT_WIDTH = 227
@ -224,16 +225,18 @@ const VarReferencePicker: FC<Props> = ({
isConstant: !!isConstant, isConstant: !!isConstant,
}) })
const { isEnv, isChatVar, isValidVar } = useMemo(() => { const { isEnv, isChatVar, isValidVar, isException } = useMemo(() => {
const isEnv = isENV(value as ValueSelector) const isEnv = isENV(value as ValueSelector)
const isChatVar = isConversationVar(value as ValueSelector) const isChatVar = isConversationVar(value as ValueSelector)
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar
const isException = isExceptionVariable(varName, outputVarNode?.type)
return { return {
isEnv, isEnv,
isChatVar, isChatVar,
isValidVar, isValidVar,
isException,
} }
}, [value, outputVarNode]) }, [value, outputVarNode, varName])
// 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff // 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff
const availableWidth = triggerWidth - 56 const availableWidth = triggerWidth - 56
@ -335,7 +338,7 @@ const VarReferencePicker: FC<Props> = ({
{!hasValue && <Variable02 className='w-3.5 h-3.5' />} {!hasValue && <Variable02 className='w-3.5 h-3.5' />}
{isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />} {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<div className={cn('ml-0.5 text-xs font-medium truncate', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700')} title={varName} style={{ <div className={cn('ml-0.5 text-xs font-medium truncate', isEnv && '!text-text-secondary', isChatVar && 'text-util-colors-teal-teal-700', isException && 'text-text-warning')} title={varName} style={{
maxWidth: maxVarNameWidth, maxWidth: maxVarNameWidth,
}}>{varName}</div> }}>{varName}</div>
</div> </div>

View File

@ -37,6 +37,7 @@ type ItemProps = {
onHovering?: (value: boolean) => void onHovering?: (value: boolean) => void
itemWidth?: number itemWidth?: number
isSupportFileVar?: boolean isSupportFileVar?: boolean
isException?: boolean
} }
const Item: FC<ItemProps> = ({ const Item: FC<ItemProps> = ({
@ -48,6 +49,7 @@ const Item: FC<ItemProps> = ({
onHovering, onHovering,
itemWidth, itemWidth,
isSupportFileVar, isSupportFileVar,
isException,
}) => { }) => {
const isFile = itemData.type === VarType.file const isFile = itemData.type === VarType.file
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && itemData.children.length > 0) const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && itemData.children.length > 0)
@ -109,7 +111,7 @@ const Item: FC<ItemProps> = ({
onClick={handleChosen} onClick={handleChosen}
> >
<div className='flex items-center w-0 grow'> <div className='flex items-center w-0 grow'>
{!isEnv && !isChatVar && <Variable02 className='shrink-0 w-3.5 h-3.5 text-text-accent' />} {!isEnv && !isChatVar && <Variable02 className={cn('shrink-0 w-3.5 h-3.5 text-text-accent', isException && 'text-text-warning')} />}
{isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />} {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
{!isEnv && !isChatVar && ( {!isEnv && !isChatVar && (
@ -216,6 +218,7 @@ const ObjectChildren: FC<ObjectChildrenProps> = ({
onChange={onChange} onChange={onChange}
onHovering={setIsChildrenHovering} onHovering={setIsChildrenHovering}
isSupportFileVar={isSupportFileVar} isSupportFileVar={isSupportFileVar}
isException={v.isException}
/> />
)) ))
} }
@ -312,6 +315,7 @@ const VarReferenceVars: FC<Props> = ({
onChange={onChange} onChange={onChange}
itemWidth={itemWidth} itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar} isSupportFileVar={isSupportFileVar}
isException={v.isException}
/> />
))} ))}
</div>)) </div>))

View File

@ -1,12 +1,22 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import produce from 'immer' import produce from 'immer'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { type OutputVar } from '../../code/types' import type {
import type { ValueSelector } from '@/app/components/workflow/types' CodeNodeType,
import { VarType } from '@/app/components/workflow/types' OutputVar,
} from '../../code/types'
import type {
ValueSelector,
} from '@/app/components/workflow/types'
import {
BlockEnum,
VarType,
} from '@/app/components/workflow/types'
import { import {
useWorkflow, useWorkflow,
} from '@/app/components/workflow/hooks' } from '@/app/components/workflow/hooks'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import { getDefaultValue } from '@/app/components/workflow/nodes/_base/components/error-handle/utils'
type Params<T> = { type Params<T> = {
id: string id: string
@ -29,6 +39,9 @@ function useOutputVarList<T>({
const handleVarsChange = useCallback((newVars: OutputVar, changedIndex?: number, newKey?: string) => { const handleVarsChange = useCallback((newVars: OutputVar, changedIndex?: number, newKey?: string) => {
const newInputs = produce(inputs, (draft: any) => { const newInputs = produce(inputs, (draft: any) => {
draft[varKey] = newVars draft[varKey] = newVars
if ((inputs as CodeNodeType).type === BlockEnum.Code && (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && varKey === 'outputs')
draft.default_value = getDefaultValue(draft as any)
}) })
setInputs(newInputs) setInputs(newInputs)
@ -59,6 +72,9 @@ function useOutputVarList<T>({
children: null, children: null,
}, },
} }
if ((inputs as CodeNodeType).type === BlockEnum.Code && (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && varKey === 'outputs')
draft.default_value = getDefaultValue(draft as any)
}) })
setInputs(newInputs) setInputs(newInputs)
onOutputKeyOrdersChange([...outputKeyOrders, newKey]) onOutputKeyOrdersChange([...outputKeyOrders, newKey])
@ -84,6 +100,9 @@ function useOutputVarList<T>({
const newInputs = produce(inputs, (draft: any) => { const newInputs = produce(inputs, (draft: any) => {
delete draft[varKey][key] delete draft[varKey][key]
if ((inputs as CodeNodeType).type === BlockEnum.Code && (inputs as CodeNodeType).error_strategy === ErrorHandleTypeEnum.defaultValue && varKey === 'outputs')
draft.default_value = getDefaultValue(draft as any)
}) })
setInputs(newInputs) setInputs(newInputs)
onOutputKeyOrdersChange(outputKeyOrders.filter((_, i) => i !== index)) onOutputKeyOrdersChange(outputKeyOrders.filter((_, i) => i !== index))

View File

@ -10,8 +10,9 @@ import {
useRef, useRef,
} from 'react' } from 'react'
import { import {
RiCheckboxCircleLine, RiAlertFill,
RiErrorWarningLine, RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoader2Line, RiLoader2Line,
} from '@remixicon/react' } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -24,6 +25,7 @@ import {
useNodesReadOnly, useNodesReadOnly,
useToolIcon, useToolIcon,
} from '../../hooks' } from '../../hooks'
import { hasErrorHandleNode } from '../../utils'
import { useNodeIterationInteractions } from '../iteration/use-interactions' import { useNodeIterationInteractions } from '../iteration/use-interactions'
import type { IterationNodeType } from '../iteration/types' import type { IterationNodeType } from '../iteration/types'
import { import {
@ -32,6 +34,7 @@ import {
} from './components/node-handle' } from './components/node-handle'
import NodeResizer from './components/node-resizer' import NodeResizer from './components/node-resizer'
import NodeControl from './components/node-control' import NodeControl from './components/node-control'
import ErrorHandleOnNode from './components/error-handle/error-handle-on-node'
import AddVariablePopupWithPosition from './components/add-variable-popup-with-position' import AddVariablePopupWithPosition from './components/add-variable-popup-with-position'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import BlockIcon from '@/app/components/workflow/block-icon' import BlockIcon from '@/app/components/workflow/block-icon'
@ -71,11 +74,13 @@ const BaseNode: FC<BaseNodeProps> = ({
showRunningBorder, showRunningBorder,
showSuccessBorder, showSuccessBorder,
showFailedBorder, showFailedBorder,
showExceptionBorder,
} = useMemo(() => { } = useMemo(() => {
return { return {
showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder, showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder,
showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder, showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder,
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder, showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
} }
}, [data._runningStatus, showSelectedBorder]) }, [data._runningStatus, showSelectedBorder])
@ -85,6 +90,7 @@ const BaseNode: FC<BaseNodeProps> = ({
'flex border-[2px] rounded-2xl', 'flex border-[2px] rounded-2xl',
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent', showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
!showSelectedBorder && data._inParallelHovering && 'border-workflow-block-border-highlight', !showSelectedBorder && data._inParallelHovering && 'border-workflow-block-border-highlight',
data._waitingRun && 'opacity-70',
)} )}
ref={nodeRef} ref={nodeRef}
style={{ style={{
@ -99,9 +105,10 @@ const BaseNode: FC<BaseNodeProps> = ({
data.type !== BlockEnum.Iteration && 'w-[240px] bg-workflow-block-bg', data.type !== BlockEnum.Iteration && 'w-[240px] bg-workflow-block-bg',
data.type === BlockEnum.Iteration && 'flex flex-col w-full h-full bg-[#fcfdff]/80', data.type === BlockEnum.Iteration && 'flex flex-col w-full h-full bg-[#fcfdff]/80',
!data._runningStatus && 'hover:shadow-lg', !data._runningStatus && 'hover:shadow-lg',
showRunningBorder && '!border-primary-500', showRunningBorder && '!border-state-accent-solid',
showSuccessBorder && '!border-[#12B76A]', showSuccessBorder && '!border-state-success-solid',
showFailedBorder && '!border-[#F04438]', showFailedBorder && '!border-state-destructive-solid',
showExceptionBorder && '!border-state-warning-solid',
data._isBundled && '!shadow-lg', data._isBundled && '!shadow-lg',
)} )}
> >
@ -192,24 +199,29 @@ const BaseNode: FC<BaseNodeProps> = ({
</div> </div>
{ {
data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running && ( data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running && (
<div className='mr-1.5 text-xs font-medium text-primary-600'> <div className='mr-1.5 text-xs font-medium text-text-accent'>
{data._iterationIndex > data._iterationLength ? data._iterationLength : data._iterationIndex}/{data._iterationLength} {data._iterationIndex > data._iterationLength ? data._iterationLength : data._iterationIndex}/{data._iterationLength}
</div> </div>
) )
} }
{ {
(data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && ( (data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && (
<RiLoader2Line className='w-3.5 h-3.5 text-primary-600 animate-spin' /> <RiLoader2Line className='w-3.5 h-3.5 text-text-accent animate-spin' />
) )
} }
{ {
data._runningStatus === NodeRunningStatus.Succeeded && ( data._runningStatus === NodeRunningStatus.Succeeded && (
<RiCheckboxCircleLine className='w-3.5 h-3.5 text-[#12B76A]' /> <RiCheckboxCircleFill className='w-3.5 h-3.5 text-text-success' />
) )
} }
{ {
data._runningStatus === NodeRunningStatus.Failed && ( data._runningStatus === NodeRunningStatus.Failed && (
<RiErrorWarningLine className='w-3.5 h-3.5 text-[#F04438]' /> <RiErrorWarningFill className='w-3.5 h-3.5 text-text-destructive' />
)
}
{
data._runningStatus === NodeRunningStatus.Exception && (
<RiAlertFill className='w-3.5 h-3.5 text-text-warning-secondary' />
) )
} }
</div> </div>
@ -225,6 +237,14 @@ const BaseNode: FC<BaseNodeProps> = ({
</div> </div>
) )
} }
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnNode
id={id}
data={data}
/>
)
}
{ {
data.desc && data.type !== BlockEnum.Iteration && ( data.desc && data.type !== BlockEnum.Iteration && (
<div className='px-3 pt-1 pb-2 system-xs-regular text-text-tertiary whitespace-pre-line break-words'> <div className='px-3 pt-1 pb-2 system-xs-regular text-text-tertiary whitespace-pre-line break-words'>

View File

@ -20,6 +20,7 @@ import {
DescriptionInput, DescriptionInput,
TitleInput, TitleInput,
} from './components/title-description-input' } from './components/title-description-input'
import ErrorHandleOnPanel from './components/error-handle/error-handle-on-panel'
import { useResizePanel } from './hooks/use-resize-panel' import { useResizePanel } from './hooks/use-resize-panel'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import BlockIcon from '@/app/components/workflow/block-icon' import BlockIcon from '@/app/components/workflow/block-icon'
@ -34,7 +35,10 @@ import {
useWorkflow, useWorkflow,
useWorkflowHistory, useWorkflowHistory,
} from '@/app/components/workflow/hooks' } from '@/app/components/workflow/hooks'
import { canRunBySingle } from '@/app/components/workflow/utils' import {
canRunBySingle,
hasErrorHandleNode,
} from '@/app/components/workflow/utils'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import type { Node } from '@/app/components/workflow/types' import type { Node } from '@/app/components/workflow/types'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
@ -161,9 +165,17 @@ const BasePanel: FC<BasePanelProps> = ({
/> />
</div> </div>
</div> </div>
<div className='py-2'> <div>
{cloneElement(children, { id, data })} {cloneElement(children, { id, data })}
</div> </div>
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{ {
!!availableNextBlocks.length && ( !!availableNextBlocks.length && (
<div className='p-4 border-t-[0.5px] border-t-black/5'> <div className='p-4 border-t-[0.5px] border-t-black/5'>

View File

@ -72,7 +72,7 @@ const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({
</Field> </Field>
</div> </div>
<Split /> <Split />
<div className='px-4 pt-4 pb-2'> <div>
<OutputVars> <OutputVars>
<VarItem <VarItem
name='text' name='text'

View File

@ -2,11 +2,9 @@
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import type { Timeout as TimeoutPayloadType } from '../../types' import type { Timeout as TimeoutPayloadType } from '../../types'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
type Props = { type Props = {
readonly: boolean readonly: boolean
@ -53,20 +51,8 @@ const Timeout: FC<Props> = ({ readonly, payload, onChange }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { connect, read, write, max_connect_timeout, max_read_timeout, max_write_timeout } = payload ?? {} const { connect, read, write, max_connect_timeout, max_read_timeout, max_write_timeout } = payload ?? {}
const [isFold, {
toggle: toggleFold,
}] = useBoolean(true)
return ( return (
<> <FieldCollapse title={t(`${i18nPrefix}.timeout.title`)}>
<div>
<div
onClick={toggleFold}
className={cn('flex justify-between leading-[18px] text-[13px] font-semibold text-gray-700 uppercase cursor-pointer')}>
<div>{t(`${i18nPrefix}.timeout.title`)}</div>
<ChevronRight className='w-4 h-4 text-gray-500 transform transition-transform' style={{ transform: isFold ? 'rotate(0deg)' : 'rotate(90deg)' }} />
</div>
{!isFold && (
<div className='mt-2 space-y-1'> <div className='mt-2 space-y-1'>
<div className="space-y-3"> <div className="space-y-3">
<InputField <InputField
@ -101,10 +87,7 @@ const Timeout: FC<Props> = ({ readonly, payload, onChange }) => {
/> />
</div> </div>
</div> </div>
)} </FieldCollapse>
</div>
</>
) )
} }
export default React.memo(Timeout) export default React.memo(Timeout)

View File

@ -65,7 +65,7 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
return null return null
return ( return (
<div className='mt-2'> <div className='pt-2'>
<div className='px-4 pb-4 space-y-4'> <div className='px-4 pb-4 space-y-4'>
<Field <Field
title={t(`${i18nPrefix}.api`)} title={t(`${i18nPrefix}.api`)}
@ -136,14 +136,12 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
</Field> </Field>
</div> </div>
<Split /> <Split />
<div className='px-4 pt-4 pb-4'>
<Timeout <Timeout
nodeId={id} nodeId={id}
readonly={readOnly} readonly={readOnly}
payload={inputs.timeout} payload={inputs.timeout}
onChange={setTimeout} onChange={setTimeout}
/> />
</div>
{(isShowAuthorization && !readOnly) && ( {(isShowAuthorization && !readOnly) && (
<AuthorizationModal <AuthorizationModal
nodeId={id} nodeId={id}
@ -154,7 +152,7 @@ const Panel: FC<NodePanelProps<HttpNodeType>> = ({
/> />
)} )}
<Split /> <Split />
<div className='px-4 pt-4 pb-2'> <div className=''>
<OutputVars> <OutputVars>
<> <>
<VarItem <VarItem

View File

@ -3,6 +3,7 @@ import {
useMemo, useMemo,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import { ComparisonOperator } from '../types' import { ComparisonOperator } from '../types'
import { import {
comparisonOperatorNotRequireValue, comparisonOperatorNotRequireValue,
@ -13,6 +14,11 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop
import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others' import { BubbleX, Env } from '@/app/components/base/icons/src/vender/line/others'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
type ConditionValueProps = { type ConditionValueProps = {
variableSelector: string[] variableSelector: string[]
@ -27,11 +33,14 @@ const ConditionValue = ({
value, value,
}: ConditionValueProps) => { }: ConditionValueProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const nodes = useNodes()
const variableName = labelName || (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.')) const variableName = labelName || (isSystemVar(variableSelector) ? variableSelector.slice(0).join('.') : variableSelector.slice(1).join('.'))
const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator const operatorName = isComparisonOperatorNeedTranslate(operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${operator}`) : operator
const notHasValue = comparisonOperatorNotRequireValue(operator) const notHasValue = comparisonOperatorNotRequireValue(operator)
const isEnvVar = isENV(variableSelector) const isEnvVar = isENV(variableSelector)
const isChatVar = isConversationVar(variableSelector) const isChatVar = isConversationVar(variableSelector)
const node: Node<CommonNodeType> | undefined = nodes.find(n => n.id === variableSelector[0]) as Node<CommonNodeType>
const isException = isExceptionVariable(variableName, node?.data.type)
const formatValue = useMemo(() => { const formatValue = useMemo(() => {
if (notHasValue) if (notHasValue)
return '' return ''
@ -67,7 +76,7 @@ const ConditionValue = ({
return ( return (
<div className='flex items-center px-1 h-6 rounded-md bg-workflow-block-parma-bg'> <div className='flex items-center px-1 h-6 rounded-md bg-workflow-block-parma-bg'>
{!isEnvVar && !isChatVar && <Variable02 className='shrink-0 mr-1 w-3.5 h-3.5 text-text-accent' />} {!isEnvVar && !isChatVar && <Variable02 className={cn('shrink-0 mr-1 w-3.5 h-3.5 text-text-accent', isException && 'text-text-warning')} />}
{isEnvVar && <Env className='shrink-0 mr-1 w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnvVar && <Env className='shrink-0 mr-1 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />} {isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
@ -75,6 +84,7 @@ const ConditionValue = ({
className={cn( className={cn(
'shrink-0 ml-0.5 truncate text-xs font-medium text-text-accent', 'shrink-0 ml-0.5 truncate text-xs font-medium text-text-accent',
!notHasValue && 'max-w-[70px]', !notHasValue && 'max-w-[70px]',
isException && 'text-text-warning',
)} )}
title={variableName} title={variableName}
> >

View File

@ -18,7 +18,6 @@ import Switch from '@/app/components/base/switch'
import Select from '@/app/components/base/select' import Select from '@/app/components/base/select'
import Slider from '@/app/components/base/slider' import Slider from '@/app/components/base/slider'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Divider from '@/app/components/base/divider'
const i18nPrefix = 'workflow.nodes.iteration' const i18nPrefix = 'workflow.nodes.iteration'
@ -72,7 +71,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
} = useConfig(id, data) } = useConfig(id, data)
return ( return (
<div className='mt-2'> <div className='pt-2 pb-2'>
<div className='px-4 pb-4 space-y-4'> <div className='px-4 pb-4 space-y-4'>
<Field <Field
title={t(`${i18nPrefix}.input`)} title={t(`${i18nPrefix}.input`)}
@ -131,9 +130,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
</Field> </Field>
</div>) </div>)
} }
<div className='px-4 py-2'> <Split />
<Divider className='h-[1px]'/>
</div>
<div className='px-4 py-2'> <div className='px-4 py-2'>
<Field title={t(`${i18nPrefix}.errorResponseMethod`)} > <Field title={t(`${i18nPrefix}.errorResponseMethod`)} >

View File

@ -53,7 +53,7 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({
}, [setRerankModelOpen]) }, [setRerankModelOpen])
return ( return (
<div className='mt-2'> <div className='pt-2'>
<div className='px-4 pb-4 space-y-4'> <div className='px-4 pb-4 space-y-4'>
{/* {JSON.stringify(inputs, null, 2)} */} {/* {JSON.stringify(inputs, null, 2)} */}
<Field <Field
@ -108,7 +108,7 @@ const Panel: FC<NodePanelProps<KnowledgeRetrievalNodeType>> = ({
</div> </div>
<Split /> <Split />
<div className='px-4 pt-4 pb-2'> <div>
<OutputVars> <OutputVars>
<> <>
<VarItem <VarItem

View File

@ -42,8 +42,8 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({
} = useConfig(id, data) } = useConfig(id, data)
return ( return (
<div className='mt-2'> <div className='pt-2'>
<div className='px-4 pb-4 space-y-4'> <div className='px-4 space-y-4'>
<Field <Field
title={t(`${i18nPrefix}.inputVar`)} title={t(`${i18nPrefix}.inputVar`)}
> >
@ -157,7 +157,7 @@ const Panel: FC<NodePanelProps<ListFilterNodeType>> = ({
</Field> </Field>
<Split /> <Split />
</div> </div>
<div className='px-4 pt-4 pb-2'> <div>
<OutputVars> <OutputVars>
<> <>
<VarItem <VarItem

View File

@ -270,7 +270,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
/> />
</div> </div>
<Split /> <Split />
<div className='px-4 pt-4 pb-2'>
<OutputVars> <OutputVars>
<> <>
<VarItem <VarItem
@ -280,7 +279,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
/> />
</> </>
</OutputVars> </OutputVars>
</div>
{isShowSingleRun && ( {isShowSingleRun && (
<BeforeRunForm <BeforeRunForm
nodeName={inputs.title} nodeName={inputs.title}

View File

@ -20,6 +20,7 @@ import { InputVarType, type NodePanelProps } from '@/app/components/workflow/typ
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form' import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
import { VarType } from '@/app/components/workflow/types' import { VarType } from '@/app/components/workflow/types'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
const i18nPrefix = 'workflow.nodes.parameterExtractor' const i18nPrefix = 'workflow.nodes.parameterExtractor'
const i18nCommonPrefix = 'workflow.common' const i18nCommonPrefix = 'workflow.common'
@ -67,8 +68,8 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
const model = inputs.model const model = inputs.model
return ( return (
<div className='mt-2'> <div className='pt-2'>
<div className='px-4 pb-4 space-y-4'> <div className='px-4 space-y-4'>
<Field <Field
title={t(`${i18nCommonPrefix}.model`)} title={t(`${i18nCommonPrefix}.model`)}
> >
@ -157,12 +158,9 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
nodesOutputVars={availableVars} nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent} availableNodes={availableNodesWithParent}
/> />
<Field </div>
title={t(`${i18nPrefix}.advancedSetting`)} <FieldCollapse title={t(`${i18nPrefix}.advancedSetting`)}>
supportFold
>
<> <>
{/* Memory */} {/* Memory */}
{isChatMode && ( {isChatMode && (
<div className='mt-4'> <div className='mt-4'>
@ -183,12 +181,10 @@ const Panel: FC<NodePanelProps<ParameterExtractorNodeType>> = ({
</div> </div>
)} )}
</> </>
</Field> </FieldCollapse>
</div>
{inputs.parameters?.length > 0 && (<> {inputs.parameters?.length > 0 && (<>
<Split /> <Split />
<div className='px-4 pt-4 pb-2'> <div>
<OutputVars> <OutputVars>
<> <>
{inputs.parameters.map((param, index) => ( {inputs.parameters.map((param, index) => (

View File

@ -26,6 +26,16 @@ const nodeDefault: NodeDefault<QuestionClassifierNodeType> = {
name: '', name: '',
}, },
], ],
_targetBranches: [
{
id: '1',
name: '',
},
{
id: '2',
name: '',
},
],
vision: { vision: {
enabled: false, enabled: false,
}, },

View File

@ -14,6 +14,7 @@ import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/befo
import ResultPanel from '@/app/components/workflow/run/result-panel' import ResultPanel from '@/app/components/workflow/run/result-panel'
import Split from '@/app/components/workflow/nodes/_base/components/split' import Split from '@/app/components/workflow/nodes/_base/components/split'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
const i18nPrefix = 'workflow.nodes.questionClassifiers' const i18nPrefix = 'workflow.nodes.questionClassifiers'
@ -55,8 +56,8 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
const model = inputs.model const model = inputs.model
return ( return (
<div className='mt-2'> <div className='pt-2'>
<div className='px-4 pb-4 space-y-4'> <div className='px-4 space-y-4'>
<Field <Field
title={t(`${i18nPrefix}.model`)} title={t(`${i18nPrefix}.model`)}
> >
@ -107,9 +108,10 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
readonly={readOnly} readonly={readOnly}
/> />
</Field> </Field>
<Field <Split />
</div>
<FieldCollapse
title={t(`${i18nPrefix}.advancedSetting`)} title={t(`${i18nPrefix}.advancedSetting`)}
supportFold
> >
<AdvancedSetting <AdvancedSetting
hideMemorySetting={!isChatMode} hideMemorySetting={!isChatMode}
@ -124,10 +126,9 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
nodesOutputVars={availableVars} nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent} availableNodes={availableNodesWithParent}
/> />
</Field> </FieldCollapse>
</div>
<Split /> <Split />
<div className='px-4 pt-4 pb-2'> <div>
<OutputVars> <OutputVars>
<> <>
<VarItem <VarItem

View File

@ -95,7 +95,7 @@ const Panel: FC<NodePanelProps<TemplateTransformNodeType>> = ({
/> />
</div> </div>
<Split /> <Split />
<div className='px-4 pt-4 pb-2'> <div>
<OutputVars> <OutputVars>
<> <>
<VarItem <VarItem

View File

@ -56,10 +56,10 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
} }
return ( return (
<div className='mt-2'> <div className='pt-2'>
{!readOnly && isShowAuthBtn && ( {!readOnly && isShowAuthBtn && (
<> <>
<div className='px-4 pb-3'> <div className='px-4'>
<Button <Button
variant='primary' variant='primary'
className='w-full' className='w-full'
@ -71,7 +71,7 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
</> </>
)} )}
{!isShowAuthBtn && <> {!isShowAuthBtn && <>
<div className='px-4 pb-4 space-y-4'> <div className='px-4 space-y-4'>
{toolInputVarSchema.length > 0 && ( {toolInputVarSchema.length > 0 && (
<Field <Field
title={t(`${i18nPrefix}.inputVars`)} title={t(`${i18nPrefix}.inputVars`)}
@ -118,7 +118,7 @@ const Panel: FC<NodePanelProps<ToolNodeType>> = ({
/> />
)} )}
<div className='px-4 pt-4 pb-2'> <div>
<OutputVars> <OutputVars>
<> <>
<VarItem <VarItem

View File

@ -21,6 +21,7 @@ import AddVariable from './add-variable'
import NodeVariableItem from './node-variable-item' import NodeVariableItem from './node-variable-item'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils' import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { isExceptionVariable } from '@/app/components/workflow/utils'
const i18nPrefix = 'workflow.nodes.variableAssigner' const i18nPrefix = 'workflow.nodes.variableAssigner'
type GroupItem = { type GroupItem = {
@ -128,12 +129,14 @@ const NodeGroupItem = ({
const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0]) const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0])
const varName = isSystem ? `sys.${variable[variable.length - 1]}` : variable.slice(1).join('.') const varName = isSystem ? `sys.${variable[variable.length - 1]}` : variable.slice(1).join('.')
const isException = isExceptionVariable(varName, node?.data.type)
return ( return (
<NodeVariableItem <NodeVariableItem
key={index} key={index}
isEnv={isEnv} isEnv={isEnv}
isChatVar={isChatVar} isChatVar={isChatVar}
isException={isException}
node={node as Node} node={node as Node}
varName={varName} varName={varName}
showBorder={showSelectedBorder || showSelectionBorder} showBorder={showSelectedBorder || showSelectionBorder}

View File

@ -17,6 +17,7 @@ type NodeVariableItemProps = {
writeMode?: string writeMode?: string
showBorder?: boolean showBorder?: boolean
className?: string className?: string
isException?: boolean
} }
const i18nPrefix = 'workflow.nodes.assigner' const i18nPrefix = 'workflow.nodes.assigner'
@ -29,6 +30,7 @@ const NodeVariableItem = ({
writeMode, writeMode,
showBorder, showBorder,
className, className,
isException,
}: NodeVariableItemProps) => { }: NodeVariableItemProps) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@ -50,14 +52,14 @@ const NodeVariableItem = ({
</div> </div>
)} )}
<div className='flex items-center text-primary-600 w-full'> <div className='flex items-center text-primary-600 w-full'>
{!isEnv && !isChatVar && <Variable02 className='shrink-0 w-3.5 h-3.5 text-primary-500' />} {!isEnv && !isChatVar && <Variable02 className={cn('shrink-0 w-3.5 h-3.5 text-primary-500', isException && 'text-text-warning')} />}
{isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />} {isEnv && <Env className='shrink-0 w-3.5 h-3.5 text-util-colors-violet-violet-600' />}
{!isChatVar && <div className={cn('max-w-[75px] truncate ml-0.5 system-xs-medium overflow-hidden text-ellipsis', isEnv && 'text-gray-900')} title={varName}>{varName}</div>} {!isChatVar && <div className={cn('max-w-[75px] truncate ml-0.5 system-xs-medium overflow-hidden text-ellipsis', isEnv && 'text-gray-900', isException && 'text-text-warning')} title={varName}>{varName}</div>}
{isChatVar {isChatVar
&& <div className='flex items-center w-full gap-1'> && <div className='flex items-center w-full gap-1'>
<div className='flex h-[18px] min-w-[18px] items-center gap-0.5 flex-1'> <div className='flex h-[18px] min-w-[18px] items-center gap-0.5 flex-1'>
<BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' /> <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />
<div className='max-w-[75px] truncate ml-0.5 system-xs-medium overflow-hidden text-ellipsis text-util-colors-teal-teal-700'>{varName}</div> <div className={cn('max-w-[75px] truncate ml-0.5 system-xs-medium overflow-hidden text-ellipsis text-util-colors-teal-teal-700')}>{varName}</div>
</div> </div>
{writeMode && <Badge className='shrink-0' text={t(`${i18nPrefix}.operations.${writeMode}`)} />} {writeMode && <Badge className='shrink-0' text={t(`${i18nPrefix}.operations.${writeMode}`)} />}
</div> </div>

View File

@ -193,6 +193,7 @@ const WorkflowPreview = () => {
created_at={workflowRunningData?.result?.created_at} created_at={workflowRunningData?.result?.created_at}
created_by={(workflowRunningData?.result?.created_by as any)?.name} created_by={(workflowRunningData?.result?.created_by as any)?.name}
steps={workflowRunningData?.result?.total_steps} steps={workflowRunningData?.result?.total_steps}
exceptionCounts={workflowRunningData?.result?.exceptions_count}
/> />
)} )}
{currentTab === 'DETAIL' && !workflowRunningData?.result && ( {currentTab === 'DETAIL' && !workflowRunningData?.result && (

View File

@ -258,6 +258,7 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
created_at={runDetail.created_at} created_at={runDetail.created_at}
created_by={executor} created_by={executor}
steps={runDetail.total_steps} steps={runDetail.total_steps}
exceptionCounts={runDetail.exceptions_count}
/> />
)} )}
{!loading && currentTab === 'TRACING' && ( {!loading && currentTab === 'TRACING' && (

View File

@ -38,6 +38,12 @@ const MetaData: FC<Props> = ({
{status === 'succeeded' && ( {status === 'succeeded' && (
<span>SUCCESS</span> <span>SUCCESS</span>
)} )}
{status === 'partial-succeeded' && (
<span>PARTIAL SUCCESS</span>
)}
{status === 'exception' && (
<span>EXCEPTION</span>
)}
{status === 'failed' && ( {status === 'failed' && (
<span>FAIL</span> <span>FAIL</span>
)} )}

View File

@ -19,6 +19,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { IterationDurationMap, NodeTracing } from '@/types/workflow' import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
type Props = { type Props = {
className?: string className?: string
@ -128,6 +129,9 @@ const NodePanel: FC<Props> = ({
{nodeInfo.status === 'stopped' && ( {nodeInfo.status === 'stopped' && (
<RiAlertFill className={cn('shrink-0 ml-2 w-4 h-4 text-text-warning-secondary', inMessage && 'w-3.5 h-3.5')} /> <RiAlertFill className={cn('shrink-0 ml-2 w-4 h-4 text-text-warning-secondary', inMessage && 'w-3.5 h-3.5')} />
)} )}
{nodeInfo.status === 'exception' && (
<RiAlertFill className={cn('shrink-0 ml-2 w-4 h-4 text-text-warning-secondary', inMessage && 'w-3.5 h-3.5')} />
)}
{nodeInfo.status === 'running' && ( {nodeInfo.status === 'running' && (
<div className='shrink-0 flex items-center text-text-accent 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> <span className='mr-2 text-xs font-normal'>Running</span>
@ -165,12 +169,24 @@ const NodePanel: FC<Props> = ({
<Split className='mt-2' /> <Split className='mt-2' />
</div> </div>
)} )}
<div className={cn('px-[10px]', hideInfo && '!px-2 !py-0.5')}> <div className={cn('mb-1', hideInfo && '!px-2 !py-0.5')}>
{nodeInfo.status === 'stopped' && ( {(nodeInfo.status === 'stopped') && (
<StatusContainer status='stopped'> <StatusContainer status='stopped'>
{t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })} {t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })}
</StatusContainer> </StatusContainer>
)} )}
{(nodeInfo.status === 'exception') && (
<StatusContainer status='stopped'>
{nodeInfo.error}
<a
href='https://docs.dify.ai/guides/workflow/error-handling/predefined-nodes-failure-logic'
target='_blank'
className='text-text-accent'
>
{t('workflow.common.learnMore')}
</a>
</StatusContainer>
)}
{nodeInfo.status === 'failed' && ( {nodeInfo.status === 'failed' && (
<StatusContainer status='failed'> <StatusContainer status='failed'>
{nodeInfo.error} {nodeInfo.error}
@ -207,6 +223,7 @@ const NodePanel: FC<Props> = ({
language={CodeLanguage.json} language={CodeLanguage.json}
value={nodeInfo.outputs} value={nodeInfo.outputs}
isJSONStringifyBeauty isJSONStringifyBeauty
tip={<ErrorHandleTip type={nodeInfo.execution_metadata?.error_strategy} />}
/> />
</div> </div>
)} )}

View File

@ -5,6 +5,7 @@ import StatusPanel from './status'
import MetaData from './meta' import MetaData from './meta'
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 ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
type ResultPanelProps = { type ResultPanelProps = {
inputs?: string inputs?: string
@ -19,6 +20,8 @@ type ResultPanelProps = {
finished_at?: number finished_at?: number
steps?: number steps?: number
showSteps?: boolean showSteps?: boolean
exceptionCounts?: number
execution_metadata?: any
} }
const ResultPanel: FC<ResultPanelProps> = ({ const ResultPanel: FC<ResultPanelProps> = ({
@ -33,6 +36,8 @@ const ResultPanel: FC<ResultPanelProps> = ({
created_by, created_by,
steps, steps,
showSteps, showSteps,
exceptionCounts,
execution_metadata,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@ -43,6 +48,7 @@ const ResultPanel: FC<ResultPanelProps> = ({
time={elapsed_time} time={elapsed_time}
tokens={total_tokens} tokens={total_tokens}
error={error} error={error}
exceptionCounts={exceptionCounts}
/> />
</div> </div>
<div className='px-4 py-2 flex flex-col gap-2'> <div className='px-4 py-2 flex flex-col gap-2'>
@ -69,6 +75,7 @@ const ResultPanel: FC<ResultPanelProps> = ({
language={CodeLanguage.json} language={CodeLanguage.json}
value={outputs} value={outputs}
isJSONStringifyBeauty isJSONStringifyBeauty
tip={<ErrorHandleTip type={execution_metadata?.error_strategy} />}
/> />
)} )}
</div> </div>

View File

@ -14,10 +14,12 @@ const StatusContainer: FC<Props> = ({
return ( return (
<div <div
className={cn( className={cn(
'relative px-3 py-2.5 rounded-lg border system-xs-regular', 'relative px-3 py-2.5 rounded-lg border system-xs-regular break-all',
status === 'succeeded' && 'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-success', status === 'succeeded' && 'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-success',
status === 'partial-succeeded' && 'border-[rgba(23,178,106,0.8)] bg-workflow-display-success-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-success.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(23,178,106,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-success',
status === 'failed' && 'border-[rgba(240,68,56,0.8)] bg-workflow-display-error-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-error.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(240,68,56,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-warning', status === 'failed' && 'border-[rgba(240,68,56,0.8)] bg-workflow-display-error-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-error.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(240,68,56,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-warning',
status === 'stopped' && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-destructive', status === 'stopped' && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-destructive',
status === 'exception' && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-text-destructive',
status === 'running' && 'border-[rgba(11,165,236,0.8)] bg-workflow-display-normal-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-running.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(11,165,236,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-util-colors-blue-light-blue-light-600', status === 'running' && 'border-[rgba(11,165,236,0.8)] bg-workflow-display-normal-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-running.svg)] shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(11,165,236,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)] text-util-colors-blue-light-blue-light-600',
)} )}
> >

View File

@ -10,6 +10,7 @@ type ResultProps = {
time?: number time?: number
tokens?: number tokens?: number
error?: string error?: string
exceptionCounts?: number
} }
const StatusPanel: FC<ResultProps> = ({ const StatusPanel: FC<ResultProps> = ({
@ -17,18 +18,23 @@ const StatusPanel: FC<ResultProps> = ({
time, time,
tokens, tokens,
error, error,
exceptionCounts,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<StatusContainer status={status}> <StatusContainer status={status}>
<div className='flex'> <div className='flex'>
<div className='flex-[33%] max-w-[120px]'> <div className={cn(
'flex-[33%] max-w-[120px]',
status === 'partial-succeeded' && 'min-w-[140px]',
)}>
<div className='mb-1 text-text-tertiary system-2xs-medium-uppercase'>{t('runLog.resultPanel.status')}</div> <div className='mb-1 text-text-tertiary system-2xs-medium-uppercase'>{t('runLog.resultPanel.status')}</div>
<div <div
className={cn( className={cn(
'flex items-center gap-1 system-xs-semibold-uppercase', 'flex items-center gap-1 system-xs-semibold-uppercase',
status === 'succeeded' && 'text-util-colors-green-green-600', status === 'succeeded' && 'text-util-colors-green-green-600',
status === 'partial-succeeded' && 'text-util-colors-green-green-600',
status === 'failed' && 'text-util-colors-red-red-600', status === 'failed' && 'text-util-colors-red-red-600',
status === 'stopped' && 'text-util-colors-warning-warning-600', status === 'stopped' && 'text-util-colors-warning-warning-600',
status === 'running' && 'text-util-colors-blue-light-blue-light-600', status === 'running' && 'text-util-colors-blue-light-blue-light-600',
@ -46,6 +52,18 @@ const StatusPanel: FC<ResultProps> = ({
<span>SUCCESS</span> <span>SUCCESS</span>
</> </>
)} )}
{status === 'partial-succeeded' && (
<>
<Indicator color={'green'} />
<span>PARTIAL SUCCESS</span>
</>
)}
{status === 'exception' && (
<>
<Indicator color={'yellow'} />
<span>EXCEPTION</span>
</>
)}
{status === 'failed' && ( {status === 'failed' && (
<> <>
<Indicator color={'red'} /> <Indicator color={'red'} />
@ -87,8 +105,45 @@ const StatusPanel: FC<ResultProps> = ({
<> <>
<div className='my-2 h-[0.5px] bg-divider-subtle'/> <div className='my-2 h-[0.5px] bg-divider-subtle'/>
<div className='system-xs-regular text-text-destructive'>{error}</div> <div className='system-xs-regular text-text-destructive'>{error}</div>
{
!!exceptionCounts && (
<>
<div className='my-2 h-[0.5px] bg-divider-subtle'/>
<div className='system-xs-regular text-text-destructive'>
{t('workflow.nodes.common.errorHandle.partialSucceeded.tip', { num: exceptionCounts })}
</div>
</>
)
}
</> </>
)} )}
{
status === 'partial-succeeded' && !!exceptionCounts && (
<>
<div className='my-2 h-[0.5px] bg-divider-deep'/>
<div className='system-xs-medium text-text-warning'>
{t('workflow.nodes.common.errorHandle.partialSucceeded.tip', { num: exceptionCounts })}
</div>
</>
)
}
{
status === 'exception' && (
<>
<div className='my-2 h-[0.5px] bg-divider-deep'/>
<div className='system-xs-medium text-text-warning'>
{error}
<a
href='https://docs.dify.ai/guides/workflow/error-handling/predefined-nodes-failure-logic'
target='_blank'
className='text-text-accent'
>
{t('workflow.common.learnMore')}
</a>
</div>
</>
)
}
</StatusContainer> </StatusContainer>
) )
} }

View File

@ -9,6 +9,10 @@ import type { VarType as VarKindType } from '@/app/components/workflow/nodes/too
import type { FileResponse, NodeTracing } from '@/types/workflow' import type { FileResponse, NodeTracing } from '@/types/workflow'
import type { Collection, Tool } from '@/app/components/tools/types' import type { Collection, Tool } from '@/app/components/tools/types'
import type { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' import type { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import type {
DefaultValueForm,
ErrorHandleTypeEnum,
} from '@/app/components/workflow/nodes/_base/components/error-handle/types'
export enum BlockEnum { export enum BlockEnum {
Start = 'start', Start = 'start',
@ -52,6 +56,7 @@ export type CommonNodeType<T = {}> = {
_targetBranches?: Branch[] _targetBranches?: Branch[]
_isSingleRun?: boolean _isSingleRun?: boolean
_runningStatus?: NodeRunningStatus _runningStatus?: NodeRunningStatus
_runningBranchId?: string
_singleRunningStatus?: NodeRunningStatus _singleRunningStatus?: NodeRunningStatus
_isCandidate?: boolean _isCandidate?: boolean
_isBundled?: boolean _isBundled?: boolean
@ -62,6 +67,7 @@ export type CommonNodeType<T = {}> = {
_iterationLength?: number _iterationLength?: number
_iterationIndex?: number _iterationIndex?: number
_inParallelHovering?: boolean _inParallelHovering?: boolean
_waitingRun?: boolean
isInIteration?: boolean isInIteration?: boolean
iteration_id?: string iteration_id?: string
selected?: boolean selected?: boolean
@ -70,14 +76,18 @@ export type CommonNodeType<T = {}> = {
type: BlockEnum type: BlockEnum
width?: number width?: number
height?: number height?: number
error_strategy?: ErrorHandleTypeEnum
default_value?: DefaultValueForm[]
} & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>> } & T & Partial<Pick<ToolDefaultValue, 'provider_id' | 'provider_type' | 'provider_name' | 'tool_name'>>
export type CommonEdgeType = { export type CommonEdgeType = {
_hovering?: boolean _hovering?: boolean
_connectedNodeIsHovering?: boolean _connectedNodeIsHovering?: boolean
_connectedNodeIsSelected?: boolean _connectedNodeIsSelected?: boolean
_run?: boolean
_isBundled?: boolean _isBundled?: boolean
_sourceRunningStatus?: NodeRunningStatus
_targetRunningStatus?: NodeRunningStatus
_waitingRun?: boolean
isInIteration?: boolean isInIteration?: boolean
iteration_id?: string iteration_id?: string
sourceType: BlockEnum sourceType: BlockEnum
@ -242,6 +252,7 @@ export type Var = {
options?: string[] options?: string[]
required?: boolean required?: boolean
des?: string des?: string
isException?: boolean
} }
export type NodeOutPutVar = { export type NodeOutPutVar = {
@ -281,6 +292,7 @@ export enum NodeRunningStatus {
Running = 'running', Running = 'running',
Succeeded = 'succeeded', Succeeded = 'succeeded',
Failed = 'failed', Failed = 'failed',
Exception = 'exception',
} }
export type OnNodeAdd = ( export type OnNodeAdd = (
@ -331,6 +343,7 @@ export type WorkflowRunningData = {
showSteps?: boolean showSteps?: boolean
total_steps?: number total_steps?: number
files?: FileResponse[] files?: FileResponse[]
exceptions_count?: number
} }
tracing?: NodeTracing[] tracing?: NodeTracing[]
} }

View File

@ -19,7 +19,11 @@ import type {
ToolWithProvider, ToolWithProvider,
ValueSelector, ValueSelector,
} from './types' } from './types'
import { BlockEnum, ErrorHandleMode } from './types' import {
BlockEnum,
ErrorHandleMode,
NodeRunningStatus,
} from './types'
import { import {
CUSTOM_NODE, CUSTOM_NODE,
ITERATION_CHILDREN_Z_INDEX, ITERATION_CHILDREN_Z_INDEX,
@ -761,3 +765,34 @@ export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: str
hasAbnormalEdges, hasAbnormalEdges,
} }
} }
export const hasErrorHandleNode = (nodeType?: BlockEnum) => {
return nodeType === BlockEnum.LLM || nodeType === BlockEnum.Tool || nodeType === BlockEnum.HttpRequest || nodeType === BlockEnum.Code
}
export const getEdgeColor = (nodeRunningStatus?: NodeRunningStatus, isFailBranch?: boolean) => {
if (nodeRunningStatus === NodeRunningStatus.Succeeded)
return 'var(--color-workflow-link-line-success-handle)'
if (nodeRunningStatus === NodeRunningStatus.Failed)
return 'var(--color-workflow-link-line-error-handle)'
if (nodeRunningStatus === NodeRunningStatus.Exception)
return 'var(--color-workflow-link-line-failure-handle)'
if (nodeRunningStatus === NodeRunningStatus.Running) {
if (isFailBranch)
return 'var(--color-workflow-link-line-failure-handle)'
return 'var(--color-workflow-link-line-handle)'
}
return 'var(--color-workflow-link-line-normal)'
}
export const isExceptionVariable = (variable: string, nodeType?: BlockEnum) => {
if ((variable === 'error_message' || variable === 'error_type') && hasErrorHandleNode(nodeType))
return true
return false
}

View File

@ -102,6 +102,8 @@ const translation = {
addParallelNode: 'Add Parallel Node', addParallelNode: 'Add Parallel Node',
parallel: 'PARALLEL', parallel: 'PARALLEL',
branch: 'BRANCH', branch: 'BRANCH',
onFailure: 'On Failure',
addFailureBranch: 'Add Fail Branch',
}, },
env: { env: {
envPanelTitle: 'Environment Variables', envPanelTitle: 'Environment Variables',
@ -302,6 +304,31 @@ const translation = {
tip: 'Chat memory', tip: 'Chat memory',
builtIn: 'Built-in', builtIn: 'Built-in',
}, },
errorHandle: {
title: 'Error Handling',
tip: 'Exception handling strategy, triggered when a node encounters an exception.',
none: {
title: 'None',
desc: 'The node will stop running if an exception occurs and is not handled',
},
defaultValue: {
title: 'Default Value',
desc: 'When an error occurs, specify a static output content.',
tip: 'On error, will return below value.',
inLog: 'Node exception, outputting according to default values.',
output: 'Output Default Value',
},
failBranch: {
title: 'Fail Branch',
desc: 'When an error occurs, it will execute the exception branch',
customize: 'Go to the canvas to customize the fail branch logic.',
customizeTip: 'When the fail branch is activated, exceptions thrown by nodes will not terminate the process. Instead, it will automatically execute the predefined fail branch, allowing you to flexibly provide error messages, reports, fixes, or skip actions.',
inLog: 'Node exception, will automatically execute the fail branch. The node output will return an error type and error message and pass them to downstream.',
},
partialSucceeded: {
tip: 'There are {{num}} nodes in the process running abnormally, please go to tracing to check the logs.',
},
},
}, },
start: { start: {
required: 'required', required: 'required',

View File

@ -101,6 +101,8 @@ const translation = {
addParallelNode: '添加并行节点', addParallelNode: '添加并行节点',
parallel: '并行', parallel: '并行',
branch: '分支', branch: '分支',
onFailure: '异常时',
addFailureBranch: '添加异常分支',
}, },
env: { env: {
envPanelTitle: '环境变量', envPanelTitle: '环境变量',
@ -301,6 +303,31 @@ const translation = {
tip: '聊天记忆', tip: '聊天记忆',
builtIn: '内置', builtIn: '内置',
}, },
errorHandle: {
title: '异常处理',
tip: '配置异常处理策略,当节点发生异常时触发。',
none: {
title: '无',
desc: '当发生异常且未处理时,节点将停止运行',
},
defaultValue: {
title: '默认值',
desc: '当发生异常时,指定默认输出内容。',
tip: '当发生异常时,将返回以下值。',
inLog: '节点异常,根据默认值输出。',
output: '输出默认值',
},
failBranch: {
title: '异常分支',
desc: '当发生异常时,将执行异常分支',
customize: '在画布自定义失败分支逻辑。',
customizeTip: '当节点发生异常时,将自动执行失败分支。失败分支允许您灵活地提供错误消息、报告、修复或跳过操作。',
inLog: '节点异常,将自动执行失败分支。节点输出将返回错误类型和错误信息,并传递给下游。',
},
partialSucceeded: {
tip: '流程中有 {{num}} 个节点运行异常,请前往追踪查看日志。',
},
},
}, },
start: { start: {
required: '必填', required: '必填',

View File

@ -297,6 +297,7 @@ export type WorkflowRunDetailResponse = {
created_by_end_user?: EndUserInfo created_by_end_user?: EndUserInfo
created_at: number created_at: number
finished_at: number finished_at: number
exceptions_count?: number
} }
export type AgentLogMeta = { export type AgentLogMeta = {

View File

@ -7,6 +7,7 @@ import type {
Node, Node,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
import type { TransferMethod } from '@/types/app' import type { TransferMethod } from '@/types/app'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
export type NodeTracing = { export type NodeTracing = {
id: string id: string
@ -22,7 +23,7 @@ export type NodeTracing = {
parallel_run_id?: string parallel_run_id?: string
error?: string error?: string
elapsed_time: number elapsed_time: number
execution_metadata: { execution_metadata?: {
total_tokens: number total_tokens: number
total_price: number total_price: number
currency: string currency: string
@ -34,6 +35,7 @@ export type NodeTracing = {
parent_parallel_start_node_id?: string parent_parallel_start_node_id?: string
parallel_mode_run_id?: string parallel_mode_run_id?: string
iteration_duration_map?: IterationDurationMap iteration_duration_map?: IterationDurationMap
error_strategy?: ErrorHandleTypeEnum
} }
metadata: { metadata: {
iterator_length: number iterator_length: number
@ -172,6 +174,7 @@ export type NodeFinishedResponse = {
iteration_index?: number iteration_index?: number
iteration_id?: string iteration_id?: string
parallel_mode_run_id: string parallel_mode_run_id: string
error_strategy?: ErrorHandleTypeEnum
} }
created_at: number created_at: number
files?: FileResponse[] files?: FileResponse[]