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

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