Feature/newnew workflow loop node (#14863)

Co-authored-by: arkunzz <4873204@qq.com>
This commit is contained in:
Wood
2025-03-05 17:41:15 +08:00
committed by GitHub
parent da91217bc9
commit 2c17bb2c36
131 changed files with 6031 additions and 159 deletions

View File

@ -37,3 +37,6 @@ NEXT_PUBLIC_TOP_K_MAX_VALUE=10
# The maximum number of tokens for segmentation
NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
# Maximum loop count in the workflow
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=100

View File

@ -46,6 +46,7 @@ export const mockedWorkflowProcess = {
parent_parallel_id: null,
parent_parallel_start_node_id: null,
iteration_id: null,
loop_id: null,
},
{
extras: {},
@ -107,6 +108,7 @@ export const mockedWorkflowProcess = {
parent_parallel_id: null,
parent_parallel_start_node_id: null,
iteration_id: null,
loop_id: null,
},
{
extras: {},

View File

@ -305,7 +305,7 @@ export const useChat = (
else
ttsUrl = `/apps/${params.appId}/text-to-audio`
}
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {})
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { })
ssePost(
url,
{
@ -537,6 +537,9 @@ export const useChat = (
if (nodeStartedData.iteration_id)
return
if (data.loop_id)
return
responseItem.workflowProcess!.tracing!.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
@ -552,6 +555,9 @@ export const useChat = (
if (nodeFinishedData.iteration_id)
return
if (data.loop_id)
return
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.node_id === nodeFinishedData.node_id
@ -576,6 +582,35 @@ export const useChat = (
onTTSEnd: (messageId: string, audio: string) => {
player.playAudioWithAudio(audio, false)
},
onLoopStart: ({ data: loopStartedData }) => {
responseItem.workflowProcess!.tracing!.push({
...loopStartedData,
status: WorkflowRunningStatus.Running,
} as any)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
onLoopFinish: ({ data: loopFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
tracing[loopIndex] = {
...tracing[loopIndex],
...loopFinishedData,
status: WorkflowRunningStatus.Succeeded,
} as any
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
})
return true
}, [

View File

@ -0,0 +1,66 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "40",
"height": "40",
"viewBox": "0 0 40 40",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"filter": "url(#filter0_dd_10886_10012)",
"style": "transform: scale(2.5) translate(-12px, -8px)"
},
"children": [
{
"type": "element",
"name": "rect",
"attributes": {
"x": "8",
"y": "5",
"width": "24",
"height": "24",
"rx": "8",
"fill": "#06AED4"
},
"children": []
},
{
"type": "element",
"name": "rect",
"attributes": {
"x": "8.25",
"y": "5.25",
"width": "23.5",
"height": "23.5",
"rx": "7.75",
"stroke": "#101828",
"stroke-opacity": "0.04",
"stroke-width": "0.5"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M13.0293 14.3451C14.5076 12.885 16.9007 12.885 18.3791 14.3451L19.9999 15.9459L21.6208 14.3451C23.0992 12.885 25.4922 12.885 26.9706 14.3451C28.4541 15.8103 28.4541 18.1897 26.9707 19.6549C25.4923 21.115 23.0992 21.115 21.6208 19.655L19.9999 18.0541L18.3791 19.655C16.9007 21.115 14.5076 21.115 13.0293 19.655C11.5457 18.1897 11.5457 15.8103 13.0293 14.3451ZM18.9326 17L17.325 15.4123C16.4309 14.5292 14.9774 14.5292 14.0833 15.4123C13.1944 16.2903 13.1944 17.7097 14.0833 18.5877C14.9774 19.4708 16.4309 19.4707 17.325 18.5877C17.325 18.5877 17.325 18.5877 17.325 18.5877L18.9326 17ZM21.0673 17L22.6748 18.5877C22.6748 18.5877 22.6748 18.5877 22.6748 18.5877C23.569 19.4707 25.0224 19.4707 25.9166 18.5877C26.8055 17.7098 26.8055 16.2902 25.9166 15.4123C25.0224 14.5292 23.569 14.5292 22.6748 15.4123C22.6748 15.4123 22.6748 15.4123 22.6748 15.4123L21.0673 17Z",
"fill": "white"
},
"children": []
}
]
}
]
},
"name": "Loop"
}

View File

@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './Loop.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'Loop'
export default Icon

View File

@ -0,0 +1,36 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "12",
"height": "12",
"viewBox": "0 0 12 12",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "icons/block-start"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector",
"d": "M6.8498 1.72732C6.3379 1.3754 5.6621 1.3754 5.1502 1.72732L2.1502 3.78982C1.74317 4.06965 1.5 4.53193 1.5 5.02588V8.99983C1.5 9.82828 2.17158 10.4998 3 10.4998H4.25C4.52614 10.4998 4.75 10.276 4.75 9.99983V8.24983C4.75 7.55948 5.30965 6.99983 6 6.99983C6.69035 6.99983 7.25 7.55948 7.25 8.24983V9.99983C7.25 10.276 7.47385 10.4998 7.75 10.4998H9C9.82845 10.4998 10.5 9.82828 10.5 8.99983V5.02588C10.5 4.53193 10.2568 4.06965 9.8498 3.78982L6.8498 1.72732Z",
"fill": "red"
},
"children": []
}
]
}
]
},
"name": "LoopStart"
}

View File

@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './LoopStart.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'LoopStart'
export default Icon

View File

@ -9,6 +9,8 @@ export { default as Http } from './Http'
export { default as IfElse } from './IfElse'
export { default as IterationStart } from './IterationStart'
export { default as Iteration } from './Iteration'
export { default as LoopStart } from './LoopStart'
export { default as Loop } from './Loop'
export { default as Jinja } from './Jinja'
export { default as KnowledgeRetrieval } from './KnowledgeRetrieval'
export { default as ListFilter } from './ListFilter'

View File

