Commit 27ebc0ee by DigHuang Committed by GitHub

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
parent 81dfc589
......@@ -305,6 +305,8 @@ export const getFlatAppResponses = (res: ChatHistoryItemResType[]): ChatHistoryI
...getFlatAppResponses(item.pluginDetail || []),
...getFlatAppResponses(item.toolDetail || []),
...getFlatAppResponses(item.loopDetail || []),
...getFlatAppResponses(item.loopRunDetail || []),
...getFlatAppResponses(item.parallelDetail || []),
...getFlatAppResponses(item.childrenResponses || [])
];
})
......@@ -369,6 +371,14 @@ export const mergeChatResponseData = (
...(existing.loopDetail || []),
...(item.loopDetail || [])
]),
loopRunDetail: mergeChatResponseData([
...(existing.loopRunDetail || []),
...(item.loopRunDetail || [])
]),
parallelDetail: mergeChatResponseData([
...(existing.parallelDetail || []),
...(item.parallelDetail || [])
]),
pluginDetail: mergeChatResponseData([
...(existing.pluginDetail || []),
...(item.pluginDetail || [])
......
......@@ -253,6 +253,11 @@ export enum NodeInputKeyEnum {
parallelRunMaxConcurrency = 'parallelRunMaxConcurrency',
parallelRunMaxRetryTimes = 'parallelRunMaxRetryTimes',
// loopRun
loopRunMode = 'loopRunMode',
loopRunInputArray = 'loopRunInputArray',
loopCustomOutputs = 'loopCustomOutputs',
// form input
userInputForms = 'userInputForms',
......@@ -320,6 +325,11 @@ export enum NodeOutputKeyEnum {
parallelFullResults = 'parallelFullResults',
parallelStatus = 'parallelStatus',
// loopRunStart dynamic outputs
currentIndex = 'currentIndex',
currentItem = 'currentItem',
currentIteration = 'currentIteration',
// form input
formInputResult = 'formInputResult',
......
......@@ -160,6 +160,9 @@ export enum FlowNodeTypeEnum {
nestedStart = 'loopStart',
nestedEnd = 'loopEnd',
parallelRun = 'parallelRun',
loopRun = 'loopRun',
loopRunStart = 'loopRunStart',
loopRunBreak = 'loopRunBreak',
formInput = 'formInput',
tool = 'tool',
toolSet = 'toolSet',
......@@ -303,7 +306,8 @@ export const NodeGradients = {
skyBlue: 'linear-gradient(180deg, rgba(137, 229, 255, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
salmon: 'linear-gradient(180deg, rgba(255, 160, 160, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
gray: 'linear-gradient(180deg, rgba(136, 136, 136, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
emerald: 'linear-gradient(180deg, rgba(20, 168, 70, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)'
emerald: 'linear-gradient(180deg, rgba(20, 168, 70, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
loopRun: 'linear-gradient(180deg, rgba(110, 231, 183, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)'
};
export const NodeBorderColors = {
pink: 'rgba(255, 161, 206, 0.6)',
......@@ -325,7 +329,8 @@ export const NodeBorderColors = {
skyBlue: 'rgba(137, 229, 255, 0.6)',
salmon: 'rgba(255, 160, 160, 0.6)',
gray: 'rgba(136, 136, 136, 0.6)',
emerald: 'rgba(20, 168, 70, 0.6)'
emerald: 'rgba(20, 168, 70, 0.6)',
loopRun: 'rgba(110, 231, 183, 0.6)'
};
export const NodeColorSchemaEnum = [
'pink',
......@@ -347,19 +352,35 @@ export const NodeColorSchemaEnum = [
'skyBlue',
'salmon',
'gray',
'emerald'
'emerald',
'loopRun'
] as const;
/** 返回 true 表示该节点是嵌套父容器(loop / parallelRun)。 */
/** 嵌套父容器节点类型集合(loop / parallelRun / loopRun)。 */
export const NESTED_PARENT_NODE_TYPES: ReadonlySet<FlowNodeTypeEnum> = new Set([
FlowNodeTypeEnum.loop,
FlowNodeTypeEnum.parallelRun,
FlowNodeTypeEnum.loopRun
]);
export const isNestedParentNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean =>
flowNodeType === FlowNodeTypeEnum.loop || flowNodeType === FlowNodeTypeEnum.parallelRun;
NESTED_PARENT_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum);
/** 交互类节点类型集合(在 parallelRun 体内禁止使用)。 */
/** 交互类节点类型集合(在 parallelRun 体内禁止使用;loopRun 允许)。 */
export const INTERACTIVE_NODE_TYPES: ReadonlySet<FlowNodeTypeEnum> = new Set([
FlowNodeTypeEnum.userSelect,
FlowNodeTypeEnum.formInput
]);
/** 返回 true 表示该节点是交互类节点(userSelect / formInput)。 */
export const isInteractiveNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean =>
INTERACTIVE_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum);
/** 嵌套容器的系统子节点类型集合(只能由容器自动创建,不允许从模板面板添加)。 */
export const NESTED_CHILD_SYSTEM_NODE_TYPES: ReadonlySet<FlowNodeTypeEnum> = new Set([
FlowNodeTypeEnum.nestedStart,
FlowNodeTypeEnum.nestedEnd,
FlowNodeTypeEnum.loopRunStart
]);
export const isNestedChildSystemNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean =>
NESTED_CHILD_SYSTEM_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum);
......@@ -314,6 +314,18 @@ export const DispatchNodeResponseSchema = z
.optional()
.meta({ description: '成功任务子工作流完整响应列表' }),
// loopRun
loopRunInput: z
.any()
.optional()
.meta({ description: 'loopRun 循环输入(数组或条件模式标记)' }),
loopRunIterations: z.number().optional().meta({ description: 'loopRun 实际执行轮数' }),
loopRunHistory: z.array(z.any()).optional().meta({ description: 'loopRun 每轮快照' }),
loopRunDetail: z
.array(z.any())
.optional()
.meta({ description: 'loopRun 各轮子工作流节点响应聚合' }),
childrenResponses: z.array(z.any()).optional().meta({ description: '子节点响应' }),
// Tools
......
......@@ -30,6 +30,9 @@ import { LafModule } from './system/laf';
import { LoopNode } from './system/loop/loop';
import { LoopEndNode } from './system/loop/loopEnd';
import { LoopStartNode } from './system/loop/loopStart';
import { LoopRunNode } from './system/loopRun/loopRun';
import { LoopRunStartNode } from './system/loopRun/loopRunStart';
import { LoopRunBreakNode } from './system/loopRun/loopRunBreak';
import { ParallelRunNode } from './system/parallelRun/parallelRun';
import { ReadFilesNode } from './system/readFiles';
import { RunToolNode } from './system/runTool';
......@@ -59,7 +62,9 @@ const systemNodes: FlowNodeTemplateType[] = [
VariableUpdateNode,
CodeNode,
LoopNode,
ParallelRunNode
ParallelRunNode,
LoopRunNode,
LoopRunBreakNode
];
/* app flow module templates */
export const appSystemModuleTemplates: FlowNodeTemplateType[] = [
......@@ -91,6 +96,7 @@ export const moduleTemplatesFlat: FlowNodeTemplateType[] = [
RunAppModule,
LoopStartNode,
LoopEndNode,
LoopRunStartNode,
RunToolNode,
RunToolSetNode
];
......@@ -4,7 +4,8 @@ export const isChildInteractive = (type: InteractiveNodeResponseType['type']) =>
if (
type === 'childrenInteractive' ||
type === 'toolChildrenInteractive' ||
type === 'loopInteractive'
type === 'loopInteractive' ||
type === 'loopRunInteractive'
) {
return true;
}
......
......@@ -5,6 +5,7 @@ import { AppFileSelectConfigTypeSchema } from '../../../../app/type/config.schem
import { RuntimeEdgeItemTypeSchema } from '../../../type/edge';
import z from 'zod';
import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type';
import type { ChatHistoryItemResType } from '../../../../chat/type';
export const InteractiveBasicTypeSchema = z.object({
entryNodeIds: z.array(z.string()),
......@@ -67,6 +68,25 @@ export type LoopInteractive = InteractiveNodeType & {
};
};
export const LoopRunInteractiveSchema = z.object({
type: z.literal('loopRunInteractive'),
params: z.object({
loopHistory: z.array(z.any()),
childrenResponse: z.any(),
iteration: z.number(),
pendingIterationResponses: z.array(z.any()).optional()
})
});
export type LoopRunInteractive = InteractiveNodeType & {
type: 'loopRunInteractive';
params: {
loopHistory: any[];
childrenResponse: WorkflowInteractiveResponseType;
iteration: number;
pendingIterationResponses?: ChatHistoryItemResType[];
};
};
// Agent Interactive
export const AgentPlanCheckInteractiveSchema = z.object({
type: z.literal('agentPlanCheck'),
......@@ -146,6 +166,7 @@ export const InteractiveNodeResponseTypeSchema = z.intersection(
ChildrenInteractiveSchema,
ToolCallChildrenInteractiveSchema,
LoopInteractiveSchema,
LoopRunInteractiveSchema,
PaymentPauseInteractiveSchema,
AgentPlanCheckInteractiveSchema,
AgentPlanAskQueryInteractiveSchema
......
import {
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '../../../node/constant';
import { type FlowNodeTemplateType } from '../../../type/node';
import {
FlowNodeTemplateTypeEnum,
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '../../../constants';
import { i18nT } from '../../../../../../web/i18n/utils';
import {
Input_Template_Children_Node_List,
Input_Template_NESTED_NODE_OFFSET,
Input_Template_Node_Height,
Input_Template_Node_Width
} from '../../input';
export enum LoopRunModeEnum {
array = 'array',
conditional = 'conditional'
}
export const LoopRunNode: FlowNodeTemplateType = {
id: FlowNodeTypeEnum.loopRun,
templateType: FlowNodeTemplateTypeEnum.tools,
flowNodeType: FlowNodeTypeEnum.loopRun,
showSourceHandle: true,
showTargetHandle: true,
avatar: 'core/workflow/template/loopRun',
avatarLinear: 'core/workflow/template/loopRunLinear',
colorSchema: 'loopRun',
name: i18nT('workflow:loop_run'),
intro: i18nT('workflow:intro_loop_run'),
showStatus: true,
catchError: false,
inputs: [
{
key: NodeInputKeyEnum.loopRunMode,
renderTypeList: [FlowNodeInputTypeEnum.select],
valueType: WorkflowIOValueTypeEnum.string,
required: true,
label: i18nT('workflow:loop_run_mode'),
description: i18nT('workflow:loop_run_mode_tip'),
list: [
{
label: i18nT('workflow:loop_run_mode_array'),
value: LoopRunModeEnum.array,
icon: 'core/workflow/inputType/array',
description: i18nT('workflow:loop_run_mode_array_desc')
},
{
label: i18nT('workflow:loop_run_mode_conditional'),
value: LoopRunModeEnum.conditional,
icon: 'core/workflow/inputType/conditional',
description: i18nT('workflow:loop_run_mode_conditional_desc')
}
],
value: LoopRunModeEnum.array
},
{
key: NodeInputKeyEnum.loopRunInputArray,
renderTypeList: [FlowNodeInputTypeEnum.reference],
valueType: WorkflowIOValueTypeEnum.arrayAny,
required: true,
label: i18nT('workflow:loop_run_input_array'),
value: []
},
{
key: NodeInputKeyEnum.loopCustomOutputs,
renderTypeList: [FlowNodeInputTypeEnum.addInputParam],
valueType: WorkflowIOValueTypeEnum.dynamic,
label: i18nT('workflow:loop_custom_outputs'),
description: i18nT('workflow:loop_custom_outputs_tip'),
required: false,
customInputConfig: {
selectValueTypeList: Object.values(WorkflowIOValueTypeEnum),
showDescription: false,
showDefaultValue: false,
hideBottomDivider: true
}
},
Input_Template_Children_Node_List,
Input_Template_Node_Width,
Input_Template_Node_Height,
Input_Template_NESTED_NODE_OFFSET
],
outputs: [
{
id: NodeOutputKeyEnum.errorText,
key: NodeOutputKeyEnum.errorText,
label: i18nT('workflow:error_text'),
type: FlowNodeOutputTypeEnum.error,
valueType: WorkflowIOValueTypeEnum.string
}
]
};
import { FlowNodeTypeEnum } from '../../../node/constant';
import { type FlowNodeTemplateType } from '../../../type/node';
import { FlowNodeTemplateTypeEnum } from '../../../constants';
import { i18nT } from '../../../../../../web/i18n/utils';
export const LoopRunBreakNode: FlowNodeTemplateType = {
id: FlowNodeTypeEnum.loopRunBreak,
templateType: FlowNodeTemplateTypeEnum.tools,
flowNodeType: FlowNodeTypeEnum.loopRunBreak,
showSourceHandle: false,
showTargetHandle: true,
avatar: 'core/workflow/template/loopRunBreak',
avatarLinear: 'core/workflow/template/loopRunBreakLinear',
colorSchema: 'loopRun',
name: i18nT('workflow:loop_run_break'),
intro: i18nT('workflow:loop_run_break_tip'),
showStatus: false,
inputs: [],
outputs: []
};
import {
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '../../../node/constant';
import { type FlowNodeTemplateType } from '../../../type/node';
import {
FlowNodeTemplateTypeEnum,
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '../../../constants';
import { i18nT } from '../../../../../../web/i18n/utils';
import { LoopRunModeEnum } from './loopRun';
export const LoopRunStartNode: FlowNodeTemplateType = {
id: FlowNodeTypeEnum.loopRunStart,
templateType: FlowNodeTemplateTypeEnum.systemInput,
flowNodeType: FlowNodeTypeEnum.loopRunStart,
showSourceHandle: true,
showTargetHandle: false,
avatar: 'core/workflow/template/loopRunStart',
avatarLinear: 'core/workflow/template/loopRunStartLinear',
colorSchema: 'loopRun',
name: i18nT('workflow:loop_run_start'),
unique: true,
forbidDelete: true,
showStatus: false,
inputs: [
{
key: NodeInputKeyEnum.loopRunMode,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.string,
label: '',
value: LoopRunModeEnum.array
},
{
key: NodeInputKeyEnum.nestedStartInput,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.any,
label: '',
value: ''
},
{
key: NodeInputKeyEnum.nestedStartIndex,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.number,
label: ''
}
],
outputs: [
{
id: NodeOutputKeyEnum.currentIndex,
key: NodeOutputKeyEnum.currentIndex,
label: i18nT('workflow:current_index'),
description: i18nT('workflow:current_index_desc'),
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
},
{
id: NodeOutputKeyEnum.currentItem,
key: NodeOutputKeyEnum.currentItem,
label: i18nT('workflow:current_item'),
description: i18nT('workflow:current_item_desc'),
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.any
},
{
id: NodeOutputKeyEnum.currentIteration,
key: NodeOutputKeyEnum.currentIteration,
label: i18nT('workflow:current_iteration'),
description: i18nT('workflow:current_iteration_desc'),
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
}
]
};
......@@ -20,7 +20,8 @@ export const CustomFieldConfigTypeSchema = z.object({
// reference
selectValueTypeList: z.array(z.enum(WorkflowIOValueTypeEnum)).optional(), // 可以选哪个数据类型, 只有1个的话,则默认选择
showDefaultValue: z.boolean().optional(),
showDescription: z.boolean().optional()
showDescription: z.boolean().optional(),
hideBottomDivider: z.boolean().optional()
});
export type CustomFieldConfigType = z.infer<typeof CustomFieldConfigTypeSchema>;
......@@ -38,7 +39,16 @@ export const InputComponentPropsTypeSchema = z.object({
placeholder: z.string().optional(), // input,textarea
maxLength: z.number().optional(), // input,textarea
minLength: z.number().optional(), // password
list: z.array(z.object({ label: z.string(), value: z.string() })).optional(), // select
list: z
.array(
z.object({
label: z.string(),
value: z.string(),
icon: z.string().optional(),
description: z.string().optional()
})
)
.optional(), // select
markList: z.array(z.object({ label: z.string(), value: z.number() })).optional(), // slider
step: z.number().optional(), // slider
max: z.number().optional(), // slider, number input
......
......@@ -17,6 +17,9 @@ import { dispatchLoop } from './loop/runLoop';
import { dispatchLoopEnd } from './loop/runLoopEnd';
import { dispatchLoopStart } from './loop/runLoopStart';
import { dispatchParallelRun } from './parallelRun/runParallelRun';
import { dispatchLoopRun } from './loopRun/runLoopRun';
import { dispatchLoopRunStart } from './loopRun/runLoopRunStart';
import { dispatchLoopRunBreak } from './loopRun/runLoopRunBreak';
import { dispatchRunPlugin } from './plugin/run';
import { dispatchRunAppNode } from './child/runApp';
import { dispatchPluginInput } from './plugin/runInput';
......@@ -67,6 +70,9 @@ export const callbackMap: Record<FlowNodeTypeEnum, Function> = {
[FlowNodeTypeEnum.userSelect]: dispatchUserSelect,
[FlowNodeTypeEnum.loop]: dispatchLoop,
[FlowNodeTypeEnum.parallelRun]: dispatchParallelRun,
[FlowNodeTypeEnum.loopRun]: dispatchLoopRun,
[FlowNodeTypeEnum.loopRunStart]: dispatchLoopRunStart,
[FlowNodeTypeEnum.loopRunBreak]: dispatchLoopRunBreak,
[FlowNodeTypeEnum.nestedStart]: dispatchLoopStart,
[FlowNodeTypeEnum.nestedEnd]: dispatchLoopEnd,
[FlowNodeTypeEnum.formInput]: dispatchFormInput,
......
......@@ -920,8 +920,18 @@ export class WorkflowQueue {
if (result.error) {
// Run error and not catch error, skip all edges
if (!node.catchError) {
// Callback returned with `result.error` set instead of throwing;
// mirror the catch-branch convention and copy it onto nodeResponse
// so runLoopRun / parallelRun failure detection and OTel span
// status see `.error` uniformly across both failure paths.
const nodeResponseBase = result[DispatchNodeResponseKeyEnum.nodeResponse];
const errText = nodeResponseBase?.errorText ?? getErrText(result.error as any);
return {
...result,
[DispatchNodeResponseKeyEnum.nodeResponse]: {
...nodeResponseBase,
error: errText
},
[DispatchNodeResponseKeyEnum.skipHandleId]: targetEdges.map(
(item) => item.sourceHandle
)
......
......@@ -14,8 +14,8 @@ import { cloneDeep } from 'lodash';
import { type WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { storeEdges2RuntimeEdges } from '@fastgpt/global/core/workflow/runtime/utils';
import { env } from '../../../../env';
import { getNestedEndOutputValue, pushSubWorkflowUsage, collectResponseFeedbacks } from './service';
import { injectNestedStartInputs } from '../utils';
import { getNestedEndOutputValue } from './service';
import { collectResponseFeedbacks, injectNestedStartInputs, pushSubWorkflowUsage } from '../utils';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.nestedInputArray]: Array<any>;
......@@ -98,7 +98,12 @@ export const dispatchLoop = async (props: Props): Promise<Response> => {
loopResponseDetail.push(...response.flowResponses);
assistantResponses.push(...response.assistantResponses);
totalPoints += pushSubWorkflowUsage({ usagePush: props.usagePush, response, name, index });
totalPoints += pushSubWorkflowUsage({
usagePush: props.usagePush,
response,
name,
iteration: index
});
collectResponseFeedbacks(response, customFeedbacks);
......
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
import type { DispatchFlowResponse } from '../type';
import { safePoints } from '../utils';
// ─── 1. getNestedEndOutputValue ───────────────────────────────────────────────
/**
* Extract the output value produced by the nestedEnd node in a sub-workflow
* response. Returns undefined when the nestedEnd node was never reached
* (e.g. the sub-workflow terminated with an error before completion).
*/
// Returns undefined if nestedEnd was never reached (sub-workflow errored early).
export const getNestedEndOutputValue = (response: DispatchFlowResponse): any =>
response.flowResponses.find((res) => res.moduleType === FlowNodeTypeEnum.nestedEnd)
?.loopOutputValue;
// ─── 2. pushSubWorkflowUsage ─────────────────────────────────────────────────
/**
* Compute the total usage points for a single sub-workflow run, push the entry
* to the parent dispatcher's usage accumulator, and return the computed value
* so the caller can keep a running total.
*
* Pattern shared by runLoop and runParallelRun:
* const pts = pushSubWorkflowUsage({ usagePush: props.usagePush, response, name, index });
* totalPoints += pts;
*/
export const pushSubWorkflowUsage = ({
usagePush,
response,
name,
index
}: {
usagePush: (usages: ChatNodeUsageType[]) => void;
response: DispatchFlowResponse;
name: string;
index: number;
}): number => {
const itemUsagePoint = response.flowUsages.reduce(
(acc, usage) => acc + safePoints(usage.totalPoints),
0
);
usagePush([{ totalPoints: itemUsagePoint, moduleName: `${name}-${index}` }]);
return itemUsagePoint;
};
// ─── 3. collectResponseFeedbacks ─────────────────────────────────────────────
/**
* Append any customFeedbacks from a sub-workflow response into the provided
* accumulator array. Returns the same array for convenience.
*/
export const collectResponseFeedbacks = (
response: DispatchFlowResponse,
target: string[]
): string[] => {
const feedbacks = response[DispatchNodeResponseKeyEnum.customFeedbacks];
if (feedbacks && feedbacks.length > 0) {
target.push(...feedbacks);
}
return target;
};
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import {
type DispatchNodeResultType,
type ModuleDispatchProps
} from '@fastgpt/global/core/workflow/runtime/type';
type Props = ModuleDispatchProps<Record<string, never>>;
type Response = DispatchNodeResultType<Record<string, never>>;
// Signal-only node. The parent loopRun detects the moduleType in flowResponses
// to decide whether to terminate the loop.
export const dispatchLoopRunBreak = async (_props: Props): Promise<Response> => {
return {
data: {},
[DispatchNodeResponseKeyEnum.nodeResponse]: {}
};
};
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import {
type DispatchNodeResultType,
type ModuleDispatchProps
} from '@fastgpt/global/core/workflow/runtime/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum;
[NodeInputKeyEnum.nestedStartInput]: any;
[NodeInputKeyEnum.nestedStartIndex]: number;
}>;
type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.currentIndex]?: number;
[NodeOutputKeyEnum.currentItem]?: any;
[NodeOutputKeyEnum.currentIteration]?: number;
}>;
export const dispatchLoopRunStart = async (props: Props): Promise<Response> => {
const { params } = props;
const mode = params[NodeInputKeyEnum.loopRunMode];
const rawIndex = params[NodeInputKeyEnum.nestedStartIndex];
const item = params[NodeInputKeyEnum.nestedStartInput];
const data: Record<string, any> = {};
if (mode === LoopRunModeEnum.array) {
data[NodeOutputKeyEnum.currentIndex] = rawIndex;
data[NodeOutputKeyEnum.currentItem] = item;
} else {
data[NodeOutputKeyEnum.currentIteration] = rawIndex;
}
return {
data,
[DispatchNodeResponseKeyEnum.nodeResponse]: {
loopInputValue: mode === LoopRunModeEnum.array ? item : rawIndex
}
};
};
import {
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
import {
formatVariableValByType,
getReferenceVariableValue
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import type {
FlowNodeInputItemType,
FlowNodeOutputItemType
} from '@fastgpt/global/core/workflow/type/io';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
export type LoopRunHistoryItem = {
iteration: number;
customOutputs: Record<string, any>;
success: boolean;
error?: string;
};
// 自定义输出声明:canEdit 本身是通用的「用户可改 key/type」标记,单独用会把未来
// 新增的 canEdit 输入(如迭代配置项)误当成输出声明。这里通过「是否存在同 key 的
// dynamic output 镜像」二次校验,确保只挑出真正被 NodeLoopRun.useEffect 镜像过
// 的声明项。
export const pickCustomOutputInputs = (
inputs: FlowNodeInputItemType[],
outputs: FlowNodeOutputItemType[]
): FlowNodeInputItemType[] => {
const dynamicOutputKeys = new Set(
outputs.filter((o) => o.type === FlowNodeOutputTypeEnum.dynamic).map((o) => o.key)
);
return inputs.filter((i) => i.canEdit === true && dynamicOutputKeys.has(i.key));
};
export const extractFinishedNodeIds = (flowResponses: ChatHistoryItemResType[]): Set<string> => {
const ids = new Set<string>();
for (const r of flowResponses) {
if (r.nodeId) ids.add(r.nodeId);
}
return ids;
};
/**
* When `finishedNodeIds` is provided (failure iteration), refs whose target
* did not run resolve to undefined so stale values from earlier iterations
* don't leak. Global variable refs and refs targeting nodes *outside* the
* loop body bypass the filter — only in-body nodes are subject to the
* skipped-branch guard.
*/
export const readCustomOutputSnapshot = ({
customOutputInputs,
runtimeNodes,
variables,
finishedNodeIds,
childrenNodeIdList
}: {
customOutputInputs: FlowNodeInputItemType[];
runtimeNodes: RuntimeNodeItemType[];
variables: Record<string, any>;
finishedNodeIds?: Set<string>;
childrenNodeIdList?: string[];
}): Record<string, any> => {
const nodesMap = new Map(runtimeNodes.map((n) => [n.nodeId, n]));
const childrenSet = childrenNodeIdList ? new Set(childrenNodeIdList) : undefined;
const snapshot: Record<string, any> = {};
for (const item of customOutputInputs) {
const refValue = item.value;
if (finishedNodeIds) {
// Single reference: [nodeId, outputId?] — refValue[0] is a string
// Reference array: [[nodeId, outputId?], ...] — refValue[0] is a tuple
const refs: [string, string | undefined][] = !Array.isArray(refValue)
? []
: Array.isArray(refValue[0])
? (refValue as [string, string | undefined][])
: [refValue as [string, string | undefined]];
const allFinished = refs.every(([nodeId]) => {
if (!nodeId) return true;
if (nodeId === VARIABLE_NODE_ID) return true;
// Refs to nodes outside the loop body (e.g. an outer 代码运行 whose
// output is being mutated via 变量更新) aren't in this iteration's
// flowResponses — exempt them from the skipped-branch guard.
if (childrenSet && !childrenSet.has(nodeId)) return true;
return finishedNodeIds.has(nodeId);
});
if (!allFinished) {
snapshot[item.key] = undefined;
continue;
}
}
const resolved = getReferenceVariableValue({
value: refValue,
nodesMap,
variables
});
snapshot[item.key] = formatVariableValByType(resolved, item.valueType);
}
return snapshot;
};
/**
* Array mode injects 0-based index; conditional mode injects 1-based iteration.
* Mutates in place.
*/
export const injectLoopRunStart = ({
nodes,
childrenNodeIdList,
mode,
item,
index,
iteration
}: {
nodes: RuntimeNodeItemType[];
childrenNodeIdList: string[];
mode: LoopRunModeEnum;
item?: any;
index?: number;
iteration: number;
}): void => {
nodes.forEach((node) => {
if (!childrenNodeIdList.includes(node.nodeId)) return;
if (node.flowNodeType !== FlowNodeTypeEnum.loopRunStart) return;
node.isEntry = true;
node.inputs.forEach((input) => {
if (input.key === NodeInputKeyEnum.loopRunMode) {
input.value = mode;
} else if (input.key === NodeInputKeyEnum.nestedStartInput) {
input.value = mode === LoopRunModeEnum.array ? item : undefined;
} else if (input.key === NodeInputKeyEnum.nestedStartIndex) {
input.value = mode === LoopRunModeEnum.array ? index ?? 0 : iteration;
}
});
});
};
export const isLoopBreakHit = (flowResponses: ChatHistoryItemResType[]): boolean =>
flowResponses.some((r) => r.moduleType === FlowNodeTypeEnum.loopRunBreak);
export const hasLoopRunBreakChild = (
runtimeNodes: RuntimeNodeItemType[],
childrenNodeIdList: string[]
): boolean => {
const childSet = new Set(childrenNodeIdList);
return runtimeNodes.some(
(n) => childSet.has(n.nodeId) && n.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
};
......@@ -19,7 +19,7 @@ import {
aggregateParallelResults,
type ParallelFullResultItem
} from './service';
import { safePoints } from '../utils';
import { pushSubWorkflowUsage } from '../utils';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.nestedInputArray]: Array<any>;
......@@ -87,12 +87,12 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
});
// Push usage per attempt (resources were consumed regardless of success)
const itemUsagePoint = response.flowUsages.reduce(
(acc, usage) => acc + safePoints(usage.totalPoints),
0
);
accumulatedPoints += itemUsagePoint;
props.usagePush([{ totalPoints: itemUsagePoint, moduleName: `${name}-${index}` }]);
accumulatedPoints += pushSubWorkflowUsage({
usagePush: props.usagePush,
response,
name,
iteration: index
});
const result = parseTaskResponse({ index, response });
if (result.success) return { ...result, totalPoints: accumulatedPoints };
......
import { cloneDeep } from 'lodash';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { ParallelRunStatusEnum } from '@fastgpt/global/core/workflow/constants';
import { injectNestedStartInputs, safePoints } from '../utils';
import { collectResponseFeedbacks, injectNestedStartInputs, safePoints } from '../utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { i18nT } from '../../../../../web/i18n/utils';
......@@ -269,11 +269,7 @@ export const aggregateParallelResults = (
if (result.response) {
const response = result.response;
assistantResponses.push(...(response[DispatchNodeResponseKeyEnum.assistantResponses] || []));
const feedbacks = response[DispatchNodeResponseKeyEnum.customFeedbacks];
if (feedbacks && feedbacks.length > 0) {
customFeedbacks.push(...feedbacks);
}
collectResponseFeedbacks(response, customFeedbacks);
}
}
......
......@@ -2,6 +2,8 @@ import path from 'path';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
import type { DispatchFlowResponse } from './type';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
......@@ -598,19 +600,41 @@ export const getNodeErrResponse = ({
};
};
/**
* Coerce a points value to a finite number, defaulting to 0 for
* NaN / Infinity / null / undefined.
*/
export const safePoints = (val: number | undefined | null): number =>
Number.isFinite(val) ? (val as number) : 0;
/**
* Mutates nodes in-place: sets the nestedStart node as entry and injects the
* current item / 1-based index into its inputs.
*
* Shared by loop and parallelRun dispatchers.
*/
export const pushSubWorkflowUsage = ({
usagePush,
response,
name,
iteration
}: {
usagePush: (usages: ChatNodeUsageType[]) => void;
response: DispatchFlowResponse;
name: string;
iteration: number;
}): number => {
const itemUsagePoint = response.flowUsages.reduce(
(acc, usage) => acc + safePoints(usage.totalPoints),
0
);
usagePush([{ totalPoints: itemUsagePoint, moduleName: `${name}-${iteration}` }]);
return itemUsagePoint;
};
export const collectResponseFeedbacks = (
response: DispatchFlowResponse,
target: string[]
): string[] => {
const feedbacks = response[DispatchNodeResponseKeyEnum.customFeedbacks];
if (feedbacks && feedbacks.length > 0) {
target.push(...feedbacks);
}
return target;
};
// Sets nestedStart as entry and injects current item + 1-based index.
// Shared by loop and parallelRun dispatchers.
export const injectNestedStartInputs = ({
nodes,
childrenNodeIdList,
......
......@@ -251,6 +251,8 @@ export const iconPaths = {
'core/workflow/edgeArrow': () => import('./icons/core/workflow/edgeArrow.svg'),
'core/workflow/edgeArrowBold': () => import('./icons/core/workflow/edgeArrowBold.svg'),
'core/workflow/inputType/array': () => import('./icons/core/workflow/inputType/array.svg'),
'core/workflow/inputType/conditional': () =>
import('./icons/core/workflow/inputType/conditional.svg'),
'core/workflow/inputType/customVariable': () =>
import('./icons/core/workflow/inputType/customVariable.svg'),
'core/workflow/inputType/dynamic': () => import('./icons/core/workflow/inputType/dynamic.svg'),
......@@ -341,6 +343,17 @@ export const iconPaths = {
import('./icons/core/workflow/template/parallelRun.svg'),
'core/workflow/template/parallelRunLinear': () =>
import('./icons/core/workflow/template/parallelRunLinear.tsx'),
'core/workflow/template/loopRun': () => import('./icons/core/workflow/template/loopRun.svg'),
'core/workflow/template/loopRunLinear': () =>
import('./icons/core/workflow/template/loopRunLinear.tsx'),
'core/workflow/template/loopRunStart': () =>
import('./icons/core/workflow/template/loopRunStart.svg'),
'core/workflow/template/loopRunStartLinear': () =>
import('./icons/core/workflow/template/loopRunStartLinear.tsx'),
'core/workflow/template/loopRunBreak': () =>
import('./icons/core/workflow/template/loopRunBreak.svg'),
'core/workflow/template/loopRunBreakLinear': () =>
import('./icons/core/workflow/template/loopRunBreakLinear.tsx'),
'core/workflow/template/mathCall': () => import('./icons/core/workflow/template/mathCall.svg'),
'core/workflow/template/pluginOutput': () =>
import('./icons/core/workflow/template/pluginOutput.svg'),
......
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2.34247 2.25885C1.89753 2.25885 1.425 2.51697 1.34164 3.06164C1.2721 3.6342 1.64747 4.08372 2.23097 4.08372C2.81554 4.08372 3.28702 3.81139 3.28702 3.18414C3.28702 2.66735 2.96686 2.25885 2.34247 2.25885ZM2.25859 5.70407C1.92515 5.70407 1.55031 5.88126 1.52163 6.38437V12.6782C1.52163 13.114 1.84233 13.3191 2.2724 13.3191C2.77254 13.3191 3.06403 13.0872 3.05022 12.583V9.62563C3.03748 8.60464 3.06403 7.55521 3.05022 6.53477C3.05075 5.86706 2.71732 5.70407 2.25859 5.70407ZM9.10296 2.23097C8.83142 2.07522 8.52494 1.99564 8.21416 2.00018C6.04684 2.00018 5.37892 3.44446 5.35132 5.05002V5.79977H4.55967C4.01759 5.79977 3.79619 6.07212 3.80998 6.48007C3.80998 6.90171 3.9905 7.1199 4.49013 7.1199H5.33804C5.33804 8.23713 5.35132 9.25813 5.35132 10.1572V12.6251C5.33804 13.1425 5.69854 13.3333 6.10154 13.3333C6.49072 13.3333 6.83795 13.1282 6.83795 12.6935C6.83795 12.203 6.82468 10.6072 6.82468 10.144C6.82468 9.31281 6.81087 8.31862 6.81087 7.12044L8.15894 7.13358C8.61661 7.13358 8.81147 6.84812 8.79766 6.43905C8.79766 6.01742 8.61661 5.79925 8.08938 5.79925H6.81087C6.81087 5.48534 6.79814 5.22665 6.81087 4.94066C6.82468 4.08372 7.20006 3.32031 7.95028 3.29297C8.29699 3.27929 8.53379 3.37444 8.86722 3.37444C9.15924 3.37444 9.35357 3.04687 9.35357 2.77455C9.35357 2.54486 9.28402 2.33978 9.10296 2.23097ZM11.4349 5.74289C11.0929 5.53072 10.6411 5.63244 10.4266 5.96766C9.88292 6.81474 9.58984 8.34432 9.58984 9.35327C9.58984 10.2917 9.84893 12.0181 10.4266 12.9183C10.5657 13.1348 10.8036 13.2529 11.0467 13.2529C11.1805 13.2529 11.3149 13.2179 11.4349 13.143C11.7778 12.9325 11.8803 12.489 11.6658 12.1538C11.3212 11.6167 11.0536 10.2064 11.0536 9.35328C11.0536 8.54775 11.2995 7.30256 11.6658 6.73164C11.8798 6.39749 11.7773 5.95344 11.4349 5.74289ZM12.8206 5.74289C12.4776 5.95398 12.3752 6.39585 12.5902 6.73218C12.956 7.3031 13.2024 8.54829 13.2024 9.35383C13.2024 10.2075 12.9348 11.6173 12.5902 12.1543C12.3752 12.489 12.4776 12.933 12.8206 13.1436C12.938 13.2157 13.0721 13.2536 13.2087 13.2535C13.4525 13.2535 13.6903 13.1348 13.8294 12.9188C14.4076 12.0187 14.6667 10.2922 14.6667 9.35383C14.6667 8.34432 14.3731 6.81476 13.8294 5.96823C13.6149 5.63245 13.1625 5.53181 12.8206 5.74289Z" fill="#3370FF"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="4" fill="url(#paint0_linear_loopRun)"/>
<g transform="translate(4 4)" fill="none">
<path d="M11.3333 1.33325L14 3.99992M14 3.99992L11.3333 6.66658M14 3.99992H4.66667C3.95942 3.99992 3.28115 4.28087 2.78105 4.78097C2.28095 5.28106 2 5.95934 2 6.66658V7.33325M4.66667 14.6666L2 11.9999M2 11.9999L4.66667 9.33325M2 11.9999H11.3333C12.0406 11.9999 12.7189 11.719 13.219 11.2189C13.719 10.7188 14 10.0405 14 9.33325V8.66658" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<linearGradient id="paint0_linear_loopRun" x1="12" y1="24" x2="12" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#6EE7B7"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="4" fill="url(#paint0_linear_loopRunBreak)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 19.0467C15.8918 19.0467 19.0467 15.8918 19.0467 12C19.0467 8.10823 15.8918 4.95337 12 4.95337C8.10824 4.95337 4.95334 8.10823 4.95334 12C4.95334 15.8918 8.10824 19.0467 12 19.0467ZM10.335 9.66833C9.96678 9.66833 9.6683 9.96681 9.6683 10.335V13.665C9.6683 14.0332 9.96678 14.3317 10.335 14.3317H13.665C14.0332 14.3317 14.3317 14.0332 14.3317 13.665V10.335C14.3317 9.96681 14.0332 9.66833 13.665 9.66833H10.335Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_loopRunBreak" x1="12" y1="24" x2="12" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#6EE7B7"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
</svg>
import React, { useId } from 'react';
type LoopRunBreakLinearProps = React.SVGProps<SVGSVGElement>;
const LoopRunBreakLinear: React.FC<LoopRunBreakLinearProps> = (props) => {
const gradientId = useId();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
{...props}
>
<path
d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
stroke={`url(#${gradientId})`}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M30 18H18V30H30V18Z"
stroke={`url(#${gradientId})`}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<linearGradient
id="paint0_linear_32941_93"
x1="24"
y1="44"
x2="24"
y2="4"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6EE7B7" />
<stop offset="1" stop-color="#2DD4BF" />
</linearGradient>
<linearGradient
id={gradientId}
x1="24"
y1="44"
x2="24"
y2="4"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6EE7B7" />
<stop offset="1" stop-color="#2DD4BF" />
</linearGradient>
</defs>
</svg>
);
};
export default LoopRunBreakLinear;
import React, { useId } from 'react';
type LoopRunLinearProps = React.SVGProps<SVGSVGElement>;
const LoopRunLinear: React.FC<LoopRunLinearProps> = (props) => {
const gradientId = useId();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" {...props}>
<g transform="translate(4 4) scale(2.5)">
<path
d="M11.3333 1.33325L14 3.99992M14 3.99992L11.3333 6.66658M14 3.99992H4.66667C3.95942 3.99992 3.28115 4.28087 2.78105 4.78097C2.28095 5.28106 2 5.95934 2 6.66658V7.33325M4.66667 14.6666L2 11.9999M2 11.9999L4.66667 9.33325M2 11.9999H11.3333C12.0406 11.9999 12.7189 11.719 13.219 11.2189C13.719 10.7188 14 10.0405 14 9.33325V8.66658"
stroke={`url(#${gradientId})`}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<linearGradient
id={gradientId}
x1="24"
y1="44"
x2="24"
y2="4"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6EE7B7" />
<stop offset="1" stopColor="#2DD4BF" />
</linearGradient>
</defs>
</svg>
);
};
export default LoopRunLinear;
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="4" fill="url(#paint0_linear_loopRunStart)"/>
<path d="M17.8498 12.1179C18.4125 11.9865 18.7471 11.407 18.5795 10.8539L17.3578 6.82341C17.1039 5.98573 15.9724 5.85545 15.5348 6.61349L14.9437 7.63725C13.9606 7.31824 12.9154 7.20957 11.877 7.3254C10.3437 7.49642 8.9039 8.14811 7.76357 9.1872C6.62324 10.2263 5.84087 11.5995 5.52844 13.1103C5.28383 14.2931 5.3371 15.5127 5.67632 16.6613C5.83275 17.191 6.43226 17.4139 6.93565 17.1867L8.22228 16.606C8.72567 16.3788 8.93516 15.7852 8.84311 15.2406C8.7629 14.7662 8.77073 14.2781 8.86935 13.8012C9.03598 12.9954 9.45323 12.2631 10.0614 11.7089C10.6696 11.1547 11.4375 10.8072 12.2552 10.716C12.5588 10.6821 12.8635 10.6842 13.1632 10.7211L12.6551 11.6013C12.2174 12.3593 12.896 13.274 13.7484 13.0751L17.8498 12.1179Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_loopRunStart" x1="12" y1="24" x2="12" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#6EE7B7"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
</svg>
import React, { useId } from 'react';
type LoopRunStartLinearProps = React.SVGProps<SVGSVGElement>;
const LoopRunStartLinear: React.FC<LoopRunStartLinearProps> = (props) => {
const gradientId = useId();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
{...props}
>
<path
d="M42 14V26M42 26H30M42 26L36 20.6C32.7023 17.6423 28.4298 16.0045 24 16C19.2261 16 14.6477 17.8964 11.2721 21.2721C7.89642 24.6477 6 29.2261 6 34"
stroke={`url(#${gradientId})`}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<linearGradient
id={gradientId}
x1="24"
y1="34"
x2="24"
y2="14"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6EE7B7" />
<stop offset="1" stop-color="#2DD4BF" />
</linearGradient>
</defs>
</svg>
);
};
export default LoopRunStartLinear;
......@@ -18,7 +18,7 @@ import {
Flex,
Input
} from '@chakra-ui/react';
import type { ButtonProps, MenuItemProps } from '@chakra-ui/react';
import type { ButtonProps, MenuItemProps, MenuProps } from '@chakra-ui/react';
import MyIcon from '../Icon';
import { useRequest } from '../../../hooks/useRequest';
import MyDivider from '../MyDivider';
......@@ -54,6 +54,7 @@ export type SelectProps<T = any> = Omit<ButtonProps, 'onChange'> & {
ScrollData?: ReturnType<typeof useScrollPagination>['ScrollData'];
customOnOpen?: () => void;
customOnClose?: () => void;
menuPlacement?: MenuProps['placement'];
isInvalid?: boolean;
isDisabled?: boolean;
......@@ -86,6 +87,7 @@ const MySelect = <T = any,>(
ScrollData,
customOnOpen,
customOnClose,
menuPlacement,
isInvalid,
isDisabled,
...props
......@@ -205,6 +207,7 @@ const MySelect = <T = any,>(
onOpen={onOpen}
onClose={onClose}
strategy={'fixed'}
placement={menuPlacement}
// matchWidth
>
<MenuButton
......
......@@ -394,6 +394,9 @@
"core.chat.response.parallel_input": "Parallel Input Array",
"core.chat.response.parallel_output": "Parallel Success Results",
"core.chat.response.parallel_run_detail": "Parallel Run Details",
"core.chat.response.loop_run_input": "Loop Input",
"core.chat.response.loop_run_iterations": "Iterations",
"core.chat.response.loop_run_history": "Loop History",
"core.chat.response.loop_input_element": "Loop Input Element",
"core.chat.response.loop_output": "Loop Output Array",
"core.chat.response.loop_output_element": "Loop Output Element",
......
......@@ -143,6 +143,31 @@
"parallel_task": "Task {{index}}",
"parallel_task_not_reach_end": "Sub-workflow did not reach the end node",
"parallel_task_interactive_not_supported": "Sub-workflow triggered an interactive response, which is not supported in parallel runs",
"loop_run": "Loop",
"intro_loop_run": "Run a repeating sub-workflow; the loop's outputs decide whether to continue into the next iteration.",
"loop_run_mode": "Loop type",
"loop_run_mode_tip": "Choose how the loop is driven: iterate over array elements, or run until a Loop break node is hit.",
"loop_run_mode_array": "Array loop",
"loop_run_mode_array_desc": "Process each item in the list in order",
"loop_run_mode_conditional": "Conditional loop",
"loop_run_mode_conditional_desc": "Keep running until a specific condition is met",
"loop_run_input_array": "Array",
"loop_custom_outputs": "Custom outputs",
"loop_custom_outputs_tip": "Declare output fields exposed by the loop. Each field references an output of a node inside the sub-workflow.",
"loop_run_break": "Loop break",
"loop_run_break_tip": "This module must be used inside a loop node. When executed, it will force the loop node to stop running.",
"loop_run_break_must_inside_loop_run": "Loop break node can only be placed inside a Loop container",
"loop_run_conditional_requires_break": "Conditional loop must contain at least one Loop break node",
"loop_run_input_not_array": "Loop input is not an array",
"loop_run_iteration_failed": "Loop iteration failed",
"loop_run_max_iterations_exceeded": "Loop reached maximum iteration limit",
"loop_run_start": "Loop start",
"current_index": "Current index",
"current_index_desc": "0-based index of the current element in array mode.",
"current_item": "Current item",
"current_item_desc": "Element being processed in the current iteration (array mode).",
"current_iteration": "Current loop count",
"current_iteration_desc": "1-based iteration counter in conditional mode.",
"max_dialog_rounds": "Maximum Number of Dialog Rounds",
"max_tokens": "Maximum Tokens",
"mouse_priority": "Mouse first\n- Press the left button to drag the canvas\n- Hold down shift and left click to select batches",
......
......@@ -394,6 +394,9 @@
"core.chat.response.parallel_input": "并行输入数组",
"core.chat.response.parallel_output": "并行成功结果",
"core.chat.response.parallel_run_detail": "并行执行明细",
"core.chat.response.loop_run_input": "循环输入",
"core.chat.response.loop_run_iterations": "循环轮数",
"core.chat.response.loop_run_history": "循环历史",
"core.chat.response.loop_input_element": "输入数组元素",
"core.chat.response.loop_output": "输出数组",
"core.chat.response.loop_output_element": "输出数组元素",
......
......@@ -143,6 +143,31 @@
"parallel_task": "任务 {{index}}",
"parallel_task_not_reach_end": "子工作流未到达结束节点",
"parallel_task_interactive_not_supported": "子工作流触发了交互式响应,并行执行节点不支持交互式节点",
"loop_run": "循环节点",
"intro_loop_run": "执行重复性工作流,并由循环结果决定是否进行下一次操作。",
"loop_run_mode": "循环类型",
"loop_run_mode_tip": "选择循环驱动方式:按数组元素依次执行,或持续执行直到命中「循环终止」。",
"loop_run_mode_array": "数组循环",
"loop_run_mode_array_desc": "按顺序处理列表内每一项",
"loop_run_mode_conditional": "条件循环",
"loop_run_mode_conditional_desc": "重复运行直至满足特定条件",
"loop_run_input_array": "数组",
"loop_custom_outputs": "自定义输出",
"loop_custom_outputs_tip": "声明对外暴露的输出字段,每个字段引用子流程内某个节点的输出。",
"loop_run_break": "循环终止",
"loop_run_break_tip": "该模块需要在循环节点内部使用。当该模块被执行时,将强制结束循环节点的运行。",
"loop_run_break_must_inside_loop_run": "循环终止节点只能放在循环执行节点内部",
"loop_run_conditional_requires_break": "条件循环必须至少包含一个循环终止节点",
"loop_run_input_not_array": "循环输入值不是数组",
"loop_run_iteration_failed": "循环执行失败",
"loop_run_max_iterations_exceeded": "循环达最大上限",
"loop_run_start": "循环开始",
"current_index": "当前下标",
"current_index_desc": "数组模式下当前元素的 0-based 下标。",
"current_item": "当前元素",
"current_item_desc": "数组模式下当前迭代处理的元素。",
"current_iteration": "当前循环次数",
"current_iteration_desc": "条件循环模式下的 1-based 迭代次数。",
"max_dialog_rounds": "最多携带多少轮对话记录",
"max_tokens": "最大 Tokens",
"mouse_priority": "鼠标优先\n- 左键按下后可拖动画布\n- 按住 shift 后左键可批量选择",
......
......@@ -390,6 +390,9 @@
"core.chat.response.parallel_input": "並行輸入陣列",
"core.chat.response.parallel_output": "並行成功結果",
"core.chat.response.parallel_run_detail": "並行執行明細",
"core.chat.response.loop_run_input": "迴圈輸入",
"core.chat.response.loop_run_iterations": "迴圈輪數",
"core.chat.response.loop_run_history": "迴圈歷史",
"core.chat.response.loop_input_element": "輸入陣列元素",
"core.chat.response.loop_output": "輸出陣列",
"core.chat.response.loop_output_element": "輸出陣列元素",
......
......@@ -143,6 +143,31 @@
"parallel_task": "任務 {{index}}",
"parallel_task_not_reach_end": "子工作流未到達結束節點",
"parallel_task_interactive_not_supported": "子工作流觸發了互動式回應,並行執行節點不支援互動式節點",
"loop_run": "迴圈節點",
"intro_loop_run": "執行重複性工作流,由迴圈結果決定是否進行下一次操作。",
"loop_run_mode": "迴圈類型",
"loop_run_mode_tip": "選擇迴圈驅動方式:依陣列元素逐一執行,或持續執行直到命中「迴圈終止」。",
"loop_run_mode_array": "陣列迴圈",
"loop_run_mode_array_desc": "依序處理清單內每一項",
"loop_run_mode_conditional": "條件迴圈",
"loop_run_mode_conditional_desc": "重複執行直到滿足特定條件",
"loop_run_input_array": "陣列",
"loop_custom_outputs": "自訂輸出",
"loop_custom_outputs_tip": "宣告對外曝露的輸出欄位,每個欄位引用子流程內某個節點的輸出。",
"loop_run_break": "迴圈終止",
"loop_run_break_tip": "此模組需要在迴圈節點內部使用。當此模組被執行時,將強制結束迴圈節點的執行。",
"loop_run_break_must_inside_loop_run": "迴圈終止節點只能放在迴圈執行節點內部",
"loop_run_conditional_requires_break": "條件迴圈必須至少包含一個迴圈終止節點",
"loop_run_input_not_array": "迴圈輸入值不是陣列",
"loop_run_iteration_failed": "迴圈執行失敗",
"loop_run_max_iterations_exceeded": "迴圈達最大上限",
"loop_run_start": "迴圈開始",
"current_index": "目前索引",
"current_index_desc": "陣列模式下目前元素的 0-based 索引。",
"current_item": "目前元素",
"current_item_desc": "陣列模式下目前迭代處理的元素。",
"current_iteration": "目前迴圈次數",
"current_iteration_desc": "條件迴圈模式下的 1-based 迭代次數。",
"max_dialog_rounds": "最多攜帶幾輪對話紀錄",
"max_tokens": "最大 Token 數",
"mouse_priority": "滑鼠優先\n- 按下左鍵拖曳畫布\n- 按住 Shift 鍵並點選左鍵可批次選取",
......
......@@ -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));
......
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,
......
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);
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,
......
......@@ -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();
});
});
});
......@@ -345,6 +345,62 @@ describe('getFlatAppResponses', () => {
expect(result).toHaveLength(3);
});
it('should recurse into loopRunDetail and parallelDetail', () => {
const responses: ChatHistoryItemResType[] = [
{
id: 'loopRunParent',
nodeId: 'loopRunParent',
moduleName: 'LoopRun',
moduleType: FlowNodeTypeEnum.loopRun,
loopRunDetail: [
{
id: 'iter1',
nodeId: 'iter1',
moduleName: 'Iter 1',
moduleType: FlowNodeTypeEnum.loopRun,
childrenResponses: [
{
id: 'ds1',
nodeId: 'ds1',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode
}
]
}
]
},
{
id: 'parallelParent',
nodeId: 'parallelParent',
moduleName: 'Parallel',
moduleType: FlowNodeTypeEnum.parallelRun,
parallelDetail: [
{
id: 'task1',
nodeId: 'task1',
moduleName: 'Task 1',
moduleType: FlowNodeTypeEnum.parallelRun,
childrenResponses: [
{
id: 'ds2',
nodeId: 'ds2',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode
}
]
}
]
}
];
const result = getFlatAppResponses(responses);
const ids = result.map((item) => item.id);
expect(ids).toContain('ds1');
expect(ids).toContain('ds2');
expect(result).toHaveLength(6);
});
});
describe('checkInteractiveResponseStatus', () => {
......
......@@ -475,6 +475,94 @@ describe('pushChatRecords', () => {
}
}
});
it('should collect citeCollectionIds from dataset search nested in loopRun / parallelRun', async () => {
const makeQuote = (id: string, collectionId: string) => ({
id,
chunkIndex: 0,
datasetId: 'dataset-1',
collectionId,
sourceId: `src-${collectionId}`,
sourceName: `${collectionId}.pdf`,
score: [{ type: 'embedding', value: 0.9, index: 0 }],
q: 'q',
a: 'a',
updateTime: new Date()
});
const makeDatasetSearch = (collectionId: string) => ({
nodeId: `ds-${collectionId}`,
id: `ds-${collectionId}`,
moduleType: FlowNodeTypeEnum.datasetSearchNode,
moduleName: 'Dataset Search',
runningTime: 0.1,
totalPoints: 1,
quoteList: [makeQuote(`quote-${collectionId}`, collectionId)]
});
const props = createMockProps(
{
aiContent: {
obj: ChatRoleEnum.AI,
value: [],
responseData: [
{
nodeId: 'loopRun-1',
id: 'loopRun-1',
moduleType: FlowNodeTypeEnum.loopRun,
moduleName: 'LoopRun',
runningTime: 0.5,
totalPoints: 2,
loopRunDetail: [
{
nodeId: 'loopRun-1_iter_1',
id: 'loopRun-1_iter_1',
moduleType: FlowNodeTypeEnum.loopRun,
moduleName: 'Iter 1',
runningTime: 0.2,
totalPoints: 1,
childrenResponses: [makeDatasetSearch('collection-loop')]
}
]
},
{
nodeId: 'parallelRun-1',
id: 'parallelRun-1',
moduleType: FlowNodeTypeEnum.parallelRun,
moduleName: 'ParallelRun',
runningTime: 0.5,
totalPoints: 2,
parallelDetail: [
{
nodeId: 'parallelRun-1_task_0',
id: 'parallelRun-1_task_0',
moduleType: FlowNodeTypeEnum.parallelRun,
moduleName: 'Task 1',
runningTime: 0.2,
totalPoints: 1,
childrenResponses: [makeDatasetSearch('collection-parallel')]
}
]
}
]
}
},
{ appId: testAppId, teamId: testTeamId, tmbId: testTmbId }
);
await pushChatRecords(props);
const aiItem = await MongoChatItem.findOne({
appId: testAppId,
chatId: props.chatId,
obj: ChatRoleEnum.AI
});
if (!aiItem || !('citeCollectionIds' in aiItem)) {
throw new Error('aiItem does not have citeCollectionIds');
}
expect(aiItem.citeCollectionIds).toContain('collection-loop');
expect(aiItem.citeCollectionIds).toContain('collection-parallel');
});
});
describe('prepared chat round lifecycle', () => {
......
import { describe, it, expect, vi } from 'vitest';
import { getNestedEndOutputValue } from '@fastgpt/service/core/workflow/dispatch/loop/service';
import {
getNestedEndOutputValue,
pushSubWorkflowUsage,
collectResponseFeedbacks
} from '@fastgpt/service/core/workflow/dispatch/loop/service';
import { injectNestedStartInputs } from '@fastgpt/service/core/workflow/dispatch/utils';
collectResponseFeedbacks,
injectNestedStartInputs,
pushSubWorkflowUsage
} from '@fastgpt/service/core/workflow/dispatch/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
......@@ -177,7 +177,7 @@ describe('loop/service', () => {
{ totalPoints: 5, moduleName: 'b' } as any
]
});
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'myNode', index: 0 });
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'myNode', iteration: 0 });
expect(pts).toBe(15);
});
......@@ -186,7 +186,7 @@ describe('loop/service', () => {
const response = makeDispatchFlowResponse({
flowUsages: [{ totalPoints: 7, moduleName: 'x' } as any]
});
pushSubWorkflowUsage({ usagePush, response, name: 'loopNode', index: 3 });
pushSubWorkflowUsage({ usagePush, response, name: 'loopNode', iteration: 3 });
expect(usagePush).toHaveBeenCalledOnce();
expect(usagePush).toHaveBeenCalledWith([{ totalPoints: 7, moduleName: 'loopNode-3' }]);
});
......@@ -194,17 +194,17 @@ describe('loop/service', () => {
it('flowUsages 为空时返回 0', () => {
const usagePush = vi.fn();
const response = makeDispatchFlowResponse({ flowUsages: [] });
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'node', index: 0 });
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'node', iteration: 0 });
expect(pts).toBe(0);
expect(usagePush).toHaveBeenCalledWith([{ totalPoints: 0, moduleName: 'node-0' }]);
});
it('index 正确拼接到 moduleName', () => {
it('iteration 正确拼接到 moduleName', () => {
const usagePush = vi.fn();
const response = makeDispatchFlowResponse({
flowUsages: [{ totalPoints: 1, moduleName: 'z' } as any]
});
pushSubWorkflowUsage({ usagePush, response, name: 'parallel', index: 99 });
pushSubWorkflowUsage({ usagePush, response, name: 'parallel', iteration: 99 });
expect(usagePush).toHaveBeenCalledWith([{ totalPoints: 1, moduleName: 'parallel-99' }]);
});
});
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment