feat(workflow): add loop run node with start/break sub-nodes (#6797)

* feat(workflow): add loop run node with start/break sub-nodes

* fix(workflow): clear loop run resume state and polish interactions

* fix(workflow): harden loop run error paths and   dedupe template registry

* fix(workflow): route loop run precheck errors through   errorText and validate break reachability

* fix(workflow): fix loop run conditional validation and outer-node ref snapshot

* refactor: consolidate shared workflow usage and feedback collection helpers into dispatch/utils.ts

* feat(workflow): aggregate loop run iterations in response tree and polish editor/UI

* fix(workflow): i18n loop run errors and surface uncaught nested errors in chat

* fix(workflow): route node card delete button through onNodesChange

* fix(chat): recurse loopRun/parallelRun details when flattening responses

* fix(workflow): loop run resume stitching and PR review polish

* fix(workflow): loop run max-length boundary and resume isEntry leak
This commit is contained in:
DigHuang
2026-04-24 21:35:30 +08:00
committed by GitHub
parent 81dfc589f4
commit 27ebc0eeba
67 changed files with 3993 additions and 221 deletions
@@ -178,9 +178,15 @@ const InputRender = (props: InputRenderProps) => {
}
if (inputType === InputTypeEnum.select) {
const list =
const rawList: { label: string; value: string; icon?: string; description?: string }[] =
props.list || props.enums?.map((item) => ({ label: item.value, value: item.value })) || [];
return <MySelect {...commonProps} list={list} h={10} />;
const list = rawList.map((item) => ({
...item,
label: typeof item.label === 'string' ? t(item.label as any) : item.label,
description:
typeof item.description === 'string' ? t(item.description as any) : item.description
}));
return <MySelect {...commonProps} list={list} h={10} menuPlacement={props.menuPlacement} />;
}
if (inputType === InputTypeEnum.multipleSelect) {
@@ -5,7 +5,7 @@ import type {
import type { InputTypeEnum } from './constant';
import type { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import type { UseFormReturn } from 'react-hook-form';
import type { BoxProps } from '@chakra-ui/react';
import type { BoxProps, MenuProps } from '@chakra-ui/react';
import type { EditorProps } from '@fastgpt/web/components/common/Textarea/PromptEditor/Editor';
import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io';
@@ -41,8 +41,9 @@ export type SpecificProps = {
// switch - no extra props
// select & multipleSelect
list?: { label: string; value: string }[];
list?: { label: string; value: string; icon?: string; description?: string }[];
enums?: { value: string }[]; // old version
menuPlacement?: MenuProps['placement'];
// selectDataset
datasetOptions?: SelectedDatasetType[];
@@ -900,9 +900,12 @@ const ChatBox = ({
const responseData = mergeChatResponseData(item.responseData || []);
// Check node response error
if (!abortSignal?.signal?.aborted) {
const err =
responseData[responseData.length - 1]?.error ||
responseData[responseData.length - 1]?.errorText;
// `.error` is dispatcher-injected only on uncaught failures — scan all items
// so uncaught errors in nested/mid-workflow nodes still surface. Last-entry
// `.errorText` covers the misconfigured "catchError=true, no handler wired"
// case where only errorText was set.
const uncaughtErr = responseData.find((r) => r.error)?.error;
const err = uncaughtErr ?? responseData[responseData.length - 1]?.errorText;
if (err) {
toast({
title: t(getErrText(err)),
@@ -127,7 +127,7 @@ export const WholeResponseContent = ({
border: '1px solid',
borderColor: 'myGray.200',
color: 'myGray.900',
bg: '#F7F8FA'
bg: 'myGray.50'
})}
>
<Box
@@ -186,8 +186,10 @@ export const WholeResponseContent = ({
)}
/>
)}
<Row label={t('workflow:response.Error')} value={activeModule?.error} />
<Row label={t('workflow:response.Error')} value={activeModule?.errorText} />
<Row
label={t('workflow:response.Error')}
value={activeModule?.errorText ?? activeModule?.error}
/>
<Row label={t('chat:response.node_inputs')} value={activeModule?.nodeInputs} />
</>
{/* ai chat */}
@@ -246,8 +248,8 @@ export const WholeResponseContent = ({
role={'group'}
alignItems={'center'}
gap={2}
bg={'myGray.50'}
borderRadius={'8px'}
bg={'myGray.100'}
borderRadius={'6px'}
px={3}
py={2}
cursor={'pointer'}
@@ -260,7 +262,8 @@ export const WholeResponseContent = ({
flex={'1 0 0'}
w={0}
fontSize={'12px'}
lineHeight={'18px'}
lineHeight={'16px'}
letterSpacing={'0.4px'}
textOverflow={'ellipsis'}
overflow={'hidden'}
whiteSpace={'nowrap'}
@@ -509,9 +512,22 @@ export const WholeResponseContent = ({
/>
{/* update var */}
{/* `updateVarResult` is `updateList.map(...)` — outer dim = rows in the
variable-update config. Single-row is the common case, where the
outer 1-element wrapper is noise (esp. bad when inner is itself an
array → visual `[[...]]`). Unwrap it for all value types for
consistency, but keep the wrapper if inner is null/undefined:
Row hides rows whose `val` falsey-coerces to undefined, and `[null]`
preserves the "invalid reference" signal this node emits. */}
<Row
label={t('common:core.chat.response.update_var_result')}
value={activeModule?.updateVarResult}
value={(() => {
const r = activeModule?.updateVarResult;
if (Array.isArray(r) && r.length === 1 && r[0] !== null && r[0] !== undefined) {
return r[0];
}
return r;
})()}
/>
{/* loop */}
@@ -532,6 +548,20 @@ export const WholeResponseContent = ({
value={activeModule?.parallelRunDetail}
/>
{/* loopRun */}
<Row
label={t('common:core.chat.response.loop_run_input')}
value={activeModule?.loopRunInput}
/>
<Row
label={t('common:core.chat.response.loop_run_iterations')}
value={activeModule?.loopRunIterations}
/>
<Row
label={t('common:core.chat.response.loop_run_history')}
value={activeModule?.loopRunHistory}
/>
{/* loopStart */}
<Row
label={t('common:core.chat.response.loop_input_element')}
@@ -776,6 +806,9 @@ export const ResponseBox = React.memo(function ResponseBox({
if (Array.isArray(item.parallelDetail)) {
helper(item.parallelDetail);
}
if (Array.isArray(item.loopRunDetail)) {
helper(item.loopRunDetail);
}
if (Array.isArray(item.childrenResponses)) {
helper(item.childrenResponses);
}
@@ -811,6 +844,7 @@ export const ResponseBox = React.memo(function ResponseBox({
if (item?.pluginDetail) children.push(...pretreatmentResponse(item?.pluginDetail));
if (item?.loopDetail) children.push(...pretreatmentResponse(item?.loopDetail));
if (item?.parallelDetail) children.push(...pretreatmentResponse(item?.parallelDetail));
if (item?.loopRunDetail) children.push(...pretreatmentResponse(item?.loopRunDetail));
if (item?.childrenResponses)
children.push(...pretreatmentResponse(item?.childrenResponses));
@@ -1,7 +1,11 @@
import MyBox from '@fastgpt/web/components/common/MyBox';
import React from 'react';
import { useContextSelector } from 'use-context-selector';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
EDGE_TYPE,
FlowNodeTypeEnum,
isNestedChildSystemNodeType
} from '@fastgpt/global/core/workflow/node/constant';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type Node } from 'reactflow';
import { WorkflowBufferDataContext } from '../context/workflowInitContext';
@@ -57,11 +61,7 @@ const NodeTemplatesPopover = () => {
}
// 2. Exclude loop start and end nodes
if (
[FlowNodeTypeEnum.nestedStart, FlowNodeTypeEnum.nestedEnd].includes(
node.data.flowNodeType
)
) {
if (isNestedChildSystemNodeType(node.data.flowNodeType)) {
return false;
}
@@ -40,6 +40,7 @@ import { useWorkflowUtils } from '../../hooks/useUtils';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/loopEnd';
import { LoopRunStartNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunStart';
import { useReactFlow } from 'reactflow';
import type { Node } from 'reactflow';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
@@ -224,6 +225,7 @@ const NodeTemplateList = ({
const { computedNewNodeName } = useWorkflowUtils();
const { getNodeList, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const handleParams = useContextSelector(WorkflowModalContext, (v) => v.handleParams);
const { getIntersectingNodes } = useReactFlow();
const showSkill = !!feConfigs?.show_skill;
@@ -278,8 +280,27 @@ const NodeTemplateList = ({
});
const currentNode = getNodeById(handleParams?.nodeId);
// Popover insertion inherits the source node's parent; a dragged
// loopRunBreak with no inherited parent falls back to hit-testing.
let effectiveParentNodeId: string | undefined = currentNode?.parentNodeId;
if (templateNode.flowNodeType === FlowNodeTypeEnum.loopRunBreak && !effectiveParentNodeId) {
const dropLoopRun = getIntersectingNodes({
x: position.x,
y: position.y,
width: 1,
height: 1
}).find((n) => n.type === FlowNodeTypeEnum.loopRun && !n.data?.isFolded);
if (dropLoopRun) {
effectiveParentNodeId = dropLoopRun.id;
}
}
const effectiveParentNode = effectiveParentNodeId
? getNodeById(effectiveParentNodeId)
: undefined;
const isNestedParentNode = isNestedParentNodeType(templateNode.flowNodeType);
if (isNestedParentNode && !!currentNode?.parentNodeId) {
if (isNestedParentNode && !!effectiveParentNodeId) {
toast({
status: 'warning',
title: t('workflow:can_not_loop')
@@ -287,10 +308,8 @@ const NodeTemplateList = ({
return;
}
// Forbid interactive nodes inside parallelRun
if (currentNode?.parentNodeId && isInteractiveNodeType(templateNode.flowNodeType)) {
const parentNode = getNodeById(currentNode.parentNodeId);
if (parentNode?.flowNodeType === FlowNodeTypeEnum.parallelRun) {
if (effectiveParentNodeId && isInteractiveNodeType(templateNode.flowNodeType)) {
if (effectiveParentNode?.flowNodeType === FlowNodeTypeEnum.parallelRun) {
toast({
status: 'warning',
title: t('workflow:can_not_parallel')
@@ -299,6 +318,16 @@ const NodeTemplateList = ({
}
}
if (templateNode.flowNodeType === FlowNodeTypeEnum.loopRunBreak) {
if (effectiveParentNode?.flowNodeType !== FlowNodeTypeEnum.loopRun) {
toast({
status: 'warning',
title: t('workflow:loop_run_break_must_inside_loop_run')
});
return;
}
}
const newNode = nodeTemplate2FlowNode({
template: {
...templateNode,
@@ -318,7 +347,15 @@ const NodeTemplateList = ({
description: input.description ? t(input.description as any) : undefined,
placeholder: input.placeholder ? t(input.placeholder as any) : undefined,
debugLabel: input.debugLabel ? t(input.debugLabel as any) : undefined,
toolDescription: input.toolDescription ? t(input.toolDescription as any) : undefined
toolDescription: input.toolDescription
? t(input.toolDescription as any)
: undefined,
list: Array.isArray(input.list)
? input.list.map((opt: any) => ({
...opt,
label: opt?.label ? t(opt.label as any) : opt?.label
}))
: input.list
})),
outputs: templateNode.outputs
.filter((output) => output.deprecated !== true)
@@ -331,27 +368,37 @@ const NodeTemplateList = ({
},
position,
selected: true,
parentNodeId: currentNode?.parentNodeId,
parentNodeId: effectiveParentNodeId,
t
});
const newNodes = [newNode];
if (isNestedParentNodeType(templateNode.flowNodeType)) {
const startNode = nodeTemplate2FlowNode({
template: LoopStartNode,
position: { x: position.x + 60, y: position.y + 280 },
parentNodeId: newNode.id,
t
});
const endNode = nodeTemplate2FlowNode({
template: LoopEndNode,
position: { x: position.x + 420, y: position.y + 680 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode, endNode);
// loopRun uses its own Start node and no End node.
if (templateNode.flowNodeType === FlowNodeTypeEnum.loopRun) {
const startNode = nodeTemplate2FlowNode({
template: LoopRunStartNode,
position: { x: position.x + 60, y: position.y + 280 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode);
} else {
const startNode = nodeTemplate2FlowNode({
template: LoopStartNode,
position: { x: position.x + 60, y: position.y + 280 },
parentNodeId: newNode.id,
t
});
const endNode = nodeTemplate2FlowNode({
template: LoopEndNode,
position: { x: position.x + 420, y: position.y + 680 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode, endNode);
}
}
if (newNodes && newNodes.length > 0) {
@@ -363,7 +410,16 @@ const NodeTemplateList = ({
console.error('Failed to create node template:', error);
}
},
[computedNewNodeName, getNodeById, handleParams?.nodeId, getNodeList, onAddNode, t, toast]
[
computedNewNodeName,
getNodeById,
handleParams?.nodeId,
getNodeList,
getIntersectingNodes,
onAddNode,
t,
toast
]
);
const formatTemplatesArrayData = useMemo(() => {
@@ -23,10 +23,8 @@ export const useNodeTemplates = () => {
const [parentId, setParentId] = useState<ParentIdType>('');
const appId = useContextSelector(AppContext, (v) => v.appDetail._id);
const { basicNodeTemplates, hasToolNode, getNodeList, nodeAmount } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
const { basicNodeTemplates, hasToolNode, hasLoopRunNode, getNodeList, nodeAmount } =
useContextSelector(WorkflowBufferDataContext, (v) => v);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const { data: toolTags = [] } = useRequest(getPluginToolTags, {
@@ -59,6 +57,10 @@ export const useNodeTemplates = () => {
) {
return false;
}
// loopRunBreak only shows when a loopRun node exists on the canvas
if (!hasLoopRunNode && item.flowNodeType === FlowNodeTypeEnum.loopRunBreak) {
return false;
}
return true;
})
.map<NodeTemplateListItemType>((item) => ({
@@ -74,7 +76,7 @@ export const useNodeTemplates = () => {
{
manual: false,
throttleWait: 100,
refreshDeps: [basicNodeTemplates, nodeAmount, hasToolNode, templateType]
refreshDeps: [basicNodeTemplates, nodeAmount, hasToolNode, hasLoopRunNode, templateType]
}
);
@@ -15,7 +15,7 @@ import { isValidArrayReferenceValue } from '@fastgpt/global/core/workflow/utils'
import { type ReferenceArrayValueType } from '@fastgpt/global/core/workflow/type/io';
import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { WorkflowBufferDataContext } from '../../context/workflowInitContext';
import { WorkflowBufferDataContext, WorkflowInitContext } from '../../context/workflowInitContext';
import { WorkflowActionsContext } from '../../context/workflowActionsContext';
import { WorkflowLayoutContext } from '../../context/workflowComputeContext';
import { getWorkflowGlobalVariables } from '@/web/core/workflow/utils';
@@ -24,6 +24,8 @@ import { AppContext } from '../../../context';
type UseNestedNodeParams = {
nodeId: string;
inputs: FlowNodeInputItemType[];
// Pass `undefined` to skip array valueType inference (loopRun conditional mode).
arrayInputKey?: NodeInputKeyEnum;
};
type UseNestedNodeResult = {
@@ -32,19 +34,12 @@ type UseNestedNodeResult = {
inputBoxRef: React.RefObject<HTMLDivElement>;
};
/**
* Shared hook for nested-container nodes (Loop & ParallelRun).
*
* Encapsulates five pieces of logic that are identical in both components:
* 1. Read nodeWidth / nodeHeight / nestedInputArray / loopNodeInputHeight from inputs
* 2. Infer array valueType from the referenced output and sync it back
* 3. Maintain childrenNodeIdList and trigger resetParentNodeSizeAndPosition
* 4. Measure the input-box height with useSize and sync nestedNodeInputHeight
* 5. Trigger resetParentNodeSizeAndPosition after height changes
*
* Returns only what the component JSX needs (nodeWidth, nodeHeight, inputBoxRef).
*/
export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNestedNodeResult => {
// Shared hook for nested-container nodes (Loop / ParallelRun / LoopRun).
export const useNestedNode = ({
nodeId,
inputs,
arrayInputKey = NodeInputKeyEnum.nestedInputArray
}: UseNestedNodeParams): UseNestedNodeResult => {
const { getNodeById, nodeIds, childNodeIds, getNodeList, systemConfigNode } = useContextSelector(
WorkflowBufferDataContext,
(v) => {
@@ -57,6 +52,17 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
};
}
);
// 订阅子节点尺寸变化:ReactFlow 完成测量后会更新 node.width / node.height,
// 把它们压成字符串当 signal,有变化就重算 bounds,避免 50ms 定时器抢跑在测量前。
const childDimensionsSignal = useContextSelector(WorkflowInitContext, (v) => {
let signal = '';
for (const node of v.nodes) {
if (node.data.parentNodeId === nodeId) {
signal += `${node.id}:${node.width ?? 0}x${node.height ?? 0}|`;
}
}
return signal;
});
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const resetParentNodeSizeAndPosition = useContextSelector(
@@ -73,12 +79,14 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
nodeHeight: Math.round(
Number(inputs.find((input) => input.key === NodeInputKeyEnum.nodeHeight)?.value) || 500
),
nestedInputArray: inputs.find((input) => input.key === NodeInputKeyEnum.nestedInputArray),
nestedInputArray: arrayInputKey
? inputs.find((input) => input.key === arrayInputKey)
: undefined,
loopNodeInputHeight: inputs.find(
(input) => input.key === NodeInputKeyEnum.nestedNodeInputHeight
)
};
}, [inputs]);
}, [inputs, arrayInputKey]);
const nestedInputArray = useMemoEnhance(
() => computedResult.nestedInputArray,
@@ -117,19 +125,19 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
}, [appDetail.chatConfig, getNodeById, nestedInputArray, nodeIds, systemConfigNode]);
useEffect(() => {
if (!nestedInputArray || nestedInputArray.valueType === newValueType) return;
if (!nestedInputArray || !arrayInputKey || nestedInputArray.valueType === newValueType) return;
onChangeNode({
nodeId,
type: 'updateInput',
key: NodeInputKeyEnum.nestedInputArray,
key: arrayInputKey,
value: {
...nestedInputArray,
valueType: newValueType
}
});
}, [nestedInputArray, newValueType, nodeId, onChangeNode]);
}, [nestedInputArray, newValueType, nodeId, onChangeNode, arrayInputKey]);
// ── 3. Maintain childrenNodeIdList ─────────────────────────────────────────
// ── 3a. Maintain childrenNodeIdList ─────────────────────────────────────────
useEffect(() => {
onChangeNode({
nodeId,
@@ -140,10 +148,15 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
value: childNodeIds
}
});
// 等待 ReactFlow 完成新子节点的宽高测量后再计算,否则 bounds 会少算整个新节点
}, [childNodeIds, nodeId, onChangeNode]);
// ── 3b. Trigger layout reset on child id / dimension change ─────────────────
// 依赖 childDimensionsSignal,子节点被 ReactFlow 测量出新的 w/h 后会再触发一次,
// 确保 bounds 计算基于真实尺寸,而不是赶在 50ms 定时器到期时还是 0 的状态。
useEffect(() => {
const timer = setTimeout(() => resetParentNodeSizeAndPosition(nodeId), 50);
return () => clearTimeout(timer);
}, [childNodeIds, nodeId, onChangeNode, resetParentNodeSizeAndPosition]);
}, [childNodeIds, childDimensionsSignal, nodeId, resetParentNodeSizeAndPosition]);
// ── 4 & 5. Measure input-box height, sync and re-layout ────────────────────
const inputBoxRef = useRef<HTMLDivElement>(null);
@@ -19,6 +19,7 @@ import {
FlowNodeTypeEnum,
isNestedParentNodeType
} from '@fastgpt/global/core/workflow/node/constant';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import 'reactflow/dist/style.css';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next';
@@ -450,7 +451,11 @@ export const useRAF = () => {
export const popoverWidth = 400;
export const popoverHeight = 600;
// 嵌套父容器节点类型集合
const PARENT_NODE_TYPES = new Set([FlowNodeTypeEnum.loop, FlowNodeTypeEnum.parallelRun]);
const PARENT_NODE_TYPES = new Set([
FlowNodeTypeEnum.loop,
FlowNodeTypeEnum.parallelRun,
FlowNodeTypeEnum.loopRun
]);
export const useWorkflow = () => {
const { toast } = useToast();
@@ -500,11 +505,12 @@ export const useWorkflow = () => {
}
);
// Check if a node is placed on top of a nested parent node (loop / parallelRun)
// Check if a node is placed on top of a nested parent node (loop / parallelRun / loopRun)
const checkNodeOverLoopNode = useMemoizedFn((node: Node) => {
const unSupportedInLoop = [
FlowNodeTypeEnum.workflowStart,
FlowNodeTypeEnum.loop,
FlowNodeTypeEnum.loopRun,
FlowNodeTypeEnum.parallelRun,
FlowNodeTypeEnum.pluginInput,
FlowNodeTypeEnum.pluginOutput,
@@ -526,6 +532,16 @@ export const useWorkflow = () => {
);
if (parentNode) {
if (
node.type === FlowNodeTypeEnum.loopRunBreak &&
parentNode.type !== FlowNodeTypeEnum.loopRun
) {
return toast({
status: 'warning',
title: t('workflow:loop_run_break_must_inside_loop_run')
});
}
const isParallel = parentNode.type === FlowNodeTypeEnum.parallelRun;
const unSupportedTypes = isParallel ? unSupportedInParallel : unSupportedInLoop;
if (unSupportedTypes.includes(node.type as FlowNodeTypeEnum)) {
@@ -727,6 +743,9 @@ export const useWorkflow = () => {
);
const handleNodesChange = useMemoizedFn((changes: NodeChange[]) => {
const childChanges: NodeChange[] = [];
const removedIds = new Set(
changes.filter((c): c is NodeRemoveChange => c.type === 'remove').map((c) => c.id)
);
for (const change of changes) {
if (change.type === 'remove') {
@@ -744,6 +763,35 @@ export const useWorkflow = () => {
});
continue;
}
// Conditional loopRun must retain at least one loopRunBreak child.
if (
node.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak &&
node.data.parentNodeId &&
!parentNodeDeleted
) {
const parent = getRawNodeById(node.data.parentNodeId);
const parentMode = parent?.data.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)
?.value as LoopRunModeEnum | undefined;
if (
parent?.data.flowNodeType === FlowNodeTypeEnum.loopRun &&
parentMode === LoopRunModeEnum.conditional
) {
const remainingBreak = nodes.some(
(n) =>
n.data.parentNodeId === parent.id &&
n.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak &&
!removedIds.has(n.id)
);
if (!remainingBreak) {
toast({
status: 'warning',
title: t('workflow:loop_run_conditional_requires_break')
});
removedIds.delete(change.id);
continue;
}
}
}
handleRemoveNode(change, node.id);
} else if (change.type === 'select') {
handleSelectNode(change);
@@ -774,6 +822,8 @@ export const useWorkflow = () => {
const onNodeDragStop = useCallback(
(_: any, node: Node) => {
setHelperLineHorizontal(undefined);
setHelperLineVertical(undefined);
checkNodeOverLoopNode(node);
},
[checkNodeOverLoopNode]
@@ -62,6 +62,9 @@ const nodeTypes: Record<FlowNodeTypeEnum, any> = {
[FlowNodeTypeEnum.userSelect]: dynamic(() => import('./nodes/NodeUserSelect')),
[FlowNodeTypeEnum.loop]: dynamic(() => import('./nodes/Loop/NodeLoop')),
[FlowNodeTypeEnum.parallelRun]: dynamic(() => import('./nodes/Loop/NodeParallelRun')),
[FlowNodeTypeEnum.loopRun]: dynamic(() => import('./nodes/Loop/NodeLoopRun')),
[FlowNodeTypeEnum.loopRunStart]: dynamic(() => import('./nodes/Loop/NodeLoopRunStart')),
[FlowNodeTypeEnum.loopRunBreak]: dynamic(() => import('./nodes/Loop/NodeLoopRunBreak')),
[FlowNodeTypeEnum.nestedStart]: dynamic(() => import('./nodes/Loop/NodeLoopStart')),
[FlowNodeTypeEnum.nestedEnd]: dynamic(() => import('./nodes/Loop/NodeLoopEnd')),
[FlowNodeTypeEnum.formInput]: dynamic(() => import('./nodes/NodeFormInput')),
@@ -39,7 +39,7 @@ const NodeLoop = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
flex={1}
position={'relative'}
border={'base'}
bg={'myGray.50'}
bg={'myGray.100'}
rounded={'8px'}
{...(!isFolded && {
minW: nodeWidth,
@@ -0,0 +1,269 @@
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import React, { useEffect, useMemo, useRef } from 'react';
import { type NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
import Container from '../../components/Container';
import IOTitle from '../../components/IOTitle';
import { useTranslation } from 'next-i18next';
import RenderInput from '../render/RenderInput';
import { Box } from '@chakra-ui/react';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import RenderOutput from '../render/RenderOutput';
import CatchError from '../render/RenderOutput/CatchError';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import {
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { LoopRunBreakNode as LoopRunBreakTemplate } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunBreak';
import { useNestedNode } from '../../hooks/useNestedNode';
import { useContextSelector } from 'use-context-selector';
import { WorkflowActionsContext } from '../../../context/workflowActionsContext';
import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext';
import { WorkflowBufferDataContext } from '../../../context/workflowInitContext';
import { WorkflowInitContext } from '../../../context/workflowInitContext';
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
const NodeLoopRun = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, inputs, outputs, isFolded, catchError } = data;
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const splitOutput = useContextSelector(WorkflowUtilsContext, (v) => v.splitOutput);
const { getNodeById, setNodes, childrenNodeIdListMap } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
const childNodeIds = childrenNodeIdListMap[nodeId] ?? [];
const getRawNodeById = useContextSelector(WorkflowInitContext, (v) => v.getRawNodeById);
const mode =
(inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined) ?? LoopRunModeEnum.array;
// Conditional mode has no array input; skip valueType inference in the hook.
const arrayInputKey =
mode === LoopRunModeEnum.array ? NodeInputKeyEnum.loopRunInputArray : undefined;
const { nodeWidth, nodeHeight, inputBoxRef } = useNestedNode({ nodeId, inputs, arrayInputKey });
const inputAreaInputs = useMemo(
() =>
inputs.filter((i) => {
if (i.key === NodeInputKeyEnum.loopCustomOutputs) return false;
if (i.canEdit) return false;
if (mode !== LoopRunModeEnum.array && i.key === NodeInputKeyEnum.loopRunInputArray) {
return false;
}
return true;
}),
[inputs, mode]
);
const outputDeclarationInputs = useMemo(
() => inputs.filter((i) => i.key === NodeInputKeyEnum.loopCustomOutputs || !!i.canEdit),
[inputs]
);
const { successOutputs, errorOutputs } = useMemoEnhance(
() => splitOutput(outputs),
[splitOutput, outputs]
);
// Mode sync is owned by the container, not the start node, because the start
// node doesn't re-render reliably when the parent's mode input changes.
const prevModeRef = useRef<LoopRunModeEnum>(mode);
useEffect(() => {
const prevMode = prevModeRef.current;
prevModeRef.current = mode;
const startChildId = childNodeIds.find(
(id) => getNodeById(id)?.flowNodeType === FlowNodeTypeEnum.loopRunStart
);
const startNode = startChildId ? getNodeById(startChildId) : undefined;
if (startNode) {
const hasIndex = startNode.outputs.some((o) => o.key === NodeOutputKeyEnum.currentIndex);
const hasItem = startNode.outputs.some((o) => o.key === NodeOutputKeyEnum.currentItem);
const hasIteration = startNode.outputs.some(
(o) => o.key === NodeOutputKeyEnum.currentIteration
);
// Store i18n keys so downstream `t(label)` stays reactive.
if (mode === LoopRunModeEnum.array) {
if (hasIteration) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'delOutput',
key: NodeOutputKeyEnum.currentIteration
});
}
if (!hasIndex) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'addOutput',
value: {
id: NodeOutputKeyEnum.currentIndex,
key: NodeOutputKeyEnum.currentIndex,
label: 'workflow:current_index',
description: 'workflow:current_index_desc',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
}
});
}
if (!hasItem) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'addOutput',
value: {
id: NodeOutputKeyEnum.currentItem,
key: NodeOutputKeyEnum.currentItem,
label: 'workflow:current_item',
description: 'workflow:current_item_desc',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.any
}
});
}
} else {
if (hasIndex) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'delOutput',
key: NodeOutputKeyEnum.currentIndex
});
}
if (hasItem) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'delOutput',
key: NodeOutputKeyEnum.currentItem
});
}
if (!hasIteration) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'addOutput',
value: {
id: NodeOutputKeyEnum.currentIteration,
key: NodeOutputKeyEnum.currentIteration,
label: 'workflow:current_iteration',
description: 'workflow:current_iteration_desc',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
}
});
}
}
}
// Transition-only, so a user-deleted break node isn't re-created.
if (mode === LoopRunModeEnum.conditional && prevMode !== LoopRunModeEnum.conditional) {
const hasBreak = childNodeIds.some(
(id) => getNodeById(id)?.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
if (!hasBreak) {
const startRaw = startChildId ? getRawNodeById(startChildId) : undefined;
const position = startRaw?.position
? { x: startRaw.position.x + 500, y: startRaw.position.y + 150 }
: { x: 500, y: 400 };
const breakNode = nodeTemplate2FlowNode({
template: LoopRunBreakTemplate,
position,
parentNodeId: nodeId,
t
});
setNodes((state) => state.concat(breakNode));
}
}
}, [mode, childNodeIds, nodeId, getNodeById, getRawNodeById, onChangeNode, setNodes, t]);
useEffect(() => {
const declared = inputs.filter((i) => i.canEdit === true);
const currentDynamic = outputs.filter((o) => o.type === FlowNodeOutputTypeEnum.dynamic);
const declaredKeys = new Set(declared.map((i) => i.key));
currentDynamic.forEach((o) => {
if (!declaredKeys.has(o.key)) {
onChangeNode({ nodeId, type: 'delOutput', key: o.key });
}
});
declared.forEach((input) => {
const existing = currentDynamic.find((o) => o.key === input.key);
if (!existing) {
onChangeNode({
nodeId,
type: 'addOutput',
value: {
id: input.key,
key: input.key,
label: input.label || input.key,
type: FlowNodeOutputTypeEnum.dynamic,
valueType: input.valueType
}
});
} else if (
existing.valueType !== input.valueType ||
existing.label !== (input.label || input.key)
) {
onChangeNode({
nodeId,
type: 'updateOutput',
key: input.key,
value: {
...existing,
label: input.label || input.key,
valueType: input.valueType
}
});
}
});
}, [inputs, outputs, nodeId, onChangeNode]);
return (
<NodeCard selected={selected} maxW="full" menuForbid={{ copy: true }} {...data}>
<Container position={'relative'} flex={1}>
<IOTitle text={t('common:Input')} />
<Box mb={6} maxW={'500px'} ref={inputBoxRef}>
<RenderInput nodeId={nodeId} flowInputList={inputAreaInputs} />
</Box>
<>
<FormLabel required fontWeight={'medium'} mb={3} color={'myGray.600'}>
{t('workflow:loop_body')}
</FormLabel>
<Box
flex={1}
position={'relative'}
border={'base'}
bg={'myGray.100'}
rounded={'8px'}
{...(!isFolded && {
minW: nodeWidth,
minH: nodeHeight
})}
/>
</>
</Container>
<Container>
<IOTitle text={t('common:Output')} nodeId={nodeId} catchError={catchError} />
<Box maxW={'600px'}>
<RenderInput nodeId={nodeId} flowInputList={outputDeclarationInputs} />
</Box>
<RenderOutput nodeId={nodeId} flowOutputList={successOutputs} />
</Container>
{catchError && <CatchError nodeId={nodeId} errorOutputs={errorOutputs} />}
</NodeCard>
);
};
export default React.memo(NodeLoopRun);
@@ -0,0 +1,21 @@
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import React from 'react';
import { type NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
const NodeLoopRunBreak = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
return (
<NodeCard
selected={selected}
{...data}
w={'420px'}
minH={'168px'}
menuForbid={{
copy: true,
debug: true
}}
/>
);
};
export default React.memo(NodeLoopRunBreak);
@@ -0,0 +1,110 @@
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { useTranslation } from 'next-i18next';
import { type NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
import { useContextSelector } from 'use-context-selector';
import { WorkflowBufferDataContext } from '../../../context/workflowInitContext';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import { Box, Flex, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react';
import React, { useEffect, useMemo } from 'react';
import { FlowValueTypeMap } from '@fastgpt/global/core/workflow/node/constant';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { WorkflowActionsContext } from '../../../context/workflowActionsContext';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
const arrayItemTypeMap: Partial<Record<WorkflowIOValueTypeEnum, WorkflowIOValueTypeEnum>> = {
[WorkflowIOValueTypeEnum.arrayString]: WorkflowIOValueTypeEnum.string,
[WorkflowIOValueTypeEnum.arrayNumber]: WorkflowIOValueTypeEnum.number,
[WorkflowIOValueTypeEnum.arrayBoolean]: WorkflowIOValueTypeEnum.boolean,
[WorkflowIOValueTypeEnum.arrayObject]: WorkflowIOValueTypeEnum.object,
[WorkflowIOValueTypeEnum.arrayAny]: WorkflowIOValueTypeEnum.any
};
const NodeLoopRunStart = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, outputs } = data;
const { getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const startNode = getNodeById(nodeId);
const parentNode = getNodeById(startNode?.parentNodeId);
const parentMode =
(parentNode?.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined) ?? LoopRunModeEnum.array;
const currentItemType = useMemo(() => {
if (parentMode !== LoopRunModeEnum.array) return undefined;
const parentArrayInput = parentNode?.inputs.find(
(i) => i.key === NodeInputKeyEnum.loopRunInputArray
);
return arrayItemTypeMap[parentArrayInput?.valueType as keyof typeof arrayItemTypeMap];
}, [parentNode?.inputs, parentMode]);
// Output add/remove on mode switches lives in NodeLoopRun; this effect only
// keeps currentItem.valueType in sync with the inferred parent array type.
useEffect(() => {
if (parentMode !== LoopRunModeEnum.array || !currentItemType) return;
const currentItem = startNode?.outputs.find((o) => o.key === NodeOutputKeyEnum.currentItem);
if (currentItem && currentItem.valueType !== currentItemType) {
onChangeNode({
nodeId,
type: 'updateOutput',
key: NodeOutputKeyEnum.currentItem,
value: { ...currentItem, valueType: currentItemType }
});
}
}, [parentMode, currentItemType, nodeId, onChangeNode, startNode?.outputs]);
return (
<NodeCard
selected={selected}
{...data}
menuForbid={{
copy: true,
delete: true,
debug: true
}}
>
<Box px={4} pt={2} w={'420px'}>
<Box bg={'white'} borderRadius={'md'} overflow={'hidden'} border={'base'}>
<TableContainer>
<Table bg={'white'} variant={'workflow'}>
<Thead>
<Tr>
<Th>{t('workflow:Variable_name')}</Th>
<Th>{t('common:core.workflow.Value type')}</Th>
</Tr>
</Thead>
<Tbody>
{outputs.map((output) => (
<Tr key={output.id}>
<Td>
<Flex alignItems={'center'}>
<MyIcon
name={'core/workflow/inputType/array'}
w={'14px'}
mr={1}
color={'primary.600'}
/>
{t(output.label as any)}
</Flex>
</Td>
{output.valueType && <Td>{FlowValueTypeMap[output.valueType]?.label}</Td>}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
</Box>
</NodeCard>
);
};
export default React.memo(NodeLoopRunStart);
@@ -56,7 +56,7 @@ const NodeParallelRun = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
flex={1}
position={'relative'}
border={'base'}
bg={'myGray.50'}
bg={'myGray.100'}
rounded={'8px'}
{...(!isFolded && {
minW: nodeWidth,
@@ -327,7 +327,7 @@ const NodeCard = (props: Props) => {
{foldedOverlay}
{!isFolded && (
<Box bg={'white'} borderRadius={'lg'}>
<Box bg={'white'} borderRadius={'lg'} flex={1} display={'flex'} flexDirection={'column'}>
{/* Header */}
<Box position={'relative'}>
{gradient && (
@@ -686,11 +686,9 @@ const MenuRender = React.memo(function MenuRender({
}) {
const { t } = useTranslation();
const { openDebugNode, DebugInputModal } = useDebug();
const { setNodes, setEdges, getNodeList, getNodeById } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
const { setNodes, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const { deleteElements } = useReactFlow();
const { computedNewNodeName } = useWorkflowUtils();
@@ -772,30 +770,6 @@ const MenuRender = React.memo(function MenuRender({
},
[computedNewNodeName, setNodes, t]
);
const onDelNode = useCallback(
(nodeId: string) => {
// Remove node and its child nodes
setNodes((state) =>
state.filter((item) => item.data.nodeId !== nodeId && item.data.parentNodeId !== nodeId)
);
// Remove edges connected to the node and its child nodes
const childNodeIds = getNodeList()
.filter((node) => node.parentNodeId === nodeId)
.map((node) => node.nodeId);
setEdges((state) =>
state.filter(
(edge) =>
edge.source !== nodeId &&
edge.target !== nodeId &&
!childNodeIds.includes(edge.target) &&
!childNodeIds.includes(edge.source)
)
);
},
[getNodeList, setEdges, setNodes]
);
const Render = useMemo(() => {
const menuList = [
...(menuForbid?.fold
@@ -842,7 +816,7 @@ const MenuRender = React.memo(function MenuRender({
icon: 'delete',
label: t('common:Delete'),
variant: 'whiteDanger',
onClick: () => onDelNode(nodeId)
onClick: () => deleteElements({ nodes: [{ id: nodeId }] })
}
])
];
@@ -891,7 +865,7 @@ const MenuRender = React.memo(function MenuRender({
openDebugNode,
nodeId,
onCopyNode,
onDelNode,
deleteElements,
isFolded,
onChangeNode
]);
@@ -11,6 +11,7 @@ import { getEditorVariables } from '@/pageComponents/app/detail/WorkflowComponen
import { InputTypeEnum } from '@/components/core/app/formRender/constant';
import { getWebDefaultLLMModel } from '@/web/common/system/utils';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { isNestedParentNodeType } from '@fastgpt/global/core/workflow/node/constant';
import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover';
import { WorkflowActionsContext } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
@@ -78,6 +79,13 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => {
const inputType = nodeInputTypeToInputType(item.renderTypeList);
// 嵌套容器节点(loop/parallelRun/loopRun)里的 select 下拉向上展开,避免被子节点覆盖。
const menuPlacement = useMemo(() => {
const node = getNodeById(nodeId);
if (!node) return undefined;
return isNestedParentNodeType(node.flowNodeType) ? ('top-start' as const) : undefined;
}, [getNodeById, nodeId]);
// 添加默认值处理的效果
useEffect(() => {
if (inputType === InputTypeEnum.selectLLMModel && item.value === undefined && defaultModel) {
@@ -110,6 +118,7 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => {
variableLabels={editorVariables}
modelList={llmModelList}
ExtensionPopover={canOptimizePrompt ? [OptimizerPopverComponent] : undefined}
menuPlacement={menuPlacement}
{...item}
/>
);
@@ -38,11 +38,13 @@ const DynamicInputs = ({ item, inputs = [], nodeId }: RenderInputProps) => {
const dynamicInputs = useMemoEnhance(() => inputs.filter((item) => item.canEdit), [inputs]);
const existsKeys = useMemoEnhance(() => inputs.map((item) => item.key), [inputs]);
const hideBottomDivider = item.customInputConfig?.hideBottomDivider;
return (
<Box borderBottom={'base'} pb={3}>
<Box borderBottom={hideBottomDivider ? undefined : 'base'} pb={hideBottomDivider ? 0 : 3}>
<HStack className="nodrag" cursor={'default'} position={'relative'}>
<HStack spacing={1} position={'relative'} fontWeight={'medium'} color={'myGray.600'}>
<Box>{item.label || t('workflow:custom_input')}</Box>
<Box>{item.label ? t(item.label as any) : t('workflow:custom_input')}</Box>
{item.description && <QuestionTip label={t(item.description as any)} />}
{item.deprecated && (
@@ -134,7 +136,9 @@ const Reference = ({
const { referenceList } = useReference({
nodeId,
valueType: WorkflowIOValueTypeEnum.any
valueType: WorkflowIOValueTypeEnum.any,
// Container nodes (loopRun) need to reference outputs from their sub-workflow.
includeChildren: true
});
const onlBlurLabel = useCallback(
@@ -61,15 +61,21 @@ type SelectProps<T extends boolean> = CommonSelectProps & {
export const useReference = ({
nodeId,
valueType = WorkflowIOValueTypeEnum.any
valueType = WorkflowIOValueTypeEnum.any,
includeChildren
}: {
nodeId: string;
valueType?: WorkflowIOValueTypeEnum;
// Include the container's own children as reference sources.
includeChildren?: boolean;
}) => {
const { t } = useTranslation();
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges);
const { getNodeById, systemConfigNode } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const { getNodeById, systemConfigNode, childrenNodeIdListMap } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
// 获取可选的变量列表
const referenceList = useMemoEnhance(() => {
@@ -79,7 +85,9 @@ export const useReference = ({
getNodeById,
edges: edges,
chatConfig: appDetail.chatConfig,
t
t,
includeChildren,
childrenNodeIdListMap
});
const isArray = valueType?.includes('array');
@@ -114,7 +122,17 @@ export const useReference = ({
.filter((item) => item.children.length > 0);
return list;
}, [nodeId, systemConfigNode, getNodeById, edges, appDetail.chatConfig, t, valueType]);
}, [
nodeId,
systemConfigNode,
getNodeById,
edges,
appDetail.chatConfig,
t,
valueType,
includeChildren,
childrenNodeIdListMap
]);
return {
referenceList
@@ -78,6 +78,10 @@ export const WorkflowComputeProvider = ({ children }: { children: React.ReactNod
);
if (!loopNode) return;
if (childNodes.length === 0) return;
// 任一子节点尚未被 ReactFlow 测量(width/height 未定义),直接放弃本次计算,
// 由上游的 dimensionsSignal 监听在尺寸到齐后再触发一次。
if (childNodes.some((n) => !n.width || !n.height)) return;
const loopChilWidth =
loopNode.data.inputs.find((node) => node.key === NodeInputKeyEnum.nodeWidth)?.value ?? 0;
const loopChilHeight =
@@ -59,6 +59,7 @@ export type WorkflowDataContextType = {
systemConfigNode: StoreNodeItemType | undefined;
allNodeFolded: boolean;
hasToolNode: boolean;
hasLoopRunNode: boolean;
toolNodesMap: Record<string, boolean>;
nodeIds: string[];
nodeAmount: number;
@@ -84,6 +85,7 @@ export const WorkflowBufferDataContext = createContext<WorkflowDataContextType>(
systemConfigNode: undefined,
allNodeFolded: false,
hasToolNode: false,
hasLoopRunNode: false,
toolNodesMap: {},
nodeIds: [],
nodeAmount: 0,
@@ -140,6 +142,7 @@ const WorkflowInitContextProvider = ({
let systemConfigNode: StoreNodeItemType | undefined = undefined;
let allNodeFolded = true;
let hasToolNode = false;
let hasLoopRunNode = false;
let llmMaxQuoteContext = 0;
nodes.forEach((node) => {
@@ -213,6 +216,9 @@ const WorkflowInitContextProvider = ({
if (flowNodeType === FlowNodeTypeEnum.toolCall) {
hasToolNode = true;
}
if (flowNodeType === FlowNodeTypeEnum.loopRun) {
hasLoopRunNode = true;
}
});
return {
@@ -225,6 +231,7 @@ const WorkflowInitContextProvider = ({
systemConfigNode,
allNodeFolded,
hasToolNode,
hasLoopRunNode,
llmMaxQuoteContext,
foldedNodesMap,
compareNodeList
@@ -261,6 +268,7 @@ const WorkflowInitContextProvider = ({
);
const allNodeFolded = nodeFormat.allNodeFolded;
const hasToolNode = nodeFormat.hasToolNode;
const hasLoopRunNode = nodeFormat.hasLoopRunNode;
const llmMaxQuoteContext = nodeFormat.llmMaxQuoteContext;
const getNodeList = useMemoizedFn(() => nodeList);
@@ -365,6 +373,7 @@ const WorkflowInitContextProvider = ({
systemConfigNode,
allNodeFolded,
hasToolNode,
hasLoopRunNode,
toolNodesMap,
foldedNodesMap,
getNodeById,
@@ -387,6 +396,7 @@ const WorkflowInitContextProvider = ({
systemConfigNode,
allNodeFolded,
hasToolNode,
hasLoopRunNode,
toolNodesMap,
foldedNodesMap,
getNodeById,
+54 -4
View File
@@ -28,6 +28,7 @@ import {
type ReferenceItemValueType
} from '@fastgpt/global/core/workflow/type/io';
import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import { type AppChatConfigType } from '@fastgpt/global/core/app/type';
import { cloneDeep, isEqual } from 'lodash';
@@ -355,7 +356,9 @@ export const getNodeAllSource = ({
getNodeById,
edges,
chatConfig,
t
t,
includeChildren,
childrenNodeIdListMap
}: {
nodeId: string;
systemConfigNode?: StoreNodeItemType;
@@ -363,6 +366,8 @@ export const getNodeAllSource = ({
edges: Edge[];
chatConfig: AppChatConfigType;
t: TFunction;
includeChildren?: boolean;
childrenNodeIdListMap?: Record<string, string[]>;
}): FlowNodeItemType[] => {
// get current node
const node = getNodeById(nodeId);
@@ -409,6 +414,17 @@ export const getNodeAllSource = ({
}
}
// Edge traversal only reaches upstream; children must be added explicitly.
if (includeChildren && childrenNodeIdListMap) {
const childIds = childrenNodeIdListMap[nodeId] ?? [];
childIds.forEach((childId) => {
if (sourceNodes.has(childId)) return;
const childNode = getNodeById(childId);
if (!childNode) return;
sourceNodes.set(childId, childNode);
});
}
sourceNodes.set(
'system_global_variable',
getGlobalVariableNode({
@@ -499,6 +515,24 @@ export const checkWorkflowNodeAndConnection = ({
return [data.nodeId];
}
}
if (data.flowNodeType === FlowNodeTypeEnum.loopRun) {
const mode = inputs.find((input) => input.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined;
if (mode === LoopRunModeEnum.conditional) {
const children =
(inputs.find((input) => input.key === NodeInputKeyEnum.childrenNodeIdList)
?.value as string[]) ?? [];
const childSet = new Set(children);
const hasBreak = nodes.some(
(n) =>
childSet.has(n.data.nodeId) && n.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
if (!hasBreak) {
return [data.nodeId];
}
}
}
if (data.flowNodeType === FlowNodeTypeEnum.toolCall) {
const toolConnections = edges.filter(
(edge) =>
@@ -512,9 +546,21 @@ export const checkWorkflowNodeAndConnection = ({
}
}
// check node input
if (
inputs.some((input) => {
// Conditional loopRun hides loopRunInputArray in the UI; its required flag is
// only meaningful in array mode, so skip it here to avoid spurious failures.
if (input.key === NodeInputKeyEnum.loopRunInputArray) {
const loopRunMode =
data.flowNodeType === FlowNodeTypeEnum.loopRun
? (inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined)
: undefined;
if (loopRunMode === LoopRunModeEnum.conditional) {
return false;
}
}
if (
!input.valueType ||
[WorkflowIOValueTypeEnum.any, WorkflowIOValueTypeEnum.boolean].includes(input.valueType)
@@ -640,7 +686,10 @@ export const checkWorkflowNodeAndConnection = ({
};
dfsFromStart(startNode.data.nodeId);
nodes.forEach((node) => {
if (node.data.flowNodeType === FlowNodeTypeEnum.nestedStart) {
if (
node.data.flowNodeType === FlowNodeTypeEnum.nestedStart ||
node.data.flowNodeType === FlowNodeTypeEnum.loopRunStart
) {
dfsFromStart(node.data.nodeId);
}
});
@@ -666,7 +715,8 @@ export const checkWorkflowNodeAndConnection = ({
const isStartNode = [
FlowNodeTypeEnum.workflowStart,
FlowNodeTypeEnum.pluginInput,
FlowNodeTypeEnum.nestedStart
FlowNodeTypeEnum.nestedStart,
FlowNodeTypeEnum.loopRunStart
].includes(nodeType);
// Check if node is reachable from start
@@ -13,6 +13,7 @@ import {
} from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import {
nodeTemplate2FlowNode,
storeNode2FlowNode,
@@ -236,4 +237,161 @@ describe('checkWorkflowNodeAndConnection', () => {
const result = checkWorkflowNodeAndConnection({ nodes: [], edges: [] });
expect(result).toBeUndefined();
});
describe('loopRun conditional mode', () => {
const makeLoopRunNode = (
mode: LoopRunModeEnum | undefined,
children: string[]
): Node<FlowNodeItemType> => ({
id: 'loop1',
type: FlowNodeTypeEnum.loopRun,
data: {
nodeId: 'loop1',
flowNodeType: FlowNodeTypeEnum.loopRun,
inputs: [
{
key: NodeInputKeyEnum.loopRunMode,
value: mode,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.select]
} as any,
{
// 模板里这个字段永远 required: true + value: []
// 条件循环模式下不该因此被判无效
key: NodeInputKeyEnum.loopRunInputArray,
value: [],
required: true,
valueType: WorkflowIOValueTypeEnum.arrayAny,
renderTypeList: [FlowNodeInputTypeEnum.reference]
} as any,
{
key: NodeInputKeyEnum.childrenNodeIdList,
value: children,
renderTypeList: [FlowNodeInputTypeEnum.hidden]
} as any
],
outputs: []
} as any,
position: { x: 0, y: 0 }
});
const makeChild = (id: string, flowNodeType: FlowNodeTypeEnum): Node<FlowNodeItemType> => ({
id,
type: flowNodeType,
data: {
nodeId: id,
flowNodeType,
inputs: [],
outputs: []
} as any,
position: { x: 0, y: 0 }
});
const workflowStart: Node<FlowNodeItemType> = {
id: 'ws',
type: FlowNodeTypeEnum.workflowStart,
data: {
nodeId: 'ws',
flowNodeType: FlowNodeTypeEnum.workflowStart,
inputs: [],
outputs: []
} as any,
position: { x: 0, y: 0 }
};
const wsToLoop: Edge = {
id: 'e-ws-loop',
source: 'ws',
target: 'loop1',
type: EDGE_TYPE
};
// 通用「节点必须有边」校验针对画布上的每个节点,给循环子节点挂上占位边
const stubEdge = (nodeId: string): Edge => ({
id: `e-stub-${nodeId}`,
source: nodeId,
target: '__stub__',
type: EDGE_TYPE
});
it('条件循环无 loopRunBreak → 返回该 loopRun 为无效', () => {
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart)
];
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, stubEdge('start1')]
});
expect(result).toEqual(['loop1']);
});
it('条件循环含 loopRunBreak → 有效', () => {
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1', 'break1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart),
makeChild('break1', FlowNodeTypeEnum.loopRunBreak)
];
const startToBreak: Edge = {
id: 'e-start-break',
source: 'start1',
target: 'break1',
type: EDGE_TYPE
};
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, startToBreak]
});
expect(result).toBeUndefined();
});
it('break 节点不在 childrenNodeIdList 内 → 视为无 break', () => {
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart),
makeChild('break1', FlowNodeTypeEnum.loopRunBreak) // 属于别的 loopRun
];
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, stubEdge('start1'), stubEdge('break1')]
});
expect(result).toEqual(['loop1']);
});
it('数组模式不强制要求 loopRunBreak', () => {
const loop = makeLoopRunNode(LoopRunModeEnum.array, ['start1']);
// 数组模式下 loopRunInputArray 必填,填个非空 value 走通用校验
const arrInput = loop.data.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunInputArray)!;
arrInput.value = ['ws', 'userChatInput'];
const nodes = [workflowStart, loop, makeChild('start1', FlowNodeTypeEnum.loopRunStart)];
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, stubEdge('start1')]
});
expect(result).toBeUndefined();
});
it('条件循环下 loopRunInputArray 必填标记被忽略', () => {
// 模板静态定义里 loopRunInputArray 永远 required: true + value: []
// 条件循环模式下这个字段被 UI 隐藏,不应该因此拦校验。
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1', 'break1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart),
makeChild('break1', FlowNodeTypeEnum.loopRunBreak)
];
const startToBreak: Edge = {
id: 'e-start-break-2',
source: 'start1',
target: 'break1',
type: EDGE_TYPE
};
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, startToBreak]
});
expect(result).toBeUndefined();
});
});
});