@ -240,10 +240,42 @@ const Result: FC<IResultProps> = ({
} as any
}))
},
onLoopStart: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
expand: true,
} as any)
}))
},
onLoopNext: () => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const loops = draft.tracing.find(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
loops?.details!.push([])
}))
},
onLoopFinish: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
draft.tracing[loopsIndex] = {
...data,
expand: !!data.error,
} as any
}))
},
onNodeStarted: ({ data }) => {
if (data.iteration_id)
return
if (data.loop_id)
return
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
@ -257,6 +289,9 @@ const Result: FC<IResultProps> = ({
if (data.iteration_id)
return
if (data.loop_id)
return
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id
&& (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id))

View File

@ -15,6 +15,7 @@ import {
KnowledgeRetrieval,
ListFilter,
Llm,
Loop,
ParameterExtractor,
QuestionClassifier,
TemplatingTransform,
@ -51,6 +52,8 @@ const getIcon = (type: BlockEnum, className: string) => {
[BlockEnum.Tool]: <VariableX className={className} />,
[BlockEnum.IterationStart]: <VariableX className={className} />,
[BlockEnum.Iteration]: <Iteration className={className} />,
[BlockEnum.LoopStart]: <VariableX className={className} />,
[BlockEnum.Loop]: <Loop className={className} />,
[BlockEnum.ParameterExtractor]: <ParameterExtractor className={className} />,
[BlockEnum.DocExtractor]: <DocsExtractor className={className} />,
[BlockEnum.ListFilter]: <ListFilter className={className} />,
@ -64,6 +67,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[BlockEnum.End]: 'bg-util-colors-warning-warning-500',
[BlockEnum.IfElse]: 'bg-util-colors-cyan-cyan-500',
[BlockEnum.Iteration]: 'bg-util-colors-cyan-cyan-500',
[BlockEnum.Loop]: 'bg-util-colors-cyan-cyan-500',
[BlockEnum.HttpRequest]: 'bg-util-colors-violet-violet-500',
[BlockEnum.Answer]: 'bg-util-colors-warning-warning-500',
[BlockEnum.KnowledgeRetrieval]: 'bg-util-colors-green-green-500',

View File

@ -44,6 +44,11 @@ export const BLOCKS: Block[] = [
type: BlockEnum.Iteration,
title: 'Iteration',
},
{
classification: BlockClassificationEnum.Logic,
type: BlockEnum.Loop,
title: 'Loop',
},
{
classification: BlockClassificationEnum.Transform,
type: BlockEnum.Code,

View File

@ -14,7 +14,7 @@ import {
} from './store'
import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks'
import { CUSTOM_NODE } from './constants'
import { getIterationStartNode } from './utils'
import { getIterationStartNode, getLoopStartNode } from './utils'
import CustomNode from './nodes'
import CustomNoteNode from './note-node'
import { CUSTOM_NOTE_NODE } from './note-node/constants'
@ -56,6 +56,9 @@ const CandidateNode = () => {
})
if (candidateNode.data.type === BlockEnum.Iteration)
draft.push(getIterationStartNode(candidateNode.id))
if (candidateNode.data.type === BlockEnum.Loop)
draft.push(getLoopStartNode(candidateNode.id))
})
setNodes(newNodes)
if (candidateNode.type === CUSTOM_NOTE_NODE)

View File

@ -15,10 +15,12 @@ import VariableAssignerDefault from './nodes/variable-assigner/default'
import AssignerDefault from './nodes/assigner/default'
import EndNodeDefault from './nodes/end/default'
import IterationDefault from './nodes/iteration/default'
import LoopDefault from './nodes/loop/default'
import DocExtractorDefault from './nodes/document-extractor/default'
import ListFilterDefault from './nodes/list-operator/default'
import IterationStartDefault from './nodes/iteration-start/default'
import AgentDefault from './nodes/agent/default'
import LoopStartDefault from './nodes/loop-start/default'
type NodesExtraData = {
author: string
@ -102,6 +104,24 @@ export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
getAvailableNextNodes: IterationStartDefault.getAvailableNextNodes,
checkValid: IterationStartDefault.checkValid,
},
[BlockEnum.Loop]: {
author: 'AICT-Team',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LoopDefault.getAvailablePrevNodes,
getAvailableNextNodes: LoopDefault.getAvailableNextNodes,
checkValid: LoopDefault.checkValid,
},
[BlockEnum.LoopStart]: {
author: 'AICT-Team',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: LoopStartDefault.getAvailablePrevNodes,
getAvailableNextNodes: LoopStartDefault.getAvailableNextNodes,
checkValid: LoopStartDefault.checkValid,
},
[BlockEnum.Code]: {
author: 'Dify',
about: '',
@ -265,6 +285,18 @@ export const NODES_INITIAL_DATA = {
desc: '',
...IterationStartDefault.defaultValue,
},
[BlockEnum.Loop]: {
type: BlockEnum.Loop,
title: '',
desc: '',
...LoopDefault.defaultValue,
},
[BlockEnum.LoopStart]: {
type: BlockEnum.LoopStart,
title: '',
desc: '',
...LoopStartDefault.defaultValue,
},
[BlockEnum.Code]: {
type: BlockEnum.Code,
title: '',
@ -355,6 +387,7 @@ export const NODES_INITIAL_DATA = {
export const MAX_ITERATION_PARALLEL_NUM = 10
export const MIN_ITERATION_PARALLEL_NUM = 1
export const DEFAULT_ITER_TIMES = 1
export const DEFAULT_LOOP_TIMES = 1
export const NODE_WIDTH = 240
export const X_OFFSET = 60
export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET
@ -373,6 +406,16 @@ export const ITERATION_PADDING = {
bottom: 20,
left: 16,
}
export const LOOP_NODE_Z_INDEX = 1
export const LOOP_CHILDREN_Z_INDEX = 1002
export const LOOP_PADDING = {
top: 65,
right: 16,
bottom: 20,
left: 16,
}
export const PARALLEL_LIMIT = 10
export const PARALLEL_DEPTH_LIMIT = 3
@ -399,7 +442,7 @@ export const RETRIEVAL_OUTPUT_STRUCT = `{
export const SUPPORT_OUTPUT_VARS_NODE = [
BlockEnum.Start, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform,
BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier,
BlockEnum.ParameterExtractor, BlockEnum.Iteration,
BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop,
BlockEnum.DocExtractor, BlockEnum.ListFilter,
BlockEnum.Agent,
]

View File

@ -23,7 +23,7 @@ import type {
} from './types'
import { NodeRunningStatus } from './types'
import { getEdgeColor } from './utils'
import { ITERATION_CHILDREN_Z_INDEX } from './constants'
import { ITERATION_CHILDREN_Z_INDEX, LOOP_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'
@ -56,8 +56,8 @@ const CustomEdge = ({
})
const [open, setOpen] = useState(false)
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 { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop)
const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration, (data as Edge['data'])?.isInLoop)
const {
_sourceRunningStatus,
_targetRunningStatus,
@ -144,6 +144,7 @@ const CustomEdge = ({
data?._hovering ? 'block' : 'hidden',
open && '!block',
data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
)}
style={{
position: 'absolute',

View File

@ -21,6 +21,14 @@ export const useHelpline = () => {
showVerticalHelpLineNodes: [],
}
}
if (node.data.isInLoop) {
return {
showHorizontalHelpLineNodes: [],
showVerticalHelpLineNodes: [],
}
}
const showHorizontalHelpLineNodes = nodes.filter((n) => {
if (n.id === node.id)
return false
@ -28,6 +36,9 @@ export const useHelpline = () => {
if (n.data.isInIteration)
return false
if (n.data.isInLoop)
return false
const nY = Math.ceil(n.position.y)
const nodeY = Math.ceil(node.position.y)
@ -67,6 +78,8 @@ export const useHelpline = () => {
return false
if (n.data.isInIteration)
return false
if (n.data.isInLoop)
return false
const nX = Math.ceil(n.position.x)
const nodeX = Math.ceil(node.position.x)

View File

@ -31,7 +31,7 @@ export const useNodesExtraData = () => {
}), [t, isChatMode])
}
export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean) => {
export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean, isInLoop?: boolean) => {
const nodesExtraData = useNodesExtraData()
const availablePrevBlocks = useMemo(() => {
if (!nodeType)
@ -48,15 +48,23 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, isInIteration?: boolean
return useMemo(() => {
return {
availablePrevBlocks: availablePrevBlocks.filter((nType) => {
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.End))
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
return true
}),
availableNextBlocks: availableNextBlocks.filter((nType) => {
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.End))
if (isInIteration && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
if (isInLoop && (nType === BlockEnum.Iteration || nType === BlockEnum.Loop || nType === BlockEnum.End))
return false
return true
}),
}
}, [isInIteration, availablePrevBlocks, availableNextBlocks])
}, [isInIteration, availablePrevBlocks, availableNextBlocks, isInLoop])
}

View File

@ -29,6 +29,8 @@ import {
CUSTOM_EDGE,
ITERATION_CHILDREN_Z_INDEX,
ITERATION_PADDING,
LOOP_CHILDREN_Z_INDEX,
LOOP_PADDING,
NODES_INITIAL_DATA,
NODE_WIDTH_X_OFFSET,
X_OFFSET,
@ -42,9 +44,12 @@ import {
} from '../utils'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useHelpline } from './use-helpline'
@ -73,6 +78,10 @@ export const useNodesInteractions = () => {
handleNodeIterationChildDrag,
handleNodeIterationChildrenCopy,
} = useNodeIterationInteractions()
const {
handleNodeLoopChildDrag,
handleNodeLoopChildrenCopy,
} = useNodeLoopInteractions()
const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number })
const { saveStateToHistory, undo, redo } = useWorkflowHistory()
@ -86,6 +95,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_NOTE_NODE)
return
if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE)
return
dragNodeStartPosition.current = { x: node.position.x, y: node.position.y }
}, [workflowStore, getNodesReadOnly])
@ -96,6 +108,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_ITERATION_START_NODE)
return
if (node.type === CUSTOM_LOOP_START_NODE)
return
const {
getNodes,
setNodes,
@ -105,6 +120,7 @@ export const useNodesInteractions = () => {
const nodes = getNodes()
const { restrictPosition } = handleNodeIterationChildDrag(node)
const { restrictPosition: restrictLoopPosition } = handleNodeLoopChildDrag(node)
const {
showHorizontalHelpLineNodes,
@ -120,6 +136,8 @@ export const useNodesInteractions = () => {
currentNode.position.x = showVerticalHelpLineNodes[0].position.x
else if (restrictPosition.x !== undefined)
currentNode.position.x = restrictPosition.x
else if (restrictLoopPosition.x !== undefined)
currentNode.position.x = restrictLoopPosition.x
else
currentNode.position.x = node.position.x
@ -127,12 +145,13 @@ export const useNodesInteractions = () => {
currentNode.position.y = showHorizontalHelpLineNodes[0].position.y
else if (restrictPosition.y !== undefined)
currentNode.position.y = restrictPosition.y
else if (restrictLoopPosition.y !== undefined)
currentNode.position.y = restrictLoopPosition.y
else
currentNode.position.y = node.position.y
})
setNodes(newNodes)
}, [store, getNodesReadOnly, handleSetHelpline, handleNodeIterationChildDrag])
}, [getNodesReadOnly, store, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline])
const handleNodeDragStop = useCallback<NodeDragHandler>((_, node) => {
const {
@ -163,6 +182,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return
if (node.type === CUSTOM_LOOP_START_NODE || node.type === CUSTOM_NOTE_NODE)
return
const {
getNodes,
setNodes,
@ -237,6 +259,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE)
return
const {
setEnteringNodePayload,
} = workflowStore.getState()
@ -311,6 +336,8 @@ export const useNodesInteractions = () => {
const handleNodeClick = useCallback<NodeMouseHandler>((_, node) => {
if (node.type === CUSTOM_ITERATION_START_NODE)
return
if (node.type === CUSTOM_LOOP_START_NODE)
return
handleNodeSelect(node.id)
}, [handleNodeSelect])
@ -344,6 +371,10 @@ export const useNodesInteractions = () => {
if (edges.find(edge => edge.source === source && edge.sourceHandle === sourceHandle && edge.target === target && edge.targetHandle === targetHandle))
return
const parendNode = nodes.find(node => node.id === targetNode?.parentId)
const isInIteration = parendNode && parendNode.data.type === BlockEnum.Iteration
const isInLoop = !!parendNode && parendNode.data.type === BlockEnum.Loop
const newEdge = {
id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
type: CUSTOM_EDGE,
@ -354,10 +385,12 @@ export const useNodesInteractions = () => {
data: {
sourceType: nodes.find(node => node.id === source)!.data.type,
targetType: nodes.find(node => node.id === target)!.data.type,
isInIteration: !!targetNode?.parentId,
iteration_id: targetNode?.parentId,
isInIteration,
iteration_id: isInIteration ? targetNode?.parentId : undefined,
isInLoop,
loop_id: isInLoop ? targetNode?.parentId : undefined,
},
zIndex: targetNode?.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: targetNode?.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
@ -554,6 +587,45 @@ export const useNodesInteractions = () => {
}
}
}
if (currentNode.data.type === BlockEnum.Loop) {
const loopChildren = nodes.filter(node => node.parentId === currentNode.id)
if (loopChildren.length) {
if (currentNode.data._isBundled) {
loopChildren.forEach((child) => {
handleNodeDelete(child.id)
})
return handleNodeDelete(nodeId)
}
else {
if (loopChildren.length === 1) {
handleNodeDelete(loopChildren[0].id)
handleNodeDelete(nodeId)
return
}
const { setShowConfirm, showConfirm } = workflowStore.getState()
if (!showConfirm) {
setShowConfirm({
title: t('workflow.nodes.loop.deleteTitle'),
desc: t('workflow.nodes.loop.deleteDesc') || '',
onConfirm: () => {
loopChildren.forEach((child) => {
handleNodeDelete(child.id)
})
handleNodeDelete(nodeId)
handleSyncWorkflowDraft()
setShowConfirm(undefined)
},
})
return
}
}
}
}
const connectedEdges = getConnectedEdges([{ id: nodeId } as Node], edges)
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(connectedEdges.map(edge => ({ type: 'remove', edge })), nodes)
const newNodes = produce(nodes, (draft: Node[]) => {
@ -612,6 +684,7 @@ export const useNodesInteractions = () => {
const {
newNode,
newIterationStartNode,
newLoopStartNode,
} = generateNewNode({
data: {
...NODES_INITIAL_DATA[nodeType],
@ -640,13 +713,28 @@ export const useNodesInteractions = () => {
}
newNode.parentId = prevNode.parentId
newNode.extent = prevNode.extent
const parentNode = nodes.find(node => node.id === prevNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
if (prevNode.parentId) {
newNode.data.isInIteration = true
newNode.data.iteration_id = prevNode.parentId
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
if (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner) {
const parentIterNodeIndex = nodes.findIndex(node => node.id === prevNode.parentId)
const iterNodeData: IterationNodeType = nodes[parentIterNodeIndex].data
newNode.data.isInIteration = isInIteration
newNode.data.isInLoop = isInLoop
if (isInIteration) {
newNode.data.iteration_id = parentNode.id
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
if (isInLoop) {
newNode.data.loop_id = parentNode.id
newNode.zIndex = LOOP_CHILDREN_Z_INDEX
}
if (isInIteration && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner)) {
const iterNodeData: IterationNodeType = parentNode.data
iterNodeData._isShowTips = true
}
if (isInLoop && (newNode.data.type === BlockEnum.Answer || newNode.data.type === BlockEnum.Tool || newNode.data.type === BlockEnum.Assigner)) {
const iterNodeData: IterationNodeType = parentNode.data
iterNodeData._isShowTips = true
}
}
@ -661,11 +749,13 @@ export const useNodesInteractions = () => {
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
isInIteration: !!prevNode.parentId,
iteration_id: prevNode.parentId,
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: prevNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: prevNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
@ -686,10 +776,17 @@ export const useNodesInteractions = () => {
if (node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)
if (node.data.type === BlockEnum.Loop && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
@ -736,10 +833,22 @@ export const useNodesInteractions = () => {
}
newNode.parentId = nextNode.parentId
newNode.extent = nextNode.extent
if (nextNode.parentId) {
newNode.data.isInIteration = true
newNode.data.iteration_id = nextNode.parentId
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
const parentNode = nodes.find(node => node.id === nextNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
if (parentNode && nextNode.parentId) {
newNode.data.isInIteration = isInIteration
newNode.data.isInLoop = isInLoop
if (isInIteration) {
newNode.data.iteration_id = parentNode.id
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
if (isInLoop) {
newNode.data.loop_id = parentNode.id
newNode.zIndex = LOOP_CHILDREN_Z_INDEX
}
}
let newEdge
@ -755,11 +864,13 @@ export const useNodesInteractions = () => {
data: {
sourceType: newNode.data.type,
targetType: nextNode.data.type,
isInIteration: !!nextNode.parentId,
iteration_id: nextNode.parentId,
isInIteration,
isInLoop,
iteration_id: isInIteration ? nextNode.parentId : undefined,
loop_id: isInLoop ? nextNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: nextNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
}
@ -796,10 +907,20 @@ export const useNodesInteractions = () => {
node.data.start_node_id = newNode.id
node.data.startNodeType = newNode.data.type
}
if (node.data.type === BlockEnum.Loop && nextNode.parentId === node.id)
node.data._children?.push(newNode.id)
if (node.data.type === BlockEnum.Loop && node.data.start_node_id === nextNodeId) {
node.data.start_node_id = newNode.id
node.data.startNodeType = newNode.data.type
}
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
if (newEdge) {
const newEdges = produce(edges, (draft) => {
@ -840,10 +961,22 @@ export const useNodesInteractions = () => {
}
newNode.parentId = prevNode.parentId
newNode.extent = prevNode.extent
if (prevNode.parentId) {
newNode.data.isInIteration = true
newNode.data.iteration_id = prevNode.parentId
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
const parentNode = nodes.find(node => node.id === prevNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
if (parentNode && prevNode.parentId) {
newNode.data.isInIteration = isInIteration
newNode.data.isInLoop = isInLoop
if (isInIteration) {
newNode.data.iteration_id = parentNode.id
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
if (isInLoop) {
newNode.data.loop_id = parentNode.id
newNode.zIndex = LOOP_CHILDREN_Z_INDEX
}
}
const currentEdgeIndex = edges.findIndex(edge => edge.source === prevNodeId && edge.target === nextNodeId)
@ -857,13 +990,20 @@ export const useNodesInteractions = () => {
data: {
sourceType: prevNode.data.type,
targetType: newNode.data.type,
isInIteration: !!prevNode.parentId,
iteration_id: prevNode.parentId,
isInIteration,
isInLoop,
iteration_id: isInIteration ? prevNode.parentId : undefined,
loop_id: isInLoop ? prevNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: prevNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: prevNode.parentId ? (isInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
let newNextEdge: Edge | null = null
const nextNodeParentNode = nodes.find(node => node.id === nextNode.parentId) || null
const isNextNodeInIteration = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Iteration
const isNextNodeInLoop = !!nextNodeParentNode && nextNodeParentNode.data.type === BlockEnum.Loop
if (nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier) {
newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
@ -875,11 +1015,13 @@ export const useNodesInteractions = () => {
data: {
sourceType: newNode.data.type,
targetType: nextNode.data.type,
isInIteration: !!nextNode.parentId,
iteration_id: nextNode.parentId,
isInIteration: isNextNodeInIteration,
isInLoop: isNextNodeInLoop,
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: nextNode.parentId ? ITERATION_CHILDREN_Z_INDEX : 0,
zIndex: nextNode.parentId ? (isNextNodeInIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX) : 0,
}
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
@ -908,10 +1050,14 @@ export const useNodesInteractions = () => {
if (node.data.type === BlockEnum.Iteration && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)
if (node.data.type === BlockEnum.Loop && prevNode.parentId === node.id)
node.data._children?.push(newNode.id)
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
setNodes(newNodes)
if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
@ -969,6 +1115,7 @@ export const useNodesInteractions = () => {
const {
newNode: newCurrentNode,
newIterationStartNode,
newLoopStartNode,
} = generateNewNode({
data: {
...NODES_INITIAL_DATA[nodeType],
@ -978,7 +1125,9 @@ export const useNodesInteractions = () => {
_connectedTargetHandleIds: [],
selected: currentNode.data.selected,
isInIteration: currentNode.data.isInIteration,
isInLoop: currentNode.data.isInLoop,
iteration_id: currentNode.data.iteration_id,
loop_id: currentNode.data.loop_id,
},
position: {
x: currentNode.position.x,
@ -1010,6 +1159,8 @@ export const useNodesInteractions = () => {
draft.splice(index, 1, newCurrentNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
if (newLoopStartNode)
draft.push(newLoopStartNode)
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
@ -1058,6 +1209,9 @@ export const useNodesInteractions = () => {
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE)
return
e.preventDefault()
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
@ -1085,13 +1239,15 @@ export const useNodesInteractions = () => {
if (nodeId) {
// If nodeId is provided, copy that specific node
const nodeToCopy = nodes.find(node => node.id === nodeId && node.data.type !== BlockEnum.Start && node.type !== CUSTOM_ITERATION_START_NODE)
const nodeToCopy = nodes.find(node => node.id === nodeId && node.data.type !== BlockEnum.Start
&& node.type !== CUSTOM_ITERATION_START_NODE && node.type !== CUSTOM_LOOP_START_NODE)
if (nodeToCopy)
setClipboardElements([nodeToCopy])
}
else {
// If no nodeId is provided, fall back to the current behavior
const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && !node.data.isInIteration)
const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start
&& !node.data.isInIteration && !node.data.isInLoop)
if (bundledNodes.length) {
setClipboardElements(bundledNodes)
@ -1138,6 +1294,7 @@ export const useNodesInteractions = () => {
const {
newNode,
newIterationStartNode,
newLoopStartNode,
} = generateNewNode({
type: nodeToPaste.type,
data: {
@ -1176,6 +1333,17 @@ export const useNodesInteractions = () => {
newChildren.push(newIterationStartNode!)
}
if (nodeToPaste.data.type === BlockEnum.Loop) {
newLoopStartNode!.parentId = newNode.id;
(newNode.data as LoopNodeType).start_node_id = newLoopStartNode!.id
newChildren = handleNodeLoopChildrenCopy(nodeToPaste.id, newNode.id)
newChildren.forEach((child) => {
newNode.data._children?.push(child.id)
})
newChildren.push(newLoopStartNode!)
}
nodesToPaste.push(newNode)
if (newChildren.length)
@ -1206,7 +1374,7 @@ export const useNodesInteractions = () => {
saveStateToHistory(WorkflowHistoryEvent.NodePaste)
handleSyncWorkflowDraft()
}
}, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy])
}, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy, handleNodeLoopChildrenCopy])
const handleNodesDuplicate = useCallback((nodeId?: string) => {
if (getNodesReadOnly())
@ -1278,9 +1446,12 @@ export const useNodesInteractions = () => {
})
if (rightNode! && bottomNode!) {
if (width < rightNode!.position.x + rightNode.width! + ITERATION_PADDING.right)
const parentNode = nodes.find(n => n.id === rightNode.parentId)
const paddingMap = parentNode?.data.type === BlockEnum.Iteration ? ITERATION_PADDING : LOOP_PADDING
if (width < rightNode!.position.x + rightNode.width! + paddingMap.right)
return
if (height < bottomNode.position.y + bottomNode.height! + ITERATION_PADDING.bottom)
if (height < bottomNode.position.y + bottomNode.height! + paddingMap.bottom)
return
}
const newNodes = produce(nodes, (draft) => {

View File

@ -6,6 +6,9 @@ export * from './use-workflow-node-finished'
export * from './use-workflow-node-iteration-started'
export * from './use-workflow-node-iteration-next'
export * from './use-workflow-node-iteration-finished'
export * from './use-workflow-node-loop-started'
export * from './use-workflow-node-loop-next'
export * from './use-workflow-node-loop-finished'
export * from './use-workflow-node-retry'
export * from './use-workflow-text-chunk'
export * from './use-workflow-text-replace'

View File

@ -0,0 +1,46 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import produce from 'immer'
import type { LoopFinishedResponse } from '@/types/workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants'
export const useWorkflowNodeLoopFinished = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const handleWorkflowNodeLoopFinished = useCallback((params: LoopFinishedResponse) => {
const { data } = params
const {
workflowRunningData,
setWorkflowRunningData,
setLoopTimes,
} = workflowStore.getState()
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const currentIndex = draft.tracing!.findIndex(item => item.id === data.id)
if (currentIndex > -1) {
draft.tracing![currentIndex] = {
...draft.tracing![currentIndex],
...data,
}
}
}))
setLoopTimes(DEFAULT_LOOP_TIMES)
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)!
currentNode.data._runningStatus = data.status
})
setNodes(newNodes)
}, [workflowStore, store])
return {
handleWorkflowNodeLoopFinished,
}
}

View File

@ -0,0 +1,35 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import produce from 'immer'
import type { LoopNextResponse } from '@/types/workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
export const useWorkflowNodeLoopNext = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const handleWorkflowNodeLoopNext = useCallback((params: LoopNextResponse) => {
const {
loopTimes,
setLoopTimes,
} = workflowStore.getState()
const { data } = params
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)!
currentNode.data._loopIndex = loopTimes
setLoopTimes(loopTimes + 1)
})
setNodes(newNodes)
}, [workflowStore, store])
return {
handleWorkflowNodeLoopNext,
}
}

View File

@ -0,0 +1,85 @@
import { useCallback } from 'react'
import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import produce from 'immer'
import { useWorkflowStore } from '@/app/components/workflow/store'
import type { LoopStartedResponse } from '@/types/workflow'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import { DEFAULT_LOOP_TIMES } from '@/app/components/workflow/constants'
export const useWorkflowNodeLoopStarted = () => {
const store = useStoreApi()
const reactflow = useReactFlow()
const workflowStore = useWorkflowStore()
const handleWorkflowNodeLoopStarted = useCallback((
params: LoopStartedResponse,
containerParams: {
clientWidth: number,
clientHeight: number,
},
) => {
const { data } = params
const {
workflowRunningData,
setWorkflowRunningData,
setLoopTimes,
} = workflowStore.getState()
const {
getNodes,
setNodes,
edges,
setEdges,
transform,
} = store.getState()
const nodes = getNodes()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
}))
setLoopTimes(DEFAULT_LOOP_TIMES)
const {
setViewport,
} = reactflow
const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id)
const currentNode = nodes[currentNodeIndex]
const position = currentNode.position
const zoom = transform[2]
if (!currentNode.parentId) {
setViewport({
x: (containerParams.clientWidth - 400 - currentNode.width! * zoom) / 2 - position.x * zoom,
y: (containerParams.clientHeight - currentNode.height! * zoom) / 2 - position.y * zoom,
zoom: transform[2],
})
}
const newNodes = produce(nodes, (draft) => {
draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running
draft[currentNodeIndex].data._loopLength = data.metadata.loop_length
draft[currentNodeIndex].data._waitingRun = false
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
const incomeEdges = draft.filter(edge => edge.target === data.node_id)
incomeEdges.forEach((edge) => {
edge.data = {
...edge.data,
_sourceRunningStatus: nodes.find(node => node.id === edge.source)!.data._runningStatus,
_targetRunningStatus: NodeRunningStatus.Running,
_waitingRun: false,
}
})
})
setEdges(newEdges)
}, [workflowStore, store, reactflow])
return {
handleWorkflowNodeLoopStarted,
}
}

View File

@ -6,6 +6,9 @@ import {
useWorkflowNodeIterationFinished,
useWorkflowNodeIterationNext,
useWorkflowNodeIterationStarted,
useWorkflowNodeLoopFinished,
useWorkflowNodeLoopNext,
useWorkflowNodeLoopStarted,
useWorkflowNodeRetry,
useWorkflowNodeStarted,
useWorkflowStarted,
@ -22,6 +25,9 @@ export const useWorkflowRunEvent = () => {
const { handleWorkflowNodeIterationStarted } = useWorkflowNodeIterationStarted()
const { handleWorkflowNodeIterationNext } = useWorkflowNodeIterationNext()
const { handleWorkflowNodeIterationFinished } = useWorkflowNodeIterationFinished()
const { handleWorkflowNodeLoopStarted } = useWorkflowNodeLoopStarted()
const { handleWorkflowNodeLoopNext } = useWorkflowNodeLoopNext()
const { handleWorkflowNodeLoopFinished } = useWorkflowNodeLoopFinished()
const { handleWorkflowNodeRetry } = useWorkflowNodeRetry()
const { handleWorkflowTextChunk } = useWorkflowTextChunk()
const { handleWorkflowTextReplace } = useWorkflowTextReplace()
@ -36,6 +42,9 @@ export const useWorkflowRunEvent = () => {
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowTextChunk,
handleWorkflowTextReplace,

View File

@ -36,6 +36,9 @@ export const useWorkflowRun = () => {
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowAgentLog,
handleWorkflowTextChunk,
@ -118,6 +121,9 @@ export const useWorkflowRun = () => {
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onAgentLog,
onError,
@ -162,7 +168,7 @@ export const useWorkflowRun = () => {
else
ttsUrl = `/apps/${params.appId}/text-to-audio`
}
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {})
const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { })
ssePost(
url,
@ -230,6 +236,30 @@ export const useWorkflowRun = () => {
if (onIterationFinish)
onIterationFinish(params)
},
onLoopStart: (params) => {
handleWorkflowNodeLoopStarted(
params,
{
clientWidth,
clientHeight,
},
)
if (onLoopStart)
onLoopStart(params)
},
onLoopNext: (params) => {
handleWorkflowNodeLoopNext(params)
if (onLoopNext)
onLoopNext(params)
},
onLoopFinish: (params) => {
handleWorkflowNodeLoopFinished(params)
if (onLoopFinish)
onLoopFinish(params)
},
onNodeRetry: (params) => {
handleWorkflowNodeRetry(params)
@ -260,7 +290,27 @@ export const useWorkflowRun = () => {
...restCallback,
},
)
}, [store, workflowStore, doSyncWorkflowDraft, handleWorkflowStarted, handleWorkflowFinished, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeRetry, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowAgentLog, pathname])
}, [
store,
workflowStore,
doSyncWorkflowDraft,
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowAgentLog,
pathname],
)
const handleStopRun = useCallback((taskId: string) => {
const appId = useAppStore.getState().appDetail?.id

View File

@ -44,6 +44,7 @@ export const useWorkflowVariables = () => {
parentNode,
valueSelector,
isIterationItem,
isLoopItem,
availableNodes,
isChatMode,
isConstant,
@ -51,6 +52,7 @@ export const useWorkflowVariables = () => {
valueSelector: ValueSelector
parentNode?: Node | null
isIterationItem?: boolean
isLoopItem?: boolean
availableNodes: any[]
isChatMode: boolean
isConstant?: boolean
@ -59,6 +61,7 @@ export const useWorkflowVariables = () => {
parentNode,
valueSelector,
isIterationItem,
isLoopItem,
availableNodes,
isChatMode,
isConstant,

View File

@ -57,6 +57,7 @@ import {
import I18n from '@/context/i18n'
import { CollectionType } from '@/app/components/tools/types'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { useWorkflowConfig } from '@/service/use-workflow'
import { canFindTool } from '@/utils'
@ -89,7 +90,7 @@ export const useWorkflow = () => {
const currentNode = nodes.find(node => node.id === nodeId)
if (currentNode?.parentId)
startNode = nodes.find(node => node.parentId === currentNode.parentId && node.type === CUSTOM_ITERATION_START_NODE)
startNode = nodes.find(node => node.parentId === currentNode.parentId && (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_LOOP_START_NODE))
if (!startNode)
return []
@ -239,6 +240,15 @@ export const useWorkflow = () => {
return nodes.filter(node => node.parentId === nodeId)
}, [store])
const getLoopNodeChildren = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
return nodes.filter(node => node.parentId === nodeId)
}, [store])
const isFromStartNode = useCallback((nodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
@ -280,7 +290,7 @@ export const useWorkflow = () => {
setNodes(newNodes)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store])
const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => {
@ -425,6 +435,7 @@ export const useWorkflow = () => {
getNode,
getBeforeNodeById,
getIterationNodeChildren,
getLoopNodeChildren,
}
}
@ -520,7 +531,7 @@ export const useWorkflowInit = () => {
useEffect(() => {
handleGetInitialWorkflowData()
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleFetchPreloadData = useCallback(async () => {
@ -537,7 +548,7 @@ export const useWorkflowInit = () => {
workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at)
}
catch (e) {
console.error(e)
}
}, [workflowStore, appDetail])
@ -638,3 +649,26 @@ export const useIsNodeInIteration = (iterationId: string) => {
isNodeInIteration,
}
}
export const useIsNodeInLoop = (loopId: string) => {
const store = useStoreApi()
const isNodeInLoop = useCallback((nodeId: string) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const node = nodes.find(node => node.id === nodeId)
if (!node)
return false
if (node.parentId === loopId)
return true
return false
}, [loopId, store])
return {
isNodeInLoop,
}
}

View File

@ -59,6 +59,8 @@ import CustomNoteNode from './note-node'
import { CUSTOM_NOTE_NODE } from './note-node/constants'
import CustomIterationStartNode from './nodes/iteration-start'
import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-start/constants'
import CustomLoopStartNode from './nodes/loop-start'
import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants'
import Operator from './operator'
import CustomEdge from './custom-edge'
import CustomConnectionLine from './custom-connection-line'
@ -102,6 +104,7 @@ const nodeTypes = {
[CUSTOM_NODE]: CustomNode,
[CUSTOM_NOTE_NODE]: CustomNoteNode,
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
[CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
}
const edgeTypes = {
[CUSTOM_EDGE]: CustomEdge,
@ -353,6 +356,7 @@ const Workflow: FC<WorkflowProps> = memo(({
onSelectionDrag={handleSelectionDrag}
onPaneContextMenu={handlePaneContextMenu}
connectionLineComponent={CustomConnectionLine}
// TODO: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same?
connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}
defaultViewport={viewport}
multiSelectionKeyCode={null}

View File

@ -38,7 +38,7 @@ const Add = ({
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration)
const { availableNextBlocks } = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop)
const { checkParallelLimit } = useWorkflow()
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {

View File

@ -36,7 +36,7 @@ const ChangeItem = ({
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(data.type, data.isInIteration)
} = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)

View File

@ -47,7 +47,7 @@ export const NodeTargetHandle = memo(({
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const connected = data._connectedTargetHandleIds?.includes(handleId)
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration)
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
const isConnectable = !!availablePrevBlocks.length
const handleOpenChange = useCallback((v: boolean) => {
@ -129,7 +129,7 @@ export const NodeSourceHandle = memo(({
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration)
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
const isConnectable = !!availableNextBlocks.length
const isChatMode = useIsChatMode()
const { checkParallelLimit } = useWorkflow()

View File

@ -30,7 +30,7 @@ const ChangeBlock = ({
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration)
} = useAvailableBlocks(nodeData.type, nodeData.isInIteration, nodeData.isInLoop)
const availableNodes = useMemo(() => {
if (availablePrevBlocks.length && availableNextBlocks.length)

View File

@ -79,7 +79,7 @@ const PanelOperatorPopup = ({
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
}, [data, nodesExtraData, language, buildInTools, customTools, workflowTools])
const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration
const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop
const link = useNodeHelpLink(data.type)

View File

@ -13,6 +13,7 @@ import { VarType as ToolVarType } from '../../../tool/types'
import type { ToolNodeType } from '../../../tool/types'
import type { ParameterExtractorNodeType } from '../../../parameter-extractor/types'
import type { IterationNodeType } from '../../../iteration/types'
import type { LoopNodeType } from '../../../loop/types'
import type { ListFilterNodeType } from '../../../list-operator/types'
import { OUTPUT_FILE_SUB_VARIABLES } from '../../../constants'
import type { DocExtractorNodeType } from '../../../document-extractor/types'
@ -518,10 +519,61 @@ const getIterationItemType = ({
}
}
const getLoopItemType = ({
valueSelector,
beforeNodesOutputVars,
}: {
valueSelector: ValueSelector
beforeNodesOutputVars: NodeOutPutVar[]
}): VarType => {
const outputVarNodeId = valueSelector[0]
const isSystem = isSystemVar(valueSelector)
const targetVar = isSystem ? beforeNodesOutputVars.find(v => v.isStartNode) : beforeNodesOutputVars.find(v => v.nodeId === outputVarNodeId)
if (!targetVar)
return VarType.string
let arrayType: VarType = VarType.string
let curr: any = targetVar.vars
if (isSystem) {
arrayType = curr.find((v: any) => v.variable === (valueSelector).join('.'))?.type
}
else {
(valueSelector).slice(1).forEach((key, i) => {
const isLast = i === valueSelector.length - 2
curr = curr?.find((v: any) => v.variable === key)
if (isLast) {
arrayType = curr?.type
}
else {
if (curr?.type === VarType.object || curr?.type === VarType.file)
curr = curr.children
}
})
}
switch (arrayType as VarType) {
case VarType.arrayString:
return VarType.string
case VarType.arrayNumber:
return VarType.number
case VarType.arrayObject:
return VarType.object
case VarType.array:
return VarType.any
case VarType.arrayFile:
return VarType.file
default:
return VarType.string
}
}
export const getVarType = ({
parentNode,
valueSelector,
isIterationItem,
isLoopItem,
availableNodes,
isChatMode,
isConstant,
@ -532,6 +584,7 @@ export const getVarType = ({
valueSelector: ValueSelector
parentNode?: Node | null
isIterationItem?: boolean
isLoopItem?: boolean
availableNodes: any[]
isChatMode: boolean
isConstant?: boolean
@ -567,6 +620,26 @@ export const getVarType = ({
if (valueSelector[1] === 'index')
return VarType.number
}
const isLoopInnerVar = parentNode?.data.type === BlockEnum.Loop
if (isLoopItem) {
return getLoopItemType({
valueSelector,
beforeNodesOutputVars,
})
}
if (isLoopInnerVar) {
if (valueSelector[1] === 'item') {
const itemType = getLoopItemType({
valueSelector: (parentNode?.data as any).iterator_selector || [],
beforeNodesOutputVars,
})
return itemType
}
if (valueSelector[1] === 'index')
return VarType.number
}
const isSystem = isSystemVar(valueSelector)
const isEnv = isENV(valueSelector)
const isChatVar = isConversationVar(valueSelector)
@ -802,6 +875,14 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
break
}
case BlockEnum.Loop: {
const payload = data as LoopNodeType
res = payload.break_conditions?.map((c) => {
return c.variable_selector || []
}) || []
break
}
case BlockEnum.ListFilter: {
res = [(data as ListFilterNodeType).variable]
break
@ -1079,6 +1160,17 @@ export const updateNodeVars = (oldNode: Node, oldVarSelector: ValueSelector, new
break
}
case BlockEnum.Loop: {
const payload = data as LoopNodeType
if (payload.break_conditions) {
payload.break_conditions = payload.break_conditions.map((c) => {
if (c.variable_selector?.join('.') === oldVarSelector.join('.'))
c.variable_selector = newVarSelector
return c
})
}
break
}
case BlockEnum.ListFilter: {
const payload = data as ListFilterNodeType
if (payload.variable.join('.') === oldVarSelector.join('.'))
@ -1200,6 +1292,11 @@ export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelecto
break
}
case BlockEnum.Loop: {
res.push([id, 'output'])
break
}
case BlockEnum.DocExtractor: {
res.push([id, 'text'])
break

View File

@ -114,6 +114,9 @@ const VarReferencePicker: FC<Props> = ({
const isInIteration = !!node?.data.isInIteration
const iterationNode = isInIteration ? getNodes().find(n => n.id === node.parentId) : null
const isInLoop = !!node?.data.isInLoop
const loopNode = isInLoop ? getNodes().find(n => n.id === node.parentId) : null
const triggerRef = useRef<HTMLDivElement>(null)
const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH)
useEffect(() => {
@ -142,6 +145,14 @@ const VarReferencePicker: FC<Props> = ({
return false
}, [isInIteration, value, node])
const isLoopVar = useMemo(() => {
if (!isInLoop)
return false
if (value[0] === node?.parentId && ['item', 'index'].includes(value[1]))
return true
return false
}, [isInLoop, value, node])
const outputVarNodeId = hasValue ? value[0] : ''
const outputVarNode = useMemo(() => {
if (!hasValue || isConstant)
@ -150,11 +161,14 @@ const VarReferencePicker: FC<Props> = ({
if (isIterationVar)
return iterationNode?.data
if (isLoopVar)
return loopNode?.data
if (isSystemVar(value as ValueSelector))
return startNode?.data
return getNodeInfoById(availableNodes, outputVarNodeId)?.data
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode])
}, [value, hasValue, isConstant, isIterationVar, iterationNode, availableNodes, outputVarNodeId, startNode, isLoopVar, loopNode])
const varName = useMemo(() => {
if (hasValue) {
@ -220,7 +234,7 @@ const VarReferencePicker: FC<Props> = ({
}, [onChange, varKindType])
const type = getCurrentVariableType({
parentNode: iterationNode,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: value as ValueSelector,
availableNodes,
isChatMode,

View File

@ -13,6 +13,7 @@ type Params = {
passedInAvailableNodes?: Node[]
}
// TODO: loop type?
const useAvailableVarList = (nodeId: string, {
onlyLeafNodeVar,
filterVar,

View File

@ -27,6 +27,8 @@ export const useNodeHelpLink = (nodeType: BlockEnum) => {
[BlockEnum.Assigner]: 'variable-assigner',
[BlockEnum.Iteration]: 'iteration',
[BlockEnum.IterationStart]: 'iteration',
[BlockEnum.Loop]: 'loop',
[BlockEnum.LoopStart]: 'loop',
[BlockEnum.ParameterExtractor]: 'parameter-extractor',
[BlockEnum.HttpRequest]: 'http-request',
[BlockEnum.Tool]: 'tools',
@ -50,11 +52,14 @@ export const useNodeHelpLink = (nodeType: BlockEnum) => {
[BlockEnum.Assigner]: 'variable-assigner',
[BlockEnum.Iteration]: 'iteration',
[BlockEnum.IterationStart]: 'iteration',
[BlockEnum.Loop]: 'loop',
[BlockEnum.LoopStart]: 'loop',
[BlockEnum.ParameterExtractor]: 'parameter-extractor',
[BlockEnum.HttpRequest]: 'http-request',
[BlockEnum.Tool]: 'tools',
[BlockEnum.DocExtractor]: 'doc-extractor',
[BlockEnum.ListFilter]: 'list-operator',
[BlockEnum.Agent]: 'agent',
}
}, [language])

View File

@ -8,11 +8,13 @@ const useNodeInfo = (nodeId: string) => {
const allNodes = getNodes()
const node = allNodes.find(n => n.id === nodeId)
const isInIteration = !!node?.data.isInIteration
const isInLoop = !!node?.data.isInLoop
const parentNodeId = node?.parentId
const parentNode = allNodes.find(n => n.id === parentNodeId)
return {
node,
isInIteration,
isInLoop,
parentNode,
}
}

View File

@ -13,7 +13,7 @@ import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/a
import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { getIterationSingleNodeRunUrl, singleNodeRun } from '@/service/workflow'
import { getIterationSingleNodeRunUrl, getLoopSingleNodeRunUrl, singleNodeRun } from '@/service/workflow'
import Toast from '@/app/components/base/toast'
import LLMDefault from '@/app/components/workflow/nodes/llm/default'
import KnowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
@ -28,6 +28,7 @@ import Assigner from '@/app/components/workflow/nodes/assigner/default'
import ParameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default'
import IterationDefault from '@/app/components/workflow/nodes/iteration/default'
import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
import LoopDefault from '@/app/components/workflow/nodes/loop/default'
import { ssePost } from '@/service/base'
import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
@ -45,6 +46,7 @@ const { checkValid: checkAssignerValid } = Assigner
const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault
const { checkValid: checkIterationValid } = IterationDefault
const { checkValid: checkDocumentExtractorValid } = DocumentExtractorDefault
const { checkValid: checkLoopValid } = LoopDefault
// eslint-disable-next-line ts/no-unsafe-function-type
const checkValidFns: Record<BlockEnum, Function> = {
@ -61,6 +63,7 @@ const checkValidFns: Record<BlockEnum, Function> = {
[BlockEnum.ParameterExtractor]: checkParameterExtractorValid,
[BlockEnum.Iteration]: checkIterationValid,
[BlockEnum.DocExtractor]: checkDocumentExtractorValid,
[BlockEnum.Loop]: checkLoopValid,
} as any
type Params<T> = {
@ -69,6 +72,7 @@ type Params<T> = {
defaultRunInputData: Record<string, any>
moreDataForCheckValid?: any
iteratorInputKey?: string
loopInputKey?: string
}
const varTypeToInputVarType = (type: VarType, {
@ -100,12 +104,14 @@ const useOneStepRun = <T>({
defaultRunInputData,
moreDataForCheckValid,
iteratorInputKey,
loopInputKey,
}: Params<T>) => {
const { t } = useTranslation()
const { getBeforeNodesInSameBranch, getBeforeNodesInSameBranchIncludeParent } = useWorkflow() as any
const conversationVariables = useStore(s => s.conversationVariables)
const isChatMode = useIsChatMode()
const isIteration = data.type === BlockEnum.Iteration
const isLoop = data.type === BlockEnum.Loop
const availableNodes = getBeforeNodesInSameBranch(id)
const availableNodesIncludeParent = getBeforeNodesInSameBranchIncludeParent(id)
@ -145,12 +151,14 @@ const useOneStepRun = <T>({
setRunInputData(data)
}, [])
const iterationTimes = iteratorInputKey ? runInputData[iteratorInputKey].length : 0
const loopTimes = loopInputKey ? runInputData[loopInputKey].length : 0
const [runResult, setRunResult] = useState<any>(null)
const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate()
const [canShowSingleRun, setCanShowSingleRun] = useState(false)
const isShowSingleRun = data._isSingleRun && canShowSingleRun
const [iterationRunResult, setIterationRunResult] = useState<NodeTracing[]>([])
const [loopRunResult, setLoopRunResult] = useState<NodeTracing[]>([])
useEffect(() => {
if (!checkValid) {
@ -175,7 +183,7 @@ const useOneStepRun = <T>({
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data._isSingleRun])
const workflowStore = useWorkflowStore()
@ -214,10 +222,10 @@ const useOneStepRun = <T>({
})
let res: any
try {
if (!isIteration) {
if (!isIteration && !isLoop) {
res = await singleNodeRun(appId!, id, { inputs: submitData }) as any
}
else {
else if (isIteration) {
setIterationRunResult([])
let _iterationResult: NodeTracing[] = []
let _runResult: any = null
@ -315,11 +323,111 @@ const useOneStepRun = <T>({
},
)
}
if (res.error)
else if (isLoop) {
setLoopRunResult([])
let _loopResult: NodeTracing[] = []
let _runResult: any = null
ssePost(
getLoopSingleNodeRunUrl(isChatMode, appId!, id),
{ body: { inputs: submitData } },
{
onWorkflowStarted: () => {
},
onWorkflowFinished: (params) => {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.Succeeded,
},
})
const { data: loopData } = params
_runResult.created_by = loopData.created_by.name
setRunResult(_runResult)
},
onLoopStart: (params) => {
const newLoopRunResult = produce(_loopResult, (draft) => {
draft.push({
...params.data,
status: NodeRunningStatus.Running,
})
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onLoopNext: () => {
// loop next trigger time is triggered one more time than loopTimes
if (_loopResult.length >= loopTimes!)
return _loopResult.length >= loopTimes!
},
onLoopFinish: (params) => {
_runResult = params.data
setRunResult(_runResult)
const loopRunResult = _loopResult
const currentIndex = loopRunResult.findIndex(trace => trace.id === params.data.id)
const newLoopRunResult = produce(loopRunResult, (draft) => {
if (currentIndex > -1) {
draft[currentIndex] = {
...draft[currentIndex],
...data,
}
}
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onNodeStarted: (params) => {
const newLoopRunResult = produce(_loopResult, (draft) => {
draft.push({
...params.data,
status: NodeRunningStatus.Running,
})
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onNodeFinished: (params) => {
const loopRunResult = _loopResult
const { data } = params
const currentIndex = loopRunResult.findIndex(trace => trace.id === data.id)
const newLoopRunResult = produce(loopRunResult, (draft) => {
if (currentIndex > -1) {
draft[currentIndex] = {
...draft[currentIndex],
...data,
}
}
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onNodeRetry: (params) => {
const newLoopRunResult = produce(_loopResult, (draft) => {
draft.push(params.data)
})
_loopResult = newLoopRunResult
setLoopRunResult(newLoopRunResult)
},
onError: () => {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.Failed,
},
})
},
},
)
}
if (res && res.error)
throw new Error(res.error)
}
catch (e: any) {
if (!isIteration) {
console.error(e)
if (!isIteration && !isLoop) {
handleNodeDataUpdate({
id,
data: {
@ -331,7 +439,7 @@ const useOneStepRun = <T>({
}
}
finally {
if (!isIteration) {
if (!isIteration && !isLoop) {
setRunResult({
...res,
total_tokens: res.execution_metadata?.total_tokens || 0,
@ -339,7 +447,7 @@ const useOneStepRun = <T>({
})
}
}
if (!isIteration) {
if (!isIteration && !isLoop) {
handleNodeDataUpdate({
id,
data: {
@ -430,6 +538,7 @@ const useOneStepRun = <T>({
setRunInputData: handleSetRunInputData,
runResult,
iterationRunResult,
loopRunResult,
}
}

View File

@ -30,6 +30,7 @@ import {
hasRetryNode,
} from '../../utils'
import { useNodeIterationInteractions } from '../iteration/use-interactions'
import { useNodeLoopInteractions } from '../loop/use-interactions'
import type { IterationNodeType } from '../iteration/types'
import {
NodeSourceHandle,
@ -57,6 +58,7 @@ const BaseNode: FC<BaseNodeProps> = ({
const nodeRef = useRef<HTMLDivElement>(null)
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
const toolIcon = useToolIcon(data)
useEffect(() => {
@ -73,6 +75,20 @@ const BaseNode: FC<BaseNodeProps> = ({
}
}, [data.isInIteration, data.selected, id, handleNodeIterationChildSizeChange])
useEffect(() => {
if (nodeRef.current && data.selected && data.isInLoop) {
const resizeObserver = new ResizeObserver(() => {
handleNodeLoopChildSizeChange(id)
})
resizeObserver.observe(nodeRef.current)
return () => {
resizeObserver.disconnect()
}
}
}, [data.isInLoop, data.selected, id, handleNodeLoopChildSizeChange])
const showSelectedBorder = data.selected || data._isBundled || data._isEntering
const {
showRunningBorder,
@ -98,16 +114,16 @@ const BaseNode: FC<BaseNodeProps> = ({
)}
ref={nodeRef}
style={{
width: data.type === BlockEnum.Iteration ? data.width : 'auto',
height: data.type === BlockEnum.Iteration ? data.height : 'auto',
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
}}
>
<div
className={cn(
'group relative pb-1 shadow-xs',
'border border-transparent rounded-[15px]',
data.type !== BlockEnum.Iteration && 'w-[240px] bg-workflow-block-bg',
data.type === BlockEnum.Iteration && 'flex flex-col w-full h-full bg-workflow-block-bg-transparent border-workflow-block-border',
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex flex-col w-full h-full bg-workflow-block-bg-transparent border-workflow-block-border',
!data._runningStatus && 'hover:shadow-lg',
showRunningBorder && '!border-state-accent-solid',
showSuccessBorder && '!border-state-success-solid',
@ -139,6 +155,14 @@ const BaseNode: FC<BaseNodeProps> = ({
/>
)
}
{
data.type === BlockEnum.Loop && (
<NodeResizer
nodeId={id}
nodeData={data}
/>
)
}
{
!data._isCandidate && (
<NodeTargetHandle
@ -169,7 +193,7 @@ const BaseNode: FC<BaseNodeProps> = ({
}
<div className={cn(
'flex items-center px-3 pt-3 pb-2 rounded-t-2xl',
data.type === BlockEnum.Iteration && 'bg-transparent',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'bg-transparent',
)}>
<BlockIcon
className='shrink-0 mr-2'
@ -208,6 +232,13 @@ const BaseNode: FC<BaseNodeProps> = ({
</div>
)
}
{
data._loopLength && data._loopIndex && data._runningStatus === NodeRunningStatus.Running && (
<div className='mr-1.5 text-xs font-medium text-primary-600'>
{data._loopIndex > data._loopLength ? data._loopLength : data._loopIndex}/{data._loopLength}
</div>
)
}
{
(data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && (
<RiLoader2Line className='w-3.5 h-3.5 text-text-accent animate-spin' />
@ -230,12 +261,12 @@ const BaseNode: FC<BaseNodeProps> = ({
}
</div>
{
data.type !== BlockEnum.Iteration && (
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
cloneElement(children, { id, data })
)
}
{
data.type === BlockEnum.Iteration && (
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && (
<div className='grow pl-1 pr-1 pb-1'>
{cloneElement(children, { id, data })}
</div>
@ -258,7 +289,7 @@ const BaseNode: FC<BaseNodeProps> = ({
)
}
{
data.desc && data.type !== BlockEnum.Iteration && (
data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
<div className='px-3 pt-1 pb-2 system-xs-regular text-text-tertiary whitespace-pre-line break-words'>
{data.desc}
</div>

View File

@ -61,14 +61,14 @@ const BasePanel: FC<BasePanelProps> = ({
showMessageLogModal: state.showMessageLogModal,
})))
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
const panelWidth = localStorage.getItem('workflow-node-panel-width') ? parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420
const panelWidth = localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 420
const {
setPanelWidth,
} = useWorkflow()
const { handleNodeSelect } = useNodesInteractions()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { nodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration)
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration, data.isInLoop)
const toolIcon = useToolIcon(data)
const handleResize = useCallback((width: number) => {

View File

@ -39,6 +39,8 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
const currentNode = getNodes().find(n => n.id === id)
const isInIteration = payload.isInIteration
const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
const isInLoop = payload.isInLoop
const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
const availableNodes = useMemo(() => {
return getBeforeNodesInSameBranch(id)
}, [getBeforeNodesInSameBranch, id])
@ -54,13 +56,13 @@ const useConfig = (id: string, rawPayload: AssignerNodeType) => {
const { getCurrentVariableType } = useWorkflowVariables()
const getAssignedVarType = useCallback((valueSelector: ValueSelector) => {
return getCurrentVariableType({
parentNode: iterationNode,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: valueSelector || [],
availableNodes,
isChatMode,
isConstant: false,
})
}, [getCurrentVariableType, iterationNode, availableNodes, isChatMode])
}, [getCurrentVariableType, isInIteration, iterationNode, loopNode, availableNodes, isChatMode])
const handleOperationListChanges = useCallback((items: AssignerNodeOperation[]) => {
const newInputs = produce(inputs, (draft) => {

View File

@ -30,6 +30,8 @@ import ParameterExtractorNode from './parameter-extractor/node'
import ParameterExtractorPanel from './parameter-extractor/panel'
import IterationNode from './iteration/node'
import IterationPanel from './iteration/panel'
import LoopNode from './loop/node'
import LoopPanel from './loop/panel'
import DocExtractorNode from './document-extractor/node'
import DocExtractorPanel from './document-extractor/panel'
import ListFilterNode from './list-operator/node'
@ -55,6 +57,7 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.VariableAggregator]: VariableAssignerNode,
[BlockEnum.ParameterExtractor]: ParameterExtractorNode,
[BlockEnum.Iteration]: IterationNode,
[BlockEnum.Loop]: LoopNode,
[BlockEnum.DocExtractor]: DocExtractorNode,
[BlockEnum.ListFilter]: ListFilterNode,
[BlockEnum.Agent]: AgentNode,
@ -77,6 +80,7 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.Assigner]: AssignerPanel,
[BlockEnum.ParameterExtractor]: ParameterExtractorPanel,
[BlockEnum.Iteration]: IterationPanel,
[BlockEnum.Loop]: LoopPanel,
[BlockEnum.DocExtractor]: DocExtractorPanel,
[BlockEnum.ListFilter]: ListFilterPanel,
[BlockEnum.Agent]: AgentPanel,

View File

@ -32,6 +32,8 @@ const useConfig = (id: string, payload: DocExtractorNodeType) => {
const currentNode = getNodes().find(n => n.id === id)
const isInIteration = payload.isInIteration
const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
const isInLoop = payload.isInLoop
const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
const availableNodes = useMemo(() => {
return getBeforeNodesInSameBranch(id)
}, [getBeforeNodesInSameBranch, id])
@ -39,14 +41,14 @@ const useConfig = (id: string, payload: DocExtractorNodeType) => {
const { getCurrentVariableType } = useWorkflowVariables()
const getType = useCallback((variable?: ValueSelector) => {
const varType = getCurrentVariableType({
parentNode: iterationNode,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: variable || [],
availableNodes,
isChatMode,
isConstant: false,
})
return varType
}, [getCurrentVariableType, availableNodes, isChatMode, iterationNode])
}, [getCurrentVariableType, isInIteration, availableNodes, isChatMode, iterationNode, loopNode])
const handleVarChanges = useCallback((variable: ValueSelector | string) => {
const newInputs = produce(inputs, (draft) => {

View File

@ -35,7 +35,7 @@ export enum ComparisonOperator {
notExists = 'not exists',
}
export interface Condition {
export type Condition = {
id: string
varType: VarType
variable_selector?: ValueSelector
@ -46,7 +46,7 @@ export interface Condition {
sub_variable_condition?: CaseItem
}
export interface CaseItem {
export type CaseItem = {
case_id: string
logical_operator: LogicalOperator
conditions: Condition[]
@ -57,6 +57,7 @@ export type IfElseNodeType = CommonNodeType & {
conditions?: Condition[]
cases: CaseItem[]
isInIteration: boolean
isInLoop: boolean
}
export type HandleAddCondition = (caseId: string, valueSelector: ValueSelector, varItem: Var) => void

View File

@ -57,6 +57,7 @@ const useConfig = (id: string, payload: IfElseNodeType) => {
} = useIsVarFileAttribute({
nodeId: id,
isInIteration: payload.isInIteration,
isInLoop: payload.isInLoop,
})
const varsIsVarFileAttribute = useMemo(() => {

View File

@ -7,10 +7,12 @@ import { VarType } from '../../types'
type Params = {
nodeId: string
isInIteration: boolean
isInLoop: boolean
}
const useIsVarFileAttribute = ({
nodeId,
isInIteration,
isInLoop,
}: Params) => {
const isChatMode = useIsChatMode()
const store = useStoreApi()
@ -20,6 +22,7 @@ const useIsVarFileAttribute = ({
} = store.getState()
const currentNode = getNodes().find(n => n.id === nodeId)
const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
const availableNodes = useMemo(() => {
return getBeforeNodesInSameBranch(nodeId)
}, [getBeforeNodesInSameBranch, nodeId])
@ -29,7 +32,7 @@ const useIsVarFileAttribute = ({
return false
const parentVariable = variable.slice(0, 2)
const varType = getCurrentVariableType({
parentNode: iterationNode,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: parentVariable,
availableNodes,
isChatMode,

View File

@ -27,6 +27,8 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
const currentNode = getNodes().find(n => n.id === id)
const isInIteration = payload.isInIteration
const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null
const isInLoop = payload.isInLoop
const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null
const availableNodes = useMemo(() => {
return getBeforeNodesInSameBranch(id)
}, [getBeforeNodesInSameBranch, id])
@ -36,7 +38,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
const { getCurrentVariableType } = useWorkflowVariables()
const getType = useCallback((variable?: ValueSelector) => {
const varType = getCurrentVariableType({
parentNode: iterationNode,
parentNode: isInIteration ? iterationNode : loopNode,
valueSelector: variable || inputs.variable || [],
availableNodes,
isChatMode,
@ -60,7 +62,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
itemVarType = varType
}
return { varType, itemVarType }
}, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, iterationNode])
}, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode])
const { varType, itemVarType } = getType()

View File

@ -0,0 +1 @@
export const CUSTOM_LOOP_START_NODE = 'custom-loop-start'

View File

@ -0,0 +1,21 @@
import type { NodeDefault } from '../../types'
import type { LoopStartNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
const nodeDefault: NodeDefault<LoopStartNodeType> = {
defaultValue: {},
getAvailablePrevNodes() {
return []
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS
return nodes
},
checkValid() {
return {
isValid: true,
}
},
}
export default nodeDefault

View File

@ -0,0 +1,42 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from 'reactflow'
import { RiHome5Fill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '@/app/components/workflow/nodes/_base/components/node-handle'
const LoopStartNode = ({ id, data }: NodeProps) => {
const { t } = useTranslation()
return (
<div className='group flex nodrag items-center justify-center w-11 h-11 mt-1 rounded-2xl border border-workflow-block-border bg-white'>
<Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
<RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
</div>
</Tooltip>
<NodeSourceHandle
id={id}
data={data}
handleClassName='!top-1/2 !-right-[9px] !-translate-y-1/2'
handleId='source'
/>
</div>
)
}
export const LoopStartNodeDumb = () => {
const { t } = useTranslation()
return (
<div className='relative left-[17px] top-[21px] flex nodrag items-center justify-center w-11 h-11 rounded-2xl border border-workflow-block-border bg-white z-[11]'>
<Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
<RiHome5Fill className='w-3 h-3 text-text-primary-on-surface' />
</div>
</Tooltip>
</div>
)
}
export default memo(LoopStartNode)

View File

@ -0,0 +1,3 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
export type LoopStartNodeType = CommonNodeType

View File

@ -0,0 +1,80 @@
import {
memo,
useCallback,
} from 'react'
import {
RiAddLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
useAvailableBlocks,
useNodesInteractions,
useNodesReadOnly,
} from '../../hooks'
import type { LoopNodeType } from './types'
import cn from '@/utils/classnames'
import BlockSelector from '@/app/components/workflow/block-selector'
import type {
OnSelectBlock,
} from '@/app/components/workflow/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'
type AddBlockProps = {
loopNodeId: string
loopNodeData: LoopNodeType
}
const AddBlock = ({
loopNodeData,
}: AddBlockProps) => {
const { t } = useTranslation()
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeAdd } = useNodesInteractions()
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false, true)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
toolDefaultValue,
},
{
prevNodeId: loopNodeData.start_node_id,
prevNodeSourceHandle: 'source',
},
)
}, [handleNodeAdd, loopNodeData.start_node_id])
const renderTriggerElement = useCallback((open: boolean) => {
return (
<div className={cn(
'relative inline-flex items-center px-3 h-8 rounded-lg border-[0.5px] border-gray-50 bg-white shadow-xs cursor-pointer hover:bg-gray-200 text-[13px] font-medium text-gray-700',
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
open && '!bg-gray-50',
)}>
<RiAddLine className='mr-1 w-4 h-4' />
{t('workflow.common.addBlock')}
</div>
)
}, [nodesReadOnly, t])
return (
<div className='absolute top-7 left-14 flex items-center h-8 z-10'>
<div className='group/insert relative w-16 h-0.5 bg-gray-300'>
<div className='absolute right-0 top-1/2 -translate-y-1/2 w-0.5 h-2 bg-primary-500'></div>
</div>
<BlockSelector
disabled={nodesReadOnly}
onSelect={handleSelect}
trigger={renderTriggerElement}
triggerInnerClassName='inline-flex'
popupClassName='!min-w-[256px]'
availableBlocksTypes={availableNextBlocks}
/>
</div>
)
}
export default memo(AddBlock)

View File

@ -0,0 +1,74 @@
import {
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiAddLine } from '@remixicon/react'
import type { HandleAddCondition } from '../types'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type {
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
type ConditionAddProps = {
className?: string
variables: NodeOutPutVar[]
onSelectVariable: HandleAddCondition
disabled?: boolean
}
const ConditionAdd = ({
className,
variables,
onSelectVariable,
disabled,
}: ConditionAddProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleSelectVariable = useCallback((valueSelector: ValueSelector, varItem: Var) => {
onSelectVariable(valueSelector, varItem)
setOpen(false)
}, [onSelectVariable, setOpen])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
<Button
size='small'
className={className}
disabled={disabled}
>
<RiAddLine className='mr-1 w-3.5 h-3.5' />
{t('workflow.nodes.ifElse.addCondition')}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[296px] bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg'>
<VarReferenceVars
vars={variables}
isSupportFileVar
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ConditionAdd

View File

@ -0,0 +1,115 @@
import {
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { ComparisonOperator, type Condition } from '../types'
import {
comparisonOperatorNotRequireValue,
isComparisonOperatorNeedTranslate,
isEmptyRelatedOperator,
} from '../utils'
import type { ValueSelector } from '../../../types'
import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
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'
const i18nPrefix = 'workflow.nodes.ifElse'
type ConditionValueProps = {
condition: Condition
}
const ConditionValue = ({
condition,
}: ConditionValueProps) => {
const { t } = useTranslation()
const {
variable_selector,
comparison_operator: operator,
sub_variable_condition,
} = condition
const variableSelector = variable_selector as ValueSelector
const variableName = (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 formatValue = useCallback((c: Condition) => {
const notHasValue = comparisonOperatorNotRequireValue(c.comparison_operator)
if (notHasValue)
return ''
const value = c.value as string
return value.replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`
return `{{${arr.slice(1).join('.')}}}`
})
}, [])
const isSelect = useCallback((c: Condition) => {
return c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn
}, [])
const selectName = useCallback((c: Condition) => {
const isSelect = c.comparison_operator === ComparisonOperator.in || c.comparison_operator === ComparisonOperator.notIn
if (isSelect) {
const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(c.value) ? c.value[0] : c.value))[0]
return name
? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`
return `{{${arr.slice(1).join('.')}}}`
})
: ''
}
return ''
}, [t])
return (
<div className='rounded-md bg-workflow-block-parma-bg'>
<div className='flex items-center px-1 h-6 '>
{!isEnvVar && !isChatVar && <Variable02 className='shrink-0 mr-1 w-3.5 h-3.5 text-text-accent' />}
{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' />}
<div
className={cn(
'shrink-0 truncate text-xs font-medium text-text-accent',
!notHasValue && 'max-w-[70px]',
)}
title={variableName}
>
{variableName}
</div>
<div
className='shrink-0 mx-1 text-xs font-medium text-text-primary'
title={operatorName}
>
{operatorName}
</div>
</div>
<div className='ml-[10px] pl-[10px] border-l border-divider-regular'>
{
sub_variable_condition?.conditions.map((c: Condition, index) => (
<div className='relative flex items-center h-6 space-x-1' key={c.id}>
<div className='text-text-accent system-xs-medium'>{c.key}</div>
<div className='text-text-primary system-xs-medium'>{isComparisonOperatorNeedTranslate(c.comparison_operator) ? t(`workflow.nodes.ifElse.comparisonOperator.${c.comparison_operator}`) : c.comparison_operator}</div>
{c.comparison_operator && !isEmptyRelatedOperator(c.comparison_operator) && <div className='text-text-secondary system-xs-regular'>{isSelect(c) ? selectName(c) : formatValue(c)}</div>}
{index !== sub_variable_condition.conditions.length - 1 && (<div className='absolute z-10 right-1 bottom-[-10px] leading-4 text-[10px] font-medium text-text-accent uppercase'>{t(`${i18nPrefix}.${sub_variable_condition.logical_operator}`)}</div>)}
</div>
))
}
</div>
</div>
)
}
export default memo(ConditionValue)

View File

@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import PromptEditor from '@/app/components/base/prompt-editor'
import { BlockEnum } from '@/app/components/workflow/types'
import type {
Node,
} from '@/app/components/workflow/types'
type ConditionInputProps = {
disabled?: boolean
value: string
onChange: (value: string) => void
availableNodes: Node[]
}
const ConditionInput = ({
value,
onChange,
disabled,
availableNodes,
}: ConditionInputProps) => {
const { t } = useTranslation()
const controlPromptEditorRerenderKey = useStore(s => s.controlPromptEditorRerenderKey)
return (
<PromptEditor
key={controlPromptEditorRerenderKey}
compact
value={value}
placeholder={t('workflow.nodes.ifElse.enterValue') || ''}
workflowVariableBlock={{
show: true,
variables: [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
}}
onChange={onChange}
editable={!disabled}
/>
)
}
export default ConditionInput

View File

@ -0,0 +1,330 @@
import {
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiDeleteBinLine } from '@remixicon/react'
import produce from 'immer'
import type { VarType as NumberVarType } from '../../../tool/types'
import type {
Condition,
HandleAddSubVariableCondition,
HandleRemoveCondition,
HandleToggleSubVariableConditionLogicalOperator,
HandleUpdateCondition,
HandleUpdateSubVariableCondition,
handleRemoveSubVariableCondition,
} from '../../types'
import {
ComparisonOperator,
} from '../../types'
import ConditionNumberInput from '../condition-number-input'
import ConditionWrap from '../condition-wrap'
import { comparisonOperatorNotRequireValue, getOperators } from './../../utils'
import ConditionOperator from './condition-operator'
import ConditionInput from './condition-input'
import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from './../../default'
import type {
Node,
NodeOutPutVar,
ValueSelector,
Var,
} from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { SimpleSelect as Select } from '@/app/components/base/select'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import ConditionVarSelector from './condition-var-selector'
const optionNameI18NPrefix = 'workflow.nodes.ifElse.optionName'
type ConditionItemProps = {
className?: string
disabled?: boolean
conditionId: string // in isSubVariableKey it's the value of the parent condition's id
condition: Condition // condition may the condition of case or condition of sub variable
file?: { key: string }
isSubVariableKey?: boolean
isValueFieldShort?: boolean
onRemoveCondition?: HandleRemoveCondition
onUpdateCondition?: HandleUpdateCondition
onAddSubVariableCondition?: HandleAddSubVariableCondition
onRemoveSubVariableCondition?: handleRemoveSubVariableCondition
onUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
onToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
nodeId: string
availableNodes: Node[]
numberVariables: NodeOutPutVar[]
availableVars: NodeOutPutVar[]
}
const ConditionItem = ({
className,
disabled,
conditionId,
condition,
file,
isSubVariableKey,
isValueFieldShort,
onRemoveCondition,
onUpdateCondition,
onAddSubVariableCondition,
onRemoveSubVariableCondition,
onUpdateSubVariableCondition,
onToggleSubVariableConditionLogicalOperator,
nodeId,
availableNodes,
numberVariables,
availableVars,
}: ConditionItemProps) => {
const { t } = useTranslation()
const [isHovered, setIsHovered] = useState(false)
const [open, setOpen] = useState(false)
const doUpdateCondition = useCallback((newCondition: Condition) => {
if (isSubVariableKey)
onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition)
else
onUpdateCondition?.(condition.id, newCondition)
}, [condition, conditionId, isSubVariableKey, onUpdateCondition, onUpdateSubVariableCondition])
const canChooseOperator = useMemo(() => {
if (disabled)
return false
if (isSubVariableKey)
return !!condition.key
return true
}, [condition.key, disabled, isSubVariableKey])
const handleUpdateConditionOperator = useCallback((value: ComparisonOperator) => {
const newCondition = {
...condition,
comparison_operator: value,
}
doUpdateCondition(newCondition)
}, [condition, doUpdateCondition])
const handleUpdateConditionNumberVarType = useCallback((numberVarType: NumberVarType) => {
const newCondition = {
...condition,
numberVarType,
value: '',
}
doUpdateCondition(newCondition)
}, [condition, doUpdateCondition])
const isSubVariable = condition.varType === VarType.arrayFile && [ComparisonOperator.contains, ComparisonOperator.notContains, ComparisonOperator.allOf].includes(condition.comparison_operator!)
const fileAttr = useMemo(() => {
if (file)
return file
if (isSubVariableKey) {
return {
key: condition.key!,
}
}
return undefined
}, [condition.key, file, isSubVariableKey])
const isArrayValue = fileAttr?.key === 'transfer_method' || fileAttr?.key === 'type'
const handleUpdateConditionValue = useCallback((value: string) => {
if (value === condition.value || (isArrayValue && value === condition.value?.[0]))
return
const newCondition = {
...condition,
value: isArrayValue ? [value] : value,
}
doUpdateCondition(newCondition)
}, [condition, doUpdateCondition, isArrayValue])
const isSelect = condition.comparison_operator && [ComparisonOperator.in, ComparisonOperator.notIn].includes(condition.comparison_operator)
const selectOptions = useMemo(() => {
if (isSelect) {
if (fileAttr?.key === 'type' || condition.comparison_operator === ComparisonOperator.allOf) {
return FILE_TYPE_OPTIONS.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`),
value: item.value,
}))
}
if (fileAttr?.key === 'transfer_method') {
return TRANSFER_METHOD.map(item => ({
name: t(`${optionNameI18NPrefix}.${item.i18nKey}`),
value: item.value,
}))
}
return []
}
return []
}, [condition.comparison_operator, fileAttr?.key, isSelect, t])
const isNotInput = isSelect || isSubVariable
const isSubVarSelect = isSubVariableKey
const subVarOptions = SUB_VARIABLES.map(item => ({
name: item,
value: item,
}))
const handleSubVarKeyChange = useCallback((key: string) => {
const newCondition = produce(condition, (draft) => {
draft.key = key
if (key === 'size')
draft.varType = VarType.number
else
draft.varType = VarType.string
draft.value = ''
draft.comparison_operator = getOperators(undefined, { key })[0]
})
onUpdateSubVariableCondition?.(conditionId, condition.id, newCondition)
}, [condition, conditionId, onUpdateSubVariableCondition])
const doRemoveCondition = useCallback(() => {
if (isSubVariableKey)
onRemoveSubVariableCondition?.(conditionId, condition.id)
else
onRemoveCondition?.(condition.id)
}, [condition, conditionId, isSubVariableKey, onRemoveCondition, onRemoveSubVariableCondition])
const handleVarChange = useCallback((valueSelector: ValueSelector, varItem: Var) => {
const newCondition = produce(condition, (draft) => {
draft.variable_selector = valueSelector
draft.varType = varItem.type
draft.value = ''
draft.comparison_operator = getOperators(varItem.type)[0]
})
doUpdateCondition(newCondition)
setOpen(false)
}, [condition, doUpdateCondition])
return (
<div className={cn('flex mb-1 last-of-type:mb-0', className)}>
<div className={cn(
'grow bg-components-input-bg-normal rounded-lg',
isHovered && 'bg-state-destructive-hover',
)}>
<div className='flex items-center p-1'>
<div className='grow w-0'>
{isSubVarSelect
? (
<Select
wrapperClassName='h-6'
className='pl-0 text-xs'
optionWrapClassName='w-[165px] max-h-none'
defaultValue={condition.key}
items={subVarOptions}
onSelect={item => handleSubVarKeyChange(item.value as string)}
renderTrigger={item => (
item
? <div className='flex justify-start cursor-pointer'>
<div className='inline-flex max-w-full px-1.5 items-center h-6 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark shadow-xs text-text-accent'>
<Variable02 className='shrink-0 w-3.5 h-3.5 text-text-accent' />
<div className='ml-0.5 truncate system-xs-medium'>{item?.name}</div>
</div>
</div>
: <div className='text-left text-components-input-text-placeholder system-sm-regular'>{t('common.placeholder.select')}</div>
)}
hideChecked
/>
)
: (
<ConditionVarSelector
open={open}
onOpenChange={setOpen}
valueSelector={condition.variable_selector || []}
varType={condition.varType}
availableNodes={availableNodes}
nodesOutputVars={availableVars}
onChange={handleVarChange}
/>
)}
</div>
<div className='mx-1 w-[1px] h-3 bg-divider-regular'></div>
<ConditionOperator
disabled={!canChooseOperator}
varType={condition.varType}
value={condition.comparison_operator}
onSelect={handleUpdateConditionOperator}
file={fileAttr}
/>
</div>
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType !== VarType.number && (
<div className='px-2 py-1 max-h-[100px] border-t border-t-divider-subtle overflow-y-auto'>
<ConditionInput
disabled={disabled}
value={condition.value as string}
onChange={handleUpdateConditionValue}
availableNodes={availableNodes}
/>
</div>
)
}
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && !isNotInput && condition.varType === VarType.number && (
<div className='px-2 py-1 pt-[3px] border-t border-t-divider-subtle'>
<ConditionNumberInput
numberVarType={condition.numberVarType}
onNumberVarTypeChange={handleUpdateConditionNumberVarType}
value={condition.value as string}
onValueChange={handleUpdateConditionValue}
variables={numberVariables}
isShort={isValueFieldShort}
unit={fileAttr?.key === 'size' ? 'Byte' : undefined}
/>
</div>
)
}
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSelect && (
<div className='border-t border-t-divider-subtle'>
<Select
wrapperClassName='h-8'
className='px-2 text-xs rounded-t-none'
defaultValue={isArrayValue ? (condition.value as string[])?.[0] : (condition.value as string)}
items={selectOptions}
onSelect={item => handleUpdateConditionValue(item.value as string)}
hideChecked
notClearable
/>
</div>
)
}
{
!comparisonOperatorNotRequireValue(condition.comparison_operator) && isSubVariable && (
<div className='p-1'>
<ConditionWrap
isSubVariable
conditions={condition.sub_variable_condition?.conditions || []}
logicalOperator={condition.sub_variable_condition?.logical_operator}
conditionId={conditionId}
readOnly={!!disabled}
handleAddSubVariableCondition={onAddSubVariableCondition}
handleRemoveSubVariableCondition={onRemoveSubVariableCondition}
handleUpdateSubVariableCondition={onUpdateSubVariableCondition}
handleToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator}
nodeId={nodeId}
availableNodes={availableNodes}
availableVars={availableVars}
/>
</div>
)
}
</div>
<div
className='shrink-0 flex items-center justify-center ml-1 mt-1 w-6 h-6 rounded-lg cursor-pointer hover:bg-state-destructive-hover text-text-tertiary hover:text-text-destructive'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={doRemoveCondition}
>
<RiDeleteBinLine className='w-4 h-4' />
</div>
</div>
)
}
export default ConditionItem

