feat: workflow continue on error (#11474)

pull/11545/head
zxhlyh 1 year ago committed by GitHub
parent 86dfdcb8ec
commit bec5451f12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -26,6 +26,7 @@ import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
type WorkflowVariableBlockComponentProps = {
nodeKey: string
@ -53,6 +54,7 @@ const WorkflowVariableBlockComponent = ({
const node = localWorkflowNodesMap![variables[0]]
const isEnv = isENV(variables)
const isChatVar = isConversationVar(variables)
const isException = isExceptionVariable(varName, node?.type)
useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode]))
@ -98,10 +100,10 @@ const WorkflowVariableBlockComponent = ({
</div>
)}
<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' />}
{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 && (
<RiErrorWarningFill className='ml-0.5 w-3 h-3 text-[#D92D20]' />

@ -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

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

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

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

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

@ -59,7 +59,7 @@ const BeforeRunForm: FC<BeforeRunFormProps> = ({
}) => {
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 isFileLoaded = (() => {
// system files

@ -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

@ -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

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

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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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,
}
}

@ -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
}

@ -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 []
}

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

@ -4,6 +4,7 @@ import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type ContainerProps = {
nodeId: string
@ -11,6 +12,7 @@ type ContainerProps = {
sourceHandle: string
nextNodes: Node[]
branchName?: string
isFailBranch?: boolean
}
const Container = ({
@ -19,13 +21,20 @@ const Container = ({
sourceHandle,
nextNodes,
branchName,
isFailBranch,
}: ContainerProps) => {
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 && (
<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}
>
{branchName}
@ -44,6 +53,7 @@ const Container = ({
}
<Add
isParallel={!!nextNodes.length}
isFailBranch={isFailBranch}
nodeId={nodeId}
nodeData={nodeData}
sourceHandle={sourceHandle}

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

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

@ -2,11 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import cn from '@/utils/classnames'
import { FieldCollapse } from '@/app/components/workflow/nodes/_base/components/collapse'
type Props = {
className?: string
@ -15,28 +11,14 @@ type Props = {
}
const OutputVars: FC<Props> = ({
className,
title,
children,
}) => {
const { t } = useTranslation()
const [isFold, {
toggle: toggleFold,
}] = useBoolean(true)
return (
<div>
<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}
</div>
)}
</div>
<FieldCollapse title={title || t('workflow.nodes.common.outputVars')}>
{children}
</FieldCollapse>
)
}
type VarItemProps = {

@ -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 Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import { isExceptionVariable } from '@/app/components/workflow/utils'
type VariableTagProps = {
valueSelector: ValueSelector
@ -45,6 +46,7 @@ const VariableTag = ({
const isValid = Boolean(node) || isEnv || isChatVar
const variableName = isSystemVar(valueSelector) ? valueSelector.slice(0).join('.') : valueSelector.slice(1).join('.')
const isException = isExceptionVariable(variableName, node?.data.type)
const { t } = useTranslation()
return (
@ -67,12 +69,12 @@ const VariableTag = ({
</>
)}
<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' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
<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}
>
{variableName}

@ -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]
res.vars = res.vars.filter((v) => {
const isCurrentMatched = filterVar(v, (() => {

@ -36,6 +36,7 @@ import TypeSelector from '@/app/components/workflow/nodes/_base/components/selec
import AddButton from '@/app/components/base/button/add-button'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { isExceptionVariable } from '@/app/components/workflow/utils'
const TRIGGER_DEFAULT_WIDTH = 227
@ -224,16 +225,18 @@ const VarReferencePicker: FC<Props> = ({
isConstant: !!isConstant,
})
const { isEnv, isChatVar, isValidVar } = useMemo(() => {
const { isEnv, isChatVar, isValidVar, isException } = useMemo(() => {
const isEnv = isENV(value as ValueSelector)
const isChatVar = isConversationVar(value as ValueSelector)
const isValidVar = Boolean(outputVarNode) || isEnv || isChatVar
const isException = isExceptionVariable(varName, outputVarNode?.type)
return {
isEnv,
isChatVar,
isValidVar,
isException,
}
}, [value, outputVarNode])
}, [value, outputVarNode, varName])
// 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff
const availableWidth = triggerWidth - 56
@ -335,7 +338,7 @@ const VarReferencePicker: FC<Props> = ({
{!hasValue && <Variable02 className='w-3.5 h-3.5' />}
{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' />}
<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,
}}>{varName}</div>
</div>

@ -37,6 +37,7 @@ type ItemProps = {
onHovering?: (value: boolean) => void
itemWidth?: number
isSupportFileVar?: boolean
isException?: boolean
}
const Item: FC<ItemProps> = ({
@ -48,6 +49,7 @@ const Item: FC<ItemProps> = ({
onHovering,
itemWidth,
isSupportFileVar,
isException,
}) => {
const isFile = itemData.type === VarType.file
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}
>
<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' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
{!isEnv && !isChatVar && (
@ -216,6 +218,7 @@ const ObjectChildren: FC<ObjectChildrenProps> = ({
onChange={onChange}
onHovering={setIsChildrenHovering}
isSupportFileVar={isSupportFileVar}
isException={v.isException}
/>
))
}
@ -312,6 +315,7 @@ const VarReferenceVars: FC<Props> = ({
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
isException={v.isException}
/>
))}
</div>))

@ -1,12 +1,22 @@
import { useCallback, useState } from 'react'
import produce from 'immer'
import { useBoolean } from 'ahooks'
import { type OutputVar } from '../../code/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import type {
CodeNodeType,
OutputVar,
} from '../../code/types'
import type {
ValueSelector,
} from '@/app/components/workflow/types'
import {
BlockEnum,
VarType,
} from '@/app/components/workflow/types'
import {
useWorkflow,
} 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> = {
id: string
@ -29,6 +39,9 @@ function useOutputVarList<T>({
const handleVarsChange = useCallback((newVars: OutputVar, changedIndex?: number, newKey?: string) => {
const newInputs = produce(inputs, (draft: any) => {
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)
@ -59,6 +72,9 @@ function useOutputVarList<T>({
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)
onOutputKeyOrdersChange([...outputKeyOrders, newKey])
@ -84,6 +100,9 @@ function useOutputVarList<T>({
const newInputs = produce(inputs, (draft: any) => {
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)
onOutputKeyOrdersChange(outputKeyOrders.filter((_, i) => i !== index))

@ -10,8 +10,9 @@ import {
useRef,
} from 'react'
import {
RiCheckboxCircleLine,
RiErrorWarningLine,
RiAlertFill,
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoader2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
@ -24,6 +25,7 @@ import {
useNodesReadOnly,
useToolIcon,
} from '../../hooks'
import { hasErrorHandleNode } from '../../utils'
import { useNodeIterationInteractions } from '../iteration/use-interactions'
import type { IterationNodeType } from '../iteration/types'
import {
@ -32,6 +34,7 @@ import {
} from './components/node-handle'
import NodeResizer from './components/node-resizer'
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 cn from '@/utils/classnames'
import BlockIcon from '@/app/components/workflow/block-icon'
@ -71,11 +74,13 @@ const BaseNode: FC<BaseNodeProps> = ({
showRunningBorder,
showSuccessBorder,
showFailedBorder,
showExceptionBorder,
} = useMemo(() => {
return {
showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder,
showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder,
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
}
}, [data._runningStatus, showSelectedBorder])
@ -85,6 +90,7 @@ const BaseNode: FC<BaseNodeProps> = ({
'flex border-[2px] rounded-2xl',
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
!showSelectedBorder && data._inParallelHovering && 'border-workflow-block-border-highlight',
data._waitingRun && 'opacity-70',
)}
ref={nodeRef}
style={{
@ -99,9 +105,10 @@ const BaseNode: FC<BaseNodeProps> = ({
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._runningStatus && 'hover:shadow-lg',
showRunningBorder && '!border-primary-500',
showSuccessBorder && '!border-[#12B76A]',
showFailedBorder && '!border-[#F04438]',
showRunningBorder && '!border-state-accent-solid',
showSuccessBorder && '!border-state-success-solid',
showFailedBorder && '!border-state-destructive-solid',
showExceptionBorder && '!border-state-warning-solid',
data._isBundled && '!shadow-lg',
)}
>
@ -192,24 +199,29 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
{
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}
</div>
)
}
{
(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 && (
<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 && (
<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>
@ -225,6 +237,14 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnNode
id={id}
data={data}
/>
)
}
{
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'>

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

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

@ -2,11 +2,9 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import type { Timeout as TimeoutPayloadType } from '../../types'
import cn from '@/utils/classnames'
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 = {
readonly: boolean
@ -53,58 +51,43 @@ const Timeout: FC<Props> = ({ readonly, payload, onChange }) => {
const { t } = useTranslation()
const { connect, read, write, max_connect_timeout, max_read_timeout, max_write_timeout } = payload ?? {}
const [isFold, {
toggle: toggleFold,
}] = useBoolean(true)
return (
<>
<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)' }} />
<FieldCollapse title={t(`${i18nPrefix}.timeout.title`)}>
<div className='mt-2 space-y-1'>
<div className="space-y-3">
<InputField
title={t('workflow.nodes.http.timeout.connectLabel')!}
description={t('workflow.nodes.http.timeout.connectPlaceholder')!}
placeholder={t('workflow.nodes.http.timeout.connectPlaceholder')!}
readOnly={readonly}
value={connect}
onChange={v => onChange?.({ ...payload, connect: v })}
min={1}
max={max_connect_timeout || 300}
/>
<InputField
title={t('workflow.nodes.http.timeout.readLabel')!}
description={t('workflow.nodes.http.timeout.readPlaceholder')!}
placeholder={t('workflow.nodes.http.timeout.readPlaceholder')!}
readOnly={readonly}
value={read}
onChange={v => onChange?.({ ...payload, read: v })}
min={1}
max={max_read_timeout || 600}
/>
<InputField
title={t('workflow.nodes.http.timeout.writeLabel')!}
description={t('workflow.nodes.http.timeout.writePlaceholder')!}
placeholder={t('workflow.nodes.http.timeout.writePlaceholder')!}
readOnly={readonly}
value={write}
onChange={v => onChange?.({ ...payload, write: v })}
min={1}
max={max_write_timeout || 600}
/>
</div>
{!isFold && (
<div className='mt-2 space-y-1'>
<div className="space-y-3">
<InputField
title={t('workflow.nodes.http.timeout.connectLabel')!}
description={t('workflow.nodes.http.timeout.connectPlaceholder')!}
placeholder={t('workflow.nodes.http.timeout.connectPlaceholder')!}
readOnly={readonly}
value={connect}
onChange={v => onChange?.({ ...payload, connect: v })}
min={1}
max={max_connect_timeout || 300}
/>
<InputField
title={t('workflow.nodes.http.timeout.readLabel')!}
description={t('workflow.nodes.http.timeout.readPlaceholder')!}
placeholder={t('workflow.nodes.http.timeout.readPlaceholder')!}
readOnly={readonly}
value={read}
onChange={v => onChange?.({ ...payload, read: v })}
min={1}
max={max_read_timeout || 600}
/>
<InputField
title={t('workflow.nodes.http.timeout.writeLabel')!}
description={t('workflow.nodes.http.timeout.writePlaceholder')!}
placeholder={t('workflow.nodes.http.timeout.writePlaceholder')!}
readOnly={readonly}
value={write}
onChange={v => onChange?.({ ...payload, write: v })}
min={1}
max={max_write_timeout || 600}
/>
</div>
</div>
)}
</div>
</>
</FieldCollapse>
)
}
export default React.memo(Timeout)

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

@ -3,6 +3,7 @@ import {
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import { ComparisonOperator } from '../types'
import {
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 cn from '@/utils/classnames'
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 = {
variableSelector: string[]
@ -27,11 +33,14 @@ const ConditionValue = ({
value,
}: ConditionValueProps) => {
const { t } = useTranslation()
const nodes = useNodes()
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 notHasValue = comparisonOperatorNotRequireValue(operator)
const isEnvVar = isENV(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(() => {
if (notHasValue)
return ''
@ -67,7 +76,7 @@ const ConditionValue = ({
return (
<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' />}
{isChatVar && <BubbleX className='w-3.5 h-3.5 text-util-colors-teal-teal-700' />}
@ -75,6 +84,7 @@ const ConditionValue = ({
className={cn(
'shrink-0 ml-0.5 truncate text-xs font-medium text-text-accent',
!notHasValue && 'max-w-[70px]',
isException && 'text-text-warning',
)}
title={variableName}
>

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

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

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

@ -270,17 +270,15 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
/>
</div>
<Split />
<div className='px-4 pt-4 pb-2'>
<OutputVars>
<>
<VarItem
name='text'
type='string'
description={t(`${i18nPrefix}.outputVars.output`)}
/>
</>
</OutputVars>
</div>
<OutputVars>
<>
<VarItem
name='text'
type='string'
description={t(`${i18nPrefix}.outputVars.output`)}
/>
</>
</OutputVars>
{isShowSingleRun && (
<BeforeRunForm
nodeName={inputs.title}

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

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

@ -14,6 +14,7 @@ import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/befo
import ResultPanel from '@/app/components/workflow/run/result-panel'
import Split from '@/app/components/workflow/nodes/_base/components/split'
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'
@ -55,8 +56,8 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
const model = inputs.model
return (
<div className='mt-2'>
<div className='px-4 pb-4 space-y-4'>
<div className='pt-2'>
<div className='px-4 space-y-4'>
<Field
title={t(`${i18nPrefix}.model`)}
>
@ -107,27 +108,27 @@ const Panel: FC<NodePanelProps<QuestionClassifierNodeType>> = ({
readonly={readOnly}
/>
</Field>
<Field
title={t(`${i18nPrefix}.advancedSetting`)}
supportFold
>
<AdvancedSetting
hideMemorySetting={!isChatMode}
instruction={inputs.instruction}
onInstructionChange={handleInstructionChange}
memory={inputs.memory}
onMemoryChange={handleMemoryChange}
readonly={readOnly}
isChatApp={isChatMode}
isChatModel={isChatModel}
hasSetBlockStatus={hasSetBlockStatus}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</Field>
<Split />
</div>
<FieldCollapse
title={t(`${i18nPrefix}.advancedSetting`)}
>
<AdvancedSetting
hideMemorySetting={!isChatMode}
instruction={inputs.instruction}
onInstructionChange={handleInstructionChange}
memory={inputs.memory}
onMemoryChange={handleMemoryChange}
readonly={readOnly}
isChatApp={isChatMode}
isChatModel={isChatModel}
hasSetBlockStatus={hasSetBlockStatus}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
/>
</FieldCollapse>
<Split />
<div className='px-4 pt-4 pb-2'>
<div>
<OutputVars>
<>
<VarItem

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

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

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

@ -17,6 +17,7 @@ type NodeVariableItemProps = {
writeMode?: string
showBorder?: boolean
className?: string
isException?: boolean
}
const i18nPrefix = 'workflow.nodes.assigner'
@ -29,6 +30,7 @@ const NodeVariableItem = ({
writeMode,
showBorder,
className,
isException,
}: NodeVariableItemProps) => {
const { t } = useTranslation()
return (
@ -50,14 +52,14 @@ const NodeVariableItem = ({
</div>
)}
<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' />}
{!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
&& <div className='flex items-center w-full gap-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' />
<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>
{writeMode && <Badge className='shrink-0' text={t(`${i18nPrefix}.operations.${writeMode}`)} />}
</div>

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

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

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

@ -19,6 +19,7 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/
import Button from '@/app/components/base/button'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { IterationDurationMap, NodeTracing } from '@/types/workflow'
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
type Props = {
className?: string
@ -128,6 +129,9 @@ const NodePanel: FC<Props> = ({
{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')} />
)}
{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' && (
<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>
@ -165,12 +169,24 @@ const NodePanel: FC<Props> = ({
<Split className='mt-2' />
</div>
)}
<div className={cn('px-[10px]', hideInfo && '!px-2 !py-0.5')}>
{nodeInfo.status === 'stopped' && (
<div className={cn('mb-1', hideInfo && '!px-2 !py-0.5')}>
{(nodeInfo.status === 'stopped') && (
<StatusContainer status='stopped'>
{t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })}
</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' && (
<StatusContainer status='failed'>
{nodeInfo.error}
@ -207,6 +223,7 @@ const NodePanel: FC<Props> = ({
language={CodeLanguage.json}
value={nodeInfo.outputs}
isJSONStringifyBeauty
tip={<ErrorHandleTip type={nodeInfo.execution_metadata?.error_strategy} />}
/>
</div>
)}

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

@ -14,10 +14,12 @@ const StatusContainer: FC<Props> = ({
return (
<div
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 === '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 === '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',
)}
>

@ -10,6 +10,7 @@ type ResultProps = {
time?: number
tokens?: number
error?: string
exceptionCounts?: number
}
const StatusPanel: FC<ResultProps> = ({
@ -17,18 +18,23 @@ const StatusPanel: FC<ResultProps> = ({
time,
tokens,
error,
exceptionCounts,
}) => {
const { t } = useTranslation()
return (
<StatusContainer status={status}>
<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={cn(
'flex items-center gap-1 system-xs-semibold-uppercase',
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 === 'stopped' && 'text-util-colors-warning-warning-600',
status === 'running' && 'text-util-colors-blue-light-blue-light-600',
@ -46,6 +52,18 @@ const StatusPanel: FC<ResultProps> = ({
<span>SUCCESS</span>
</>
)}
{status === 'partial-succeeded' && (
<>
<Indicator color={'green'} />
<span>PARTIAL SUCCESS</span>
</>
)}
{status === 'exception' && (
<>
<Indicator color={'yellow'} />
<span>EXCEPTION</span>
</>
)}
{status === 'failed' && (
<>
<Indicator color={'red'} />
@ -87,8 +105,45 @@ const StatusPanel: FC<ResultProps> = ({
<>
<div className='my-2 h-[0.5px] bg-divider-subtle'/>
<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>
)
}

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

@ -19,7 +19,11 @@ import type {
ToolWithProvider,
ValueSelector,
} from './types'
import { BlockEnum, ErrorHandleMode } from './types'
import {
BlockEnum,
ErrorHandleMode,
NodeRunningStatus,
} from './types'
import {
CUSTOM_NODE,
ITERATION_CHILDREN_Z_INDEX,
@ -761,3 +765,34 @@ export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: str
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
}

@ -102,6 +102,8 @@ const translation = {
addParallelNode: 'Add Parallel Node',
parallel: 'PARALLEL',
branch: 'BRANCH',
onFailure: 'On Failure',
addFailureBranch: 'Add Fail Branch',
},
env: {
envPanelTitle: 'Environment Variables',
@ -302,6 +304,31 @@ const translation = {
tip: 'Chat memory',
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: {
required: 'required',

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

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

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

Loading…
Cancel
Save