mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-16 01:09:01 +08:00
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:
@@ -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));
|
||||
|
||||
|
||||
+6
-6
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+78
-22
@@ -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(() => {
|
||||
|
||||
+7
-5
@@ -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]
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+35
-22
@@ -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);
|
||||
|
||||
+52
-2
@@ -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')),
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+269
@@ -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);
|
||||
+21
@@ -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);
|
||||
+110
@@ -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);
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+5
-31
@@ -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
|
||||
]);
|
||||
|
||||
+9
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
+7
-3
@@ -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(
|
||||
|
||||
+22
-4
@@ -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
|
||||
|
||||
+4
@@ -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 =
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user