View File

@ -0,0 +1,94 @@
import {
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import { getOperators, isComparisonOperatorNeedTranslate } from '../../utils'
import type { ComparisonOperator } from '../../types'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { VarType } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
const i18nPrefix = 'workflow.nodes.ifElse'
type ConditionOperatorProps = {
className?: string
disabled?: boolean
varType: VarType
file?: { key: string }
value?: string
onSelect: (value: ComparisonOperator) => void
}
const ConditionOperator = ({
className,
disabled,
varType,
file,
value,
onSelect,
}: ConditionOperatorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = useMemo(() => {
return getOperators(varType, file).map((o) => {
return {
label: isComparisonOperatorNeedTranslate(o) ? t(`${i18nPrefix}.comparisonOperator.${o}`) : o,
value: o,
}
})
}, [t, varType, file])
const selectedOption = options.find(o => Array.isArray(value) ? o.value === value[0] : o.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
className={cn('shrink-0', !selectedOption && 'opacity-50', className)}
size='small'
variant='ghost'
disabled={disabled}
>
{
selectedOption
? selectedOption.label
: t(`${i18nPrefix}.select`)
}
<RiArrowDownSLine className='ml-1 w-3.5 h-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='p-1 bg-components-panel-bg-blur rounded-xl border-[0.5px] border-components-panel-border shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex items-center px-3 py-1.5 h-7 text-[13px] font-medium text-text-secondary rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => {
onSelect(option.value)
setOpen(false)
}}
>
{option.label}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ConditionOperator

View File

@ -0,0 +1,58 @@
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type { Node, NodeOutPutVar, ValueSelector, Var, VarType } from '@/app/components/workflow/types'
type ConditionVarSelectorProps = {
open: boolean
onOpenChange: (open: boolean) => void
valueSelector: ValueSelector
varType: VarType
availableNodes: Node[]
nodesOutputVars: NodeOutPutVar[]
onChange: (valueSelector: ValueSelector, varItem: Var) => void
}
const ConditionVarSelector = ({
open,
onOpenChange,
valueSelector,
varType,
availableNodes,
nodesOutputVars,
onChange,
}: ConditionVarSelectorProps) => {
return (
<PortalToFollowElem
open={open}
onOpenChange={onOpenChange}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
>
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
<div className="cursor-pointer">
<VariableTag
valueSelector={valueSelector}
varType={varType}
availableNodes={availableNodes}
isShort
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[296px] bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg'>
<VarReferenceVars
vars={nodesOutputVars}
isSupportFileVar
onChange={onChange}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ConditionVarSelector

View File

@ -0,0 +1,126 @@
import { RiLoopLeftLine } from '@remixicon/react'
import { useCallback, useMemo } from 'react'
import {
type Condition,
type HandleAddSubVariableCondition,
type HandleRemoveCondition,
type HandleToggleConditionLogicalOperator,
type HandleToggleSubVariableConditionLogicalOperator,
type HandleUpdateCondition,
type HandleUpdateSubVariableCondition,
LogicalOperator,
type handleRemoveSubVariableCondition,
} from '../../types'
import ConditionItem from './condition-item'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type ConditionListProps = {
isSubVariable?: boolean
disabled?: boolean
conditionId?: string
conditions: Condition[]
logicalOperator?: LogicalOperator
onRemoveCondition?: HandleRemoveCondition
onUpdateCondition?: HandleUpdateCondition
onToggleConditionLogicalOperator?: HandleToggleConditionLogicalOperator
nodeId: string
availableNodes: Node[]
numberVariables: NodeOutPutVar[]
onAddSubVariableCondition?: HandleAddSubVariableCondition
onRemoveSubVariableCondition?: handleRemoveSubVariableCondition
onUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
onToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
availableVars: NodeOutPutVar[]
}
const ConditionList = ({
isSubVariable,
disabled,
conditionId,
conditions,
logicalOperator,
onUpdateCondition,
onRemoveCondition,
onToggleConditionLogicalOperator,
onAddSubVariableCondition,
onRemoveSubVariableCondition,
onUpdateSubVariableCondition,
onToggleSubVariableConditionLogicalOperator,
nodeId,
availableNodes,
numberVariables,
availableVars,
}: ConditionListProps) => {
const doToggleConditionLogicalOperator = useCallback((conditionId?: string) => {
if (isSubVariable && conditionId)
onToggleSubVariableConditionLogicalOperator?.(conditionId)
else
onToggleConditionLogicalOperator?.()
}, [isSubVariable, onToggleConditionLogicalOperator, onToggleSubVariableConditionLogicalOperator])
const isValueFieldShort = useMemo(() => {
if (isSubVariable && conditions.length > 1)
return true
return false
}, [conditions.length, isSubVariable])
const conditionItemClassName = useMemo(() => {
if (!isSubVariable)
return ''
if (conditions.length < 2)
return ''
return logicalOperator === LogicalOperator.and ? 'pl-[51px]' : 'pl-[42px]'
}, [conditions.length, isSubVariable, logicalOperator])
return (
<div className={cn('relative', conditions.length > 1 && !isSubVariable && 'pl-[60px]')}>
{
conditions.length > 1 && (
<div className={cn(
'absolute top-0 bottom-0 left-0 w-[60px]',
isSubVariable && logicalOperator === LogicalOperator.and && 'left-[-10px]',
isSubVariable && logicalOperator === LogicalOperator.or && 'left-[-18px]',
)}>
<div className='absolute top-4 bottom-4 left-[46px] w-2.5 border border-divider-deep rounded-l-[8px] border-r-0'></div>
<div className='absolute top-1/2 -translate-y-1/2 right-0 w-4 h-[29px] bg-components-panel-bg'></div>
<div
className='absolute top-1/2 right-1 -translate-y-1/2 flex items-center px-1 h-[21px] rounded-md border-[0.5px] border-components-button-secondary-border shadow-xs bg-components-button-secondary-bg text-text-accent-secondary text-[10px] font-semibold cursor-pointer select-none'
onClick={() => doToggleConditionLogicalOperator(conditionId)}
>
{logicalOperator && logicalOperator.toUpperCase()}
<RiLoopLeftLine className='ml-0.5 w-3 h-3' />
</div>
</div>
)
}
{
conditions.map(condition => (
<ConditionItem
key={condition.id}
className={conditionItemClassName}
disabled={disabled}
conditionId={isSubVariable ? conditionId! : condition.id}
condition={condition}
isValueFieldShort={isValueFieldShort}
onUpdateCondition={onUpdateCondition}
onRemoveCondition={onRemoveCondition}
onAddSubVariableCondition={onAddSubVariableCondition}
onRemoveSubVariableCondition={onRemoveSubVariableCondition}
onUpdateSubVariableCondition={onUpdateSubVariableCondition}
onToggleSubVariableConditionLogicalOperator={onToggleSubVariableConditionLogicalOperator}
nodeId={nodeId}
availableNodes={availableNodes}
numberVariables={numberVariables}
isSubVariableKey={isSubVariable}
availableVars={availableVars}
/>
))
}
</div>
)
}
export default ConditionList

View File

@ -0,0 +1,168 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import { capitalize } from 'lodash-es'
import { useBoolean } from 'ahooks'
import { VarType as NumberVarType } from '../../tool/types'
import VariableTag from '../../_base/components/variable-tag'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import type {
NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import { variableTransformer } from '@/app/components/workflow/utils'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
const options = [
NumberVarType.variable,
NumberVarType.constant,
]
type ConditionNumberInputProps = {
numberVarType?: NumberVarType
onNumberVarTypeChange: (v: NumberVarType) => void
value: string
onValueChange: (v: string) => void
variables: NodeOutPutVar[]
isShort?: boolean
unit?: string
}
const ConditionNumberInput = ({
numberVarType = NumberVarType.constant,
onNumberVarTypeChange,
value,
onValueChange,
variables,
isShort,
unit,
}: ConditionNumberInputProps) => {
const { t } = useTranslation()
const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false)
const [variableSelectorVisible, setVariableSelectorVisible] = useState(false)
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean()
const handleSelectVariable = useCallback((valueSelector: ValueSelector) => {
onValueChange(variableTransformer(valueSelector) as string)
setVariableSelectorVisible(false)
}, [onValueChange])
return (
<div className='flex items-center cursor-pointer'>
<PortalToFollowElem
open={numberVarTypeVisible}
onOpenChange={setNumberVarTypeVisible}
placement='bottom-start'
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger onClick={() => setNumberVarTypeVisible(v => !v)}>
<Button
className='shrink-0'
variant='ghost'
size='small'
>
{capitalize(numberVarType)}
<RiArrowDownSLine className='ml-[1px] w-3.5 h-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='p-1 w-[112px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
{
options.map(option => (
<div
key={option}
className={cn(
'flex items-center px-3 h-7 rounded-md hover:bg-state-base-hover cursor-pointer',
'text-[13px] font-medium text-text-secondary',
numberVarType === option && 'bg-state-base-hover',
)}
onClick={() => {
onNumberVarTypeChange(option)
setNumberVarTypeVisible(false)
}}
>
{capitalize(option)}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<div className='mx-1 w-[1px] h-4 bg-divider-regular'></div>
<div className='grow w-0 ml-0.5'>
{
numberVarType === NumberVarType.variable && (
<PortalToFollowElem
open={variableSelectorVisible}
onOpenChange={setVariableSelectorVisible}
placement='bottom-start'
offset={{ mainAxis: 2, crossAxis: 0 }}
>
<PortalToFollowElemTrigger
className='w-full'
onClick={() => setVariableSelectorVisible(v => !v)}>
{
value && (
<VariableTag
valueSelector={variableTransformer(value) as string[]}
varType={VarType.number}
isShort={isShort}
/>
)
}
{
!value && (
<div className='flex items-center p-1 h-6 text-components-input-text-placeholder text-[13px]'>
<Variable02 className='shrink-0 mr-1 w-4 h-4' />
<div className='w-0 grow truncate'>{t('workflow.nodes.ifElse.selectVariable')}</div>
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={cn('w-[296px] pt-1 bg-components-panel-bg-blur rounded-lg border-[0.5px] border-components-panel-border shadow-lg', isShort && 'w-[200px]')}>
<VarReferenceVars
vars={variables}
onChange={handleSelectVariable}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
{
numberVarType === NumberVarType.constant && (
<div className=' relative'>
<input
className={cn('block w-full px-2 text-[13px] text-components-input-text-filled placeholder:text-components-input-text-placeholder outline-none appearance-none bg-transparent', unit && 'pr-6')}
type='number'
value={value}
onChange={e => onValueChange(e.target.value)}
placeholder={t('workflow.nodes.ifElse.enterValue') || ''}
onFocus={setFocus}
onBlur={setBlur}
/>
{!isFocus && unit && <div className='absolute right-2 top-[50%] translate-y-[-50%] text-text-tertiary system-sm-regular'>{unit}</div>}
</div>
)
}
</div>
</div>
)
}
export default memo(ConditionNumberInput)

View File

@ -0,0 +1,98 @@
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { ComparisonOperator } from '../types'
import {
comparisonOperatorNotRequireValue,
isComparisonOperatorNeedTranslate,
} from '../utils'
import { FILE_TYPE_OPTIONS, TRANSFER_METHOD } from './../default'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
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'
type ConditionValueProps = {
variableSelector: string[]
labelName?: string
operator: ComparisonOperator
value: string | string[]
}
const ConditionValue = ({
variableSelector,
labelName,
operator,
value,
}: ConditionValueProps) => {
const { t } = useTranslation()
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 formatValue = useMemo(() => {
if (notHasValue)
return ''
if (Array.isArray(value)) // transfer method
return value[0]
return value.replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`
return `{{${arr.slice(1).join('.')}}}`
})
}, [notHasValue, value])
const isSelect = operator === ComparisonOperator.in || operator === ComparisonOperator.notIn
const selectName = useMemo(() => {
if (isSelect) {
const name = [...FILE_TYPE_OPTIONS, ...TRANSFER_METHOD].filter(item => item.value === (Array.isArray(value) ? value[0] : value))[0]
return name
? t(`workflow.nodes.ifElse.optionName.${name.i18nKey}`).replace(/{{#([^#]*)#}}/g, (a, b) => {
const arr: string[] = b.split('.')
if (isSystemVar(arr))
return `{{${b}}}`
return `{{${arr.slice(1).join('.')}}}`
})
: ''
}
return ''
}, [isSelect, t, value])
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 && <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' />}
<div
className={cn(
'shrink-0 truncate text-xs font-medium text-text-accent',
!notHasValue && 'max-w-[70px]',
)}
title={variableName}
>
{variableName}
</div>
<div
className='shrink-0 mx-1 text-xs font-medium text-text-primary'
title={operatorName}
>
{operatorName}
</div>
{
!notHasValue && (
<div className='truncate text-xs text-text-secondary' title={formatValue}>{isSelect ? selectName : formatValue}</div>
)
}
</div>
)
}
export default memo(ConditionValue)

View File

@ -0,0 +1,149 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
} from '@remixicon/react'
import type { Condition, HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LogicalOperator, handleRemoveSubVariableCondition } from '../types'
import type { Node, NodeOutPutVar, Var } from '../../../types'
import { VarType } from '../../../types'
import { useGetAvailableVars } from '../../variable-assigner/hooks'
import ConditionList from './condition-list'
import ConditionAdd from './condition-add'
import { SUB_VARIABLES } from './../default'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import { PortalSelect as Select } from '@/app/components/base/select'
type Props = {
isSubVariable?: boolean
conditionId?: string
conditions: Condition[]
logicalOperator: LogicalOperator | undefined
readOnly: boolean
handleAddCondition?: HandleAddCondition
handleRemoveCondition?: HandleRemoveCondition
handleUpdateCondition?: HandleUpdateCondition
handleToggleConditionLogicalOperator?: HandleToggleConditionLogicalOperator
handleAddSubVariableCondition?: HandleAddSubVariableCondition
handleRemoveSubVariableCondition?: handleRemoveSubVariableCondition
handleUpdateSubVariableCondition?: HandleUpdateSubVariableCondition
handleToggleSubVariableConditionLogicalOperator?: HandleToggleSubVariableConditionLogicalOperator
nodeId: string
availableNodes: Node[]
availableVars: NodeOutPutVar[]
}
const ConditionWrap: FC<Props> = ({
isSubVariable,
conditionId,
conditions,
logicalOperator,
nodeId: id = '',
readOnly,
handleUpdateCondition,
handleAddCondition,
handleRemoveCondition,
handleToggleConditionLogicalOperator,
handleAddSubVariableCondition,
handleRemoveSubVariableCondition,
handleUpdateSubVariableCondition,
handleToggleSubVariableConditionLogicalOperator,
availableNodes = [],
availableVars = [],
}) => {
const { t } = useTranslation()
const getAvailableVars = useGetAvailableVars()
const filterNumberVar = useCallback((varPayload: Var) => {
return varPayload.type === VarType.number
}, [])
const subVarOptions = SUB_VARIABLES.map(item => ({
name: item,
value: item,
}))
if (!conditions)
return <div />
return (
<>
<div>
<div
className={cn(
'group relative rounded-[10px] bg-components-panel-bg',
!isSubVariable && 'py-1 px-3 min-h-[40px] ',
isSubVariable && 'px-1 py-2',
)}
>
{
conditions && !!conditions.length && (
<div className='mb-2'>
<ConditionList
disabled={readOnly}
conditionId={conditionId}
conditions={conditions}
logicalOperator={logicalOperator}
onUpdateCondition={handleUpdateCondition}
onRemoveCondition={handleRemoveCondition}
onToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
nodeId={id}
availableNodes={availableNodes}
numberVariables={getAvailableVars(id, '', filterNumberVar)}
onAddSubVariableCondition={handleAddSubVariableCondition}
onRemoveSubVariableCondition={handleRemoveSubVariableCondition}
onUpdateSubVariableCondition={handleUpdateSubVariableCondition}
onToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator}
isSubVariable={isSubVariable}
availableVars={availableVars}
/>
</div>
)
}
<div className={cn(
'flex items-center justify-between pr-[30px]',
!conditions.length && !isSubVariable && 'mt-1',
!conditions.length && isSubVariable && 'mt-2',
conditions.length > 1 && !isSubVariable && 'ml-[60px]',
)}>
{isSubVariable
? (
<Select
popupInnerClassName='w-[165px] max-h-none'
onSelect={value => handleAddSubVariableCondition?.(conditionId!, value.value as string)}
items={subVarOptions}
value=''
renderTrigger={() => (
<Button
size='small'
disabled={readOnly}
>
<RiAddLine className='mr-1 w-3.5 h-3.5' />
{t('workflow.nodes.ifElse.addSubVariable')}
</Button>
)}
hideChecked
/>
)
: (
<ConditionAdd
disabled={readOnly}
variables={availableVars}
onSelectVariable={handleAddCondition!}
/>
)}
</div>
</div>
{!isSubVariable && (
<div className='my-2 mx-3 h-[1px] bg-divider-subtle'></div>
)}
</div>
</>
)
}
export default React.memo(ConditionWrap)

View File

@ -0,0 +1,92 @@
import { BlockEnum } from '../../types'
import type { NodeDefault } from '../../types'
import { ComparisonOperator, LogicalOperator, type LoopNodeType } from './types'
import { isEmptyRelatedOperator } from './utils'
import { TransferMethod } from '@/types/app'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/blocks'
import { LOOP_NODE_MAX_COUNT } from '@/config'
const i18nPrefix = 'workflow.errorMsg'
const nodeDefault: NodeDefault<LoopNodeType> = {
defaultValue: {
start_node_id: '',
break_conditions: [],
loop_count: 10,
_children: [],
logical_operator: LogicalOperator.and,
},
getAvailablePrevNodes(isChatMode: boolean) {
const nodes = isChatMode
? ALL_CHAT_AVAILABLE_BLOCKS
: ALL_COMPLETION_AVAILABLE_BLOCKS.filter(type => type !== BlockEnum.End)
return nodes
},
getAvailableNextNodes(isChatMode: boolean) {
const nodes = isChatMode ? ALL_CHAT_AVAILABLE_BLOCKS : ALL_COMPLETION_AVAILABLE_BLOCKS
return nodes
},
checkValid(payload: LoopNodeType, t: any) {
let errorMessages = ''
if (!errorMessages && (!payload.break_conditions || payload.break_conditions.length === 0))
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.loop.breakCondition') })
payload.break_conditions!.forEach((condition) => {
if (!errorMessages && (!condition.variable_selector || condition.variable_selector.length === 0))
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variable`) })
if (!errorMessages && !condition.comparison_operator)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.ifElse.operator') })
if (!errorMessages) {
if (condition.sub_variable_condition
&& ![ComparisonOperator.empty, ComparisonOperator.notEmpty].includes(condition.comparison_operator!)) {
const isSet = condition.sub_variable_condition.conditions.every((c) => {
if (!c.comparison_operator)
return false
if (isEmptyRelatedOperator(c.comparison_operator!))
return true
return !!c.value
})
if (!isSet)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
}
else {
if (!isEmptyRelatedOperator(condition.comparison_operator!) && !condition.value)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
}
}
})
if (!errorMessages && (
Number.isNaN(Number(payload.loop_count))
|| !Number.isInteger(Number(payload.loop_count))
|| payload.loop_count < 1
|| payload.loop_count > LOOP_NODE_MAX_COUNT
))
errorMessages = t('workflow.nodes.loop.loopMaxCountError', { maxCount: LOOP_NODE_MAX_COUNT })
return {
isValid: !errorMessages,
errorMessage: errorMessages,
}
},
}
export const FILE_TYPE_OPTIONS = [
{ value: 'image', i18nKey: 'image' },
{ value: 'document', i18nKey: 'doc' },
{ value: 'audio', i18nKey: 'audio' },
{ value: 'video', i18nKey: 'video' },
]
export const TRANSFER_METHOD = [
{ value: TransferMethod.local_file, i18nKey: 'localUpload' },
{ value: TransferMethod.remote_url, i18nKey: 'url' },
]
export const SUB_VARIABLES = ['type', 'size', 'name', 'url', 'extension', 'mime_type', 'transfer_method']
export const OUTPUT_FILE_SUB_VARIABLES = SUB_VARIABLES.filter(key => key !== 'transfer_method')
export default nodeDefault

View File

@ -0,0 +1,61 @@
import {
memo,
useCallback,
useState,
} from 'react'
import cn from 'classnames'
import { useNodesInteractions } from '../../hooks'
import type {
BlockEnum,
OnSelectBlock,
} from '../../types'
import BlockSelector from '../../block-selector'
type InsertBlockProps = {
startNodeId: string
availableBlocksTypes: BlockEnum[]
}
const InsertBlock = ({
startNodeId,
availableBlocksTypes,
}: InsertBlockProps) => {
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
const handleInsert = useCallback<OnSelectBlock>((nodeType, toolDefaultValue) => {
handleNodeAdd(
{
nodeType,
toolDefaultValue,
},
{
nextNodeId: startNodeId,
nextNodeTargetHandle: 'target',
},
)
}, [startNodeId, handleNodeAdd])
return (
<div
className={cn(
'nopan nodrag',
'hidden group-hover/insert:block absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2',
open && '!block',
)}
>
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
asChild
onSelect={handleInsert}
availableBlocksTypes={availableBlocksTypes}
triggerClassName={() => 'hover:scale-125 transition-all'}
/>
</div>
)
}
export default memo(InsertBlock)

View File

@ -0,0 +1,61 @@
import type { FC } from 'react'
import {
memo,
useEffect,
} from 'react'
import {
Background,
useNodesInitialized,
useViewport,
} from 'reactflow'
import { LoopStartNodeDumb } from '../loop-start'
import { useNodeLoopInteractions } from './use-interactions'
import type { LoopNodeType } from './types'
import AddBlock from './add-block'
import cn from '@/utils/classnames'
import type { NodeProps } from '@/app/components/workflow/types'
const Node: FC<NodeProps<LoopNodeType>> = ({
id,
data,
}) => {
const { zoom } = useViewport()
const nodesInitialized = useNodesInitialized()
const { handleNodeLoopRerender } = useNodeLoopInteractions()
useEffect(() => {
if (nodesInitialized)
handleNodeLoopRerender(id)
}, [nodesInitialized, id, handleNodeLoopRerender])
return (
<div className={cn(
'relative min-w-[240px] min-h-[90px] w-full h-full rounded-2xl bg-[#F0F2F7]/90',
)}>
<Background
id={`loop-background-${id}`}
className='rounded-2xl !z-0'
gap={[14 / zoom, 14 / zoom]}
size={2 / zoom}
color='#E4E5E7'
/>
{
data._isCandidate && (
<LoopStartNodeDumb />
)
}
{
data._children!.length === 1 && (
<AddBlock
loopNodeId={id}
loopNodeData={data}
/>
)
}
</div>
)
}
export default memo(Node)

View File

@ -0,0 +1,120 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Split from '../_base/components/split'
import ResultPanel from '../../run/result-panel'
import InputNumberWithSlider from '../_base/components/input-number-with-slider'
import type { LoopNodeType } from './types'
import useConfig from './use-config'
import ConditionWrap from './components/condition-wrap'
import type { NodePanelProps } from '@/app/components/workflow/types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import BeforeRunForm from '@/app/components/workflow/nodes/_base/components/before-run-form'
import formatTracing from '@/app/components/workflow/run/utils/format-log'
import { useLogs } from '@/app/components/workflow/run/hooks'
import { LOOP_NODE_MAX_COUNT } from '@/config'
const i18nPrefix = 'workflow.nodes.loop'
const Panel: FC<NodePanelProps<LoopNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const {
readOnly,
inputs,
childrenNodeVars,
loopChildrenNodes,
isShowSingleRun,
hideSingleRun,
runningStatus,
handleRun,
handleStop,
runResult,
loopRunResult,
handleAddCondition,
handleUpdateCondition,
handleRemoveCondition,
handleToggleConditionLogicalOperator,
handleAddSubVariableCondition,
handleRemoveSubVariableCondition,
handleUpdateSubVariableCondition,
handleToggleSubVariableConditionLogicalOperator,
handleUpdateLoopCount,
} = useConfig(id, data)
const nodeInfo = formatTracing(loopRunResult, t)[0]
const logsParams = useLogs()
return (
<div className='mt-2'>
<div>
<Field
title={<div className='pl-3'>{t(`${i18nPrefix}.breakCondition`)}</div>}
>
<ConditionWrap
nodeId={id}
readOnly={readOnly}
handleAddCondition={handleAddCondition}
handleRemoveCondition={handleRemoveCondition}
handleUpdateCondition={handleUpdateCondition}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
handleAddSubVariableCondition={handleAddSubVariableCondition}
handleRemoveSubVariableCondition={handleRemoveSubVariableCondition}
handleUpdateSubVariableCondition={handleUpdateSubVariableCondition}
handleToggleSubVariableConditionLogicalOperator={handleToggleSubVariableConditionLogicalOperator}
availableNodes={loopChildrenNodes}
availableVars={childrenNodeVars}
conditions={inputs.break_conditions || []}
logicalOperator={inputs.logical_operator!}
/>
</Field>
<Split />
<div className='mt-2'>
<Field
title={<div className='pl-3'>{t(`${i18nPrefix}.loopMaxCount`)}</div>}
>
<div className='px-3 py-2'>
<InputNumberWithSlider
min={1}
max={LOOP_NODE_MAX_COUNT}
value={inputs.loop_count}
onChange={(val) => {
const roundedVal = Math.round(val)
handleUpdateLoopCount(Number.isNaN(roundedVal) ? 1 : roundedVal)
}}
/>
</div>
</Field>
</div>
</div>
{/* Error handling for the Loop node is currently not considered. */}
{/* <div className='px-4 py-2'>
<Field title={t(`${i18nPrefix}.errorResponseMethod`)} >
<Select items={responseMethod} defaultValue={inputs.error_handle_mode} onSelect={changeErrorResponseMode} allowSearch={false}>
</Select>
</Field>
</div> */}
{isShowSingleRun && (
<BeforeRunForm
nodeName={inputs.title}
onHide={hideSingleRun}
forms={[]}
runningStatus={runningStatus}
onRun={handleRun}
onStop={handleStop}
{...logsParams}
result={
<ResultPanel {...runResult} showSteps={false} nodeInfo={nodeInfo} {...logsParams} />
}
/>
)}
</div>
)
}
export default React.memo(Panel)

View File

@ -0,0 +1,76 @@
import type { VarType as NumberVarType } from '../tool/types'
import type {
BlockEnum,
CommonNodeType,
ErrorHandleMode,
ValueSelector,
Var,
VarType,
} from '@/app/components/workflow/types'
export enum LogicalOperator {
and = 'and',
or = 'or',
}
export enum ComparisonOperator {
contains = 'contains',
notContains = 'not contains',
startWith = 'start with',
endWith = 'end with',
is = 'is',
isNot = 'is not',
empty = 'empty',
notEmpty = 'not empty',
equal = '=',
notEqual = '≠',
largerThan = '>',
lessThan = '<',
largerThanOrEqual = '≥',
lessThanOrEqual = '≤',
isNull = 'is null',
isNotNull = 'is not null',
in = 'in',
notIn = 'not in',
allOf = 'all of',
exists = 'exists',
notExists = 'not exists',
}
export type Condition = {
id: string
varType: VarType
variable_selector?: ValueSelector
key?: string // sub variable key
comparison_operator?: ComparisonOperator
value: string | string[]
numberVarType?: NumberVarType
sub_variable_condition?: CaseItem
}
export type CaseItem = {
logical_operator: LogicalOperator
conditions: Condition[]
}
export type HandleAddCondition = (valueSelector: ValueSelector, varItem: Var) => void
export type HandleRemoveCondition = (conditionId: string) => void
export type HandleUpdateCondition = (conditionId: string, newCondition: Condition) => void
export type HandleUpdateConditionLogicalOperator = (value: LogicalOperator) => void
export type HandleToggleConditionLogicalOperator = () => void
export type HandleAddSubVariableCondition = (conditionId: string, key?: string) => void
export type handleRemoveSubVariableCondition = (conditionId: string, subConditionId: string) => void
export type HandleUpdateSubVariableCondition = (conditionId: string, subConditionId: string, newSubCondition: Condition) => void
export type HandleToggleSubVariableConditionLogicalOperator = (conditionId: string) => void
export type LoopNodeType = CommonNodeType & {
startNodeType?: BlockEnum
start_node_id: string
loop_id?: string
logical_operator?: LogicalOperator
break_conditions?: Condition[]
loop_count: number
error_handle_mode: ErrorHandleMode // how to handle error in the iteration
}

View File

@ -0,0 +1,329 @@
import { useCallback } from 'react'
import produce from 'immer'
import { useBoolean } from 'ahooks'
import { uuid4 } from '@sentry/utils'
import {
useIsChatMode,
useIsNodeInLoop,
useNodesReadOnly,
useWorkflow,
} from '../../hooks'
import { VarType } from '../../types'
import type { ErrorHandleMode, ValueSelector, Var } from '../../types'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { getNodeInfoById, getNodeUsedVarPassToServerKey, getNodeUsedVars, isSystemVar, toNodeOutputVars } from '../_base/components/variable/utils'
import useOneStepRun from '../_base/hooks/use-one-step-run'
import { getOperators } from './utils'
import { LogicalOperator } from './types'
import type { HandleAddCondition, HandleAddSubVariableCondition, HandleRemoveCondition, HandleToggleConditionLogicalOperator, HandleToggleSubVariableConditionLogicalOperator, HandleUpdateCondition, HandleUpdateSubVariableCondition, LoopNodeType } from './types'
import useIsVarFileAttribute from './use-is-var-file-attribute'
const DELIMITER = '@@@@@'
const useConfig = (id: string, payload: LoopNodeType) => {
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { isNodeInLoop } = useIsNodeInLoop(id)
const isChatMode = useIsChatMode()
const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)
const filterInputVar = useCallback((varPayload: Var) => {
return [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject, VarType.arrayFile].includes(varPayload.type)
}, [])
// output
const { getLoopNodeChildren, getBeforeNodesInSameBranch } = useWorkflow()
const beforeNodes = getBeforeNodesInSameBranch(id)
const loopChildrenNodes = getLoopNodeChildren(id)
const canChooseVarNodes = [...beforeNodes, ...loopChildrenNodes]
const childrenNodeVars = toNodeOutputVars(loopChildrenNodes, isChatMode)
// single run
const loopInputKey = `${id}.input_selector`
const {
isShowSingleRun,
showSingleRun,
hideSingleRun,
toVarInputs,
runningStatus,
handleRun: doHandleRun,
handleStop,
runInputData,
setRunInputData,
runResult,
loopRunResult,
} = useOneStepRun<LoopNodeType>({
id,
data: inputs,
loopInputKey,
defaultRunInputData: {
[loopInputKey]: [''],
},
})
const [isShowLoopDetail, {
setTrue: doShowLoopDetail,
setFalse: doHideLoopDetail,
}] = useBoolean(false)
const hideLoopDetail = useCallback(() => {
hideSingleRun()
doHideLoopDetail()
}, [doHideLoopDetail, hideSingleRun])
const showLoopDetail = useCallback(() => {
doShowLoopDetail()
}, [doShowLoopDetail])
const backToSingleRun = useCallback(() => {
hideLoopDetail()
showSingleRun()
}, [hideLoopDetail, showSingleRun])
const {
getIsVarFileAttribute,
} = useIsVarFileAttribute({
nodeId: id,
})
const { usedOutVars, allVarObject } = (() => {
const vars: ValueSelector[] = []
const varObjs: Record<string, boolean> = {}
const allVarObject: Record<string, {
inSingleRunPassedKey: string
}> = {}
loopChildrenNodes.forEach((node) => {
const nodeVars = getNodeUsedVars(node).filter(item => item && item.length > 0)
nodeVars.forEach((varSelector) => {
if (varSelector[0] === id) { // skip Loop node itself variable: item, index
return
}
const isInLoop = isNodeInLoop(varSelector[0])
if (isInLoop) // not pass loop inner variable
return
const varSectorStr = varSelector.join('.')
if (!varObjs[varSectorStr]) {
varObjs[varSectorStr] = true
vars.push(varSelector)
}
let passToServerKeys = getNodeUsedVarPassToServerKey(node, varSelector)
if (typeof passToServerKeys === 'string')
passToServerKeys = [passToServerKeys]
passToServerKeys.forEach((key: string, index: number) => {
allVarObject[[varSectorStr, node.id, index].join(DELIMITER)] = {
inSingleRunPassedKey: key,
}
})
})
})
const res = toVarInputs(vars.map((item) => {
const varInfo = getNodeInfoById(canChooseVarNodes, item[0])
return {
label: {
nodeType: varInfo?.data.type,
nodeName: varInfo?.data.title || canChooseVarNodes[0]?.data.title, // default start node title
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
},
variable: `${item.join('.')}`,
value_selector: item,
}
}))
return {
usedOutVars: res,
allVarObject,
}
})()
const handleRun = useCallback((data: Record<string, any>) => {
const formattedData: Record<string, any> = {}
Object.keys(allVarObject).forEach((key) => {
const [varSectorStr, nodeId] = key.split(DELIMITER)
formattedData[`${nodeId}.${allVarObject[key].inSingleRunPassedKey}`] = data[varSectorStr]
})
formattedData[loopInputKey] = data[loopInputKey]
doHandleRun(formattedData)
}, [allVarObject, doHandleRun, loopInputKey])
const inputVarValues = (() => {
const vars: Record<string, any> = {}
Object.keys(runInputData)
.filter(key => ![loopInputKey].includes(key))
.forEach((key) => {
vars[key] = runInputData[key]
})
return vars
})()
const setInputVarValues = useCallback((newPayload: Record<string, any>) => {
const newVars = {
...newPayload,
[loopInputKey]: runInputData[loopInputKey],
}
setRunInputData(newVars)
}, [loopInputKey, runInputData, setRunInputData])
const loop = runInputData[loopInputKey]
const setLoop = useCallback((newLoop: string[]) => {
setRunInputData({
...runInputData,
[loopInputKey]: newLoop,
})
}, [loopInputKey, runInputData, setRunInputData])
const changeErrorResponseMode = useCallback((item: { value: unknown }) => {
const newInputs = produce(inputs, (draft) => {
draft.error_handle_mode = item.value as ErrorHandleMode
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleAddCondition = useCallback<HandleAddCondition>((valueSelector, varItem) => {
const newInputs = produce(inputs, (draft) => {
if (!draft.break_conditions)
draft.break_conditions = []
draft.break_conditions?.push({
id: uuid4(),
varType: varItem.type,
variable_selector: valueSelector,
comparison_operator: getOperators(varItem.type, getIsVarFileAttribute(valueSelector) ? { key: valueSelector.slice(-1)[0] } : undefined)[0],
value: '',
})
})
setInputs(newInputs)
}, [getIsVarFileAttribute, inputs, setInputs])
const handleRemoveCondition = useCallback<HandleRemoveCondition>((conditionId) => {
const newInputs = produce(inputs, (draft) => {
draft.break_conditions = draft.break_conditions?.filter(item => item.id !== conditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleUpdateCondition = useCallback<HandleUpdateCondition>((conditionId, newCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition)
Object.assign(targetCondition, newCondition)
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => {
const newInputs = produce(inputs, (draft) => {
draft.logical_operator = draft.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleAddSubVariableCondition = useCallback<HandleAddSubVariableCondition>((conditionId: string, key?: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition) {
condition.sub_variable_condition = {
logical_operator: LogicalOperator.and,
conditions: [],
}
}
const subVarCondition = condition.sub_variable_condition
if (subVarCondition) {
if (!subVarCondition.conditions)
subVarCondition.conditions = []
const svcComparisonOperators = getOperators(VarType.string, { key: key || '' })
subVarCondition.conditions.push({
id: uuid4(),
key: key || '',
varType: VarType.string,
comparison_operator: (svcComparisonOperators && svcComparisonOperators.length) ? svcComparisonOperators[0] : undefined,
value: '',
})
}
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleRemoveSubVariableCondition = useCallback((conditionId: string, subConditionId: string) => {
const newInputs = produce(inputs, (draft) => {
const condition = draft.break_conditions?.find(item => item.id === conditionId)
if (!condition)
return
if (!condition?.sub_variable_condition)
return
const subVarCondition = condition.sub_variable_condition
if (subVarCondition)
subVarCondition.conditions = subVarCondition.conditions.filter(item => item.id !== subConditionId)
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleUpdateSubVariableCondition = useCallback<HandleUpdateSubVariableCondition>((conditionId, subConditionId, newSubCondition) => {
const newInputs = produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition) {
const targetSubCondition = targetCondition.sub_variable_condition.conditions.find(item => item.id === subConditionId)
if (targetSubCondition)
Object.assign(targetSubCondition, newSubCondition)
}
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleToggleSubVariableConditionLogicalOperator = useCallback<HandleToggleSubVariableConditionLogicalOperator>((conditionId) => {
const newInputs = produce(inputs, (draft) => {
const targetCondition = draft.break_conditions?.find(item => item.id === conditionId)
if (targetCondition && targetCondition.sub_variable_condition)
targetCondition.sub_variable_condition.logical_operator = targetCondition.sub_variable_condition.logical_operator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
})
setInputs(newInputs)
}, [inputs, setInputs])
const handleUpdateLoopCount = useCallback((value: number) => {
const newInputs = produce(inputs, (draft) => {
draft.loop_count = value
})
setInputs(newInputs)
}, [inputs, setInputs])
return {
readOnly,
inputs,
filterInputVar,
childrenNodeVars,
loopChildrenNodes,
isShowSingleRun,
showSingleRun,
hideSingleRun,
isShowLoopDetail,
showLoopDetail,
hideLoopDetail,
backToSingleRun,
runningStatus,
handleRun,
handleStop,
runResult,
inputVarValues,
setInputVarValues,
usedOutVars,
loop,
setLoop,
loopInputKey,
loopRunResult,
handleAddCondition,
handleRemoveCondition,
handleUpdateCondition,
handleToggleConditionLogicalOperator,
handleAddSubVariableCondition,
handleUpdateSubVariableCondition,
handleRemoveSubVariableCondition,
handleToggleSubVariableConditionLogicalOperator,
handleUpdateLoopCount,
changeErrorResponseMode,
}
}
export default useConfig

View File

@ -0,0 +1,146 @@
import { useCallback } from 'react'
import produce from 'immer'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import type {
BlockEnum,
Node,
} from '../../types'
import { generateNewNode } from '../../utils'
import {
LOOP_PADDING,
NODES_INITIAL_DATA,
} from '../../constants'
import { CUSTOM_LOOP_START_NODE } from '../loop-start/constants'
export const useNodeLoopInteractions = () => {
const { t } = useTranslation()
const store = useStoreApi()
const handleNodeLoopRerender = useCallback((nodeId: string) => {
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
let rightNode: Node
let bottomNode: Node
childrenNodes.forEach((n) => {
if (rightNode) {
if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
rightNode = n
}
else {
rightNode = n
}
if (bottomNode) {
if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
bottomNode = n
}
else {
bottomNode = n
}
})
const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
if (widthShouldExtend || heightShouldExtend) {
const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
if (n.id === nodeId) {
if (widthShouldExtend) {
n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
}
if (heightShouldExtend) {
n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
}
}
})
})
setNodes(newNodes)
}
}, [store])
const handleNodeLoopChildDrag = useCallback((node: Node) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const restrictPosition: { x?: number; y?: number } = { x: undefined, y: undefined }
if (node.data.isInLoop) {
const parentNode = nodes.find(n => n.id === node.parentId)
if (parentNode) {
if (node.position.y < LOOP_PADDING.top)
restrictPosition.y = LOOP_PADDING.top
if (node.position.x < LOOP_PADDING.left)
restrictPosition.x = LOOP_PADDING.left
if (node.position.x + node.width! > parentNode!.width! - LOOP_PADDING.right)
restrictPosition.x = parentNode!.width! - LOOP_PADDING.right - node.width!
if (node.position.y + node.height! > parentNode!.height! - LOOP_PADDING.bottom)
restrictPosition.y = parentNode!.height! - LOOP_PADDING.bottom - node.height!
}
}
return {
restrictPosition,
}
}, [store])
const handleNodeLoopChildSizeChange = useCallback((nodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const currentNode = nodes.find(n => n.id === nodeId)!
const parentId = currentNode.parentId
if (parentId)
handleNodeLoopRerender(parentId)
}, [store, handleNodeLoopRerender])
const handleNodeLoopChildrenCopy = useCallback((nodeId: string, newNodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
return childrenNodes.map((child, index) => {
const childNodeType = child.data.type as BlockEnum
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
const { newNode } = generateNewNode({
data: {
...NODES_INITIAL_DATA[childNodeType],
...child.data,
selected: false,
_isBundled: false,
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${childNodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${childNodeType}`),
loop_id: newNodeId,
},
position: child.position,
positionAbsolute: child.positionAbsolute,
parentId: newNodeId,
extent: child.extent,
zIndex: child.zIndex,
})
newNode.id = `${newNodeId}${newNode.id + index}`
return newNode
})
}, [store, t])
return {
handleNodeLoopRerender,
handleNodeLoopChildDrag,
handleNodeLoopChildSizeChange,
handleNodeLoopChildrenCopy,
}
}

View File

@ -0,0 +1,35 @@
import { useMemo } from 'react'
import { useIsChatMode, useWorkflow, useWorkflowVariables } from '../../hooks'
import type { ValueSelector } from '../../types'
import { VarType } from '../../types'
type Params = {
nodeId: string
}
const useIsVarFileAttribute = ({
nodeId,
}: Params) => {
const isChatMode = useIsChatMode()
const { getBeforeNodesInSameBranch } = useWorkflow()
const availableNodes = useMemo(() => {
return getBeforeNodesInSameBranch(nodeId)
}, [getBeforeNodesInSameBranch, nodeId])
const { getCurrentVariableType } = useWorkflowVariables()
const getIsVarFileAttribute = (variable: ValueSelector) => {
if (variable.length !== 3)
return false
const parentVariable = variable.slice(0, 2)
const varType = getCurrentVariableType({
valueSelector: parentVariable,
availableNodes,
isChatMode,
isConstant: false,
})
return varType === VarType.file
}
return {
getIsVarFileAttribute,
}
}
export default useIsVarFileAttribute

View File

@ -0,0 +1,179 @@
import { ComparisonOperator } from './types'
import { VarType } from '@/app/components/workflow/types'
import type { Branch } from '@/app/components/workflow/types'
export const isEmptyRelatedOperator = (operator: ComparisonOperator) => {
return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator)
}
const notTranslateKey = [
ComparisonOperator.equal, ComparisonOperator.notEqual,
ComparisonOperator.largerThan, ComparisonOperator.largerThanOrEqual,
ComparisonOperator.lessThan, ComparisonOperator.lessThanOrEqual,
]
export const isComparisonOperatorNeedTranslate = (operator?: ComparisonOperator) => {
if (!operator)
return false
return !notTranslateKey.includes(operator)
}
export const getOperators = (type?: VarType, file?: { key: string }) => {
const isFile = !!file
if (isFile) {
const { key } = file
switch (key) {
case 'name':
return [
ComparisonOperator.contains,
ComparisonOperator.notContains,
ComparisonOperator.startWith,
ComparisonOperator.endWith,
ComparisonOperator.is,
ComparisonOperator.isNot,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
case 'type':
return [
ComparisonOperator.in,
ComparisonOperator.notIn,
]
case 'size':
return [
ComparisonOperator.largerThan,
ComparisonOperator.largerThanOrEqual,
ComparisonOperator.lessThan,
ComparisonOperator.lessThanOrEqual,
]
case 'extension':
return [
ComparisonOperator.is,
ComparisonOperator.isNot,
ComparisonOperator.contains,
ComparisonOperator.notContains,
]
case 'mime_type':
return [
ComparisonOperator.contains,
ComparisonOperator.notContains,
ComparisonOperator.startWith,
ComparisonOperator.endWith,
ComparisonOperator.is,
ComparisonOperator.isNot,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
case 'transfer_method':
return [
ComparisonOperator.in,
ComparisonOperator.notIn,
]
case 'url':
return [
ComparisonOperator.contains,
ComparisonOperator.notContains,
ComparisonOperator.startWith,
ComparisonOperator.endWith,
ComparisonOperator.is,
ComparisonOperator.isNot,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
}
return []
}
switch (type) {
case VarType.string:
return [
ComparisonOperator.contains,
ComparisonOperator.notContains,
ComparisonOperator.startWith,
ComparisonOperator.endWith,
ComparisonOperator.is,
ComparisonOperator.isNot,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
case VarType.number:
return [
ComparisonOperator.equal,
ComparisonOperator.notEqual,
ComparisonOperator.largerThan,
ComparisonOperator.lessThan,
ComparisonOperator.largerThanOrEqual,
ComparisonOperator.lessThanOrEqual,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
case VarType.object:
return [
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
case VarType.file:
return [
ComparisonOperator.exists,
ComparisonOperator.notExists,
]
case VarType.arrayString:
case VarType.arrayNumber:
return [
ComparisonOperator.contains,
ComparisonOperator.notContains,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
case VarType.array:
case VarType.arrayObject:
return [
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
case VarType.arrayFile:
return [
ComparisonOperator.contains,
ComparisonOperator.notContains,
ComparisonOperator.allOf,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
default:
return [
ComparisonOperator.is,
ComparisonOperator.isNot,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
]
}
}
export const comparisonOperatorNotRequireValue = (operator?: ComparisonOperator) => {
if (!operator)
return false
return [ComparisonOperator.empty, ComparisonOperator.notEmpty, ComparisonOperator.isNull, ComparisonOperator.isNotNull, ComparisonOperator.exists, ComparisonOperator.notExists].includes(operator)
}
export const branchNameCorrect = (branches: Branch[]) => {
const branchLength = branches.length
if (branchLength < 2)
throw new Error('if-else node branch number must than 2')
if (branchLength === 2) {
return branches.map((branch) => {
return {
...branch,
name: branch.id === 'false' ? 'ELSE' : 'IF',
}
})
}
return branches.map((branch, index) => {
return {
...branch,
name: branch.id === 'false' ? 'ELSE' : `CASE ${index + 1}`,
}
})
}

View File

@ -11,7 +11,7 @@ import { uniqBy } from 'lodash-es'
import { useWorkflowRun } from '../../hooks'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { useWorkflowStore } from '../../store'
import { DEFAULT_ITER_TIMES } from '../../constants'
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../../constants'
import type {
ChatItem,
ChatItemInTree,
@ -57,6 +57,7 @@ export const useChat = (
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
const {
setIterTimes,
setLoopTimes,
} = workflowStore.getState()
const handleResponding = useCallback((isResponding: boolean) => {
@ -128,20 +129,23 @@ export const useChat = (
if (stopChat && taskIdRef.current)
stopChat(taskIdRef.current)
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
}, [handleResponding, setIterTimes, stopChat])
}, [handleResponding, setIterTimes, setLoopTimes, stopChat])
const handleRestart = useCallback(() => {
conversationId.current = ''
taskIdRef.current = ''
handleStop()
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
setChatTree([])
setSuggestQuestions([])
}, [
handleStop,
setIterTimes,
setLoopTimes,
])
const updateCurrentQAOnTree = useCallback(({
@ -381,8 +385,35 @@ export const useChat = (
})
}
},
onLoopStart: ({ data }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onLoopFinish: ({ data }) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
onNodeStarted: ({ data }) => {
if (data.iteration_id)
if (data.iteration_id || data.loop_id)
return
responseItem.workflowProcess!.tracing!.push({
@ -397,7 +428,7 @@ export const useChat = (
})
},
onNodeRetry: ({ data }) => {
if (data.iteration_id)
if (data.iteration_id || data.loop_id)
return
responseItem.workflowProcess!.tracing!.push(data)
@ -410,7 +441,7 @@ export const useChat = (
})
},
onNodeFinished: ({ data }) => {
if (data.iteration_id)
if (data.iteration_id || data.loop_id)
return
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)

View File

@ -7,6 +7,7 @@ import { useBoolean } from 'ahooks'
import type {
AgentLogItemWithChildren,
IterationDurationMap,
LoopDurationMap,
NodeTracing,
} from '@/types/workflow'
@ -33,6 +34,18 @@ export const useLogs = () => {
setIterationResultDurationMap(iterDurationMap)
}, [setShowIteratingDetailTrue, setIterationResultList, setIterationResultDurationMap])
const [showLoopingDetail, {
setTrue: setShowLoopingDetailTrue,
setFalse: setShowLoopingDetailFalse,
}] = useBoolean(false)
const [loopResultList, setLoopResultList] = useState<NodeTracing[][]>([])
const [loopResultDurationMap, setLoopResultDurationMap] = useState<LoopDurationMap>({})
const handleShowLoopResultList = useCallback((detail: NodeTracing[][], loopDurationMap: LoopDurationMap) => {
setShowLoopingDetailTrue()
setLoopResultList(detail)
setLoopResultDurationMap(loopDurationMap)
}, [setShowLoopingDetailTrue, setLoopResultList, setLoopResultDurationMap])
const [agentOrToolLogItemStack, setAgentOrToolLogItemStack] = useState<AgentLogItemWithChildren[]>([])
const agentOrToolLogItemStackRef = useRef(agentOrToolLogItemStack)
const [agentOrToolLogListMap, setAgentOrToolLogListMap] = useState<Record<string, AgentLogItemWithChildren[]>>({})
@ -64,7 +77,7 @@ export const useLogs = () => {
}, [setAgentOrToolLogItemStack, setAgentOrToolLogListMap])
return {
showSpecialResultPanel: showRetryDetail || showIteratingDetail || !!agentOrToolLogItemStack.length,
showSpecialResultPanel: showRetryDetail || showIteratingDetail || showLoopingDetail || !!agentOrToolLogItemStack.length,
showRetryDetail,
setShowRetryDetailTrue,
setShowRetryDetailFalse,
@ -81,6 +94,15 @@ export const useLogs = () => {
setIterationResultDurationMap,
handleShowIterationResultList,
showLoopingDetail,
setShowLoopingDetailTrue,
setShowLoopingDetailFalse,
loopResultList,
setLoopResultList,
loopResultDurationMap,
setLoopResultDurationMap,
handleShowLoopResultList,
agentOrToolLogItemStack,
agentOrToolLogListMap,
handleShowAgentOrToolLog,

View File

@ -0,0 +1,2 @@
export { default as LoopLogTrigger } from './loop-log-trigger'
export { default as LoopResultPanel } from './loop-result-panel'

View File

@ -0,0 +1,57 @@
import { useTranslation } from 'react-i18next'
import { RiArrowRightSLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import type {
LoopDurationMap,
NodeTracing,
} from '@/types/workflow'
import { Loop } from '@/app/components/base/icons/src/vender/workflow'
type LoopLogTriggerProps = {
nodeInfo: NodeTracing
onShowLoopResultList: (loopResultList: NodeTracing[][], loopResultDurationMap: LoopDurationMap) => void
}
const LoopLogTrigger = ({
nodeInfo,
onShowLoopResultList,
}: LoopLogTriggerProps) => {
const { t } = useTranslation()
const getErrorCount = (details: NodeTracing[][] | undefined) => {
if (!details || details.length === 0)
return 0
return details.reduce((acc, loop) => {
if (loop.some(item => item.status === 'failed'))
acc++
return acc
}, 0)
}
const getCount = (loop_curr_length: number | undefined, loop_length: number) => {
if ((loop_curr_length && loop_curr_length < loop_length) || !loop_length)
return loop_curr_length
return loop_length
}
const handleOnShowLoopDetail = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onShowLoopResultList(nodeInfo.details || [], nodeInfo?.loopDurationMap || nodeInfo.execution_metadata?.loop_duration_map || {})
}
return (
<Button
className='flex items-center w-full self-stretch gap-2 px-3 py-2 bg-components-button-tertiary-bg-hover hover:bg-components-button-tertiary-bg-hover rounded-lg cursor-pointer border-none'
onClick={handleOnShowLoopDetail}
>
<Loop className='w-4 h-4 text-components-button-tertiary-text shrink-0' />
<div className='flex-1 text-left system-sm-medium text-components-button-tertiary-text'>{t('workflow.nodes.loop.loop', { count: getCount(nodeInfo.details?.length, nodeInfo.metadata?.loop_length) })}{getErrorCount(nodeInfo.details) > 0 && (
<>
{t('workflow.nodes.loop.comma')}
{t('workflow.nodes.loop.error', { count: getErrorCount(nodeInfo.details) })}
</>
)}</div>
<RiArrowRightSLine className='w-4 h-4 text-components-button-tertiary-text shrink-0' />
</Button>
)
}
export default LoopLogTrigger

View File

@ -0,0 +1,128 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowLeftLine,
RiArrowRightSLine,
RiErrorWarningLine,
RiLoader2Line,
} from '@remixicon/react'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
import { Loop } from '@/app/components/base/icons/src/vender/workflow'
import cn from '@/utils/classnames'
import type { LoopDurationMap, NodeTracing } from '@/types/workflow'
const i18nPrefix = 'workflow.singleRun'
type Props = {
list: NodeTracing[][]
onBack: () => void
loopDurationMap?: LoopDurationMap
}
const LoopResultPanel: FC<Props> = ({
list,
onBack,
loopDurationMap,
}) => {
const { t } = useTranslation()
const [expandedLoops, setExpandedLoops] = useState<Record<number, boolean>>({})
const toggleLoop = useCallback((index: number) => {
setExpandedLoops(prev => ({
...prev,
[index]: !prev[index],
}))
}, [])
const countLoopDuration = (loop: NodeTracing[], loopDurationMap: LoopDurationMap): string => {
const loopRunIndex = loop[0]?.execution_metadata?.loop_index as number
const loopRunId = loop[0]?.execution_metadata?.parallel_mode_run_id
const loopItem = loopDurationMap[loopRunId || loopRunIndex]
const duration = loopItem
return `${(duration && duration > 0.01) ? duration.toFixed(2) : 0.01}s`
}
const loopStatusShow = (index: number, loop: NodeTracing[], loopDurationMap?: LoopDurationMap) => {
const hasFailed = loop.some(item => item.status === NodeRunningStatus.Failed)
const isRunning = loop.some(item => item.status === NodeRunningStatus.Running)
const hasDurationMap = loopDurationMap && Object.keys(loopDurationMap).length !== 0
if (hasFailed)
return <RiErrorWarningLine className='w-4 h-4 text-text-destructive' />
if (isRunning)
return <RiLoader2Line className='w-3.5 h-3.5 text-primary-600 animate-spin' />
return (
<>
{hasDurationMap && (
<div className='system-xs-regular text-text-tertiary'>
{countLoopDuration(loop, loopDurationMap)}
</div>
)}
<RiArrowRightSLine
className={cn(
'w-4 h-4 text-text-tertiary transition-transform duration-200 flex-shrink-0',
expandedLoops[index] && 'transform rotate-90',
)}
/>
</>
)
}
return (
<div className='bg-components-panel-bg'>
<div
className='flex items-center px-4 h-8 text-text-accent-secondary cursor-pointer border-b-[0.5px] border-b-divider-regular'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onBack()
}}
>
<RiArrowLeftLine className='mr-1 w-4 h-4' />
<div className='system-sm-medium'>{t(`${i18nPrefix}.back`)}</div>
</div>
{/* List */}
<div className='p-2 bg-components-panel-bg'>
{list.map((loop, index) => (
<div key={index} className={cn('mb-1 overflow-hidden rounded-xl bg-background-section-burn border-none')}>
<div
className={cn(
'flex items-center justify-between w-full px-3 cursor-pointer',
expandedLoops[index] ? 'pt-3 pb-2' : 'py-3',
'rounded-xl text-left',
)}
onClick={() => toggleLoop(index)}
>
<div className={cn('flex items-center gap-2 flex-grow')}>
<div className='flex items-center justify-center w-4 h-4 rounded-[5px] border-divider-subtle bg-util-colors-cyan-cyan-500 shrink-0'>
<Loop className='w-3 h-3 text-text-primary-on-surface' />
</div>
<span className='system-sm-semibold-uppercase text-text-primary grow'>
{t(`${i18nPrefix}.loop`)} {index + 1}
</span>
{loopStatusShow(index, loop, loopDurationMap)}
</div>
</div>
{expandedLoops[index] && <div
className="grow h-px bg-divider-subtle"
></div>}
<div className={cn(
'overflow-hidden transition-all duration-200',
expandedLoops[index] ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0',
)}>
<TracingPanel
list={loop}
className='bg-background-section-burn'
/>
</div>
</div>
))}
</div>
</div>
)
}
export default React.memo(LoopResultPanel)

View File

@ -0,0 +1,122 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowRightSLine,
RiCloseLine,
} from '@remixicon/react'
import { ArrowNarrowLeft } from '../../base/icons/src/vender/line/arrows'
import TracingPanel from './tracing-panel'
import { Loop } from '@/app/components/base/icons/src/vender/workflow'
import cn from '@/utils/classnames'
import type { NodeTracing } from '@/types/workflow'
const i18nPrefix = 'workflow.singleRun'
type Props = {
list: NodeTracing[][]
onHide: () => void
onBack: () => void
noWrap?: boolean
}
const LoopResultPanel: FC<Props> = ({
list,
onHide,
onBack,
noWrap,
}) => {
const { t } = useTranslation()
const [expandedLoops, setExpandedLoops] = useState<Record<number, boolean>>([])
const toggleLoop = useCallback((index: number) => {
setExpandedLoops(prev => ({
...prev,
[index]: !prev[index],
}))
}, [])
const main = (
<>
<div className={cn(!noWrap && 'shrink-0 ', 'px-4 pt-3')}>
<div className='shrink-0 flex justify-between items-center h-8'>
<div className='system-xl-semibold text-text-primary truncate'>
{t(`${i18nPrefix}.testRunLoop`)}
</div>
<div className='ml-2 shrink-0 p-1 cursor-pointer' onClick={onHide}>
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
</div>
</div>
<div className='flex items-center py-2 space-x-1 text-text-accent-secondary cursor-pointer' onClick={onBack}>
<ArrowNarrowLeft className='w-4 h-4' />
<div className='system-sm-medium'>{t(`${i18nPrefix}.back`)}</div>
</div>
</div>
{/* List */}
<div className={cn(!noWrap ? 'flex-grow overflow-auto' : 'max-h-full', 'p-2 bg-components-panel-bg')}>
{list.map((loop, index) => (
<div key={index} className={cn('mb-1 overflow-hidden rounded-xl bg-background-section-burn border-none')}>
<div
className={cn(
'flex items-center justify-between w-full px-3 cursor-pointer',
expandedLoops[index] ? 'pt-3 pb-2' : 'py-3',
'rounded-xl text-left',
)}
onClick={() => toggleLoop(index)}
>
<div className={cn('flex items-center gap-2 flex-grow')}>
<div className='flex items-center justify-center w-4 h-4 rounded-[5px] border-divider-subtle bg-util-colors-cyan-cyan-500 shrink-0'>
<Loop className='w-3 h-3 text-text-primary-on-surface' />
</div>
<span className='system-sm-semibold-uppercase text-text-primary grow'>
{t(`${i18nPrefix}.loop`)} {index + 1}
</span>
<RiArrowRightSLine className={cn(
'w-4 h-4 text-text-tertiary transition-transform duration-200 flex-shrink-0',
expandedLoops[index] && 'transform rotate-90',
)} />
</div>
</div>
{expandedLoops[index] && <div
className="grow h-px bg-divider-subtle"
></div>}
<div className={cn(
'overflow-hidden transition-all duration-200',
expandedLoops[index] ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0',
)}>
<TracingPanel
list={loop}
className='bg-background-section-burn'
/>
</div>
</div>
))}
</div>
</>
)
const handleNotBubble = useCallback((e: React.MouseEvent) => {
// if not do this, it will trigger the message log modal disappear(useClickAway)
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}, [])
if (noWrap)
return main
return (
<div
className='absolute inset-0 z-10 rounded-2xl pt-10'
style={{
backgroundColor: 'rgba(16, 24, 40, 0.20)',
}}
onClick={handleNotBubble}
>
<div className='h-full rounded-2xl bg-components-panel-bg flex flex-col'>
{main}
</div>
</div >
)
}
export default React.memo(LoopResultPanel)

View File

@ -13,6 +13,7 @@ import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import { RetryLogTrigger } from './retry-log'
import { IterationLogTrigger } from './iteration-log'
import { LoopLogTrigger } from './loop-log'
import { AgentLogTrigger } from './agent-log'
import cn from '@/utils/classnames'
import StatusContainer from '@/app/components/workflow/run/status-container'
@ -21,6 +22,7 @@ import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type {
AgentLogItemWithChildren,
IterationDurationMap,
LoopDurationMap,
NodeTracing,
} from '@/types/workflow'
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
@ -33,9 +35,11 @@ type Props = {
hideInfo?: boolean
hideProcessDetail?: boolean
onShowIterationDetail?: (detail: NodeTracing[][], iterDurationMap: IterationDurationMap) => void
onShowLoopDetail?: (detail: NodeTracing[][], loopDurationMap: LoopDurationMap) => void
onShowRetryDetail?: (detail: NodeTracing[]) => void
onShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void
notShowIterationNav?: boolean
notShowLoopNav?: boolean
}
const NodePanel: FC<Props> = ({
@ -45,9 +49,11 @@ const NodePanel: FC<Props> = ({
hideInfo = false,
hideProcessDetail,
onShowIterationDetail,
onShowLoopDetail,
onShowRetryDetail,
onShowAgentOrToolLog,
notShowIterationNav,
notShowLoopNav,
}) => {
const [collapseState, doSetCollapseState] = useState<boolean>(true)
const setCollapseState = useCallback((state: boolean) => {
@ -79,6 +85,7 @@ const NodePanel: FC<Props> = ({
}, [nodeInfo.expand, setCollapseState])
const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration && !!nodeInfo.details?.length
const isLoopNode = nodeInfo.node_type === BlockEnum.Loop && !!nodeInfo.details?.length
const isRetryNode = hasRetryNode(nodeInfo.node_type) && !!nodeInfo.retryDetail?.length
const isAgentNode = nodeInfo.node_type === BlockEnum.Agent && !!nodeInfo.agentLog?.length
const isToolNode = nodeInfo.node_type === BlockEnum.Tool && !!nodeInfo.agentLog?.length
@ -138,6 +145,13 @@ const NodePanel: FC<Props> = ({
onShowIterationResultList={onShowIterationDetail}
/>
)}
{/* The nav to the Loop detail */}
{isLoopNode && !notShowLoopNav && onShowLoopDetail && (
<LoopLogTrigger
nodeInfo={nodeInfo}
onShowLoopResultList={onShowLoopDetail}
/>
)}
{isRetryNode && onShowRetryDetail && (
<RetryLogTrigger
nodeInfo={nodeInfo}

View File

@ -13,6 +13,7 @@ import type {
import { BlockEnum } from '@/app/components/workflow/types'
import { hasRetryNode } from '@/app/components/workflow/utils'
import { IterationLogTrigger } from '@/app/components/workflow/run/iteration-log'
import { LoopLogTrigger } from '@/app/components/workflow/run/loop-log'
import { RetryLogTrigger } from '@/app/components/workflow/run/retry-log'
import { AgentLogTrigger } from '@/app/components/workflow/run/agent-log'
@ -33,6 +34,7 @@ type ResultPanelProps = {
exceptionCounts?: number
execution_metadata?: any
handleShowIterationResultList?: (detail: NodeTracing[][], iterDurationMap: any) => void
handleShowLoopResultList?: (detail: NodeTracing[][], loopDurationMap: any) => void
onShowRetryDetail?: (detail: NodeTracing[]) => void
handleShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void
}
@ -53,11 +55,13 @@ const ResultPanel: FC<ResultPanelProps> = ({
exceptionCounts,
execution_metadata,
handleShowIterationResultList,
handleShowLoopResultList,
onShowRetryDetail,
handleShowAgentOrToolLog,
}) => {
const { t } = useTranslation()
const isIterationNode = nodeInfo?.node_type === BlockEnum.Iteration && !!nodeInfo?.details?.length
const isLoopNode = nodeInfo?.node_type === BlockEnum.Loop && !!nodeInfo?.details?.length
const isRetryNode = hasRetryNode(nodeInfo?.node_type) && !!nodeInfo?.retryDetail?.length
const isAgentNode = nodeInfo?.node_type === BlockEnum.Agent && !!nodeInfo?.agentLog?.length
const isToolNode = nodeInfo?.node_type === BlockEnum.Tool && !!nodeInfo?.agentLog?.length
@ -82,6 +86,14 @@ const ResultPanel: FC<ResultPanelProps> = ({
/>
)
}
{
isLoopNode && handleShowLoopResultList && (
<LoopLogTrigger
nodeInfo={nodeInfo}
onShowLoopResultList={handleShowLoopResultList}
/>
)
}
{
isRetryNode && onShowRetryDetail && (
<RetryLogTrigger

View File

@ -1,9 +1,11 @@
import { RetryResultPanel } from './retry-log'
import { IterationResultPanel } from './iteration-log'
import { LoopResultPanel } from './loop-log'
import { AgentResultPanel } from './agent-log'
import type {
AgentLogItemWithChildren,
IterationDurationMap,
LoopDurationMap,
NodeTracing,
} from '@/types/workflow'
@ -17,6 +19,11 @@ export type SpecialResultPanelProps = {
iterationResultList?: NodeTracing[][]
iterationResultDurationMap?: IterationDurationMap
showLoopingDetail?: boolean
setShowLoopingDetailFalse?: () => void
loopResultList?: NodeTracing[][]
loopResultDurationMap?: LoopDurationMap
agentOrToolLogItemStack?: AgentLogItemWithChildren[]
agentOrToolLogListMap?: Record<string, AgentLogItemWithChildren[]>
handleShowAgentOrToolLog?: (detail?: AgentLogItemWithChildren) => void
@ -31,6 +38,11 @@ const SpecialResultPanel = ({
iterationResultList,
iterationResultDurationMap,
showLoopingDetail,
setShowLoopingDetailFalse,
loopResultList,
loopResultDurationMap,
agentOrToolLogItemStack,
agentOrToolLogListMap,
handleShowAgentOrToolLog,
@ -57,6 +69,15 @@ const SpecialResultPanel = ({
/>
)
}
{
showLoopingDetail && !!loopResultList?.length && setShowLoopingDetailFalse && (
<LoopResultPanel
list={loopResultList}
onBack={setShowLoopingDetailFalse}
loopDurationMap={loopResultDurationMap}
/>
)
}
{
!!agentOrToolLogItemStack?.length && agentOrToolLogListMap && handleShowAgentOrToolLog && (
<AgentResultPanel

View File

@ -82,6 +82,12 @@ const TracingPanel: FC<TracingPanelProps> = ({
iterationResultDurationMap,
handleShowIterationResultList,
showLoopingDetail,
setShowLoopingDetailFalse,
loopResultList,
loopResultDurationMap,
handleShowLoopResultList,
agentOrToolLogItemStack,
agentOrToolLogListMap,
handleShowAgentOrToolLog,
@ -139,6 +145,7 @@ const TracingPanel: FC<TracingPanelProps> = ({
<NodePanel
nodeInfo={node!}
onShowIterationDetail={handleShowIterationResultList}
onShowLoopDetail={handleShowLoopResultList}
onShowRetryDetail={handleShowRetryResultList}
onShowAgentOrToolLog={handleShowAgentOrToolLog}
hideInfo={hideNodeInfo}
@ -161,6 +168,11 @@ const TracingPanel: FC<TracingPanelProps> = ({
iterationResultList={iterationResultList}
iterationResultDurationMap={iterationResultDurationMap}
showLoopingDetail={showLoopingDetail}
setShowLoopingDetailFalse={setShowLoopingDetailFalse}
loopResultList={loopResultList}
loopResultDurationMap={loopResultDurationMap}
agentOrToolLogItemStack={agentOrToolLogItemStack}
agentOrToolLogListMap={agentOrToolLogListMap}
handleShowAgentOrToolLog={handleShowAgentOrToolLog}

View File

@ -31,6 +31,16 @@ describe('parseDSL', () => {
])
})
it('should parse loop nodes correctly', () => {
const dsl = '(loop, loopNode, plainNode1 -> plainNode2)'
const result = parseDSL(dsl)
expect(result).toEqual([
{ id: 'loopNode', node_id: 'loopNode', title: 'loopNode', node_type: 'loop', execution_metadata: {}, status: 'succeeded' },
{ id: 'plainNode1', node_id: 'plainNode1', title: 'plainNode1', execution_metadata: { loop_id: 'loopNode', loop_index: 0 }, status: 'succeeded' },
{ id: 'plainNode2', node_id: 'plainNode2', title: 'plainNode2', execution_metadata: { loop_id: 'loopNode', loop_index: 0 }, status: 'succeeded' },
])
})
it('should parse parallel nodes correctly', () => {
const dsl = '(parallel, parallelNode, nodeA, nodeB -> nodeC)'
const result = parseDSL(dsl)

View File

@ -1,6 +1,7 @@
type IterationInfo = { iterationId: string; iterationIndex: number }
type NodePlain = { nodeType: 'plain'; nodeId: string; } & Partial<IterationInfo>
type NodeComplex = { nodeType: string; nodeId: string; params: (NodePlain | (NodeComplex & Partial<IterationInfo>) | Node[] | number)[] } & Partial<IterationInfo>
type LoopInfo = { loopId: string; loopIndex: number }
type NodePlain = { nodeType: 'plain'; nodeId: string; } & (Partial<IterationInfo> & Partial<LoopInfo>)
type NodeComplex = { nodeType: string; nodeId: string; params: (NodePlain | (NodeComplex & (Partial<IterationInfo> & Partial<LoopInfo>)) | Node[] | number)[] } & (Partial<IterationInfo> & Partial<LoopInfo>)
type Node = NodePlain | NodeComplex
/**
@ -46,9 +47,10 @@ function parseTopLevelFlow(dsl: string): string[] {
* If the node is complex (e.g., has parentheses), it extracts the node type, node ID, and parameters.
* @param nodeStr - The node string to parse.
* @param parentIterationId - The ID of the parent iteration node (if applicable).
* @param parentLoopId - The ID of the parent loop node (if applicable).
* @returns A parsed node object.
*/
function parseNode(nodeStr: string, parentIterationId?: string): Node {
function parseNode(nodeStr: string, parentIterationId?: string, parentLoopId?: string): Node {
// Check if the node is a complex node
if (nodeStr.startsWith('(') && nodeStr.endsWith(')')) {
const innerContent = nodeStr.slice(1, -1).trim() // Remove outer parentheses
@ -74,7 +76,7 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node {
// Extract nodeType, nodeId, and params
const [nodeType, nodeId, ...paramsRaw] = parts
const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationId)
const params = parseParams(paramsRaw, nodeType === 'iteration' ? nodeId.trim() : parentIterationId, nodeType === 'loop' ? nodeId.trim() : parentLoopId)
const complexNode = {
nodeType: nodeType.trim(),
nodeId: nodeId.trim(),
@ -84,6 +86,10 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node {
(complexNode as any).iterationId = parentIterationId;
(complexNode as any).iterationIndex = 0 // Fixed as 0
}
if (parentLoopId) {
(complexNode as any).loopId = parentLoopId;
(complexNode as any).loopIndex = 0 // Fixed as 0
}
return complexNode
}
@ -93,6 +99,10 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node {
plainNode.iterationId = parentIterationId
plainNode.iterationIndex = 0 // Fixed as 0
}
if (parentLoopId) {
plainNode.loopId = parentLoopId
plainNode.loopIndex = 0 // Fixed as 0
}
return plainNode
}
@ -101,18 +111,19 @@ function parseNode(nodeStr: string, parentIterationId?: string): Node {
* Supports nested flows and complex sub-nodes.
* Adds iteration-specific metadata recursively.
* @param paramParts - The parameters string split by commas.
* @param iterationId - The ID of the iteration node, if applicable.
* @param parentIterationId - The ID of the parent iteration node (if applicable).
* @param parentLoopId - The ID of the parent loop node (if applicable).
* @returns An array of parsed parameters (plain nodes, nested nodes, or flows).
*/
function parseParams(paramParts: string[], iterationId?: string): (Node | Node[] | number)[] {
function parseParams(paramParts: string[], parentIteration?: string, parentLoopId?: string): (Node | Node[] | number)[] {
return paramParts.map((part) => {
if (part.includes('->')) {
// Parse as a flow and return an array of nodes
return parseTopLevelFlow(part).map(node => parseNode(node, iterationId))
return parseTopLevelFlow(part).map(node => parseNode(node, parentIteration || undefined, parentLoopId || undefined))
}
else if (part.startsWith('(')) {
// Parse as a nested complex node
return parseNode(part, iterationId)
return parseNode(part, parentIteration || undefined, parentLoopId || undefined)
}
else if (!Number.isNaN(Number(part.trim()))) {
// Parse as a numeric parameter
@ -120,7 +131,7 @@ function parseParams(paramParts: string[], iterationId?: string): (Node | Node[]
}
else {
// Parse as a plain node
return parseNode(part, iterationId)
return parseNode(part, parentIteration || undefined, parentLoopId || undefined)
}
})
}
@ -153,7 +164,7 @@ function convertPlainNode(node: Node): NodeData[] {
* Converts a retry node to node data.
*/
function convertRetryNode(node: Node): NodeData[] {
const { nodeId, iterationId, iterationIndex, params } = node as NodeComplex
const { nodeId, iterationId, iterationIndex, loopId, loopIndex, params } = node as NodeComplex
const retryCount = params ? Number.parseInt(params[0] as unknown as string, 10) : 0
const result: NodeData[] = [
{
@ -173,6 +184,9 @@ function convertRetryNode(node: Node): NodeData[] {
execution_metadata: iterationId ? {
iteration_id: iterationId,
iteration_index: iterationIndex || 0,
} : loopId ? {
loop_id: loopId,
loop_index: loopIndex || 0,
} : {},
status: 'retry',
})
@ -216,6 +230,41 @@ function convertIterationNode(node: Node): NodeData[] {
return result
}
/**
* Converts an loop node to node data.
*/
function convertLoopNode(node: Node): NodeData[] {
const { nodeId, params } = node as NodeComplex
const result: NodeData[] = [
{
id: nodeId,
node_id: nodeId,
title: nodeId,
node_type: 'loop',
status: 'succeeded',
execution_metadata: {},
},
]
params?.forEach((param: any) => {
if (Array.isArray(param)) {
param.forEach((childNode: Node) => {
const childData = convertToNodeData([childNode])
childData.forEach((data) => {
data.execution_metadata = {
...data.execution_metadata,
loop_id: nodeId,
loop_index: 0,
}
})
result.push(...childData)
})
}
})
return result
}
/**
* Converts a parallel node to node data.
*/
@ -290,6 +339,9 @@ function convertToNodeData(nodes: Node[], parentParallelId?: string, parentStart
case 'iteration':
result.push(...convertIterationNode(node))
break
case 'loop':
result.push(...convertLoopNode(node))
break
case 'parallel':
result.push(...convertParallelNode(node, parentParallelId, parentStartNodeId))
break

View File

@ -1,9 +1,80 @@
import type { NodeTracing } from '@/types/workflow'
import formatIterationNode from './iteration'
import { addChildrenToIterationNode } from './iteration'
import { addChildrenToLoopNode } from './loop'
import formatParallelNode from './parallel'
import formatRetryNode from './retry'
import formatAgentNode from './agent'
import { cloneDeep } from 'lodash-es'
import { BlockEnum } from '../../../types'
const formatIterationAndLoopNode = (list: NodeTracing[], t: any) => {
const clonedList = cloneDeep(list)
// Identify all loop and iteration nodes
const loopNodeIds = clonedList
.filter(item => item.node_type === BlockEnum.Loop)
.map(item => item.node_id)
const iterationNodeIds = clonedList
.filter(item => item.node_type === BlockEnum.Iteration)
.map(item => item.node_id)
// Identify all child nodes for both loop and iteration
const loopChildrenNodeIds = clonedList
.filter(item => item.execution_metadata?.loop_id && loopNodeIds.includes(item.execution_metadata.loop_id))
.map(item => item.node_id)
const iterationChildrenNodeIds = clonedList
.filter(item => item.execution_metadata?.iteration_id && iterationNodeIds.includes(item.execution_metadata.iteration_id))
.map(item => item.node_id)
// Filter out child nodes as they will be included in their parent nodes
const result = clonedList
.filter(item => !loopChildrenNodeIds.includes(item.node_id) && !iterationChildrenNodeIds.includes(item.node_id))
.map((item) => {
// Process Loop nodes
if (item.node_type === BlockEnum.Loop) {
const childrenNodes = clonedList.filter(child => child.execution_metadata?.loop_id === item.node_id)
const error = childrenNodes.find(child => child.status === 'failed')
if (error) {
item.status = 'failed'
item.error = error.error
}
const addedChildrenList = addChildrenToLoopNode(item, childrenNodes)
// Handle parallel nodes in loop node
if (addedChildrenList.details && addedChildrenList.details.length > 0) {
addedChildrenList.details = addedChildrenList.details.map((row) => {
return formatParallelNode(row, t)
})
}
return addedChildrenList
}
// Process Iteration nodes
if (item.node_type === BlockEnum.Iteration) {
const childrenNodes = clonedList.filter(child => child.execution_metadata?.iteration_id === item.node_id)
const error = childrenNodes.find(child => child.status === 'failed')
if (error) {
item.status = 'failed'
item.error = error.error
}
const addedChildrenList = addChildrenToIterationNode(item, childrenNodes)
// Handle parallel nodes in iteration node
if (addedChildrenList.details && addedChildrenList.details.length > 0) {
addedChildrenList.details = addedChildrenList.details.map((row) => {
return formatParallelNode(row, t)
})
}
return addedChildrenList
}
return item
})
return result
}
const formatToTracingNodeList = (list: NodeTracing[], t: any) => {
const allItems = cloneDeep([...list]).sort((a, b) => a.index - b.index)
@ -14,8 +85,8 @@ const formatToTracingNodeList = (list: NodeTracing[], t: any) => {
const formattedAgentList = formatAgentNode(allItems)
const formattedRetryList = formatRetryNode(formattedAgentList) // retry one node
// would change the structure of the list. Iteration and parallel can include each other.
const formattedIterationList = formatIterationNode(formattedRetryList, t)
const formattedParallelList = formatParallelNode(formattedIterationList, t)
const formattedLoopAndIterationList = formatIterationAndLoopNode(formattedRetryList, t)
const formattedParallelList = formatParallelNode(formattedLoopAndIterationList, t)
const result = formattedParallelList
// console.log(allItems)

View File

@ -1,7 +1,8 @@
import { BlockEnum } from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
import formatParallelNode from '../parallel'
function addChildrenToIterationNode(iterationNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing {
export function addChildrenToIterationNode(iterationNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing {
const details: NodeTracing[][] = []
childrenNodes.forEach((item, index) => {
if (!item.execution_metadata) return

View File

@ -0,0 +1,22 @@
import format from '.'
import graphToLogStruct from '../graph-to-log-struct'
describe('loop', () => {
const list = graphToLogStruct('start -> (loop, loopNode, plainNode1 -> plainNode2)')
const [startNode, loopNode, ...loops] = list
const result = format(list as any, () => { })
test('result should have no nodes in loop node', () => {
expect((result as any).find((item: any) => !!item.execution_metadata?.loop_id)).toBeUndefined()
})
test('loop should put nodes in details', () => {
expect(result as any).toEqual([
startNode,
{
...loopNode,
details: [
[loops[0], loops[1]],
],
},
])
})
})

View File

@ -0,0 +1,56 @@
import { BlockEnum } from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
import formatParallelNode from '../parallel'
export function addChildrenToLoopNode(loopNode: NodeTracing, childrenNodes: NodeTracing[]): NodeTracing {
const details: NodeTracing[][] = []
childrenNodes.forEach((item) => {
if (!item.execution_metadata) return
const { parallel_mode_run_id, loop_index = 0 } = item.execution_metadata
const runIndex: number = (parallel_mode_run_id || loop_index) as number
if (!details[runIndex])
details[runIndex] = []
details[runIndex].push(item)
})
return {
...loopNode,
details,
}
}
const format = (list: NodeTracing[], t: any): NodeTracing[] => {
const loopNodeIds = list
.filter(item => item.node_type === BlockEnum.Loop)
.map(item => item.node_id)
const loopChildrenNodeIds = list
.filter(item => item.execution_metadata?.loop_id && loopNodeIds.includes(item.execution_metadata.loop_id))
.map(item => item.node_id)
// move loop children nodes to loop node's details field
const result = list
.filter(item => !loopChildrenNodeIds.includes(item.node_id))
.map((item) => {
if (item.node_type === BlockEnum.Loop) {
const childrenNodes = list.filter(child => child.execution_metadata?.loop_id === item.node_id)
const error = childrenNodes.find(child => child.status === 'failed')
if (error) {
item.status = 'failed'
item.error = error.error
}
const addedChildrenList = addChildrenToLoopNode(item, childrenNodes)
// handle parallel node in loop node
if (addedChildrenList.details && addedChildrenList.details.length > 0) {
addedChildrenList.details = addedChildrenList.details.map((row) => {
return formatParallelNode(row, t)
})
}
return addedChildrenList
}
return item
})
return result
}
export default format

View File

@ -12,6 +12,7 @@ const format = (list: NodeTracing[]): NodeTracing[] => {
}).map((item) => {
const { execution_metadata } = item
const isInIteration = !!execution_metadata?.iteration_id
const isInLoop = !!execution_metadata?.loop_id
const nodeId = item.node_id
const isRetryBelongNode = retryNodeIds.includes(nodeId)
@ -19,11 +20,18 @@ const format = (list: NodeTracing[]): NodeTracing[] => {
return {
...item,
retryDetail: retryNodes.filter((node) => {
if (!isInIteration)
if (!isInIteration && !isInLoop)
return node.node_id === nodeId
// retry node in iteration
return node.node_id === nodeId && node.execution_metadata?.iteration_index === execution_metadata?.iteration_index
if (isInIteration)
return node.node_id === nodeId && node.execution_metadata?.iteration_index === execution_metadata?.iteration_index
// retry node in loop
if (isInLoop)
return node.node_id === nodeId && node.execution_metadata?.loop_index === execution_metadata?.loop_index
return false
}),
}
}

View File

@ -169,6 +169,8 @@ type Shape = {
setShowTips: (showTips: string) => void
iterTimes: number
setIterTimes: (iterTimes: number) => void
loopTimes: number
setLoopTimes: (loopTimes: number) => void
iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>
setIterParallelLogMap: (iterParallelLogMap: Map<string, Map<string, NodeTracing[]>>) => void
versionHistory: VersionHistory[]
@ -290,6 +292,8 @@ export const createWorkflowStore = () => {
setShowTips: showTips => set(() => ({ showTips })),
iterTimes: 1,
setIterTimes: iterTimes => set(() => ({ iterTimes })),
loopTimes: 1,
setLoopTimes: loopTimes => set(() => ({ loopTimes })),
iterParallelLogMap: new Map<string, Map<string, NodeTracing[]>>(),
setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })),

View File

@ -36,6 +36,8 @@ export enum BlockEnum {
IterationStart = 'iteration-start',
Assigner = 'assigner', // is now named as VariableAssigner
Agent = 'agent',
Loop = 'loop',
LoopStart = 'loop-start',
}
export enum ControlMode {
@ -79,6 +81,10 @@ export type CommonNodeType<T = {}> = {
type: BlockEnum
width?: number
height?: number
_loopLength?: number
_loopIndex?: number
isInLoop?: boolean
loop_id?: string
error_strategy?: ErrorHandleTypeEnum
retry_config?: WorkflowRetryConfig
default_value?: DefaultValueForm[]
@ -94,6 +100,8 @@ export type CommonEdgeType = {
_waitingRun?: boolean
isInIteration?: boolean
iteration_id?: string
isInLoop?: boolean
loop_id?: string
sourceType: BlockEnum
targetType: BlockEnum
}
@ -168,6 +176,7 @@ export enum InputVarType {
iterator = 'iterator', // iteration input
singleFile = 'file',
multiFiles = 'file-list',
loop = 'loop', // loop input
}
export type InputVar = {

View File

@ -30,15 +30,19 @@ import {
DEFAULT_RETRY_MAX,
ITERATION_CHILDREN_Z_INDEX,
ITERATION_NODE_Z_INDEX,
LOOP_CHILDREN_Z_INDEX,
LOOP_NODE_Z_INDEX,
NODE_WIDTH_X_OFFSET,
START_INITIAL_POSITION,
} from './constants'
import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants'
import type { QuestionClassifierNodeType } from './nodes/question-classifier/types'
import type { IfElseNodeType } from './nodes/if-else/types'
import { branchNameCorrect } from './nodes/if-else/utils'
import type { ToolNodeType } from './nodes/tool/types'
import type { IterationNodeType } from './nodes/iteration/types'
import type { LoopNodeType } from './nodes/loop/types'
import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { canFindTool, correctModelProvider } from '@/utils'
@ -118,9 +122,31 @@ export function getIterationStartNode(iterationId: string): Node {
}).newNode
}
export function getLoopStartNode(loopId: string): Node {
return generateNewNode({
id: `${loopId}start`,
type: CUSTOM_LOOP_START_NODE,
data: {
title: '',
desc: '',
type: BlockEnum.LoopStart,
isInLoop: true,
},
position: {
x: 24,
y: 68,
},
zIndex: LOOP_CHILDREN_Z_INDEX,
parentId: loopId,
selectable: false,
draggable: false,
}).newNode
}
export function generateNewNode({ data, position, id, zIndex, type, ...rest }: Omit<Node, 'id'> & { id?: string }): {
newNode: Node
newIterationStartNode?: Node
newLoopStartNode?: Node
} {
const newNode = {
id: id || `${Date.now()}`,
@ -129,7 +155,7 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O
position,
targetPosition: Position.Left,
sourcePosition: Position.Right,
zIndex: data.type === BlockEnum.Iteration ? ITERATION_NODE_Z_INDEX : zIndex,
zIndex: data.type === BlockEnum.Iteration ? ITERATION_NODE_Z_INDEX : (data.type === BlockEnum.Loop ? LOOP_NODE_Z_INDEX : zIndex),
...rest,
} as Node
@ -143,6 +169,16 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O
}
}
if (data.type === BlockEnum.Loop) {
const newLoopStartNode = getLoopStartNode(newNode.id);
(newNode.data as LoopNodeType).start_node_id = newLoopStartNode.id;
(newNode.data as LoopNodeType)._children = [newLoopStartNode.id]
return {
newNode,
newLoopStartNode,
}
}
return {
newNode,
}
@ -150,6 +186,7 @@ export function generateNewNode({ data, position, id, zIndex, type, ...rest }: O
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
if (!hasIterationNode) {
return {
@ -157,15 +194,26 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
edges,
}
}
if (!hasLoopNode) {
return {
nodes,
edges,
}
}
const nodesMap = nodes.reduce((prev, next) => {
prev[next.id] = next
return prev
}, {} as Record<string, Node>)
const iterationNodesWithStartNode = []
const iterationNodesWithoutStartNode = []
const loopNodesWithStartNode = []
const loopNodesWithoutStartNode = []
for (let i = 0; i < nodes.length; i++) {
const currentNode = nodes[i] as Node<IterationNodeType>
const currentNode = nodes[i] as Node<IterationNodeType | LoopNodeType>
if (currentNode.data.type === BlockEnum.Iteration) {
if (currentNode.data.start_node_id) {
@ -176,7 +224,18 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
iterationNodesWithoutStartNode.push(currentNode)
}
}
if (currentNode.data.type === BlockEnum.Loop) {
if (currentNode.data.start_node_id) {
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE)
loopNodesWithStartNode.push(currentNode)
}
else {
loopNodesWithoutStartNode.push(currentNode)
}
}
}
const newIterationStartNodesMap = {} as Record<string, Node>
const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => {
const newNode = getIterationStartNode(iterationNode.id)
@ -184,13 +243,28 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
newIterationStartNodesMap[iterationNode.id] = newNode
return newNode
})
const newEdges = iterationNodesWithStartNode.map((iterationNode) => {
const newNode = newIterationStartNodesMap[iterationNode.id]
const startNode = nodesMap[iterationNode.data.start_node_id]
const newLoopStartNodesMap = {} as Record<string, Node>
const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map((loopNode, index) => {
const newNode = getLoopStartNode(loopNode.id)
newNode.id = newNode.id + index
newLoopStartNodesMap[loopNode.id] = newNode
return newNode
})
const newEdges = [...iterationNodesWithStartNode, ...loopNodesWithStartNode].map((nodeItem) => {
const isIteration = nodeItem.data.type === BlockEnum.Iteration
const newNode = (isIteration ? newIterationStartNodesMap : newLoopStartNodesMap)[nodeItem.id]
const startNode = nodesMap[nodeItem.data.start_node_id]
const source = newNode.id
const sourceHandle = 'source'
const target = startNode.id
const targetHandle = 'target'
const parentNode = nodes.find(node => node.id === startNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
return {
id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
type: 'custom',
@ -201,20 +275,25 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
data: {
sourceType: newNode.data.type,
targetType: startNode.data.type,
isInIteration: true,
iteration_id: startNode.parentId,
isInIteration,
iteration_id: isInIteration ? startNode.parentId : undefined,
isInLoop,
loop_id: isInLoop ? startNode.parentId : undefined,
_connectedNodeIsSelected: true,
},
zIndex: ITERATION_CHILDREN_Z_INDEX,
zIndex: isIteration ? ITERATION_CHILDREN_Z_INDEX : LOOP_CHILDREN_Z_INDEX,
}
})
nodes.forEach((node) => {
if (node.data.type === BlockEnum.Iteration && newIterationStartNodesMap[node.id])
(node.data as IterationNodeType).start_node_id = newIterationStartNodesMap[node.id].id
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id])
(node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id
})
return {
nodes: [...nodes, ...newIterationStartNodes],
nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes],
edges: [...edges, ...newEdges],
}
}
@ -232,7 +311,7 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
})
}
const iterationNodeMap = nodes.reduce((acc, node) => {
const iterationOrLoopNodeMap = nodes.reduce((acc, node) => {
if (node.parentId) {
if (acc[node.parentId])
acc[node.parentId].push(node.id)
@ -276,12 +355,19 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
if (node.data.type === BlockEnum.Iteration) {
const iterationNodeData = node.data as IterationNodeType
iterationNodeData._children = iterationNodeMap[node.id] || []
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
iterationNodeData.is_parallel = iterationNodeData.is_parallel || false
iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10
iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
}
// TODO: loop error handle mode
if (node.data.type === BlockEnum.Loop) {
const loopNodeData = node.data as LoopNodeType
loopNodeData._children = iterationOrLoopNodeMap[node.id] || []
loopNodeData.error_handle_mode = loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
}
// legacy provider handle
if (node.data.type === BlockEnum.LLM)
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
@ -359,7 +445,7 @@ export const getLayoutByDagre = (originNodes: Node[], originEdges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE)
const edges = cloneDeep(originEdges).filter(edge => !edge.data?.isInIteration)
const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop))
dagreGraph.setGraph({
rankdir: 'LR',
align: 'UL',
@ -397,6 +483,7 @@ export const canRunBySingle = (nodeType: BlockEnum) => {
|| nodeType === BlockEnum.Iteration
|| nodeType === BlockEnum.Agent
|| nodeType === BlockEnum.DocExtractor
|| nodeType === BlockEnum.Loop
}
type ConnectedSourceOrTargetNodesChange = {
@ -487,15 +574,22 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
if (outgoers.length) {
outgoers.forEach((outgoer) => {
list.push(outgoer)
if (outgoer.data.type === BlockEnum.Iteration)
list.push(...nodes.filter(node => node.parentId === outgoer.id))
if (outgoer.data.type === BlockEnum.Loop)
list.push(...nodes.filter(node => node.parentId === outgoer.id))
traverse(outgoer, depth + 1)
})
}
else {
list.push(root)
if (root.data.type === BlockEnum.Iteration)
list.push(...nodes.filter(node => node.parentId === root.id))
if (root.data.type === BlockEnum.Loop)
list.push(...nodes.filter(node => node.parentId === root.id))
}
}
@ -654,7 +748,7 @@ export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: str
if (!parentNode)
throw new Error('Parent node not found')
startNode = nodes.find(node => node.id === (parentNode.data as IterationNodeType).start_node_id)
startNode = nodes.find(node => node.id === (parentNode.data as (IterationNodeType | LoopNodeType)).start_node_id)
}
else {
startNode = nodes.find(node => node.data.type === BlockEnum.Start)

View File

@ -49,6 +49,7 @@ const LocaleLayout = ({
data-public-text-generation-timeout-ms={process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS}
data-public-top-k-max-value={process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE}
data-public-indexing-max-segmentation-tokens-length={process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH}
data-public-loop-node-max-count={process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT}
>
<BrowserInitor>
<SentryInitor>

View File

@ -276,3 +276,12 @@ export const GITHUB_ACCESS_TOKEN = process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN |
export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl'
export const FULL_DOC_PREVIEW_LENGTH = 50
let loopNodeMaxCount = 100
if (process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT && process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT !== '')
loopNodeMaxCount = Number.parseInt(process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT)
else if (globalThis.document?.body?.getAttribute('data-public-loop-node-max-count') && globalThis.document.body.getAttribute('data-public-loop-node-max-count') !== '')
loopNodeMaxCount = Number.parseInt(globalThis.document.body.getAttribute('data-public-loop-node-max-count') as string)
export const LOOP_NODE_MAX_COUNT = loopNodeMaxCount

View File

@ -205,6 +205,7 @@ const translation = {
testRunIteration: 'Test Run Iteration',
back: 'Back',
iteration: 'Iteration',
loop: 'Loop',
},
tabs: {
'searchBlock': 'Search block',
@ -242,6 +243,8 @@ const translation = {
'document-extractor': 'Doc Extractor',
'list-operator': 'List Operator',
'agent': 'Agent',
'loop-start': 'Loop Start',
'loop': 'Loop',
},
blocksAbout: {
'start': 'Define the initial parameters for launching a workflow',
@ -258,6 +261,7 @@ const translation = {
'assigner': 'The variable assignment node is used for assigning values to writable variables(like conversation variables).',
'variable-aggregator': 'Aggregate multi-branch variables into a single variable for unified configuration of downstream nodes.',
'iteration': 'Perform multiple steps on a list object until all results are outputted.',
'loop': 'Execute a loop of logic until the termination condition is met or the maximum loop count is reached.',
'parameter-extractor': 'Use LLM to extract structured parameters from natural language for tool invocations or HTTP requests.',
'document-extractor': 'Used to parse uploaded documents into text content that is easily understandable by LLM.',
'list-operator': 'Used to filter or sort array content.',
@ -657,6 +661,24 @@ const translation = {
},
answerNodeWarningDesc: 'Parallel mode warning: Answer nodes, conversation variable assignments, and persistent read/write operations within iterations may cause exceptions.',
},
loop: {
deleteTitle: 'Delete Loop Node?',
deleteDesc: 'Deleting the loop node will remove all child nodes',
input: 'Input',
output: 'Output Variable',
loop_one: '{{count}} Loop',
loop_other: '{{count}} Loops',
currentLoop: 'Current Loop',
breakCondition: 'Loop Termination Condition',
loopMaxCount: 'Maximum Loop Count',
loopMaxCountError: 'Please enter a valid maximum loop count, ranging from 1 to {{maxCount}}',
errorResponseMethod: 'Error Response Method',
ErrorMethod: {
operationTerminated: 'Terminated',
continueOnError: 'Continue on Error',
removeAbnormalOutput: 'Remove Abnormal Output',
},
},
note: {
addNote: 'Add Note',
editor: {

View File

@ -203,8 +203,10 @@ const translation = {
startRun: '开始运行',
running: '运行中',
testRunIteration: '测试运行迭代',
testRunLoop: '测试运行循环',
back: '返回',
iteration: '迭代',
loop: '循环',
},
tabs: {
'searchBlock': '搜索节点',
@ -242,6 +244,8 @@ const translation = {
'document-extractor': '文档提取器',
'list-operator': '列表操作',
'agent': 'Agent',
'loop-start': '循环开始',
'loop': '循环',
},
blocksAbout: {
'start': '定义一个 workflow 流程启动的初始参数',
@ -258,6 +262,7 @@ const translation = {
'assigner': '变量赋值节点用于向可写入变量(例如会话变量)进行变量赋值。',
'variable-aggregator': '将多路分支的变量聚合为一个变量,以实现下游节点统一配置。',
'iteration': '对列表对象执行多次步骤直至输出所有结果。',
'loop': '循环执行一段逻辑直到满足结束条件或者到达循环次数上限。',
'parameter-extractor': '利用 LLM 从自然语言内推理提取出结构化参数,用于后置的工具调用或 HTTP 请求。',
'document-extractor': '用于将用户上传的文档解析为 LLM 便于理解的文本内容。',
'list-operator': '用于过滤或排序数组内容。',
@ -657,6 +662,24 @@ const translation = {
},
answerNodeWarningDesc: '并行模式警告:在迭代中,回答节点、会话变量赋值和工具持久读/写操作可能会导致异常。',
},
loop: {
deleteTitle: '删除循环节点?',
deleteDesc: '删除循环节点将删除所有子节点',
input: '输入',
output: '输出变量',
loop_one: '{{count}} 个循环',
loop_other: '{{count}} 个循环',
currentLoop: '当前循环',
breakCondition: '循环终止条件',
loopMaxCount: '最大循环次数',
loopMaxCountError: '请输入正确的 最大循环次数,范围为 1 到 {{maxCount}}',
errorResponseMethod: '错误响应方法',
ErrorMethod: {
operationTerminated: '错误时终止',
continueOnError: '忽略错误并继续',
removeAbnormalOutput: '移除错误输出',
},
},
note: {
addNote: '添加注释',
editor: {

View File

@ -243,6 +243,8 @@ const translation = {
'list-operator': '清單運算子',
'document-extractor': '文件提取器',
'agent': '代理',
'loop-start': '循環開始',
'loop': '循環',
},
blocksAbout: {
'start': '定義一個 workflow 流程啟動的參數',

View File

@ -8,6 +8,9 @@ import type {
IterationFinishedResponse,
IterationNextResponse,
IterationStartedResponse,
LoopFinishedResponse,
LoopNextResponse,
LoopStartedResponse,
NodeFinishedResponse,
NodeStartedResponse,
ParallelBranchFinishedResponse,
@ -54,6 +57,9 @@ export type IOnTextChunk = (textChunk: TextChunkResponse) => void
export type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void
export type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void
export type IOnTextReplace = (textReplace: TextReplaceResponse) => void
export type IOnLoopStarted = (workflowStarted: LoopStartedResponse) => void
export type IOnLoopNext = (workflowStarted: LoopNextResponse) => void
export type IOnLoopFinished = (workflowFinished: LoopFinishedResponse) => void
export type IOnAgentLog = (agentLog: AgentLogResponse) => void
export type IOtherOptions = {
@ -86,6 +92,9 @@ export type IOtherOptions = {
onTTSChunk?: IOnTTSChunk
onTTSEnd?: IOnTTSEnd
onTextReplace?: IOnTextReplace
onLoopStart?: IOnLoopStarted
onLoopNext?: IOnLoopNext
onLoopFinish?: IOnLoopFinished
onAgentLog?: IOnAgentLog
}
@ -125,6 +134,9 @@ const handleStream = (
onIterationStart?: IOnIterationStarted,
onIterationNext?: IOnIterationNext,
onIterationFinish?: IOnIterationFinished,
onLoopStart?: IOnLoopStarted,
onLoopNext?: IOnLoopNext,
onLoopFinish?: IOnLoopFinished,
onNodeRetry?: IOnNodeRetry,
onParallelBranchStarted?: IOnParallelBranchStarted,
onParallelBranchFinished?: IOnParallelBranchFinished,
@ -218,6 +230,15 @@ const handleStream = (
else if (bufferObj.event === 'iteration_completed') {
onIterationFinish?.(bufferObj as IterationFinishedResponse)
}
else if (bufferObj.event === 'loop_started') {
onLoopStart?.(bufferObj as LoopStartedResponse)
}
else if (bufferObj.event === 'loop_next') {
onLoopNext?.(bufferObj as LoopNextResponse)
}
else if (bufferObj.event === 'loop_completed') {
onLoopFinish?.(bufferObj as LoopFinishedResponse)
}
else if (bufferObj.event === 'node_retry') {
onNodeRetry?.(bufferObj as NodeFinishedResponse)
}
@ -332,6 +353,9 @@ export const ssePost = (
onAgentLog,
onError,
getAbortController,
onLoopStart,
onLoopNext,
onLoopFinish,
} = otherOptions
const abortController = new AbortController()
@ -361,7 +385,7 @@ export const ssePost = (
options.body = JSON.stringify(body)
const accessToken = getAccessToken(isPublicAPI)
options.headers!.set('Authorization', `Bearer ${accessToken}`)
;(options.headers as Headers).set('Authorization', `Bearer ${accessToken}`)
globalThis.fetch(urlWithPrefix, options as RequestInit)
.then((res) => {
@ -400,7 +424,31 @@ export const ssePost = (
return
}
onData?.(str, isFirstMessage, moreInfo)
}, onCompleted, onThought, onMessageEnd, onMessageReplace, onFile, onWorkflowStarted, onWorkflowFinished, onNodeStarted, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onNodeRetry, onParallelBranchStarted, onParallelBranchFinished, onTextChunk, onTTSChunk, onTTSEnd, onTextReplace, onAgentLog)
},
onCompleted,
onThought,
onMessageEnd,
onMessageReplace,
onFile,
onWorkflowStarted,
onWorkflowFinished,
onNodeStarted,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onNodeRetry,
onParallelBranchStarted,
onParallelBranchFinished,
onTextChunk,
onTTSChunk,
onTTSEnd,
onTextReplace,
onAgentLog,
)
}).catch((e) => {
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
Toast.notify({ type: 'error', message: e })

View File

@ -1,4 +1,26 @@
import type { IOnCompleted, IOnData, IOnError, IOnFile, IOnIterationFinished, IOnIterationNext, IOnIterationStarted, IOnMessageEnd, IOnMessageReplace, IOnNodeFinished, IOnNodeStarted, IOnTTSChunk, IOnTTSEnd, IOnTextChunk, IOnTextReplace, IOnThought, IOnWorkflowFinished, IOnWorkflowStarted } from './base'
import type {
IOnCompleted,
IOnData,
IOnError,
IOnFile,
IOnIterationFinished,
IOnIterationNext,
IOnIterationStarted,
IOnLoopFinished,
IOnLoopNext,
IOnLoopStarted,
IOnMessageEnd,
IOnMessageReplace,
IOnNodeFinished,
IOnNodeStarted,
IOnTTSChunk,
IOnTTSEnd,
IOnTextChunk,
IOnTextReplace,
IOnThought,
IOnWorkflowFinished,
IOnWorkflowStarted,
} from './base'
import {
del as consoleDel, get as consoleGet, patch as consolePatch, post as consolePost,
delPublic as del, getPublic as get, patchPublic as patch, postPublic as post, ssePost,
@ -78,6 +100,9 @@ export const sendWorkflowMessage = async (
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onTextChunk,
onTextReplace,
}: {
@ -88,6 +113,9 @@ export const sendWorkflowMessage = async (
onIterationStart: IOnIterationStarted
onIterationNext: IOnIterationNext
onIterationFinish: IOnIterationFinished
onLoopStart: IOnLoopStarted
onLoopNext: IOnLoopNext
onLoopFinish: IOnLoopFinished
onTextChunk: IOnTextChunk
onTextReplace: IOnTextReplace
},
@ -99,7 +127,21 @@ export const sendWorkflowMessage = async (
...body,
response_mode: 'streaming',
},
}, { onNodeStarted, onWorkflowStarted, onWorkflowFinished, isPublicAPI: !isInstalledApp, onNodeFinished, onIterationStart, onIterationNext, onIterationFinish, onTextChunk, onTextReplace })
}, {
onNodeStarted,
onWorkflowStarted,
onWorkflowFinished,
isPublicAPI: !isInstalledApp,
onNodeFinished,
onIterationStart,
onIterationNext,
onIterationFinish,
onLoopStart,
onLoopNext,
onLoopFinish,
onTextChunk,
onTextReplace,
})
}
export const fetchAppInfo = async () => {

View File

@ -42,6 +42,10 @@ export const getIterationSingleNodeRunUrl = (isChatFlow: boolean, appId: string,
return `apps/${appId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/iteration/nodes/${nodeId}/run`
}
export const getLoopSingleNodeRunUrl = (isChatFlow: boolean, appId: string, nodeId: string) => {
return `apps/${appId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/loop/nodes/${nodeId}/run`
}
export const publishWorkflow = (url: string) => {
return post<CommonResponse & { created_at: number }>(url)
}

View File

@ -30,6 +30,7 @@ export type NodeTracing = {
predecessor_node_id: string
node_id: string
iteration_id?: string
loop_id?: string
node_type: BlockEnum
title: string
inputs: any
@ -45,12 +46,15 @@ export type NodeTracing = {
currency: string
iteration_id?: string
iteration_index?: number
loop_id?: string
loop_index?: number
parallel_id?: string
parallel_start_node_id?: string
parent_parallel_id?: string
parent_parallel_start_node_id?: string
parallel_mode_run_id?: string
iteration_duration_map?: IterationDurationMap
loop_duration_map?: LoopDurationMap
error_strategy?: ErrorHandleTypeEnum
agent_log?: AgentLogItem[]
tool_info?: {
@ -61,6 +65,8 @@ export type NodeTracing = {
metadata: {
iterator_length: number
iterator_index: number
loop_length: number
loop_index: number
}
created_at: number
created_by: {
@ -69,10 +75,11 @@ export type NodeTracing = {
email: string
}
iterDurationMap?: IterationDurationMap
loopDurationMap?: LoopDurationMap
finished_at: number
extras?: any
expand?: boolean // for UI
details?: NodeTracing[][] // iteration detail
details?: NodeTracing[][] // iteration or loop detail
retryDetail?: NodeTracing[] // retry detail
retry_index?: number
parallelDetail?: { // parallel detail. if is in parallel, this field will be set
@ -204,6 +211,27 @@ export type IterationFinishedResponse = {
data: NodeTracing
}
export type LoopStartedResponse = {
task_id: string
workflow_run_id: string
event: string
data: NodeTracing
}
export type LoopNextResponse = {
task_id: string
workflow_run_id: string
event: string
data: NodeTracing
}
export type LoopFinishedResponse = {
task_id: string
workflow_run_id: string
event: string
data: NodeTracing
}
export type ParallelBranchStartedResponse = {
task_id: string
workflow_run_id: string
@ -290,6 +318,7 @@ export type ConversationVariableResponse = {
}
export type IterationDurationMap = Record<string, number>
export type LoopDurationMap = Record<string, number>
export type WorkflowConfigResponse = {
parallel_depth_limit: number