feat: Parallel Execution of Nodes in Workflows (#8192)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Co-authored-by: Yi <yxiaoisme@gmail.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
takatost
2024-09-10 15:23:16 +08:00
committed by GitHub
parent 5da0182800
commit dabfd74622
156 changed files with 11158 additions and 5605 deletions

View File

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

View File

@@ -15,6 +15,7 @@ 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 IterationStartDefault from './nodes/iteration-start/default'
type NodesExtraData = {
author: string
@@ -89,6 +90,15 @@ export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
getAvailableNextNodes: IterationDefault.getAvailableNextNodes,
checkValid: IterationDefault.checkValid,
},
[BlockEnum.IterationStart]: {
author: 'Dify',
about: '',
availablePrevNodes: [],
availableNextNodes: [],
getAvailablePrevNodes: IterationStartDefault.getAvailablePrevNodes,
getAvailableNextNodes: IterationStartDefault.getAvailableNextNodes,
checkValid: IterationStartDefault.checkValid,
},
[BlockEnum.Code]: {
author: 'Dify',
about: '',
@@ -222,6 +232,12 @@ export const NODES_INITIAL_DATA = {
desc: '',
...IterationDefault.defaultValue,
},
[BlockEnum.IterationStart]: {
type: BlockEnum.IterationStart,
title: '',
desc: '',
...IterationStartDefault.defaultValue,
},
[BlockEnum.Code]: {
type: BlockEnum.Code,
title: '',
@@ -305,11 +321,13 @@ export const AUTO_LAYOUT_OFFSET = {
export const ITERATION_NODE_Z_INDEX = 1
export const ITERATION_CHILDREN_Z_INDEX = 1002
export const ITERATION_PADDING = {
top: 85,
top: 65,
right: 16,
bottom: 20,
left: 16,
}
export const PARALLEL_LIMIT = 10
export const PARALLEL_DEPTH_LIMIT = 3
export const RETRIEVAL_OUTPUT_STRUCT = `{
"content": "",
@@ -412,4 +430,5 @@ export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [
export const WORKFLOW_DATA_UPDATE = 'WORKFLOW_DATA_UPDATE'
export const CUSTOM_NODE = 'custom'
export const CUSTOM_EDGE = 'custom'
export const DSL_EXPORT_CHECK = 'DSL_EXPORT_CHECK'

View File

@@ -16,6 +16,7 @@ import {
useReactFlow,
useStoreApi,
} from 'reactflow'
import { unionBy } from 'lodash-es'
import type { ToolDefaultValue } from '../block-selector/types'
import type {
Edge,
@@ -25,6 +26,7 @@ import type {
import { BlockEnum } from '../types'
import { useWorkflowStore } from '../store'
import {
CUSTOM_EDGE,
ITERATION_CHILDREN_Z_INDEX,
ITERATION_PADDING,
NODES_INITIAL_DATA,
@@ -40,6 +42,7 @@ import {
} from '../utils'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
import type { IterationNodeType } from '../nodes/iteration/types'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
import { useWorkflowHistoryStore } from '../workflow-history-store'
@@ -60,6 +63,7 @@ export const useNodesInteractions = () => {
const { store: workflowHistoryStore } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const {
checkNestedParallelLimit,
getAfterNodesInSameBranch,
} = useWorkflow()
const { getNodesReadOnly } = useNodesReadOnly()
@@ -79,7 +83,7 @@ export const useNodesInteractions = () => {
if (getNodesReadOnly())
return
if (node.data.isIterationStart || node.type === CUSTOM_NOTE_NODE)
if (node.type === CUSTOM_ITERATION_START_NODE || node.type === CUSTOM_NOTE_NODE)
return
dragNodeStartPosition.current = { x: node.position.x, y: node.position.y }
@@ -89,7 +93,7 @@ export const useNodesInteractions = () => {
if (getNodesReadOnly())
return
if (node.data.isIterationStart)
if (node.type === CUSTOM_ITERATION_START_NODE)
return
const {
@@ -156,7 +160,7 @@ export const useNodesInteractions = () => {
if (getNodesReadOnly())
return
if (node.type === CUSTOM_NOTE_NODE)
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return
const {
@@ -207,13 +211,30 @@ export const useNodesInteractions = () => {
})
})
setEdges(newEdges)
const connectedEdges = getConnectedEdges([node], edges).filter(edge => edge.target === node.id)
const targetNodes: Node[] = []
for (let i = 0; i < connectedEdges.length; i++) {
const sourceConnectedEdges = getConnectedEdges([{ id: connectedEdges[i].source } as Node], edges).filter(edge => edge.source === connectedEdges[i].source && edge.sourceHandle === connectedEdges[i].sourceHandle)
targetNodes.push(...sourceConnectedEdges.map(edge => nodes.find(n => n.id === edge.target)!))
}
const uniqTargetNodes = unionBy(targetNodes, 'id')
if (uniqTargetNodes.length > 1) {
const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
if (uniqTargetNodes.some(targetNode => n.id === targetNode.id))
n.data._inParallelHovering = true
})
})
setNodes(newNodes)
}
}, [store, workflowStore, getNodesReadOnly])
const handleNodeLeave = useCallback<NodeMouseHandler>((_, node) => {
if (getNodesReadOnly())
return
if (node.type === CUSTOM_NOTE_NODE)
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return
const {
@@ -229,6 +250,7 @@ export const useNodesInteractions = () => {
const newNodes = produce(getNodes(), (draft) => {
draft.forEach((node) => {
node.data._isEntering = false
node.data._inParallelHovering = false
})
})
setNodes(newNodes)
@@ -287,6 +309,8 @@ export const useNodesInteractions = () => {
}, [store, handleSyncWorkflowDraft])
const handleNodeClick = useCallback<NodeMouseHandler>((_, node) => {
if (node.type === CUSTOM_ITERATION_START_NODE)
return
handleNodeSelect(node.id)
}, [handleNodeSelect])
@@ -314,25 +338,15 @@ export const useNodesInteractions = () => {
if (targetNode?.parentId !== sourceNode?.parentId)
return
if (targetNode?.data.isIterationStart)
return
if (sourceNode?.type === CUSTOM_NOTE_NODE || targetNode?.type === CUSTOM_NOTE_NODE)
return
const needDeleteEdges = edges.filter((edge) => {
if (
(edge.source === source && edge.sourceHandle === sourceHandle)
|| (edge.target === target && edge.targetHandle === targetHandle && targetNode?.data.type !== BlockEnum.VariableAssigner && targetNode?.data.type !== BlockEnum.VariableAggregator)
)
return true
if (edges.find(edge => edge.source === source && edge.sourceHandle === sourceHandle && edge.target === target && edge.targetHandle === targetHandle))
return
return false
})
const needDeleteEdgesIds = needDeleteEdges.map(edge => edge.id)
const newEdge = {
id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
type: 'custom',
type: CUSTOM_EDGE,
source: source!,
target: target!,
sourceHandle,
@@ -347,7 +361,6 @@ export const useNodesInteractions = () => {
}
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
[
...needDeleteEdges.map(edge => ({ type: 'remove', edge })),
{ type: 'add', edge: newEdge },
],
nodes,
@@ -362,19 +375,26 @@ export const useNodesInteractions = () => {
}
})
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
const filtered = draft.filter(edge => !needDeleteEdgesIds.includes(edge.id))
filtered.push(newEdge)
return filtered
draft.push(newEdge)
})
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeConnect)
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
if (checkNestedParallelLimit(newNodes, newEdges, targetNode?.parentId)) {
setNodes(newNodes)
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeConnect)
}
else {
const {
setConnectingNodePayload,
setEnteringNodePayload,
} = workflowStore.getState()
setConnectingNodePayload(undefined)
setEnteringNodePayload(undefined)
}
}, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, saveStateToHistory, checkNestedParallelLimit])
const handleNodeConnectStart = useCallback<OnConnectStart>((_, { nodeId, handleType, handleId }) => {
if (getNodesReadOnly())
@@ -393,14 +413,12 @@ export const useNodesInteractions = () => {
return
}
if (!node.data.isIterationStart) {
setConnectingNodePayload({
nodeId,
nodeType: node.data.type,
handleType,
handleId,
})
}
setConnectingNodePayload({
nodeId,
nodeType: node.data.type,
handleType,
handleId,
})
}
}, [store, workflowStore, getNodesReadOnly])
@@ -510,6 +528,12 @@ export const useNodesInteractions = () => {
return handleNodeDelete(nodeId)
}
else {
if (iterationChildren.length === 1) {
handleNodeDelete(iterationChildren[0].id)
handleNodeDelete(nodeId)
return
}
const { setShowConfirm, showConfirm } = workflowStore.getState()
if (!showConfirm) {
@@ -541,14 +565,8 @@ export const useNodesInteractions = () => {
}
}
if (node.id === currentNode.parentId) {
if (node.id === currentNode.parentId)
node.data._children = node.data._children?.filter(child => child !== nodeId)
if (currentNode.id === (node as Node<IterationNodeType>).data.start_node_id) {
(node as Node<IterationNodeType>).data.start_node_id = '';
(node as Node<IterationNodeType>).data.startNodeType = undefined
}
}
})
draft.splice(currentNodeIndex, 1)
})
@@ -559,7 +577,7 @@ export const useNodesInteractions = () => {
setEdges(newEdges)
handleSyncWorkflowDraft()
if (currentNode.type === 'custom-note')
if (currentNode.type === CUSTOM_NOTE_NODE)
saveStateToHistory(WorkflowHistoryEvent.NoteDelete)
else
@@ -591,7 +609,10 @@ export const useNodesInteractions = () => {
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
const newNode = generateNewNode({
const {
newNode,
newIterationStartNode,
} = generateNewNode({
data: {
...NODES_INITIAL_DATA[nodeType],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`),
@@ -627,7 +648,7 @@ export const useNodesInteractions = () => {
const newEdge: Edge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: 'custom',
type: CUSTOM_EDGE,
source: prevNodeId,
sourceHandle: prevNodeSourceHandle,
target: newNode.id,
@@ -662,8 +683,10 @@ export const useNodesInteractions = () => {
node.data._children?.push(newNode.id)
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
})
setNodes(newNodes)
if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
const { setShowAssignVariablePopup } = workflowStore.getState()
@@ -687,7 +710,14 @@ export const useNodesInteractions = () => {
})
draft.push(newEdge)
})
setEdges(newEdges)
if (checkNestedParallelLimit(newNodes, newEdges, prevNode.parentId)) {
setNodes(newNodes)
setEdges(newEdges)
}
else {
return false
}
}
if (!prevNodeId && nextNodeId) {
const nextNodeIndex = nodes.findIndex(node => node.id === nextNodeId)
@@ -706,15 +736,13 @@ export const useNodesInteractions = () => {
newNode.data.iteration_id = nextNode.parentId
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
if (nextNode.data.isIterationStart)
newNode.data.isIterationStart = true
let newEdge
if ((nodeType !== BlockEnum.IfElse) && (nodeType !== BlockEnum.QuestionClassifier)) {
newEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
type: 'custom',
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: nextNodeId,
@@ -763,13 +791,11 @@ export const useNodesInteractions = () => {
node.data.start_node_id = newNode.id
node.data.startNodeType = newNode.data.type
}
if (node.id === nextNodeId && node.data.isIterationStart)
node.data.isIterationStart = false
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
})
setNodes(newNodes)
if (newEdge) {
const newEdges = produce(edges, (draft) => {
draft.forEach((item) => {
@@ -780,7 +806,21 @@ export const useNodesInteractions = () => {
})
draft.push(newEdge)
})
setEdges(newEdges)
if (checkNestedParallelLimit(newNodes, newEdges, nextNode.parentId)) {
setNodes(newNodes)
setEdges(newEdges)
}
else {
return false
}
}
else {
if (checkNestedParallelLimit(newNodes, edges))
setNodes(newNodes)
else
return false
}
}
if (prevNodeId && nextNodeId) {
@@ -804,7 +844,7 @@ export const useNodesInteractions = () => {
const currentEdgeIndex = edges.findIndex(edge => edge.source === prevNodeId && edge.target === nextNodeId)
const newPrevEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: 'custom',
type: CUSTOM_EDGE,
source: prevNodeId,
sourceHandle: prevNodeSourceHandle,
target: newNode.id,
@@ -822,7 +862,7 @@ export const useNodesInteractions = () => {
if (nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier) {
newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
type: 'custom',
type: CUSTOM_EDGE,
source: newNode.id,
sourceHandle,
target: nextNodeId,
@@ -865,6 +905,8 @@ export const useNodesInteractions = () => {
node.data._children?.push(newNode.id)
})
draft.push(newNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
})
setNodes(newNodes)
if (newNode.data.type === BlockEnum.VariableAssigner || newNode.data.type === BlockEnum.VariableAggregator) {
@@ -898,7 +940,7 @@ export const useNodesInteractions = () => {
}
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
}, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch])
}, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch, checkNestedParallelLimit])
const handleNodeChange = useCallback((
currentNodeId: string,
@@ -919,7 +961,10 @@ export const useNodesInteractions = () => {
const currentNode = nodes.find(node => node.id === currentNodeId)!
const connectedEdges = getConnectedEdges([currentNode], edges)
const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
const newCurrentNode = generateNewNode({
const {
newNode: newCurrentNode,
newIterationStartNode,
} = generateNewNode({
data: {
...NODES_INITIAL_DATA[nodeType],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`),
@@ -929,7 +974,6 @@ export const useNodesInteractions = () => {
selected: currentNode.data.selected,
isInIteration: currentNode.data.isInIteration,
iteration_id: currentNode.data.iteration_id,
isIterationStart: currentNode.data.isIterationStart,
},
position: {
x: currentNode.position.x,
@@ -955,18 +999,12 @@ export const useNodesInteractions = () => {
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
if (node.id === currentNode.parentId && currentNode.data.isIterationStart) {
node.data._children = [
newCurrentNode.id,
...(node.data._children || []),
].filter(child => child !== currentNodeId)
node.data.start_node_id = newCurrentNode.id
node.data.startNodeType = newCurrentNode.data.type
}
})
const index = draft.findIndex(node => node.id === currentNodeId)
draft.splice(index, 1, newCurrentNode)
if (newIterationStartNode)
draft.push(newIterationStartNode)
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
@@ -1011,7 +1049,7 @@ export const useNodesInteractions = () => {
}, [store])
const handleNodeContextMenu = useCallback((e: MouseEvent, node: Node) => {
if (node.type === CUSTOM_NOTE_NODE)
if (node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE)
return
e.preventDefault()
@@ -1041,7 +1079,7 @@ 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)
const nodeToCopy = nodes.find(node => node.id === nodeId && node.data.type !== BlockEnum.Start && node.type !== CUSTOM_ITERATION_START_NODE)
if (nodeToCopy)
setClipboardElements([nodeToCopy])
}
@@ -1087,7 +1125,10 @@ export const useNodesInteractions = () => {
clipboardElements.forEach((nodeToPaste, index) => {
const nodeType = nodeToPaste.data.type
const newNode = generateNewNode({
const {
newNode,
newIterationStartNode,
} = generateNewNode({
type: nodeToPaste.type,
data: {
...NODES_INITIAL_DATA[nodeType],
@@ -1106,24 +1147,17 @@ export const useNodesInteractions = () => {
zIndex: nodeToPaste.zIndex,
})
newNode.id = newNode.id + index
// If only the iteration start node is copied, remove the isIterationStart flag
// This new node is movable and can be placed anywhere
if (clipboardElements.length === 1 && newNode.data.isIterationStart)
newNode.data.isIterationStart = false
let newChildren: Node[] = []
if (nodeToPaste.data.type === BlockEnum.Iteration) {
newNode.data._children = [];
(newNode.data as IterationNodeType).start_node_id = ''
newIterationStartNode!.parentId = newNode.id;
(newNode.data as IterationNodeType).start_node_id = newIterationStartNode!.id
newChildren = handleNodeIterationChildrenCopy(nodeToPaste.id, newNode.id)
newChildren.forEach((child) => {
newNode.data._children?.push(child.id)
if (child.data.isIterationStart)
(newNode.data as IterationNodeType).start_node_id = child.id
})
newChildren.push(newIterationStartNode!)
}
nodesToPaste.push(newNode)
@@ -1230,6 +1264,42 @@ export const useNodesInteractions = () => {
saveStateToHistory(WorkflowHistoryEvent.NodeResize)
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
const handleNodeDisconnect = useCallback((nodeId: string) => {
if (getNodesReadOnly())
return
const {
getNodes,
setNodes,
edges,
setEdges,
} = store.getState()
const nodes = getNodes()
const currentNode = nodes.find(node => node.id === nodeId)!
const connectedEdges = getConnectedEdges([currentNode], edges)
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
connectedEdges.map(edge => ({ type: 'remove', edge })),
nodes,
)
const newNodes = produce(nodes, (draft: Node[]) => {
draft.forEach((node) => {
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
node.data = {
...node.data,
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
}
}
})
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
return draft.filter(edge => !connectedEdges.find(connectedEdge => connectedEdge.id === edge.id))
})
setEdges(newEdges)
handleSyncWorkflowDraft()
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
}, [store, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory])
const handleHistoryBack = useCallback(() => {
if (getNodesReadOnly() || getWorkflowReadOnly())
return
@@ -1282,6 +1352,7 @@ export const useNodesInteractions = () => {
handleNodesDuplicate,
handleNodesDelete,
handleNodeResize,
handleNodeDisconnect,
handleHistoryBack,
handleHistoryForward,
}

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react'
import {
getIncomers,
useReactFlow,
useStoreApi,
} from 'reactflow'
@@ -8,6 +9,7 @@ import { v4 as uuidV4 } from 'uuid'
import { usePathname } from 'next/navigation'
import { useWorkflowStore } from '../store'
import { useNodesSyncDraft } from '../hooks'
import type { Node } from '../types'
import {
NodeRunningStatus,
WorkflowRunningStatus,
@@ -140,9 +142,6 @@ export const useWorkflowRun = () => {
resultText: '',
})
let isInIteration = false
let iterationLength = 0
let ttsUrl = ''
let ttsIsPublic = false
if (params.token) {
@@ -186,7 +185,7 @@ export const useWorkflowRun = () => {
draft.forEach((edge) => {
edge.data = {
...edge.data,
_run: false,
_runned: false,
}
})
})
@@ -249,19 +248,20 @@ export const useWorkflowRun = () => {
setEdges,
transform,
} = store.getState()
if (isInIteration) {
const nodes = getNodes()
const node = nodes.find(node => node.id === data.node_id)
if (node?.parentId) {
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const tracing = draft.tracing!
const iterations = tracing[tracing.length - 1]
const currIteration = iterations.details![iterations.details!.length - 1]
currIteration.push({
const iterations = tracing.find(trace => trace.node_id === node?.parentId)
const currIteration = iterations?.details![node.data.iteration_index] || iterations?.details![iterations.details!.length - 1]
currIteration?.push({
...data,
status: NodeRunningStatus.Running,
} as any)
}))
}
else {
const nodes = getNodes()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.tracing!.push({
...data,
@@ -288,11 +288,12 @@ export const useWorkflowRun = () => {
draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Running
})
setNodes(newNodes)
const incomeNodesId = getIncomers({ id: data.node_id } as Node, newNodes, edges).filter(node => node.data._runningStatus === NodeRunningStatus.Succeeded).map(node => node.id)
const newEdges = produce(edges, (draft) => {
const edge = draft.find(edge => edge.target === data.node_id && edge.source === prevNodeId)
if (edge)
edge.data = { ...edge.data, _run: true } as any
draft.forEach((edge) => {
if (edge.target === data.node_id && incomeNodesId.includes(edge.source))
edge.data = { ...edge.data, _runned: true } as any
})
})
setEdges(newEdges)
}
@@ -309,25 +310,46 @@ export const useWorkflowRun = () => {
getNodes,
setNodes,
} = store.getState()
if (isInIteration) {
const nodes = getNodes()
const nodeParentId = nodes.find(node => node.id === data.node_id)!.parentId
if (nodeParentId) {
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const tracing = draft.tracing!
const iterations = tracing[tracing.length - 1]
const currIteration = iterations.details![iterations.details!.length - 1]
const nodeInfo = currIteration[currIteration.length - 1]
const iterations = tracing.find(trace => trace.node_id === nodeParentId) // the iteration node
currIteration[currIteration.length - 1] = {
...nodeInfo,
...data,
status: NodeRunningStatus.Succeeded,
} as any
if (iterations && iterations.details) {
const iterationIndex = data.execution_metadata?.iteration_index || 0
if (!iterations.details[iterationIndex])
iterations.details[iterationIndex] = []
const currIteration = iterations.details[iterationIndex]
const nodeIndex = currIteration.findIndex(node =>
node.node_id === data.node_id && (
node.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || node.parallel_id === data.execution_metadata?.parallel_id),
)
if (data.status === NodeRunningStatus.Succeeded) {
if (nodeIndex !== -1) {
currIteration[nodeIndex] = {
...currIteration[nodeIndex],
...data,
} as any
}
else {
currIteration.push({
...data,
} as any)
}
}
}
}))
}
else {
const nodes = getNodes()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id)
const currentIndex = draft.tracing!.findIndex((trace) => {
if (!trace.execution_metadata?.parallel_id)
return trace.node_id === data.node_id
return trace.node_id === data.node_id && trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id
})
if (currentIndex > -1 && draft.tracing) {
draft.tracing[currentIndex] = {
...(draft.tracing[currentIndex].extras
@@ -337,16 +359,14 @@ export const useWorkflowRun = () => {
} as any
}
}))
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)!
currentNode.data._runningStatus = data.status as any
})
setNodes(newNodes)
prevNodeId = data.node_id
}
if (onNodeFinished)
onNodeFinished(params)
},
@@ -371,8 +391,6 @@ export const useWorkflowRun = () => {
details: [],
} as any)
}))
isInIteration = true
iterationLength = data.metadata.iterator_length
const {
setViewport,
@@ -398,7 +416,7 @@ export const useWorkflowRun = () => {
const edge = draft.find(edge => edge.target === data.node_id && edge.source === prevNodeId)
if (edge)
edge.data = { ...edge.data, _run: true } as any
edge.data = { ...edge.data, _runned: true } as any
})
setEdges(newEdges)
@@ -418,13 +436,13 @@ export const useWorkflowRun = () => {
} = store.getState()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const iteration = draft.tracing![draft.tracing!.length - 1]
if (iteration.details!.length >= iterationLength)
return
iteration.details!.push([])
const iteration = draft.tracing!.find(trace => trace.node_id === data.node_id)
if (iteration) {
if (iteration.details!.length >= iteration.metadata.iterator_length!)
return
}
iteration?.details!.push([])
}))
const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)!
@@ -450,13 +468,14 @@ export const useWorkflowRun = () => {
const nodes = getNodes()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const tracing = draft.tracing!
tracing[tracing.length - 1] = {
...tracing[tracing.length - 1],
...data,
status: NodeRunningStatus.Succeeded,
} as any
const currIterationNode = tracing.find(trace => trace.node_id === data.node_id)
if (currIterationNode) {
Object.assign(currIterationNode, {
...data,
status: NodeRunningStatus.Succeeded,
})
}
}))
isInIteration = false
const newNodes = produce(nodes, (draft) => {
const currentNode = draft.find(node => node.id === data.node_id)!
@@ -470,6 +489,12 @@ export const useWorkflowRun = () => {
if (onIterationFinish)
onIterationFinish(params)
},
onParallelBranchStarted: (params) => {
// console.log(params, 'parallel start')
},
onParallelBranchFinished: (params) => {
// console.log(params, 'finished')
},
onTextChunk: (params) => {
const { data: { text } } = params
const {

View File

@@ -10,13 +10,13 @@ export const useWorkflowTemplate = () => {
const isChatMode = useIsChatMode()
const nodesInitialData = useNodesInitialData()
const startNode = generateNewNode({
const { newNode: startNode } = generateNewNode({
data: nodesInitialData.start,
position: START_INITIAL_POSITION,
})
if (isChatMode) {
const llmNode = generateNewNode({
const { newNode: llmNode } = generateNewNode({
id: 'llm',
data: {
...nodesInitialData.llm,
@@ -31,7 +31,7 @@ export const useWorkflowTemplate = () => {
},
} as any)
const answerNode = generateNewNode({
const { newNode: answerNode } = generateNewNode({
id: 'answer',
data: {
...nodesInitialData.answer,

View File

@@ -6,6 +6,7 @@ import {
} from 'react'
import dayjs from 'dayjs'
import { uniqBy } from 'lodash-es'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import {
getIncomers,
@@ -29,6 +30,11 @@ import {
useWorkflowStore,
} from '../store'
import {
getParallelInfo,
} from '../utils'
import {
PARALLEL_DEPTH_LIMIT,
PARALLEL_LIMIT,
SUPPORT_OUTPUT_VARS_NODE,
} from '../constants'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
@@ -50,6 +56,7 @@ import {
} from '@/service/tools'
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'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
@@ -58,6 +65,7 @@ export const useIsChatMode = () => {
}
export const useWorkflow = () => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const store = useStoreApi()
const workflowStore = useWorkflowStore()
@@ -77,7 +85,7 @@ export const useWorkflow = () => {
const currentNode = nodes.find(node => node.id === nodeId)
if (currentNode?.parentId)
startNode = nodes.find(node => node.parentId === currentNode.parentId && node.data.isIterationStart)
startNode = nodes.find(node => node.parentId === currentNode.parentId && node.type === CUSTOM_ITERATION_START_NODE)
if (!startNode)
return []
@@ -275,6 +283,45 @@ export const useWorkflow = () => {
return isUsed
}, [isVarUsedInNodes])
const checkParallelLimit = useCallback((nodeId: string) => {
const {
getNodes,
edges,
} = store.getState()
const nodes = getNodes()
const currentNode = nodes.find(node => node.id === nodeId)!
const sourceNodeOutgoers = getOutgoers(currentNode, nodes, edges)
if (sourceNodeOutgoers.length > PARALLEL_LIMIT - 1) {
const { setShowTips } = workflowStore.getState()
setShowTips(t('workflow.common.parallelTip.limit', { num: PARALLEL_LIMIT }))
return false
}
return true
}, [store, workflowStore, t])
const checkNestedParallelLimit = useCallback((nodes: Node[], edges: Edge[], parentNodeId?: string) => {
const {
parallelList,
hasAbnormalEdges,
} = getParallelInfo(nodes, edges, parentNodeId)
if (hasAbnormalEdges)
return false
for (let i = 0; i < parallelList.length; i++) {
const parallel = parallelList[i]
if (parallel.depth > PARALLEL_DEPTH_LIMIT) {
const { setShowTips } = workflowStore.getState()
setShowTips(t('workflow.common.parallelTip.depthLimit', { num: PARALLEL_DEPTH_LIMIT }))
return false
}
}
return true
}, [t, workflowStore])
const isValidConnection = useCallback(({ source, target }: Connection) => {
const {
edges,
@@ -284,12 +331,15 @@ export const useWorkflow = () => {
const sourceNode: Node = nodes.find(node => node.id === source)!
const targetNode: Node = nodes.find(node => node.id === target)!
if (targetNode.data.isIterationStart)
if (!checkParallelLimit(source!))
return false
if (sourceNode.type === CUSTOM_NOTE_NODE || targetNode.type === CUSTOM_NOTE_NODE)
return false
if (sourceNode.parentId !== targetNode.parentId)
return false
if (sourceNode && targetNode) {
const sourceNodeAvailableNextNodes = nodesExtraData[sourceNode.data.type].availableNextNodes
const targetNodeAvailablePrevNodes = [...nodesExtraData[targetNode.data.type].availablePrevNodes, BlockEnum.Start]
@@ -316,7 +366,7 @@ export const useWorkflow = () => {
}
return !hasCycle(targetNode)
}, [store, nodesExtraData])
}, [store, nodesExtraData, checkParallelLimit])
const formatTimeFromNow = useCallback((time: number) => {
return dayjs(time).locale(locale === 'zh-Hans' ? 'zh-cn' : locale).fromNow()
@@ -339,6 +389,8 @@ export const useWorkflow = () => {
isVarUsedInNodes,
removeUsedVarInNodes,
isNodeVarsUsedInNodes,
checkParallelLimit,
checkNestedParallelLimit,
isValidConnection,
formatTimeFromNow,
getNode,

View File

@@ -55,6 +55,8 @@ import Header from './header'
import CustomNode from './nodes'
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 Operator from './operator'
import CustomEdge from './custom-edge'
import CustomConnectionLine from './custom-connection-line'
@@ -67,6 +69,7 @@ import NodeContextmenu from './node-contextmenu'
import SyncingDataModal from './syncing-data-modal'
import UpdateDSLModal from './update-dsl-modal'
import DSLExportConfirmModal from './dsl-export-confirm-modal'
import LimitTips from './limit-tips'
import {
useStore,
useWorkflowStore,
@@ -92,6 +95,7 @@ import Confirm from '@/app/components/base/confirm'
const nodeTypes = {
[CUSTOM_NODE]: CustomNode,
[CUSTOM_NOTE_NODE]: CustomNoteNode,
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
}
const edgeTypes = {
[CUSTOM_NODE]: CustomEdge,
@@ -317,6 +321,7 @@ const Workflow: FC<WorkflowProps> = memo(({
/>
)
}
<LimitTips />
<ReactFlow
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}

View File

@@ -0,0 +1,39 @@
import {
RiAlertFill,
RiCloseLine,
} from '@remixicon/react'
import { useStore } from './store'
import ActionButton from '@/app/components/base/action-button'
const LimitTips = () => {
const showTips = useStore(s => s.showTips)
const setShowTips = useStore(s => s.setShowTips)
if (!showTips)
return null
return (
<div className='absolute bottom-16 left-1/2 -translate-x-1/2 flex items-center rounded-xl p-2 h-10 border border-components-panel-border bg-components-panel-bg-blur shadow-md z-[9]'>
<div
className='absolute inset-0 opacity-[0.4] rounded-xl'
style={{
background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)',
}}
></div>
<div className='flex items-center justify-center w-5 h-5'>
<RiAlertFill className='w-4 h-4 text-text-warning-secondary' />
</div>
<div className='mx-1 px-1 system-xs-medium text-text-primary'>
{showTips}
</div>
<ActionButton
className='z-[1]'
onClick={() => setShowTips('')}
>
<RiCloseLine className='w-4 h-4' />
</ActionButton>
</div>
)
}
export default LimitTips

View File

@@ -21,13 +21,13 @@ type AddProps = {
nodeId: string
nodeData: CommonNodeType
sourceHandle: string
branchName?: string
isParallel?: boolean
}
const Add = ({
nodeId,
nodeData,
sourceHandle,
branchName,
isParallel,
}: AddProps) => {
const { t } = useTranslation()
const { handleNodeAdd } = useNodesInteractions()
@@ -57,23 +57,19 @@ const Add = ({
${nodesReadOnly && '!cursor-not-allowed'}
`}
>
{
branchName && (
<div
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-text-placeholder font-semibold'
title={branchName.toLocaleUpperCase()}
>
<div className='inline-block px-0.5 rounded-[5px] bg-background-default truncate'>{branchName.toLocaleUpperCase()}</div>
</div>
)
}
<div className='flex items-center justify-center mr-1.5 w-5 h-5 rounded-[5px] bg-background-default-dimm'>
<RiAddLine className='w-3 h-3' />
</div>
{t('workflow.panel.selectNextStep')}
<div className='flex items-center uppercase'>
{
isParallel
? t('workflow.common.addParallelNode')
: t('workflow.panel.selectNextStep')
}
</div>
</div>
)
}, [branchName, t, nodesReadOnly])
}, [t, nodesReadOnly, isParallel])
return (
<BlockSelector

View File

@@ -0,0 +1,55 @@
import Add from './add'
import Item from './item'
import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
type ContainerProps = {
nodeId: string
nodeData: CommonNodeType
sourceHandle: string
nextNodes: Node[]
branchName?: string
}
const Container = ({
nodeId,
nodeData,
sourceHandle,
nextNodes,
branchName,
}: ContainerProps) => {
return (
<div className='p-0.5 space-y-0.5 rounded-[10px] bg-background-section-burn'>
{
branchName && (
<div
className='flex items-center px-2 system-2xs-semibold-uppercase text-text-tertiary truncate'
title={branchName}
>
{branchName}
</div>
)
}
{
nextNodes.map(nextNode => (
<Item
key={nextNode.id}
nodeId={nextNode.id}
data={nextNode.data}
sourceHandle='source'
/>
))
}
<Add
isParallel={!!nextNodes.length}
nodeId={nodeId}
nodeData={nodeData}
sourceHandle={sourceHandle}
/>
</div>
)
}
export default Container

View File

@@ -1,4 +1,5 @@
import { memo } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
getConnectedEdges,
getOutgoers,
@@ -8,13 +9,11 @@ import {
import { useToolIcon } from '../../../../hooks'
import BlockIcon from '../../../../block-icon'
import type {
Branch,
Node,
} from '../../../../types'
import { BlockEnum } from '../../../../types'
import Add from './add'
import Item from './item'
import Line from './line'
import Container from './container'
type NextStepProps = {
selectedNode: Node
@@ -22,15 +21,33 @@ type NextStepProps = {
const NextStep = ({
selectedNode,
}: NextStepProps) => {
const { t } = useTranslation()
const data = selectedNode.data
const toolIcon = useToolIcon(data)
const store = useStoreApi()
const branches = data._targetBranches || []
const branches = useMemo(() => {
return data._targetBranches || []
}, [data])
const nodeWithBranches = data.type === BlockEnum.IfElse || data.type === BlockEnum.QuestionClassifier
const edges = useEdges()
const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges)
const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id)
const branchesOutgoers = useMemo(() => {
if (!branches?.length)
return []
return branches.map((branch) => {
const connected = connectedEdges.filter(edge => edge.sourceHandle === branch.id)
const nextNodes = connected.map(edge => outgoers.find(outgoer => outgoer.id === edge.target)!)
return {
branch,
nextNodes,
}
})
}, [branches, connectedEdges, outgoers])
return (
<div className='flex py-1'>
<div className='shrink-0 relative flex items-center justify-center w-9 h-9 bg-background-default rounded-lg border-[0.5px] border-divider-regular shadow-xs'>
@@ -39,59 +56,32 @@ const NextStep = ({
toolIcon={toolIcon}
/>
</div>
<Line linesNumber={nodeWithBranches ? branches.length : 1} />
<div className='grow'>
<Line
list={nodeWithBranches ? branchesOutgoers.map(item => item.nextNodes.length + 1) : [1]}
/>
<div className='grow space-y-2'>
{
!nodeWithBranches && !!outgoers.length && (
<Item
nodeId={outgoers[0].id}
data={outgoers[0].data}
sourceHandle='source'
/>
)
}
{
!nodeWithBranches && !outgoers.length && (
<Add
!nodeWithBranches && (
<Container
nodeId={selectedNode!.id}
nodeData={selectedNode!.data}
sourceHandle='source'
nextNodes={outgoers}
/>
)
}
{
!!branches?.length && nodeWithBranches && (
branches.map((branch: Branch) => {
const connected = connectedEdges.find(edge => edge.sourceHandle === branch.id)
const target = outgoers.find(outgoer => outgoer.id === connected?.target)
nodeWithBranches && (
branchesOutgoers.map((item, index) => {
return (
<div
key={branch.id}
className='mb-3 last-of-type:mb-0'
>
{
connected && (
<Item
data={target!.data!}
nodeId={target!.id}
sourceHandle={branch.id}
branchName={branch.name}
/>
)
}
{
!connected && (
<Add
key={branch.id}
nodeId={selectedNode!.id}
nodeData={selectedNode!.data}
sourceHandle={branch.id}
branchName={branch.name}
/>
)
}
</div>
<Container
key={item.branch.id}
nodeId={selectedNode!.id}
nodeData={selectedNode!.data}
sourceHandle={item.branch.id}
nextNodes={item.nextNodes}
branchName={item.branch.name || `${t('workflow.nodes.questionClassifiers.class')} ${index + 1}`}
/>
)
})
)

View File

@@ -1,94 +1,82 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { intersection } from 'lodash-es'
import Operator from './operator'
import type {
CommonNodeType,
OnSelectBlock,
} from '@/app/components/workflow/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useAvailableBlocks,
useNodesInteractions,
useNodesReadOnly,
useToolIcon,
} from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
type ItemProps = {
nodeId: string
sourceHandle: string
branchName?: string
data: CommonNodeType
}
const Item = ({
nodeId,
sourceHandle,
branchName,
data,
}: ItemProps) => {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const [open, setOpen] = useState(false)
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const toolIcon = useToolIcon(data)
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(data.type, data.isInIteration)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
}, [nodeId, sourceHandle, handleNodeChange])
const renderTrigger = useCallback((open: boolean) => {
return (
<Button
size='small'
className={`
hidden group-hover:flex
${open && '!bg-gray-100 !flex'}
`}
>
{t('workflow.panel.change')}
</Button>
)
}, [t])
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
return (
<div
className='relative group flex items-center mb-3 last-of-type:mb-0 px-2 h-9 rounded-lg border-[0.5px] border-divider-regular bg-background-default hover:bg-background-default-hover shadow-xs text-xs text-text-secondary cursor-pointer'
className='relative group flex items-center last-of-type:mb-0 px-2 h-9 rounded-lg border-[0.5px] border-divider-regular bg-background-default hover:bg-background-default-hover shadow-xs text-xs text-text-secondary cursor-pointer'
>
{
branchName && (
<div
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-gray-500 font-semibold'
title={branchName.toLocaleUpperCase()}
>
<div className='inline-block px-0.5 rounded-[5px] bg-white truncate'>{branchName.toLocaleUpperCase()}</div>
</div>
)
}
<BlockIcon
type={data.type}
toolIcon={toolIcon}
className='shrink-0 mr-1.5'
/>
<div className='grow system-xs-medium text-text-secondary'>{data.title}</div>
<div
className='grow system-xs-medium text-text-secondary truncate'
title={data.title}
>
{data.title}
</div>
{
!nodesReadOnly && (
<BlockSelector
onSelect={handleSelect}
placement='top-end'
offset={{
mainAxis: 6,
crossAxis: 8,
}}
trigger={renderTrigger}
popupClassName='!w-[328px]'
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== data.type)}
/>
<>
<Button
className='hidden group-hover:flex shrink-0 mr-1'
size='small'
onClick={() => handleNodeSelect(nodeId)}
>
{t('workflow.common.jumpToNode')}
</Button>
<div
className={cn(
'hidden shrink-0 group-hover:flex items-center',
open && 'flex',
)}
>
<Operator
data={data}
nodeId={nodeId}
sourceHandle={sourceHandle}
open={open}
onOpenChange={handleOpenChange}
/>
</div>
</>
)
}
</div>

View File

@@ -1,56 +1,70 @@
import { memo } from 'react'
type LineProps = {
linesNumber: number
list: number[]
}
const Line = ({
linesNumber,
list,
}: LineProps) => {
const svgHeight = linesNumber * 36 + (linesNumber - 1) * 12
const listHeight = list.map((item) => {
return item * 36 + (item - 1) * 2 + 12 + 6
})
const processedList = listHeight.map((item, index) => {
if (index === 0)
return item
return listHeight.slice(0, index).reduce((acc, cur) => acc + cur, 0) + item
})
const processedListLength = processedList.length
const svgHeight = processedList[processedListLength - 1] + (processedListLength - 1) * 8
return (
<svg className='shrink-0 w-6' style={{ height: svgHeight }}>
{
Array(linesNumber).fill(0).map((_, index) => (
<g key={index}>
{
index === 0 && (
<>
processedList.map((item, index) => {
const prevItem = index > 0 ? processedList[index - 1] : 0
const space = prevItem + index * 8 + 16
return (
<g key={index}>
{
index === 0 && (
<>
<path
d='M0,18 L24,18'
strokeWidth={1}
fill='none'
className='stroke-divider-solid'
/>
<rect
x={0}
y={16}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</>
)
}
{
index > 0 && (
<path
d='M0,18 L24,18'
d={`M0,18 Q12,18 12,28 L12,${space - 10 + 2} Q12,${space + 2} 24,${space + 2}`}
strokeWidth={1}
fill='none'
className='stroke-divider-solid'
/>
<rect
x={0}
y={16}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</>
)
}
{
index > 0 && (
<path
d={`M0,18 Q12,18 12,28 L12,${index * 48 + 18 - 10} Q12,${index * 48 + 18} 24,${index * 48 + 18}`}
strokeWidth={1}
fill='none'
className='stroke-divider-solid'
/>
)
}
<rect
x={23}
y={index * 48 + 18 - 2}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</g>
))
)
}
<rect
x={23}
y={space}
width={1}
height={4}
className='fill-divider-solid-alt'
/>
</g>
)
})
}
</svg>
)

View File

@@ -0,0 +1,129 @@
import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { intersection } from 'lodash-es'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useAvailableBlocks,
useNodesInteractions,
} from '@/app/components/workflow/hooks'
import type {
CommonNodeType,
OnSelectBlock,
} from '@/app/components/workflow/types'
type ChangeItemProps = {
data: CommonNodeType
nodeId: string
sourceHandle: string
}
const ChangeItem = ({
data,
nodeId,
sourceHandle,
}: ChangeItemProps) => {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const {
availablePrevBlocks,
availableNextBlocks,
} = useAvailableBlocks(data.type, data.isInIteration)
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
}, [nodeId, sourceHandle, handleNodeChange])
const renderTrigger = useCallback(() => {
return (
<div className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'>
{t('workflow.panel.change')}
</div>
)
}, [t])
return (
<BlockSelector
onSelect={handleSelect}
placement='top-end'
offset={{
mainAxis: 6,
crossAxis: 8,
}}
trigger={renderTrigger}
popupClassName='!w-[328px]'
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks).filter(item => item !== data.type)}
/>
)
}
type OperatorProps = {
open: boolean
onOpenChange: (v: boolean) => void
data: CommonNodeType
nodeId: string
sourceHandle: string
}
const Operator = ({
open,
onOpenChange,
data,
nodeId,
sourceHandle,
}: OperatorProps) => {
const { t } = useTranslation()
const {
handleNodeDelete,
handleNodeDisconnect,
} = useNodesInteractions()
return (
<PortalToFollowElem
placement='bottom-end'
offset={{ mainAxis: 4, crossAxis: -4 }}
open={open}
onOpenChange={onOpenChange}
>
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
<Button className='p-0 w-6 h-6'>
<RiMoreFill className='w-4 h-4' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg system-md-regular text-text-secondary'>
<div className='p-1'>
<ChangeItem
data={data}
nodeId={nodeId}
sourceHandle={sourceHandle}
/>
<div
className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => handleNodeDisconnect(nodeId)}
>
{t('workflow.common.disconnect')}
</div>
</div>
<div className='p-1'>
<div
className='flex items-center px-2 h-8 rounded-lg cursor-pointer hover:bg-state-base-hover'
onClick={() => handleNodeDelete(nodeId)}
>
{t('common.operation.delete')}
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Operator

View File

@@ -9,16 +9,22 @@ import {
Handle,
Position,
} from 'reactflow'
import { useTranslation } from 'react-i18next'
import { BlockEnum } from '../../../types'
import type { Node } from '../../../types'
import BlockSelector from '../../../block-selector'
import type { ToolDefaultValue } from '../../../block-selector/types'
import {
useAvailableBlocks,
useIsChatMode,
useNodesInteractions,
useNodesReadOnly,
useWorkflow,
} from '../../../hooks'
import { useStore } from '../../../store'
import {
useStore,
} from '../../../store'
import Tooltip from '@/app/components/base/tooltip'
type NodeHandleProps = {
handleId: string
@@ -38,9 +44,7 @@ export const NodeTargetHandle = memo(({
const { getNodesReadOnly } = useNodesReadOnly()
const connected = data._connectedTargetHandleIds?.includes(handleId)
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration)
const isConnectable = !!availablePrevBlocks.length && (
!data.isIterationStart
)
const isConnectable = !!availablePrevBlocks.length
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
@@ -112,12 +116,15 @@ export const NodeSourceHandle = memo(({
handleClassName,
nodeSelectorClassName,
}: NodeHandleProps) => {
const { t } = useTranslation()
const notInitialWorkflow = useStore(s => s.notInitialWorkflow)
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration)
const isConnectable = !!availableNextBlocks.length
const isChatMode = useIsChatMode()
const { checkParallelLimit } = useWorkflow()
const connected = data._connectedSourceHandleIds?.includes(handleId)
const handleOpenChange = useCallback((v: boolean) => {
@@ -125,9 +132,9 @@ export const NodeSourceHandle = memo(({
}, [])
const handleHandleClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
if (!connected)
if (checkParallelLimit(id))
setOpen(v => !v)
}, [connected])
}, [checkParallelLimit, id])
const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue) => {
handleNodeAdd(
{
@@ -142,12 +149,25 @@ export const NodeSourceHandle = memo(({
}, [handleNodeAdd, id, handleId])
useEffect(() => {
if (notInitialWorkflow && data.type === BlockEnum.Start)
if (notInitialWorkflow && data.type === BlockEnum.Start && !isChatMode)
setOpen(true)
}, [notInitialWorkflow, data.type])
}, [notInitialWorkflow, data.type, isChatMode])
return (
<>
<Tooltip
popupContent={(
<div className='system-xs-regular text-text-tertiary'>
<div>
<span className='system-xs-medium text-text-secondary'>{t('workflow.common.parallelTip.click.title')}</span>
{t('workflow.common.parallelTip.click.desc')}
</div>
<div>
<span className='system-xs-medium text-text-secondary'>{t('workflow.common.parallelTip.drag.title')}</span>
{t('workflow.common.parallelTip.drag.desc')}
</div>
</div>
)}
>
<Handle
id={handleId}
type='source'
@@ -163,7 +183,7 @@ export const NodeSourceHandle = memo(({
onClick={handleHandleClick}
>
{
!connected && isConnectable && !getNodesReadOnly() && (
isConnectable && !getNodesReadOnly() && (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
@@ -181,7 +201,7 @@ export const NodeSourceHandle = memo(({
)
}
</Handle>
</>
</Tooltip>
)
})
NodeSourceHandle.displayName = 'NodeSourceHandle'

View File

@@ -28,8 +28,8 @@ const NodeResizer = ({
nodeId,
nodeData,
icon = <Icon />,
minWidth = 272,
minHeight = 176,
minWidth = 258,
minHeight = 152,
maxWidth,
}: NodeResizerProps) => {
const { handleNodeResize } = useNodesInteractions()

View File

@@ -14,6 +14,7 @@ import {
RiErrorWarningLine,
RiLoader2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from '../../types'
import {
BlockEnum,
@@ -43,6 +44,7 @@ const BaseNode: FC<BaseNodeProps> = ({
data,
children,
}) => {
const { t } = useTranslation()
const nodeRef = useRef<HTMLDivElement>(null)
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
@@ -80,6 +82,7 @@ const BaseNode: FC<BaseNodeProps> = ({
className={cn(
'flex border-[2px] rounded-2xl',
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
!showSelectedBorder && data._inParallelHovering && 'border-workflow-block-border-highlight',
)}
ref={nodeRef}
style={{
@@ -100,6 +103,13 @@ const BaseNode: FC<BaseNodeProps> = ({
data._isBundled && '!shadow-lg',
)}
>
{
data._inParallelHovering && (
<div className='absolute left-2 -top-2.5 top system-2xs-medium-uppercase text-text-tertiary z-10'>
{t('workflow.common.parallelRun')}
</div>
)
}
{
data._showAddVariablePopup && (
<AddVariablePopupWithPosition

View File

@@ -28,7 +28,6 @@ export enum ComparisonOperator {
lessThanOrEqual = '≤',
isNull = 'is null',
isNotNull = 'is not null',
regexMatch = 'regex match',
}
export type Condition = {

View File

@@ -30,7 +30,6 @@ export const getOperators = (type?: VarType) => {
ComparisonOperator.isNot,
ComparisonOperator.empty,
ComparisonOperator.notEmpty,
ComparisonOperator.regexMatch,
]
case VarType.number:
return [

View File

@@ -0,0 +1 @@
export const CUSTOM_ITERATION_START_NODE = 'custom-iteration-start'

View File

@@ -0,0 +1,21 @@
import type { NodeDefault } from '../../types'
import type { IterationStartNodeType } from './types'
import { ALL_CHAT_AVAILABLE_BLOCKS, ALL_COMPLETION_AVAILABLE_BLOCKS } from '@/app/components/workflow/constants'
const nodeDefault: NodeDefault<IterationStartNodeType> = {
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 IterationStartNode = ({ id, data }: NodeProps) => {
const { t } = useTranslation()
return (
<div className='group flex nodrag items-center justify-center w-11 h-11 rounded-2xl border border-workflow-block-border bg-white'>
<Tooltip popupContent={t('workflow.blocks.iteration-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 IterationStartNodeDumb = () => {
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.iteration-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(IterationStartNode)

View File

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

View File

@@ -2,87 +2,49 @@ import {
memo,
useCallback,
} from 'react'
import produce from 'immer'
import {
RiAddLine,
} from '@remixicon/react'
import { useStoreApi } from 'reactflow'
import { useTranslation } from 'react-i18next'
import {
generateNewNode,
} from '../../utils'
import {
WorkflowHistoryEvent,
useAvailableBlocks,
useNodesInteractions,
useNodesReadOnly,
useWorkflowHistory,
} from '../../hooks'
import { NODES_INITIAL_DATA } from '../../constants'
import InsertBlock from './insert-block'
import type { IterationNodeType } from './types'
import cn from '@/utils/classnames'
import BlockSelector from '@/app/components/workflow/block-selector'
import { IterationStart } from '@/app/components/base/icons/src/vender/workflow'
import type {
OnSelectBlock,
} from '@/app/components/workflow/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import Tooltip from '@/app/components/base/tooltip'
type AddBlockProps = {
iterationNodeId: string
iterationNodeData: IterationNodeType
}
const AddBlock = ({
iterationNodeId,
iterationNodeData,
}: AddBlockProps) => {
const { t } = useTranslation()
const store = useStoreApi()
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeAdd } = useNodesInteractions()
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true)
const { availablePrevBlocks } = useAvailableBlocks(iterationNodeData.startNodeType, true)
const { saveStateToHistory } = useWorkflowHistory()
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const newNode = generateNewNode({
data: {
...NODES_INITIAL_DATA[type],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
...(toolDefaultValue || {}),
isIterationStart: true,
isInIteration: true,
iteration_id: iterationNodeId,
handleNodeAdd(
{
nodeType: type,
toolDefaultValue,
},
position: {
x: 117,
y: 85,
{
prevNodeId: iterationNodeData.start_node_id,
prevNodeSourceHandle: 'source',
},
zIndex: 1001,
parentId: iterationNodeId,
extent: 'parent',
})
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (node.id === iterationNodeId) {
node.data._children = [newNode.id]
node.data.start_node_id = newNode.id
node.data.startNodeType = newNode.data.type
}
})
draft.push(newNode)
})
setNodes(newNodes)
saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
}, [store, t, iterationNodeId, saveStateToHistory])
)
}, [handleNodeAdd, iterationNodeData.start_node_id])
const renderTriggerElement = useCallback((open: boolean) => {
return (
@@ -98,35 +60,18 @@ const AddBlock = ({
}, [nodesReadOnly, t])
return (
<div className='absolute top-12 left-6 flex items-center h-8 z-10'>
<Tooltip popupContent={t('workflow.blocks.iteration-start')}>
<div className='flex items-center justify-center w-6 h-6 rounded-full border-[0.5px] border-black/[0.02] shadow-md bg-primary-500'>
<IterationStart className='w-4 h-4 text-white' />
</div>
</Tooltip>
<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'>
{
iterationNodeData.startNodeType && (
<InsertBlock
startNodeId={iterationNodeData.start_node_id}
availableBlocksTypes={availablePrevBlocks}
/>
)
}
<div className='absolute right-0 top-1/2 -translate-y-1/2 w-0.5 h-2 bg-primary-500'></div>
</div>
{
!iterationNodeData.startNodeType && (
<BlockSelector
disabled={nodesReadOnly}
onSelect={handleSelect}
trigger={renderTriggerElement}
triggerInnerClassName='inline-flex'
popupClassName='!min-w-[256px]'
availableBlocksTypes={availableNextBlocks}
/>
)
}
<BlockSelector
disabled={nodesReadOnly}
onSelect={handleSelect}
trigger={renderTriggerElement}
triggerInnerClassName='inline-flex'
popupClassName='!min-w-[256px]'
availableBlocksTypes={availableNextBlocks}
/>
</div>
)
}

View File

@@ -9,6 +9,7 @@ const nodeDefault: NodeDefault<IterationNodeType> = {
start_node_id: '',
iterator_selector: [],
output_selector: [],
_children: [],
},
getAvailablePrevNodes(isChatMode: boolean) {
const nodes = isChatMode

View File

@@ -1,61 +0,0 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useNodesInteractions } from '../../hooks'
import type {
BlockEnum,
OnSelectBlock,
} from '../../types'
import BlockSelector from '../../block-selector'
import cn from '@/utils/classnames'
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

@@ -8,6 +8,7 @@ import {
useNodesInitialized,
useViewport,
} from 'reactflow'
import { IterationStartNodeDumb } from '../iteration-start'
import { useNodeIterationInteractions } from './use-interactions'
import type { IterationNodeType } from './types'
import AddBlock from './add-block'
@@ -29,7 +30,7 @@ const Node: FC<NodeProps<IterationNodeType>> = ({
return (
<div className={cn(
'relative min-w-[258px] min-h-[118px] w-full h-full rounded-2xl bg-[#F0F2F7]/90',
'relative min-w-[240px] min-h-[90px] w-full h-full rounded-2xl bg-[#F0F2F7]/90',
)}>
<Background
id={`iteration-background-${id}`}
@@ -38,10 +39,19 @@ const Node: FC<NodeProps<IterationNodeType>> = ({
size={2 / zoom}
color='#E4E5E7'
/>
<AddBlock
iterationNodeId={id}
iterationNodeData={data}
/>
{
data._isCandidate && (
<IterationStartNodeDumb />
)
}
{
data._children!.length === 1 && (
<AddBlock
iterationNodeId={id}
iterationNodeData={data}
/>
)
}
</div>
)
}

View File

@@ -11,6 +11,7 @@ import {
ITERATION_PADDING,
NODES_INITIAL_DATA,
} from '../../constants'
import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
export const useNodeIterationInteractions = () => {
const { t } = useTranslation()
@@ -107,12 +108,12 @@ export const useNodeIterationInteractions = () => {
const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_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({
const { newNode } = generateNewNode({
data: {
...NODES_INITIAL_DATA[childNodeType],
...child.data,
@@ -121,6 +122,7 @@ export const useNodeIterationInteractions = () => {
_connectedSourceHandleIds: [],
_connectedTargetHandleIds: [],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${childNodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${childNodeType}`),
iteration_id: newNodeId,
},
position: child.position,
positionAbsolute: child.positionAbsolute,

View File

@@ -55,7 +55,7 @@ const AddBlock = ({
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const newNode = generateNewNode({
const { newNode } = generateNewNode({
data: {
...NODES_INITIAL_DATA[type],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),

View File

@@ -11,7 +11,7 @@ export const useOperator = () => {
const { userProfile } = useAppContext()
const handleAddNote = useCallback(() => {
const newNode = generateNewNode({
const { newNode } = generateNewNode({
type: CUSTOM_NOTE_NODE,
data: {
title: '',

View File

@@ -180,8 +180,6 @@ export const useChat = (
isAnswer: true,
}
let isInIteration = false
handleResponding(true)
const bodyParams = {
@@ -317,11 +315,11 @@ export const useChat = (
...responseItem,
}
}))
isInIteration = true
},
onIterationNext: () => {
onIterationNext: ({ data }) => {
const tracing = responseItem.workflowProcess!.tracing!
const iterations = tracing[tracing.length - 1]
const iterations = tracing.find(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
iterations.details!.push([])
handleUpdateChatList(produce(chatListRef.current, (draft) => {
@@ -331,9 +329,10 @@ export const useChat = (
},
onIterationFinish: ({ data }) => {
const tracing = responseItem.workflowProcess!.tracing!
const iterations = tracing[tracing.length - 1]
tracing[tracing.length - 1] = {
...iterations,
const iterationsIndex = tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
tracing[iterationsIndex] = {
...tracing[iterationsIndex],
...data,
status: NodeRunningStatus.Succeeded,
} as any
@@ -341,67 +340,45 @@ export const useChat = (
const currentIndex = draft.length - 1
draft[currentIndex] = responseItem
}))
isInIteration = false
},
onNodeStarted: ({ data }) => {
if (isInIteration) {
const tracing = responseItem.workflowProcess!.tracing!
const iterations = tracing[tracing.length - 1]
const currIteration = iterations.details![iterations.details!.length - 1]
currIteration.push({
...data,
status: NodeRunningStatus.Running,
} as any)
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.length - 1
draft[currentIndex] = responseItem
}))
}
else {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
} as any)
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
}
if (data.iteration_id)
return
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
} as any)
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
},
onNodeFinished: ({ data }) => {
if (isInIteration) {
const tracing = responseItem.workflowProcess!.tracing!
const iterations = tracing[tracing.length - 1]
const currIteration = iterations.details![iterations.details!.length - 1]
currIteration[currIteration.length - 1] = {
...data,
status: NodeRunningStatus.Succeeded,
} as any
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.length - 1
draft[currentIndex] = responseItem
}))
}
else {
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
responseItem.workflowProcess!.tracing[currentIndex] = {
...(responseItem.workflowProcess!.tracing[currentIndex].extras
? { extras: responseItem.workflowProcess!.tracing[currentIndex].extras }
: {}),
...data,
} as any
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
}
if (data.iteration_id)
return
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.node_id === data.node_id
return item.node_id === data.node_id && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id)
})
responseItem.workflowProcess!.tracing[currentIndex] = {
...(responseItem.workflowProcess!.tracing[currentIndex]?.extras
? { extras: responseItem.workflowProcess!.tracing[currentIndex].extras }
: {}),
...data,
} as any
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
},
},
)

View File

@@ -63,26 +63,22 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
const formatNodeList = useCallback((list: NodeTracing[]) => {
const allItems = list.reverse()
const result: NodeTracing[] = []
let iterationIndex = 0
allItems.forEach((item) => {
const { node_type, execution_metadata } = item
if (node_type !== BlockEnum.Iteration) {
const isInIteration = !!execution_metadata?.iteration_id
if (isInIteration) {
const iterationDetails = result[result.length - 1].details!
const currentIterationIndex = execution_metadata?.iteration_index
const isIterationFirstNode = iterationIndex !== currentIterationIndex || iterationDetails.length === 0
const iterationNode = result.find(node => node.node_id === execution_metadata?.iteration_id)
const iterationDetails = iterationNode?.details
const currentIterationIndex = execution_metadata?.iteration_index ?? 0
if (isIterationFirstNode) {
iterationDetails!.push([item])
iterationIndex = currentIterationIndex!
if (Array.isArray(iterationDetails)) {
if (iterationDetails.length === 0 || !iterationDetails[currentIterationIndex])
iterationDetails[currentIterationIndex] = [item]
else
iterationDetails[currentIterationIndex].push(item)
}
else {
iterationDetails[iterationDetails.length - 1].push(item)
}
return
}
// not in iteration
@@ -90,7 +86,6 @@ const RunPanel: FC<RunProps> = ({ hideResult, activeTab = 'RESULT', runID, getRe
return
}
result.push({
...item,
details: [],

View File

@@ -1,10 +1,14 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import {
RiArrowRightSLine,
RiCloseLine,
} from '@remixicon/react'
import { ArrowNarrowLeft } from '../../base/icons/src/vender/line/arrows'
import NodePanel from './node'
import TracingPanel from './tracing-panel'
import { Iteration } from '@/app/components/base/icons/src/vender/workflow'
import cn from '@/utils/classnames'
import type { NodeTracing } from '@/types/workflow'
const i18nPrefix = 'workflow.singleRun'
@@ -23,43 +27,67 @@ const IterationResultPanel: FC<Props> = ({
noWrap,
}) => {
const { t } = useTranslation()
const [expandedIterations, setExpandedIterations] = useState<Record<number, boolean>>([])
const toggleIteration = useCallback((index: number) => {
setExpandedIterations(prev => ({
...prev,
[index]: !prev[index],
}))
}, [])
const main = (
<>
<div className={cn(!noWrap && 'shrink-0 ', 'pl-4 pr-3 pt-3')}>
<div className={cn(!noWrap && 'shrink-0 ', 'px-4 pt-3')}>
<div className='shrink-0 flex justify-between items-center h-8'>
<div className='text-base font-semibold text-gray-900 truncate'>
<div className='system-xl-semibold text-text-primary truncate'>
{t(`${i18nPrefix}.testRunIteration`)}
</div>
<div className='ml-2 shrink-0 p-1 cursor-pointer' onClick={onHide}>
<RiCloseLine className='w-4 h-4 text-gray-500 ' />
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
</div>
</div>
<div className='flex items-center py-2 space-x-1 text-primary-600 cursor-pointer' onClick={onBack}>
<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='leading-[18px] text-[13px] font-medium'>{t(`${i18nPrefix}.back`)}</div>
<div className='system-sm-medium'>{t(`${i18nPrefix}.back`)}</div>
</div>
</div>
{/* List */}
<div className={cn(!noWrap ? 'h-0 grow' : 'max-h-full', 'overflow-y-auto px-4 pb-4 bg-gray-50')}>
<div className={cn(!noWrap ? 'flex-grow overflow-auto' : 'max-h-full', 'p-2 bg-components-panel-bg')}>
{list.map((iteration, index) => (
<div key={index} className={cn('my-4', index === 0 && 'mt-2')}>
<div className='flex items-center'>
<div className='shrink-0 leading-[18px] text-xs font-semibold text-gray-500 uppercase'>{t(`${i18nPrefix}.iteration`)} {index + 1}</div>
<div
className='ml-3 grow w-0 h-px'
style={{ background: 'linear-gradient(to right, #F3F4F6, rgba(243, 244, 246, 0))' }}
></div>
<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',
expandedIterations[index] ? 'pt-3 pb-2' : 'py-3',
'rounded-xl text-left',
)}
onClick={() => toggleIteration(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 flex-shrink-0'>
<Iteration className='w-3 h-3 text-text-primary-on-surface' />
</div>
<span className='system-sm-semibold-uppercase text-text-primary flex-grow'>
{t(`${i18nPrefix}.iteration`)} {index + 1}
</span>
<RiArrowRightSLine className={cn(
'w-4 h-4 text-text-tertiary transition-transform duration-200 flex-shrink-0',
expandedIterations[index] && 'transform rotate-90',
)} />
</div>
</div>
<div className='mt-0.5 space-y-1'>
{iteration.map(node => (
<NodePanel
key={node.id}
className='!px-0 !py-0'
nodeInfo={node}
notShowIterationNav
/>
))}
{expandedIterations[index] && <div
className="flex-grow h-px bg-divider-subtle"
></div>}
<div className={cn(
'overflow-hidden transition-all duration-200',
expandedIterations[index] ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0',
)}>
<TracingPanel
list={iteration}
className='bg-background-section-burn'
/>
</div>
</div>
))}

View File

@@ -4,15 +4,17 @@ import type { FC } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
RiArrowRightSLine,
RiCheckboxCircleLine,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiLoader2Line,
} from '@remixicon/react'
import BlockIcon from '../block-icon'
import { BlockEnum } from '../types'
import Split from '../nodes/_base/components/split'
import { Iteration } from '@/app/components/base/icons/src/vender/workflow'
import cn from '@/utils/classnames'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import Button from '@/app/components/base/button'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import type { NodeTracing } from '@/types/workflow'
@@ -61,31 +63,38 @@ const NodePanel: FC<Props> = ({
return `${parseFloat((tokens / 1000000).toFixed(3))}M`
}
const getCount = (iteration_curr_length: number | undefined, iteration_length: number) => {
if ((iteration_curr_length && iteration_curr_length < iteration_length) || !iteration_length)
return iteration_curr_length
return iteration_length
}
useEffect(() => {
setCollapseState(!nodeInfo.expand)
}, [nodeInfo.expand, setCollapseState])
const isIterationNode = nodeInfo.node_type === BlockEnum.Iteration
const handleOnShowIterationDetail = (e: React.MouseEvent<HTMLDivElement>) => {
const handleOnShowIterationDetail = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onShowIterationDetail?.(nodeInfo.details || [])
}
return (
<div className={cn('px-4 py-1', className, hideInfo && '!p-0')}>
<div className={cn('group transition-all bg-white border border-gray-100 rounded-2xl shadow-xs hover:shadow-md', hideInfo && '!rounded-lg')}>
<div className={cn('px-2 py-1', className)}>
<div className='group transition-all bg-background-default border border-components-panel-border rounded-[10px] shadows-shadow-xs hover:shadow-md'>
<div
className={cn(
'flex items-center pl-[6px] pr-3 cursor-pointer',
hideInfo ? 'py-2' : 'py-3',
!collapseState && (hideInfo ? '!pb-1' : '!pb-2'),
'flex items-center pl-1 pr-3 cursor-pointer',
hideInfo ? 'py-2' : 'py-1.5',
!collapseState && (hideInfo ? '!pb-1' : '!pb-1.5'),
)}
onClick={() => setCollapseState(!collapseState)}
>
{!hideProcessDetail && (
<RiArrowRightSLine
className={cn(
'shrink-0 w-3 h-3 mr-1 text-gray-400 transition-all group-hover:text-gray-500',
'shrink-0 w-4 h-4 mr-1 text-text-quaternary transition-all group-hover:text-text-tertiary',
!collapseState && 'rotate-90',
)}
/>
@@ -93,23 +102,23 @@ const NodePanel: FC<Props> = ({
<BlockIcon size={hideInfo ? 'xs' : 'sm'} className={cn('shrink-0 mr-2', hideInfo && '!mr-1')} type={nodeInfo.node_type} toolIcon={nodeInfo.extras?.icon || nodeInfo.extras} />
<div className={cn(
'grow text-gray-700 text-[13px] leading-[16px] font-semibold truncate',
'grow text-text-secondary system-xs-semibold-uppercase truncate',
hideInfo && '!text-xs',
)} title={nodeInfo.title}>{nodeInfo.title}</div>
{nodeInfo.status !== 'running' && !hideInfo && (
<div className='shrink-0 text-gray-500 text-xs leading-[18px]'>{`${getTime(nodeInfo.elapsed_time || 0)} · ${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens`}</div>
<div className='shrink-0 text-text-tertiary system-xs-regular'>{nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''}{`${getTime(nodeInfo.elapsed_time || 0)}`}</div>
)}
{nodeInfo.status === 'succeeded' && (
<RiCheckboxCircleLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#12B76A]' />
<RiCheckboxCircleFill className='shrink-0 ml-2 w-3.5 h-3.5 text-text-success' />
)}
{nodeInfo.status === 'failed' && (
<RiErrorWarningLine className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F04438]' />
<RiErrorWarningLine className='shrink-0 ml-2 w-3.5 h-3.5 text-text-warning' />
)}
{nodeInfo.status === 'stopped' && (
<AlertTriangle className='shrink-0 ml-2 w-3.5 h-3.5 text-[#F79009]' />
)}
{nodeInfo.status === 'running' && (
<div className='shrink-0 flex items-center text-primary-600 text-[13px] leading-[16px] font-medium'>
<div className='shrink-0 flex items-center text-text-accent text-[13px] leading-[16px] font-medium'>
<span className='mr-2 text-xs font-normal'>Running</span>
<RiLoader2Line className='w-3.5 h-3.5 animate-spin' />
</div>
@@ -120,25 +129,27 @@ const NodePanel: FC<Props> = ({
{/* The nav to the iteration detail */}
{isIterationNode && !notShowIterationNav && (
<div className='mt-2 mb-1 !px-2'>
<div
className='flex items-center h-[34px] justify-between px-3 bg-gray-100 border-[0.5px] border-gray-200 rounded-lg cursor-pointer'
onClick={handleOnShowIterationDetail}>
<div className='leading-[18px] text-[13px] font-medium text-gray-700'>{t('workflow.nodes.iteration.iteration', { count: nodeInfo.metadata?.iterator_length || nodeInfo.details?.length })}</div>
<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={handleOnShowIterationDetail}
>
<Iteration className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
<div className='flex-1 text-left system-sm-medium text-components-button-tertiary-text'>{t('workflow.nodes.iteration.iteration', { count: getCount(nodeInfo.details?.length, nodeInfo.metadata?.iterator_length) })}</div>
{justShowIterationNavArrow
? (
<RiArrowRightSLine className='w-3.5 h-3.5 text-gray-500' />
<RiArrowRightSLine className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
)
: (
<div className='flex items-center space-x-1 text-[#155EEF]'>
<div className='text-[13px] font-normal '>{t('workflow.common.viewDetailInTracingPanel')}</div>
<RiArrowRightSLine className='w-3.5 h-3.5' />
<RiArrowRightSLine className='w-4 h-4 text-components-button-tertiary-text flex-shrink-0' />
</div>
)}
</div>
</Button>
<Split className='mt-2' />
</div>
)}
<div className={cn('px-[10px] py-1', hideInfo && '!px-2 !py-0.5')}>
<div className={cn('px-[10px]', hideInfo && '!px-2 !py-0.5')}>
{nodeInfo.status === 'stopped' && (
<div className='px-3 py-[10px] bg-[#fffaeb] rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] text-xs leading-[18px] text-[#dc6803] shadow-xs'>{t('workflow.tracing.stopBy', { user: nodeInfo.created_by ? nodeInfo.created_by.name : 'N/A' })}</div>
)}

View File

@@ -1,24 +1,266 @@
'use client'
import type { FC } from 'react'
import
React,
{
useCallback,
useState,
} from 'react'
import cn from 'classnames'
import {
RiArrowDownSLine,
RiMenu4Line,
} from '@remixicon/react'
import NodePanel from './node'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
type TracingPanelProps = {
list: NodeTracing[]
onShowIterationDetail: (detail: NodeTracing[][]) => void
onShowIterationDetail?: (detail: NodeTracing[][]) => void
className?: string
hideNodeInfo?: boolean
hideNodeProcessDetail?: boolean
}
const TracingPanel: FC<TracingPanelProps> = ({ list, onShowIterationDetail }) => {
type TracingNodeProps = {
id: string
uniqueId: string
isParallel: boolean
data: NodeTracing | null
children: TracingNodeProps[]
parallelTitle?: string
branchTitle?: string
hideNodeInfo?: boolean
hideNodeProcessDetail?: boolean
}
function buildLogTree(nodes: NodeTracing[]): TracingNodeProps[] {
const rootNodes: TracingNodeProps[] = []
const parallelStacks: { [key: string]: TracingNodeProps } = {}
const levelCounts: { [key: string]: number } = {}
const parallelChildCounts: { [key: string]: Set<string> } = {}
let uniqueIdCounter = 0
const getUniqueId = () => {
uniqueIdCounter++
return `unique-${uniqueIdCounter}`
}
const getParallelTitle = (parentId: string | null): string => {
const levelKey = parentId || 'root'
if (!levelCounts[levelKey])
levelCounts[levelKey] = 0
levelCounts[levelKey]++
const parentTitle = parentId ? parallelStacks[parentId]?.parallelTitle : ''
const levelNumber = parentTitle ? parseInt(parentTitle.split('-')[1]) + 1 : 1
const letter = parallelChildCounts[levelKey]?.size > 1 ? String.fromCharCode(64 + levelCounts[levelKey]) : ''
return `PARALLEL-${levelNumber}${letter}`
}
const getBranchTitle = (parentId: string | null, branchNum: number): string => {
const levelKey = parentId || 'root'
const parentTitle = parentId ? parallelStacks[parentId]?.parallelTitle : ''
const levelNumber = parentTitle ? parseInt(parentTitle.split('-')[1]) + 1 : 1
const letter = parallelChildCounts[levelKey]?.size > 1 ? String.fromCharCode(64 + levelCounts[levelKey]) : ''
const branchLetter = String.fromCharCode(64 + branchNum)
return `BRANCH-${levelNumber}${letter}-${branchLetter}`
}
// Count parallel children (for figuring out if we need to use letters)
for (const node of nodes) {
const parent_parallel_id = node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null
const parallel_id = node.parallel_id ?? node.execution_metadata?.parallel_id ?? null
if (parallel_id) {
const parentKey = parent_parallel_id || 'root'
if (!parallelChildCounts[parentKey])
parallelChildCounts[parentKey] = new Set()
parallelChildCounts[parentKey].add(parallel_id)
}
}
for (const node of nodes) {
const parallel_id = node.parallel_id ?? node.execution_metadata?.parallel_id ?? null
const parent_parallel_id = node.parent_parallel_id ?? node.execution_metadata?.parent_parallel_id ?? null
const parallel_start_node_id = node.parallel_start_node_id ?? node.execution_metadata?.parallel_start_node_id ?? null
const parent_parallel_start_node_id = node.parent_parallel_start_node_id ?? node.execution_metadata?.parent_parallel_start_node_id ?? null
if (!parallel_id || node.node_type === BlockEnum.End) {
rootNodes.push({
id: node.id,
uniqueId: getUniqueId(),
isParallel: false,
data: node,
children: [],
})
}
else {
if (!parallelStacks[parallel_id]) {
const newParallelGroup: TracingNodeProps = {
id: parallel_id,
uniqueId: getUniqueId(),
isParallel: true,
data: null,
children: [],
parallelTitle: '',
}
parallelStacks[parallel_id] = newParallelGroup
if (parent_parallel_id && parallelStacks[parent_parallel_id]) {
const sameBranchIndex = parallelStacks[parent_parallel_id].children.findLastIndex(c =>
c.data?.execution_metadata?.parallel_start_node_id === parent_parallel_start_node_id || c.data?.parallel_start_node_id === parent_parallel_start_node_id,
)
parallelStacks[parent_parallel_id].children.splice(sameBranchIndex + 1, 0, newParallelGroup)
newParallelGroup.parallelTitle = getParallelTitle(parent_parallel_id)
}
else {
newParallelGroup.parallelTitle = getParallelTitle(parent_parallel_id)
rootNodes.push(newParallelGroup)
}
}
const branchTitle = parallel_start_node_id === node.node_id ? getBranchTitle(parent_parallel_id, parallelStacks[parallel_id].children.length + 1) : ''
if (branchTitle) {
parallelStacks[parallel_id].children.push({
id: node.id,
uniqueId: getUniqueId(),
isParallel: false,
data: node,
children: [],
branchTitle,
})
}
else {
let sameBranchIndex = parallelStacks[parallel_id].children.findLastIndex(c =>
c.data?.execution_metadata?.parallel_start_node_id === parallel_start_node_id || c.data?.parallel_start_node_id === parallel_start_node_id,
)
if (parallelStacks[parallel_id].children[sameBranchIndex + 1]?.isParallel)
sameBranchIndex++
parallelStacks[parallel_id].children.splice(sameBranchIndex + 1, 0, {
id: node.id,
uniqueId: getUniqueId(),
isParallel: false,
data: node,
children: [],
branchTitle,
})
}
}
}
return rootNodes
}
const TracingPanel: FC<TracingPanelProps> = ({
list,
onShowIterationDetail,
className,
hideNodeInfo = false,
hideNodeProcessDetail = false,
}) => {
const treeNodes = buildLogTree(list)
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set())
const [hoveredParallel, setHoveredParallel] = useState<string | null>(null)
const toggleCollapse = (id: string) => {
setCollapsedNodes((prev) => {
const newSet = new Set(prev)
if (newSet.has(id))
newSet.delete(id)
else
newSet.add(id)
return newSet
})
}
const handleParallelMouseEnter = useCallback((id: string) => {
setHoveredParallel(id)
}, [])
const handleParallelMouseLeave = useCallback((e: React.MouseEvent) => {
const relatedTarget = e.relatedTarget as Element | null
if (relatedTarget && 'closest' in relatedTarget) {
const closestParallel = relatedTarget.closest('[data-parallel-id]')
if (closestParallel)
setHoveredParallel(closestParallel.getAttribute('data-parallel-id'))
else
setHoveredParallel(null)
}
else {
setHoveredParallel(null)
}
}, [])
const renderNode = (node: TracingNodeProps) => {
if (node.isParallel) {
const isCollapsed = collapsedNodes.has(node.id)
const isHovered = hoveredParallel === node.id
return (
<div
key={node.uniqueId}
className="ml-4 mb-2 relative"
data-parallel-id={node.id}
onMouseEnter={() => handleParallelMouseEnter(node.id)}
onMouseLeave={handleParallelMouseLeave}
>
<div className="flex items-center mb-1">
<button
onClick={() => toggleCollapse(node.id)}
className={cn(
'mr-2 transition-colors',
isHovered ? 'rounded border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' : 'text-text-secondary hover:text-text-primary',
)}
>
{isHovered ? <RiArrowDownSLine className="w-3 h-3" /> : <RiMenu4Line className="w-3 h-3 text-text-tertiary" />}
</button>
<div className="system-xs-semibold-uppercase text-text-secondary flex items-center">
<span>{node.parallelTitle}</span>
</div>
<div
className="mx-2 flex-grow h-px bg-divider-subtle"
style={{ background: 'linear-gradient(to right, rgba(16, 24, 40, 0.08), rgba(255, 255, 255, 0)' }}
></div>
</div>
<div className={`pl-2 relative ${isCollapsed ? 'hidden' : ''}`}>
<div className={cn(
'absolute top-0 bottom-0 left-[5px] w-[2px]',
isHovered ? 'bg-text-accent-secondary' : 'bg-divider-subtle',
)}></div>
{node.children.map(renderNode)}
</div>
</div>
)
}
else {
const isHovered = hoveredParallel === node.id
return (
<div key={node.uniqueId}>
<div className={cn('pl-4 -mb-1.5 system-2xs-medium-uppercase', isHovered ? 'text-text-tertiary' : 'text-text-quaternary')}>
{node.branchTitle}
</div>
<NodePanel
nodeInfo={node.data!}
onShowIterationDetail={onShowIterationDetail}
justShowIterationNavArrow={true}
hideInfo={hideNodeInfo}
hideProcessDetail={hideNodeProcessDetail}
/>
</div>
)
}
}
return (
<div className='bg-gray-50 py-2'>
{list.map(node => (
<NodePanel
key={node.id}
nodeInfo={node}
onShowIterationDetail={onShowIterationDetail}
justShowIterationNavArrow
/>
))}
<div className={cn(className || 'bg-components-panel-bg', 'py-2')}>
{treeNodes.map(renderNode)}
</div>
)
}

View File

@@ -162,6 +162,8 @@ type Shape = {
setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void
showImportDSLModal: boolean
setShowImportDSLModal: (showImportDSLModal: boolean) => void
showTips: string
setShowTips: (showTips: string) => void
}
export const createWorkflowStore = () => {
@@ -262,6 +264,8 @@ export const createWorkflowStore = () => {
setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })),
showImportDSLModal: false,
setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })),
showTips: '',
setShowTips: showTips => set(() => ({ showTips })),
}))
}

View File

@@ -26,6 +26,7 @@ export enum BlockEnum {
Tool = 'tool',
ParameterExtractor = 'parameter-extractor',
Iteration = 'iteration',
IterationStart = 'iteration-start',
Assigner = 'assigner', // is now named as VariableAssigner
}
@@ -54,7 +55,7 @@ export type CommonNodeType<T = {}> = {
_holdAddVariablePopup?: boolean
_iterationLength?: number
_iterationIndex?: number
isIterationStart?: boolean
_inParallelHovering?: boolean
isInIteration?: boolean
iteration_id?: string
selected?: boolean

View File

@@ -1,12 +1,15 @@
import {
Position,
getConnectedEdges,
getIncomers,
getOutgoers,
} from 'reactflow'
import dagre from '@dagrejs/dagre'
import { v4 as uuid4 } from 'uuid'
import {
cloneDeep,
groupBy,
isEqual,
uniqBy,
} from 'lodash-es'
import type {
@@ -19,14 +22,17 @@ import type {
import { BlockEnum } from './types'
import {
CUSTOM_NODE,
ITERATION_CHILDREN_Z_INDEX,
ITERATION_NODE_Z_INDEX,
NODE_WIDTH_X_OFFSET,
START_INITIAL_POSITION,
} from './constants'
import { CUSTOM_ITERATION_START_NODE } from './nodes/iteration-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 { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
@@ -84,9 +90,130 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
return cycleEdges
}
export function getIterationStartNode(iterationId: string): Node {
return generateNewNode({
id: `${iterationId}start`,
type: CUSTOM_ITERATION_START_NODE,
data: {
title: '',
desc: '',
type: BlockEnum.IterationStart,
isInIteration: true,
},
position: {
x: 24,
y: 68,
},
zIndex: ITERATION_CHILDREN_Z_INDEX,
parentId: iterationId,
selectable: false,
draggable: false,
}).newNode
}
export function generateNewNode({ data, position, id, zIndex, type, ...rest }: Omit<Node, 'id'> & { id?: string }): {
newNode: Node
newIterationStartNode?: Node
} {
const newNode = {
id: id || `${Date.now()}`,
type: type || CUSTOM_NODE,
data,
position,
targetPosition: Position.Left,
sourcePosition: Position.Right,
zIndex: data.type === BlockEnum.Iteration ? ITERATION_NODE_Z_INDEX : zIndex,
...rest,
} as Node
if (data.type === BlockEnum.Iteration) {
const newIterationStartNode = getIterationStartNode(newNode.id);
(newNode.data as IterationNodeType).start_node_id = newIterationStartNode.id;
(newNode.data as IterationNodeType)._children = [newIterationStartNode.id]
return {
newNode,
newIterationStartNode,
}
}
return {
newNode,
}
}
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
if (!hasIterationNode) {
return {
nodes,
edges,
}
}
const nodesMap = nodes.reduce((prev, next) => {
prev[next.id] = next
return prev
}, {} as Record<string, Node>)
const iterationNodesWithStartNode = []
const iterationNodesWithoutStartNode = []
for (let i = 0; i < nodes.length; i++) {
const currentNode = nodes[i] as Node<IterationNodeType>
if (currentNode.data.type === BlockEnum.Iteration) {
if (currentNode.data.start_node_id) {
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_ITERATION_START_NODE)
iterationNodesWithStartNode.push(currentNode)
}
else {
iterationNodesWithoutStartNode.push(currentNode)
}
}
}
const newIterationStartNodesMap = {} as Record<string, Node>
const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => {
const newNode = getIterationStartNode(iterationNode.id)
newNode.id = newNode.id + index
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 source = newNode.id
const sourceHandle = 'source'
const target = startNode.id
const targetHandle = 'target'
return {
id: `${source}-${sourceHandle}-${target}-${targetHandle}`,
type: 'custom',
source,
sourceHandle,
target,
targetHandle,
data: {
sourceType: newNode.data.type,
targetType: startNode.data.type,
isInIteration: true,
iteration_id: startNode.parentId,
_connectedNodeIsSelected: true,
},
zIndex: ITERATION_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
})
return {
nodes: [...nodes, ...newIterationStartNodes],
edges: [...edges, ...newEdges],
}
}
export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
const nodes = cloneDeep(originNodes)
const edges = cloneDeep(originEdges)
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
const firstNode = nodes[0]
if (!firstNode?.position) {
@@ -148,8 +275,7 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
}
export const initialEdges = (originEdges: Edge[], originNodes: Node[]) => {
const nodes = cloneDeep(originNodes)
const edges = cloneDeep(originEdges)
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
let selectedNode: Node | null = null
const nodesMap = nodes.reduce((acc, node) => {
acc[node.id] = node
@@ -291,19 +417,6 @@ export const getNodesConnectedSourceOrTargetHandleIdsMap = (changes: ConnectedSo
return nodesConnectedSourceOrTargetHandleIdsMap
}
export const generateNewNode = ({ data, position, id, zIndex, type, ...rest }: Omit<Node, 'id'> & { id?: string }) => {
return {
id: id || `${Date.now()}`,
type: type || CUSTOM_NODE,
data,
position,
targetPosition: Position.Left,
sourcePosition: Position.Right,
zIndex: data.type === BlockEnum.Iteration ? ITERATION_NODE_Z_INDEX : zIndex,
...rest,
} as Node
}
export const genNewNodeTitleFromOld = (oldTitle: string) => {
const regex = /^(.+?)\s*\((\d+)\)\s*$/
const match = oldTitle.match(regex)
@@ -479,3 +592,167 @@ export const variableTransformer = (v: ValueSelector | string) => {
return `{{#${v.join('.')}#}}`
}
type ParallelInfoItem = {
parallelNodeId: string
depth: number
isBranch?: boolean
}
type NodeParallelInfo = {
parallelNodeId: string
edgeHandleId: string
depth: number
}
type NodeHandle = {
node: Node
handle: string
}
type NodeStreamInfo = {
upstreamNodes: Set<string>
downstreamEdges: Set<string>
}
export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: string) => {
let startNode
if (parentNodeId) {
const parentNode = nodes.find(node => node.id === parentNodeId)
if (!parentNode)
throw new Error('Parent node not found')
startNode = nodes.find(node => node.id === (parentNode.data as IterationNodeType).start_node_id)
}
else {
startNode = nodes.find(node => node.data.type === BlockEnum.Start)
}
if (!startNode)
throw new Error('Start node not found')
const parallelList = [] as ParallelInfoItem[]
const nextNodeHandles = [{ node: startNode, handle: 'source' }]
let hasAbnormalEdges = false
const traverse = (firstNodeHandle: NodeHandle) => {
const nodeEdgesSet = {} as Record<string, Set<string>>
const totalEdgesSet = new Set<string>()
const nextHandles = [firstNodeHandle]
const streamInfo = {} as Record<string, NodeStreamInfo>
const parallelListItem = {
parallelNodeId: '',
depth: 0,
} as ParallelInfoItem
const nodeParallelInfoMap = {} as Record<string, NodeParallelInfo>
nodeParallelInfoMap[firstNodeHandle.node.id] = {
parallelNodeId: '',
edgeHandleId: '',
depth: 0,
}
while (nextHandles.length) {
const currentNodeHandle = nextHandles.shift()!
const { node: currentNode, handle: currentHandle = 'source' } = currentNodeHandle
const currentNodeHandleKey = currentNode.id
const connectedEdges = edges.filter(edge => edge.source === currentNode.id && edge.sourceHandle === currentHandle)
const connectedEdgesLength = connectedEdges.length
const outgoers = nodes.filter(node => connectedEdges.some(edge => edge.target === node.id))
const incomers = getIncomers(currentNode, nodes, edges)
if (!streamInfo[currentNodeHandleKey]) {
streamInfo[currentNodeHandleKey] = {
upstreamNodes: new Set<string>(),
downstreamEdges: new Set<string>(),
}
}
if (nodeEdgesSet[currentNodeHandleKey]?.size > 0 && incomers.length > 1) {
const newSet = new Set<string>()
for (const item of totalEdgesSet) {
if (!streamInfo[currentNodeHandleKey].downstreamEdges.has(item))
newSet.add(item)
}
if (isEqual(nodeEdgesSet[currentNodeHandleKey], newSet)) {
parallelListItem.depth = nodeParallelInfoMap[currentNode.id].depth
nextNodeHandles.push({ node: currentNode, handle: currentHandle })
break
}
}
if (nodeParallelInfoMap[currentNode.id].depth > parallelListItem.depth)
parallelListItem.depth = nodeParallelInfoMap[currentNode.id].depth
outgoers.forEach((outgoer) => {
const outgoerConnectedEdges = getConnectedEdges([outgoer], edges).filter(edge => edge.source === outgoer.id)
const sourceEdgesGroup = groupBy(outgoerConnectedEdges, 'sourceHandle')
const incomers = getIncomers(outgoer, nodes, edges)
if (outgoers.length > 1 && incomers.length > 1)
hasAbnormalEdges = true
Object.keys(sourceEdgesGroup).forEach((sourceHandle) => {
nextHandles.push({ node: outgoer, handle: sourceHandle })
})
if (!outgoerConnectedEdges.length)
nextHandles.push({ node: outgoer, handle: 'source' })
const outgoerKey = outgoer.id
if (!nodeEdgesSet[outgoerKey])
nodeEdgesSet[outgoerKey] = new Set<string>()
if (nodeEdgesSet[currentNodeHandleKey]) {
for (const item of nodeEdgesSet[currentNodeHandleKey])
nodeEdgesSet[outgoerKey].add(item)
}
if (!streamInfo[outgoerKey]) {
streamInfo[outgoerKey] = {
upstreamNodes: new Set<string>(),
downstreamEdges: new Set<string>(),
}
}
if (!nodeParallelInfoMap[outgoer.id]) {
nodeParallelInfoMap[outgoer.id] = {
...nodeParallelInfoMap[currentNode.id],
}
}
if (connectedEdgesLength > 1) {
const edge = connectedEdges.find(edge => edge.target === outgoer.id)!
nodeEdgesSet[outgoerKey].add(edge.id)
totalEdgesSet.add(edge.id)
streamInfo[currentNodeHandleKey].downstreamEdges.add(edge.id)
streamInfo[outgoerKey].upstreamNodes.add(currentNodeHandleKey)
for (const item of streamInfo[currentNodeHandleKey].upstreamNodes)
streamInfo[item].downstreamEdges.add(edge.id)
if (!parallelListItem.parallelNodeId)
parallelListItem.parallelNodeId = currentNode.id
const prevDepth = nodeParallelInfoMap[currentNode.id].depth + 1
const currentDepth = nodeParallelInfoMap[outgoer.id].depth
nodeParallelInfoMap[outgoer.id].depth = Math.max(prevDepth, currentDepth)
}
else {
for (const item of streamInfo[currentNodeHandleKey].upstreamNodes)
streamInfo[outgoerKey].upstreamNodes.add(item)
nodeParallelInfoMap[outgoer.id].depth = nodeParallelInfoMap[currentNode.id].depth
}
})
}
parallelList.push(parallelListItem)
}
while (nextNodeHandles.length) {
const nodeHandle = nextNodeHandles.shift()!
traverse(nodeHandle)
}
return {
parallelList,
hasAbnormalEdges,
}
}