From 27ebc0eebad5746557dd0e2b177b371d72a26a75 Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:35:30 +0800 Subject: [PATCH] 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 --- .../bug/loop-run-interactive-resume-fix.md | 294 +++++++ .../core/workflow/loop-run/development.md | 315 +++++++ packages/global/core/chat/utils.ts | 10 + packages/global/core/workflow/constants.ts | 10 + .../global/core/workflow/node/constant.ts | 37 +- packages/global/core/workflow/runtime/type.ts | 12 + .../core/workflow/template/constants.ts | 8 +- .../template/system/interactive/constants.ts | 3 +- .../template/system/interactive/type.ts | 21 + .../template/system/loopRun/loopRun.ts | 99 +++ .../template/system/loopRun/loopRunBreak.ts | 20 + .../template/system/loopRun/loopRunStart.ts | 77 ++ packages/global/core/workflow/type/io.ts | 14 +- .../core/workflow/dispatch/constants.ts | 6 + .../service/core/workflow/dispatch/index.ts | 10 + .../core/workflow/dispatch/loop/runLoop.ts | 11 +- .../core/workflow/dispatch/loop/service.ts | 58 +- .../workflow/dispatch/loopRun/runLoopRun.ts | 310 +++++++ .../dispatch/loopRun/runLoopRunBreak.ts | 17 + .../dispatch/loopRun/runLoopRunStart.ts | 41 + .../core/workflow/dispatch/loopRun/service.ts | 156 ++++ .../dispatch/parallelRun/runParallelRun.ts | 14 +- .../workflow/dispatch/parallelRun/service.ts | 8 +- .../service/core/workflow/dispatch/utils.ts | 44 +- .../web/components/common/Icon/constants.ts | 13 + .../core/workflow/inputType/conditional.svg | 3 + .../icons/core/workflow/template/loopRun.svg | 12 + .../core/workflow/template/loopRunBreak.svg | 10 + .../workflow/template/loopRunBreakLinear.tsx | 59 ++ .../core/workflow/template/loopRunLinear.tsx | 36 + .../core/workflow/template/loopRunStart.svg | 10 + .../workflow/template/loopRunStartLinear.tsx | 41 + .../web/components/common/MySelect/index.tsx | 5 +- packages/web/i18n/en/common.json | 3 + packages/web/i18n/en/workflow.json | 25 + packages/web/i18n/zh-CN/common.json | 3 + packages/web/i18n/zh-CN/workflow.json | 25 + packages/web/i18n/zh-Hant/common.json | 3 + packages/web/i18n/zh-Hant/workflow.json | 25 + .../components/core/app/formRender/index.tsx | 10 +- .../components/core/app/formRender/type.ts | 5 +- .../core/chat/ChatContainer/ChatBox/index.tsx | 9 +- .../chat/components/WholeResponseModal.tsx | 48 +- .../Flow/NodeTemplatesPopover.tsx | 12 +- .../Flow/components/NodeTemplates/list.tsx | 100 ++- .../NodeTemplates/useNodeTemplates.tsx | 12 +- .../Flow/hooks/useNestedNode.ts | 57 +- .../Flow/hooks/useWorkflow.tsx | 54 +- .../detail/WorkflowComponents/Flow/index.tsx | 3 + .../Flow/nodes/Loop/NodeLoop.tsx | 2 +- .../Flow/nodes/Loop/NodeLoopRun.tsx | 269 ++++++ .../Flow/nodes/Loop/NodeLoopRunBreak.tsx | 21 + .../Flow/nodes/Loop/NodeLoopRunStart.tsx | 110 +++ .../Flow/nodes/Loop/NodeParallelRun.tsx | 2 +- .../Flow/nodes/render/NodeCard.tsx | 36 +- .../RenderInput/templates/CommonInputForm.tsx | 9 + .../templates/DynamicInputs/index.tsx | 10 +- .../RenderInput/templates/Reference.tsx | 26 +- .../context/workflowComputeContext.tsx | 4 + .../context/workflowInitContext.tsx | 10 + projects/app/src/web/core/workflow/utils.ts | 58 +- .../test/web/core/app/workflow/utils.test.ts | 158 ++++ test/cases/global/core/chat/chatUtils.test.ts | 56 ++ test/cases/service/core/chat/saveChat.test.ts | 88 ++ .../workflow/dispatch/loop/service.test.ts | 20 +- .../dispatch/loopRun/runLoopRun.test.ts | 799 ++++++++++++++++++ .../workflow/dispatch/loopRun/service.test.ts | 358 ++++++++ 67 files changed, 3993 insertions(+), 221 deletions(-) create mode 100644 .claude/design/bug/loop-run-interactive-resume-fix.md create mode 100644 .claude/design/core/workflow/loop-run/development.md create mode 100644 packages/global/core/workflow/template/system/loopRun/loopRun.ts create mode 100644 packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts create mode 100644 packages/global/core/workflow/template/system/loopRun/loopRunStart.ts create mode 100644 packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts create mode 100644 packages/service/core/workflow/dispatch/loopRun/runLoopRunBreak.ts create mode 100644 packages/service/core/workflow/dispatch/loopRun/runLoopRunStart.ts create mode 100644 packages/service/core/workflow/dispatch/loopRun/service.ts create mode 100644 packages/web/components/common/Icon/icons/core/workflow/inputType/conditional.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/template/loopRun.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreak.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreakLinear.tsx create mode 100644 packages/web/components/common/Icon/icons/core/workflow/template/loopRunLinear.tsx create mode 100644 packages/web/components/common/Icon/icons/core/workflow/template/loopRunStart.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/template/loopRunStartLinear.tsx create mode 100644 projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRun.tsx create mode 100644 projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunBreak.tsx create mode 100644 projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunStart.tsx create mode 100644 test/cases/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts create mode 100644 test/cases/service/core/workflow/dispatch/loopRun/service.test.ts diff --git a/.claude/design/bug/loop-run-interactive-resume-fix.md b/.claude/design/bug/loop-run-interactive-resume-fix.md new file mode 100644 index 0000000000..0774f54ffa --- /dev/null +++ b/.claude/design/bug/loop-run-interactive-resume-fix.md @@ -0,0 +1,294 @@ +# 循环节点交互恢复修复 + +## 背景 + +循环节点(`loopRun`,条件/数组两种模式)的循环体内若放置交互节点(如 `formInput`、`userSelect`),用户提交交互内容后继续执行时出现: + +1. **表单循环被重置**:用户提交表单后又弹出同一个表单、`指定回复` 从未执行、`循环历史` 永远是 `[]`。(实际上是 workflow 被当成新请求从头跑) +2. **循环变量丢失**(若 resume 真的触发):下游节点引用 `循环开始 > 当前循环次数` / `当前循环值` 解析为 `undefined`。 +3. **响应详情缺失**(若 resume 真的触发):被中断那次迭代的详情树只包含 resume 之后的节点。 + +## 调用链与快照机制 + +**Interactive 冒泡与快照** + +`WorkflowQueue.handleInteractiveResult`(`dispatch/index.ts:1438`)在一次 `runWorkflow` 返回前,会把 `this.data.runtimeNodes` 里每个 node 的 `outputs[i].value` 截图到 `nodeOutputs`,连同 `entryNodeIds / memoryEdges` 包进 `InteractiveBasicType`: + +```ts +this.data.runtimeNodes.forEach((node) => { + node.outputs.forEach((output) => { + if (output.value) nodeOutputs.push({ nodeId, key, value }); + }); +}); +``` + +**Resume 时的 Top-level 还原** + +`projects/app/src/pages/api/v2/chat/completions.ts:258` + +```ts +runtimeNodes = rewriteNodeOutputByHistories(runtimeNodes, interactive); +``` + +`rewriteNodeOutputByHistories`(`runtime/utils.ts:546`)只会读 **当前这层 `interactive.nodeOutputs`**,不会递归进 `params.childrenResponse.nodeOutputs`。 + +**`runLoopRun` 的隔离** + +`runLoopRun.ts:86` + +```ts +const isolatedNodes = cloneDeep(runtimeNodes); +``` + +循环体用独立的 `isolatedNodes` 执行,避免污染父层。 + +## 为什么会出问题 + +### 问题 0(阻断性):`isChildInteractive` 白名单漏了 `loopRunInteractive` + +`packages/global/core/workflow/template/system/interactive/constants.ts` + +```ts +export const isChildInteractive = (type) => { + if ( + type === 'childrenInteractive' || + type === 'toolChildrenInteractive' || + type === 'loopInteractive' // ← 只有旧 loop,没有 loopRun + ) return true; + return false; +}; +``` + +`getLastInteractiveValue`(`runtime/utils.ts:163`)读取最后一条 AI 消息的 `interactive`,先判断 `isChildInteractive(type)` 做"直接返回",否则挨个匹配 `userSelect / userInput / paymentPause / agentPlanCheck / agentPlanAskQuery`。`loopRunInteractive` 既不在白名单,也不匹配任何具体 type,**结果返回 `undefined`**。 + +连锁反应: + +1. `chat/completions.ts` 拿到 `interactive === undefined`。 +2. `getWorkflowEntryNodeIds(nodes, undefined)` 退化成取 `workflowStart / systemConfig` 等默认入口。 +3. `rewriteNodeOutputByHistories(runtimeNodes, undefined)` 直接返回 runtimeNodes(无还原)。 +4. `runWorkflow({ lastInteractive: undefined })` → 从 `workflowStart` 重新跑一轮。 +5. 用户提交的表单 JSON 被当成新 query 的 message text,workflow 从头跑到 iter 1 表单再次中断。 + +所以用户看到的"提交表单 → 又弹同一个表单 → `指定回复` 从未执行 → 循环历史为 []",**全是因为 resume 根本没触发**,跟后面 A/B 两个问题无关。A/B 是在 resume 真的触发之后才会暴露的问题。 + +### 问题 A:变量丢失 + +1. 内层 `runWorkflow` 命中交互时,`handleInteractiveResult` 截图的是 `isolatedNodes` 的 outputs(含 `loopRunStart.currentIteration = 1`),放到内层 `interactive.nodeOutputs`。 +2. `runLoopRun.ts:250-258` 把内层 `interactiveResponse` 原样塞进 `LoopRunInteractive.params.childrenResponse`。 +3. 外层 `handleInteractiveResult` 再截图一次,但截图对象是 **父层的 runtimeNodes**(loopRun 节点自己用的那层),这层没有循环体节点的 outputs。外层 `interactive.nodeOutputs` 里**没有 `loopRunStart.currentIteration`**。 +4. Resume 时 `chat/completions.ts` 只用外层 `interactive.nodeOutputs` 还原 → `loopRunStart.currentIteration` 还是 undefined。 +5. `runLoopRun.ts:127-132` 的 resume 分支只设 `isEntry`,**不调用 `rewriteNodeOutputByHistories(isolatedNodes, interactiveData.childrenResponse)`**,也没调用 `injectLoopRunStart`,于是 `isolatedNodes` 上 `loopRunStart` 的 outputs 全空。 +6. 下游 `指定回复` / `判断器` 通过 `getReferenceVariableValue` 读 `loopRunStart` output → 得到 `undefined`。 + +### 问题 B:响应详情缺失 + +`runLoopRun.ts:173-176` + +```ts +if (response.workflowInteractiveResponse) { + interactiveResponse = response.workflowInteractiveResponse; + break; // ← 直接 break +} +``` + +中断时**跳过 `pushIterationDetail`**,注释声称「the resumed run will record it」——但: + +- `response.flowResponses` 里此时已经包含 **中断前** 跑完的 `loopRunStart / 判断器` 等节点 detail,一并被丢弃。 +- Resume 那一轮的 `response.flowResponses` 只有 **resume 之后** 的节点(`表单输入` 的提交回填 + `指定回复`)。 +- Resume 结束后调用 `pushIterationDetail({})` 组装 wrapper,`childrenResponses` 只剩后半段。 +- `saveChat.mergeChatResponseData` 按 `mergeSignId` 合并的是外层 `loopRun` 节点,`loopRunDetail` 两端是 concat(见 `chat/utils.ts:374-377`)。但**前后两轮对同一 `iteration` 都没有各自的 wrapper 互相合并**(中断那轮压根没 push),所以 iter1 只剩一条"半截 wrapper"。 + +注 1:**上一条已经完成的迭代 wrapper 不会丢**。它们在中断前已经 `pushIterationDetail` 进 `loopResponseDetail`,作为外层 `loopRunDetail` 的一部分写入中断响应;resume 后的新 `loopRunDetail` 经 `mergeChatResponseData` concat 合并回来。 + +注 2:`loopHistory`(customOutputs 等)通过 `LoopRunInteractive.params.loopHistory` 主动透传(`runLoopRun.ts:94-96`),不受此 bug 影响。 + +### 旧版 `runLoop.ts` 的差异(仅说明,不在本次修复范围) + +`runLoop.ts` 不 clone `runtimeNodes`(`runLoop.ts:84` 直接透传),内外层共享同一份节点引用,所以外层截图也能带上循环体 outputs,问题 A 恰好被绕开。问题 B 方面,`runLoop.ts` 不做 per-iteration wrapper,中断前的 `response.flowResponses` 在 `runLoop.ts:98` 已 push 进 `loopResponseDetail`,通过外层合并链保留。不过这是"恰好能用"的脆弱依赖,后续也建议收敛。 + +## 修复方案 + +### 范围 + +`runLoopRun` 流程(用户 bug 命中的是条件循环 ifo 模式)。改动点集中在四处: + +0. `packages/global/core/workflow/template/system/interactive/constants.ts` — 白名单补 `loopRunInteractive`(**阻断性,必改**) +1. `packages/global/core/workflow/template/system/interactive/type.ts` — `LoopRunInteractive` 加 `pendingIterationResponses` +2. `packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts` — 接入 `rewriteNodeOutputByHistories` + pending 机制 + +`runLoop.ts`(旧数组循环)本次不动,保留作为 follow-up。 + +### 改动 0:`isChildInteractive` 白名单补 `loopRunInteractive` + +```ts +// packages/global/core/workflow/template/system/interactive/constants.ts +export const isChildInteractive = (type: InteractiveNodeResponseType['type']) => { + if ( + type === 'childrenInteractive' || + type === 'toolChildrenInteractive' || + type === 'loopInteractive' || + type === 'loopRunInteractive' // 新增 + ) return true; + return false; +}; +``` + +### 改动 1:扩展 `LoopRunInteractive` schema + +新增 `pendingIterationResponses` 字段,用来持久化"当前这次迭代、中断前已经跑过的子节点响应"。 + +```ts +// packages/global/core/workflow/template/system/interactive/type.ts +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[]; + }; +}; +``` + +### 改动 2:Resume 前还原循环体 node outputs + +`runLoopRun.ts` 在构造 `isolatedNodes` 之后,如果正在恢复,用 `rewriteNodeOutputByHistories` 把 `interactiveData.childrenResponse.nodeOutputs` 叠加回去。 + +```ts +import { rewriteNodeOutputByHistories } from '@fastgpt/global/core/workflow/runtime/utils'; + +// ... +let isolatedNodes = cloneDeep(runtimeNodes); +const isolatedEdges = cloneDeep(runtimeEdges); + +if (interactiveData?.childrenResponse) { + isolatedNodes = rewriteNodeOutputByHistories( + isolatedNodes, + interactiveData.childrenResponse + ); +} +``` + +**不**在 resume 分支调用 `injectLoopRunStart`,原因:它会把 `loopRunStart.isEntry = true`,导致 loopRunStart 重跑并可能把已恢复 outputs 的链条再走一遍(判断器也会再跑一次,造成 detail 重复)。现在通过 rewriteNodeOutputByHistories 单一来源恢复即可。 + +### 改动 3:中断时保留 in-flight iteration 的子节点响应 + +循环里积累一个局部变量,每次看到 interactive 就把当前 `iterationChildrenResponses` 接到 pending 里;每次成功完成一次迭代就清空。 + +```ts +let pendingIterationResponses: ChatHistoryItemResType[] = + interactiveData?.pendingIterationResponses ?? []; + +while (true) { + // ...(iteration guard) + + const isResumeIteration = !!interactiveData && iteration === resumeIteration; + + // resume 分支只设 isEntry;非 resume 才走 injectLoopRunStart + if (isResumeIteration) { + isolatedNodes.forEach((n) => { + if (interactiveData?.childrenResponse?.entryNodeIds.includes(n.nodeId)) { + n.isEntry = true; + } + }); + } else { + injectLoopRunStart({ /* 原样 */ }); + } + + const response = await runWorkflow({ /* 原样 */ }); + + // 合并 pre-interrupt + 本轮 flowResponses(pending 只在进入同一 iteration 时有效) + const iterationChildrenResponses = [ + ...(isResumeIteration ? pendingIterationResponses : []), + ...response.flowResponses + ]; + + // 运行时间/usage/assistant/feedback 都算本轮新跑的 + const iterationRunningTime = response.flowResponses.reduce( + (acc, r) => acc + (typeof r.runningTime === 'number' ? r.runningTime : 0), + 0 + ); + + // ...(assistantResponses / usagePush / feedback 原样,注意 totalPoints/usage 只算新跑的,避免重复计费) + + if (response.workflowInteractiveResponse) { + interactiveResponse = response.workflowInteractiveResponse; + // 累积,支持多次中断 + pendingIterationResponses = iterationChildrenResponses; + break; + } + + // 迭代完整走完 → 使用合并后的 children 生成 wrapper + // iteration 成功或失败都走 pushIterationDetail({ childrenResponses: iterationChildrenResponses }) + // ... + + // 迭代走完,清空 pending,进下一轮 + pendingIterationResponses = []; + + interactiveData = undefined; // 原逻辑 + iteration++; +} + +// 返回的 interactive payload 带上 pending +return { + // ... + [DispatchNodeResponseKeyEnum.interactive]: interactiveResponse + ? { + type: 'loopRunInteractive', + params: { + loopHistory, + childrenResponse: interactiveResponse, + iteration, + pendingIterationResponses + } + } + : undefined, + // ... +}; +``` + +**把 `pushIterationDetail` 接上 `iterationChildrenResponses`**(原本是直接闭包读外层变量,这里改成参数传入或直接 inline 使用合并结果)。 + +### 改动 4:`iterationRunningTime` 的归集 + +原实现是 `iterationChildrenResponses.reduce(...)`,现在要区分"本轮新跑的耗时"和"累积子响应"。耗时只算本轮(中断前的耗时已经挂在之前那次请求里),避免重复。`totalPoints / assistantResponses / usagePush` 同理只算 `response` 本轮。 + +### 风险点与兼容性 + +1. **多次中断同一迭代**:按方案 pending 在每轮 resume 时被读出 → 本轮再追加 → 再次中断时整包写回 interactive payload。验证:一个 iteration 里先 `formInput` → 再 `userSelect`,两次交互后应该看到完整 children。 +2. **旧 chat 历史无 `pendingIterationResponses` 字段**:`?? []` 兜底,向前兼容。 +3. **外层 `mergeChatResponseData`**:外层 `loopRun` 节点 `mergeSignId` 不变,合并逻辑不受影响;pending 只作用在 wrapper 内部 `childrenResponses`,不冲突。 +4. **测试节点 `rewriteNodeOutputByHistories` 的落点是 clone 后的副本**:不会把循环体 outputs 泄漏给外层后续兄弟节点。 + +### 已知的次要 bug(本次不修) + +- `handleInteractiveResult` 截图 outputs 时 `if (output.value)` 会丢掉 `0 / '' / false` 等合法值(`dispatch/index.ts:1449`)。对数组模式 `currentIndex = 0` 会有影响;条件模式 `iteration >= 1` 不受影响。留作后续专项修复。 +- `runLoop.ts`(旧数组循环)依赖 `runtimeNodes` 共享引用偶然可用,建议后续同步迁移到显式 `rewriteNodeOutputByHistories`。 + +## TODO + +- [x] `packages/global/core/workflow/template/system/interactive/constants.ts`:`isChildInteractive` 白名单补 `'loopRunInteractive'` +- [x] `packages/global/core/workflow/template/system/interactive/type.ts`:给 `LoopRunInteractiveSchema` / `LoopRunInteractive` 加 `pendingIterationResponses?: ChatHistoryItemResType[]` 字段 +- [x] `packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts`: + - [x] 引入 `rewriteNodeOutputByHistories` + - [x] `isolatedNodes = cloneDeep(...)` 后若有 `interactiveData` 就叠加还原循环体 outputs + - [x] 循环内维护 `pendingIterationResponses`;`isResumeIteration` 分支合并 pending + 本轮 flowResponses + - [x] 中断分支:写入 pending;走完一次迭代后清空 + - [x] `pushIterationDetail` 使用合并后的 `iterationChildrenResponses`;`iterationRunningTime` 按合并后统计,`totalPoints / usagePush` 只算本轮 + - [x] return 的 `loopRunInteractive.params` 带 `pendingIterationResponses` +- [ ] 本地手测 1:`条件循环 + formInput`(用户原场景),确认"当前循环次数"引用可读 + 响应详情包含中断前子节点 +- [ ] 本地手测 2:同一迭代内先后两次交互(先 formInput 再 userSelect),确认 pending 累积 +- [ ] 本地手测 3:第 2 次迭代触发交互,恢复后后续迭代继续跑,确认上一条完整迭代 wrapper 不丢 +- [ ] 本地手测 4:数组模式循环 + 交互,确认没有回归 +- [ ] (follow-up,不在本 PR)`runLoop.ts` 同步显式 `rewriteNodeOutputByHistories` +- [ ] (follow-up,不在本 PR)`handleInteractiveResult` 的 `if (output.value)` 改 `!== undefined` diff --git a/.claude/design/core/workflow/loop-run/development.md b/.claude/design/core/workflow/loop-run/development.md new file mode 100644 index 0000000000..ba53928e5d --- /dev/null +++ b/.claude/design/core/workflow/loop-run/development.md @@ -0,0 +1,315 @@ +# loopRun 节点开发文档(文件级改动清单 + TODO) + +> 设计稿:[Notion - 工作流循环批量](https://www.notion.so/dighuang/341ded3f8cd88187be61fb442c7fbe8b) +> 蓝本:parallelRun 节点(commit `0855cc6e06c56f8fa2ea9aaab491d43bb4413be8`) +> 本文档只覆盖"**实现侧**"内容。节点语义、交互细节以 Notion 设计稿为准。 + +## 1. 与 parallelRun 的核心差异速览 + +| 维度 | parallelRun | loopRun | +|---|---|---| +| 并发模型 | `batchRun` 并行 | 串行 `for` + `await` | +| 输入模式 | 只 array | array / conditional 二选一 | +| 终止条件 | 数组取尽 | 数组取尽(array 模式)/ `loopRunBreak` 命中 / 系统兜底 | +| 重试 | 有(0-5 次,默认 3) | 无 | +| 并发数配置 | 有(env 上限) | 无 | +| 输出 | 固定 3 个(success/full/status) | 用户自定义字段 + `errorText`(`loopRunIterations` / `loopRunHistory` 仅在调试 nodeResponse 中) | +| 子节点 | 子流程,无独立 Start 节点 | `loopRunStart`(unique,自动生成)+ `loopRunBreak`(信号节点,可多个) | +| interactive | 不支持 | 支持(对齐旧 loop) | +| runtimeNodes 隔离 | 每任务 cloneDeep | 进入 loopRun 时 cloneDeep 一次;迭代间共享 | +| 变量 `newVariables` | 每任务独立 | 跨迭代累加,结束回写 parent | + +--- + +## 2. 文件级改动清单 + +### 2.1 Global 枚举 & 类型 + +**`packages/global/core/workflow/node/constant.ts`** +- `FlowNodeTypeEnum` 新增 3 个成员: + - `loopRun = 'loopRun'` + - `loopRunStart = 'loopRunStart'` + - `loopRunBreak = 'loopRunBreak'` +- `isNestedParentNodeType()` 增加 `FlowNodeTypeEnum.loopRun` 判断 + +**`packages/global/core/workflow/constants.ts`** +- `NodeInputKeyEnum` 新增: + - `loopRunMode`(`'array' | 'conditional'`) + - `loopRunInputArray`(不复用 `nestedInputArray`,避免与旧 loop/parallelRun 的 `loopInputArray` 字符串值冲突) + - `loopCustomOutputs`(自定义输出字段声明区) +- `NodeOutputKeyEnum` 新增: + - `errorText`(若已存在则复用) + - `currentIndex` / `currentItem` / `currentIteration`(loopRunStart 动态输出) +- 不需要新增 status enum(loopRun 无 parallelStatus 这种状态输出) + +**`packages/global/core/workflow/runtime/type.ts`** +- `DispatchNodeResponseType` 增加:`loopRunInput?`、`loopRunIterations?`、`loopRunHistory?`、`loopRunDetail?` +- `WorkflowInteractiveResponseType` 已有 `loopInteractive` 类型,loopRun 复用现有结构(`currentIndex` → 改名对齐 `iteration` 可选,或直接用复用字段) + +**`packages/global/core/workflow/template/input.ts`** +- 复用现有 `Input_Template_Children_Node_List` / `Input_Template_Node_Width` / `Input_Template_Node_Height` / `Input_Template_NESTED_NODE_OFFSET` +- 新增 `Input_Template_LoopCustomOutputs`(参考代码节点的 `Output_Template_AddOutput` 做一个声明区输入) + +**`packages/global/core/workflow/template/constants.ts`** +- 导入 `LoopRunNode` / `LoopRunStartNode` / `LoopRunBreakNode` +- 添加到 `systemNodes` 数组 + +**`packages/global/core/workflow/template/system/loopRun/`**(新建目录) +- `loopRun.ts` - `LoopRunNode` 模板(参考 `parallelRun.ts`,去掉并发/重试输入,加 `loopRunMode` / `loopRunInputArray` / `loopCustomOutputs`;outputs 只保留 `errorText`,用户自定义字段由 dynamic output 声明;迭代数与历史仅在 nodeResponse 调试信息中返回) +- `loopRunStart.ts` - `LoopRunStartNode` 模板,`unique: true, forbidDelete: true`,outputs 按 mode 动态暴露(array 模式:currentIndex + currentItem;conditional 模式:currentIteration) +- `loopRunBreak.ts` - `LoopRunBreakNode` 模板,`inputs: []`、`outputs: []`,只有 target handle + +### 2.2 后端 Dispatcher + +**`packages/service/core/workflow/dispatch/index.ts`** +- 导入 `dispatchLoopRun` / `dispatchLoopRunStart` / `dispatchLoopRunBreak` +- `callbackMap` 注册 3 个回调 + +**`packages/service/core/workflow/dispatch/loopRun/`**(新建目录,对齐 `parallelRun/`) +- `runLoopRun.ts` - 主 dispatcher,结构参考 `runParallelRun.ts`: + - 入口:`cloneDeep(runtimeNodes / runtimeEdges)` 做循环级隔离 + - `for` 循环:mode 分支(array 取数组元素;conditional 只计次) + - 每轮 `injectLoopRunStart`(新工具,向 loopRunStart 注入 iteration/index/item) + - `runWorkflow` 跑子流程 + - 出错 catch → 读快照(过滤未跑节点)→ push loopHistory → break + - 正常结束 → 读快照(全字段)→ push loopHistory → 判断 flowResponses 含 loopRunBreak → break or 继续 + - interactive 响应 → 立即 break 并返回 `loopInteractive` 状态 + - 结束聚合:最后一项 `loopHistory.customOutputs` → 动态 outputs +- `runLoopRunStart.ts` - 简单透传(参考 `runLoopStart.ts`) +- `runLoopRunBreak.ts` - 纯信号,返回空 data,仅在 flowResponses 中留下 moduleType 标记 +- `service.ts` - loopRun 专属工具: + - `injectLoopRunStart({ nodes, mode, iteration, index?, item? })` - 向 loopRunStart 注入输入 + - `readCustomOutputSnapshot({ runtimeNodes, loopCustomOutputs, finishedNodeIds?, childrenNodeIdList? })` - 读 ref 写快照(传 finishedNodeIds 时按集合过滤;childrenNodeIdList 用于放行循环体外 ref) + - `extractFinishedNodeIds(flowResponses)` - 从 runWorkflow 响应提取已完成节点集合 + - `pickCustomOutputInputs(inputs)` - 筛选 `canEdit: true` 的动态输入声明 + - `hasLoopRunBreakChild(runtimeNodes, childrenNodeIdList)` / `isLoopBreakHit(flowResponses)` - conditional 模式 break 预检 + 运行时判定 +- `pushSubWorkflowUsage` / `collectResponseFeedbacks` - **不放在 service.ts**,统一在 `dispatch/utils.ts` 里实现并由 loop/loopRun 共用(见下条) + +**`packages/service/core/workflow/dispatch/utils.ts`** +- 新增跨 dispatcher 共用工具:`pushSubWorkflowUsage({ usagePush, response, name, iteration })`、`collectResponseFeedbacks(response, target)`。原 `loop/service.ts` 中的同名实现已下沉到这里;`loop/runLoop.ts` 调用处由 `index` → `iteration` 更名(语义等价:两者都传 1-based 迭代计数) +- parallelRun 不共用这套工具——它在 retry 循环里做了带累加器的就地聚合,抽出去反而复杂 +- 沿用 `safePoints`、`injectNestedStartInputs` 不变 + +**`packages/service/env.ts`** +- 无需新增 env;复用 `WORKFLOW_MAX_LOOP_TIMES` + +### 2.3 前端画板节点组件 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/`**(目录复用,新增文件) +- `NodeLoopRun.tsx` - 容器节点组件。基本参考 `NodeParallelRun.tsx`: + - 用 `useNestedNode` hook 处理大小/子节点列表 + - 注意:conditional 模式下 **没有 `nestedInputArray`**,`useNestedNode` 要么加开关、要么条件循环模式下不读这个 input + - 需要新增:`loopRunMode` select 切换;切换时清理 loopRunStart 的 ref 并给 toast 提醒 + - 需要新增:`loopCustomOutputs` 的自定义输出声明 UI(参考代码节点的 `RenderOutput` 模式) +- `NodeLoopRunStart.tsx` - start 节点 UI。参考 `NodeLoopStart.tsx`,但输出字段按父节点 `loopRunMode` 动态显示(array: currentIndex+currentItem; conditional: currentIteration) +- `NodeLoopRunBreak.tsx` - break 信号节点 UI,极简卡片,只有 target handle 和图标文字 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx`** +- 节点类型映射新增 3 条 dynamic import + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts`** +- 适配:`nestedInputArray` 读取改为可选(conditional 模式无该 input 时跳过 valueType 推断) +- 或者:新增参数 `arrayInputKey?: NodeInputKeyEnum`,允许 loopRun 传 `loopRunInputArray`;parallelRun 走默认 `nestedInputArray` + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx`** +- 若有节点复制/删除禁用白名单,加入 loopRun / loopRunStart / loopRunBreak + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useKeyboard.tsx`** +- 禁止跨容器 copy/paste 的规则补 loopRun + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx`** +- loopRun 子流程内禁止添加:loop / loopRun / parallelRun(**允许 interactive**,区别于 parallelRun) +- 拖入 loopRun 时自动创建 `loopRunStart` 子节点 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/NodeTemplatesPopover.tsx`** +- 无需改动(模板列表从 systemNodes 派生) + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx`** +- loopRun 容器节点 `menuForbid={{ copy: true }}`(与 parallelRun 一致) + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx`** +- 确认 loopRunStart 的动态 outputs(currentIndex/currentItem/currentIteration)可以被子流程内其他节点正常引用 + +**`projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowComputeContext.tsx`** +- 无需改动(嵌套容器大小计算通用) + +**`projects/app/src/pageComponents/core/chat/components/WholeResponseModal.tsx`** +- 若需展示 loopRun 每轮详情(`loopRunDetail`),参考 parallelRun 的展示逻辑追加 + +**`projects/app/src/web/core/workflow/utils.ts`** +- 类型检查/图标映射工具函数补 loopRun + +### 2.4 图标 & i18n + +**`packages/web/components/common/Icon/`** +- `constants.ts` 注册 3 个图标:`core/workflow/template/loopRun` / `loopRunStart` / `loopRunBreak` +- `icons/core/workflow/template/loopRun.svg` / `loopRunLinear.tsx`(新建;linear 可先拷贝 parallelRunLinear 做 placeholder,后续设计出图再替换) +- 其他两个小节点如果视觉上复用现有 loop start/break 图标可跳过 + +**`packages/web/i18n/{zh-CN,en,zh-Hant}/workflow.json`** +- 新增 key: + - `loop_run` / `intro_loop_run` / `loop_run_execution_logic` + - `loop_run_mode` / `loop_run_mode_array` / `loop_run_mode_conditional` + - `loop_run_input_array` + - `loop_custom_outputs` / `loop_custom_outputs_tip` + - `loop_iterations` / `loop_history` + - `loop_run_break` / `loop_run_break_tip` + - `loop_run_start` / `current_index` / `current_item` / `current_iteration` + - `loop_run_mode_switch_warning`(模式切换警告) + - `loop_run_interactive_not_supported_in_xxx`(如有错误文案需要) +- `common.json` 若 parallelRun 在 common 里加了什么(如 limit 提示),loopRun 酌情加 + +### 2.5 配置 & 系统 + +**`packages/global/common/system/types/index.ts`** 和 **`projects/app/src/service/common/system/index.ts`** +- 若 loopRun 无需前端限制(比如没有 concurrency max),**无需改动** +- 否则参考 parallelRun 在 `FastGPTFeConfigsType.limit` 增加字段 + +**`projects/app/.env.template`** +- 无需新增 + +### 2.6 测试 + +**`test/cases/packages/service/core/workflow/dispatch/loopRun/service.test.ts`**(新建) +- `readCustomOutputSnapshot`: + - finishedNodeIds 为 undefined(成功轮)→ 全字段有值 + - finishedNodeIds 只包含部分节点 → 未包含节点的 ref 返回 undefined + - ref 目标节点不存在 → undefined +- `extractFinishedNodeIds`:从 flowResponses 正确推导 nodeId 集合 +- `injectLoopRunStart`:array 模式注入 index+item,conditional 模式注入 iteration + +**`test/cases/packages/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts`**(新建) +- array 模式数组取尽正常返回 +- array 模式中途节点出错 → loopHistory 最后一项 success:false,快照按已完成节点过滤 +- conditional 模式 loopRunBreak 命中 → 正常返回 +- conditional 模式超过 `WORKFLOW_MAX_LOOP_TIMES` → 系统兜底报错 +- interactive 响应 → 返回 loopInteractive,下次进入从中断轮次续跑 +- catchError=true 出错 → 走 errorText +- catchError=false 出错 → 直接抛 + +--- + +## 3. TODO(按依赖排序) + +### Phase 1 - 类型 & 枚举(无依赖) +- [ ] T1.1 `FlowNodeTypeEnum` 新增 3 个成员 + `isNestedParentNodeType` 更新 +- [ ] T1.2 `NodeInputKeyEnum` / `NodeOutputKeyEnum` 新增所需 key +- [ ] T1.3 `DispatchNodeResponseType` 扩展 loopRun 相关字段 +- [ ] T1.4 新增 `Input_Template_LoopCustomOutputs`(若需要) + +### Phase 2 - 节点模板(依赖 Phase 1) +- [ ] T2.1 `template/system/loopRun/loopRun.ts` +- [ ] T2.2 `template/system/loopRun/loopRunStart.ts` +- [ ] T2.3 `template/system/loopRun/loopRunBreak.ts` +- [ ] T2.4 `template/constants.ts` 注册 3 个模板到 `systemNodes` + +### Phase 3 - 后端 Dispatcher(依赖 Phase 2) +- [ ] T3.1 `dispatch/loopRun/service.ts` - `injectLoopRunStart` / `readCustomOutputSnapshot` / `extractFinishedNodeIds` +- [ ] T3.2 `dispatch/loopRun/runLoopRunStart.ts` +- [ ] T3.3 `dispatch/loopRun/runLoopRunBreak.ts` +- [ ] T3.4 `dispatch/loopRun/runLoopRun.ts` - 主循环(array 模式) +- [ ] T3.5 `dispatch/loopRun/runLoopRun.ts` - 补 conditional 模式 + loopRunBreak 判定 +- [ ] T3.6 `dispatch/loopRun/runLoopRun.ts` - 补 interactive 暂停恢复 +- [ ] T3.7 `dispatch/loopRun/runLoopRun.ts` - 补 catchError +- [ ] T3.8 `dispatch/index.ts` 注册 3 个回调 +- [ ] T3.9 局部测试:`test/cases/packages/service/core/workflow/dispatch/loopRun/service.test.ts` +- [ ] T3.10 局部测试:`test/cases/packages/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts` + +### Phase 4 - 前端节点组件(可与 Phase 3 并行) +- [ ] T4.1 图标资源(SVG + Linear TSX)+ `Icon/constants.ts` 注册 +- [ ] T4.2 i18n key 三语言 +- [ ] T4.3 `NodeLoopRunBreak.tsx`(最简单) +- [ ] T4.4 `NodeLoopRunStart.tsx`(按 mode 动态 outputs) +- [ ] T4.5 `useNestedNode` hook 适配(支持 conditional 模式无 nestedInputArray) +- [ ] T4.6 `NodeLoopRun.tsx`(含 mode select、custom outputs 声明 UI) +- [ ] T4.7 `Flow/index.tsx` 节点类型映射 +- [ ] T4.8 `NodeTemplates/list.tsx` 拖入时自动创建 loopRunStart;子流程内禁止添加循环/并行容器(允许 interactive) +- [ ] T4.9 `useKeyboard.tsx` / `useWorkflow.tsx` 复制/删除规则 +- [ ] T4.10 `WholeResponseModal.tsx` 每轮详情展示 + +### Phase 5 - 校验 & 集成 +- [ ] T5.1 保存时校验:conditional 模式至少 1 个 loopRunBreak(编辑器层) +- [ ] T5.2 运行时 dispatch 预检查:数组长度上限、嵌套规则兜底 +- [ ] T5.3 模式切换 UI 提示(ref 失效 toast) + +### Phase 6 - 全量验证 +- [ ] T6.1 `pnpm test` 全量跑 +- [ ] T6.2 `pnpm lint` +- [ ] T6.3 前端手工验证:拖节点/切模式/数组模式跑通/条件模式跑通/break 生效/interactive 暂停恢复/catchError=true/false + +--- + +## 4. 开放问题(开发中可能需要与用户对齐) + +1. **`useNestedNode` 是否重构为支持可选 arrayInputKey?** 当前只读 `nestedInputArray`,loopRun 用独立 key `loopRunInputArray`。两种方案: + - A:hook 增加 `arrayInputKey` 参数 + - B:在 loopRun 组件里不用 hook,单独写大小同步逻辑 + - 建议 A(复用度高) + +2. **自定义输出声明 UI 复用策略**:代码节点(`sandbox`)的 `Output_Template_AddOutput` 实现在 `RenderOutput` 里。loopRun 的 custom outputs 语义是"引用子流程内节点输出,声明为 parent 节点的 output",跟代码节点不完全一样(代码节点是由代码产出)。需确认: + - 是否直接复用现有组件?还是需要新写一个"ref-based custom output" 声明器? + +3. **interactive 暂停恢复的 `customOutputs` 快照**:interactive 响应本身不是"失败",是中断。恢复时: + - 中断时的快照要不要同步记录?(若恢复后正常跑完,该轮会重新读快照覆盖) + - 建议:不在 interactive 时写 loopHistory,恢复后按正常轮处理 + +4. **`loopRunBreak` 是否放在 loop/parallelRun 外部但误连到 loopRun 子流程内**:静态校验层面需要严格卡住父容器归属 + +5. **`FlowNodeTypeEnum.nestedEnd`(旧 loopEnd)与 loopRun**:loopRun 不使用 nestedEnd,runtime 也不应匹配到。 + +--- + +## 5. 实现期间引入的跨模块改动(区分于设计清单) + +这几处在 loopRun 开发过程中顺手改到了,不是 loopRun 本身的功能需求,但**对其他节点也有影响**,单独列出来避免回顾时误判是 loopRun 独占的修改。 + +### 5.1 dispatch/index.ts 错误归一化 + +**位置**:`packages/service/core/workflow/dispatch/index.ts` 在 "dispatcher 返回 `{error}` + `catchError=false`" 分支里补了一段: + +```ts +const nodeResponseBase = result[DispatchNodeResponseKeyEnum.nodeResponse]; +const errText = nodeResponseBase?.errorText ?? getErrText(result.error as any); +return { + ...result, + [DispatchNodeResponseKeyEnum.nodeResponse]: { + ...nodeResponseBase, + error: errText + }, + ... +}; +``` + +**动机**:loopRun / parallelRun 需要一种稳定方式识别失败轮。`dispatch/index.ts` 里 dispatcher throw 的 catch 兜底分支本来就在写 `nodeResponse.error`,OTel span status 也读 `nodeResponse.error`。唯独 dispatcher 返回 `{error}` + `catchError=false` 的非 throw 路径不写 `error`,导致失败检测链路断裂。 + +**方案**:给非 throw 路径补齐 `nodeResponse.error`,和 catch 兜底 + OTel 统一到同一个字段。`errorText` 保留给 UI 展示(schema 注释 `// Just show`)。 + +**顺带修复**:`parallelRun/service.ts` 早就在用 `flowResponses.find((r) => r.error)`,但同样因为这条链路没写 `error` 而始终落到 fallback 文案 `parallel_task_not_reach_end`。归一化后 parallelRun 也能拿到真实错误。OTel span error status 也终于会在这条路径上正确触发。 + +**安全性**:`DispatchNodeResponseSchema` 里 `error` 本来就是可选字段;grep 过所有消费方,没有代码因为 `.error` 现在会被填充而出问题。 + +### 5.2 WholeResponseModal 里 `updateVarResult` 单元素数组解包 + +**位置**:`projects/app/src/components/core/chat/components/WholeResponseModal.tsx` L512-L527。 + +**动机**:loopRun 调试时常见一类工作流是"loopRun 内用变量更新节点改写循环体外的代码节点输出 → 在 WholeResponseModal 里看累计结果"。`updateVarResult` 本来是 `updateList.map(...)` 的数组,单行配置时展示为 `[{...}]`,当内部 value 又是数组时会出现 `[[...]]` 的视觉双层嵌套,调试极差。 + +**行为**:长度为 1 且 `r[0] !== null && r[0] !== undefined` 时解包外层。保留两种 signal: +- 多行配置仍按数组展示(未做破坏) +- `[null]`(无效引用)仍保留外层 → 用户能看到"这里是无效引用" + +**风险面**:全体 Row 渲染规则未变,`val === undefined | '' | 'undefined'` 仍会隐藏。对非 loopRun 用户是展示改善,不是功能改动。 + +### 5.3 `useNestedNode` 子节点尺寸订阅 (`childDimensionsSignal`) + +**位置**:`projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts` L55-L65。 + +**动机**:loopRun 体积计算沿用了 loop / parallelRun 的老逻辑——`setTimeout(() => resetParentNodeSizeAndPosition(nodeId), 50)`,在快速拖入子节点时定时器会抢跑在 ReactFlow 测量宽高之前,算出的 bounds 偏小。 + +**方案**:订阅 `WorkflowInitContext.nodes` 里所有 parent 匹配的子节点 `${id}:${width}x${height}`,拼成字符串 signal。signal 变化触发重新计算 bounds。 + +**影响面**:loop / parallelRun 也在用 `useNestedNode`——同时吃到这个修复。没有看到对 loopRun 之外的回归,但建议在人工验证清单里对 loop / parallelRun 的拖拽体验回归一次。 + +### 5.4 `pushSubWorkflowUsage` / `collectResponseFeedbacks` 下沉到 `dispatch/utils.ts` + +见 `2.2 后端 Dispatcher` 一节。原本 `loop/service.ts` 里的实现被挪到 `dispatch/utils.ts`,loop / loopRun 共用。参数名 `index` → `iteration`(两者语义等价,都是 1-based 迭代计数)。这是 loopRun feature PR 的一部分去重改动,不是 bug 修复。 diff --git a/packages/global/core/chat/utils.ts b/packages/global/core/chat/utils.ts index fa9bbd15eb..ff16e1b679 100644 --- a/packages/global/core/chat/utils.ts +++ b/packages/global/core/chat/utils.ts @@ -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 || []) diff --git a/packages/global/core/workflow/constants.ts b/packages/global/core/workflow/constants.ts index 08a825b6d0..a42d811500 100644 --- a/packages/global/core/workflow/constants.ts +++ b/packages/global/core/workflow/constants.ts @@ -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', diff --git a/packages/global/core/workflow/node/constant.ts b/packages/global/core/workflow/node/constant.ts index ff0c0b7cb2..860d0ed20f 100644 --- a/packages/global/core/workflow/node/constant.ts +++ b/packages/global/core/workflow/node/constant.ts @@ -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)。 */ -export const isNestedParentNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean => - flowNodeType === FlowNodeTypeEnum.loop || flowNodeType === FlowNodeTypeEnum.parallelRun; +/** 嵌套父容器节点类型集合(loop / parallelRun / loopRun)。 */ +export const NESTED_PARENT_NODE_TYPES: ReadonlySet = new Set([ + FlowNodeTypeEnum.loop, + FlowNodeTypeEnum.parallelRun, + FlowNodeTypeEnum.loopRun +]); -/** 交互类节点类型集合(在 parallelRun 体内禁止使用)。 */ +export const isNestedParentNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean => + NESTED_PARENT_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum); + +/** 交互类节点类型集合(在 parallelRun 体内禁止使用;loopRun 允许)。 */ export const INTERACTIVE_NODE_TYPES: ReadonlySet = 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 = new Set([ + FlowNodeTypeEnum.nestedStart, + FlowNodeTypeEnum.nestedEnd, + FlowNodeTypeEnum.loopRunStart +]); + +export const isNestedChildSystemNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean => + NESTED_CHILD_SYSTEM_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum); diff --git a/packages/global/core/workflow/runtime/type.ts b/packages/global/core/workflow/runtime/type.ts index 5f284387e8..03cc2b78da 100644 --- a/packages/global/core/workflow/runtime/type.ts +++ b/packages/global/core/workflow/runtime/type.ts @@ -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 diff --git a/packages/global/core/workflow/template/constants.ts b/packages/global/core/workflow/template/constants.ts index 70bd426a02..9332b1e00e 100644 --- a/packages/global/core/workflow/template/constants.ts +++ b/packages/global/core/workflow/template/constants.ts @@ -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 ]; diff --git a/packages/global/core/workflow/template/system/interactive/constants.ts b/packages/global/core/workflow/template/system/interactive/constants.ts index 93f48fbbba..c6dfd1846c 100644 --- a/packages/global/core/workflow/template/system/interactive/constants.ts +++ b/packages/global/core/workflow/template/system/interactive/constants.ts @@ -4,7 +4,8 @@ export const isChildInteractive = (type: InteractiveNodeResponseType['type']) => if ( type === 'childrenInteractive' || type === 'toolChildrenInteractive' || - type === 'loopInteractive' + type === 'loopInteractive' || + type === 'loopRunInteractive' ) { return true; } diff --git a/packages/global/core/workflow/template/system/interactive/type.ts b/packages/global/core/workflow/template/system/interactive/type.ts index d0b43cf9bb..ad17ebc4f5 100644 --- a/packages/global/core/workflow/template/system/interactive/type.ts +++ b/packages/global/core/workflow/template/system/interactive/type.ts @@ -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 diff --git a/packages/global/core/workflow/template/system/loopRun/loopRun.ts b/packages/global/core/workflow/template/system/loopRun/loopRun.ts new file mode 100644 index 0000000000..179b32c021 --- /dev/null +++ b/packages/global/core/workflow/template/system/loopRun/loopRun.ts @@ -0,0 +1,99 @@ +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 + } + ] +}; diff --git a/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts b/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts new file mode 100644 index 0000000000..2e2b9e8e86 --- /dev/null +++ b/packages/global/core/workflow/template/system/loopRun/loopRunBreak.ts @@ -0,0 +1,20 @@ +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: [] +}; diff --git a/packages/global/core/workflow/template/system/loopRun/loopRunStart.ts b/packages/global/core/workflow/template/system/loopRun/loopRunStart.ts new file mode 100644 index 0000000000..a4bb319293 --- /dev/null +++ b/packages/global/core/workflow/template/system/loopRun/loopRunStart.ts @@ -0,0 +1,77 @@ +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 + } + ] +}; diff --git a/packages/global/core/workflow/type/io.ts b/packages/global/core/workflow/type/io.ts index 66e3dee2a0..ca7d1b3510 100644 --- a/packages/global/core/workflow/type/io.ts +++ b/packages/global/core/workflow/type/io.ts @@ -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; @@ -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 diff --git a/packages/service/core/workflow/dispatch/constants.ts b/packages/service/core/workflow/dispatch/constants.ts index 2e14120337..c5be6ed793 100644 --- a/packages/service/core/workflow/dispatch/constants.ts +++ b/packages/service/core/workflow/dispatch/constants.ts @@ -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.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, diff --git a/packages/service/core/workflow/dispatch/index.ts b/packages/service/core/workflow/dispatch/index.ts index 3488e1ee61..4bfa64774e 100644 --- a/packages/service/core/workflow/dispatch/index.ts +++ b/packages/service/core/workflow/dispatch/index.ts @@ -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 ) diff --git a/packages/service/core/workflow/dispatch/loop/runLoop.ts b/packages/service/core/workflow/dispatch/loop/runLoop.ts index 67ef34436f..6f0142e9ef 100644 --- a/packages/service/core/workflow/dispatch/loop/runLoop.ts +++ b/packages/service/core/workflow/dispatch/loop/runLoop.ts @@ -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; @@ -98,7 +98,12 @@ export const dispatchLoop = async (props: Props): Promise => { 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); diff --git a/packages/service/core/workflow/dispatch/loop/service.ts b/packages/service/core/workflow/dispatch/loop/service.ts index cb21da0c1a..9e52be982f 100644 --- a/packages/service/core/workflow/dispatch/loop/service.ts +++ b/packages/service/core/workflow/dispatch/loop/service.ts @@ -1,63 +1,7 @@ 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; -}; diff --git a/packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts b/packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts new file mode 100644 index 0000000000..0700db797e --- /dev/null +++ b/packages/service/core/workflow/dispatch/loopRun/runLoopRun.ts @@ -0,0 +1,310 @@ +import { cloneDeep } from 'lodash'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import type { + DispatchNodeResultType, + ModuleDispatchProps +} from '@fastgpt/global/core/workflow/runtime/type'; +import type { + AIChatItemValueItemType, + ChatHistoryItemResType +} from '@fastgpt/global/core/chat/type'; +import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; +import { + rewriteNodeOutputByHistories, + storeEdges2RuntimeEdges +} from '@fastgpt/global/core/workflow/runtime/utils'; +import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; + +import { env } from '../../../../env'; +import { i18nT } from '../../../../../web/i18n/utils'; +import { runWorkflow } from '..'; +import { collectResponseFeedbacks, getNodeErrResponse, pushSubWorkflowUsage } from '../utils'; +import { + extractFinishedNodeIds, + hasLoopRunBreakChild, + injectLoopRunStart, + isLoopBreakHit, + type LoopRunHistoryItem, + pickCustomOutputInputs, + readCustomOutputSnapshot +} from './service'; + +type Props = ModuleDispatchProps<{ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum; + [NodeInputKeyEnum.loopRunInputArray]?: Array; + [NodeInputKeyEnum.childrenNodeIdList]: string[]; +}>; + +type Response = DispatchNodeResultType>; + +export const dispatchLoopRun = async (props: Props): Promise => { + const { params, runtimeNodes, runtimeEdges, node, lastInteractive } = props; + const { name } = node; + const mode = params[NodeInputKeyEnum.loopRunMode] ?? LoopRunModeEnum.array; + const childrenNodeIdList = params[NodeInputKeyEnum.childrenNodeIdList] ?? []; + const inputArray = params[NodeInputKeyEnum.loopRunInputArray] ?? []; + + const maxLength = env.WORKFLOW_MAX_LOOP_TIMES; + const maxIterationsMessage = i18nT('workflow:loop_run_max_iterations_exceeded'); + + // Surface precheck failures through `errorText` to match the max-iterations + // protocol, so `catchError` and downstream error-handle routing see them. + const preCheckError = (() => { + if (mode === LoopRunModeEnum.array && !Array.isArray(inputArray)) { + return i18nT('workflow:loop_run_input_not_array'); + } + if (mode === LoopRunModeEnum.array && inputArray.length > maxLength) { + return maxIterationsMessage; + } + // Without a break node, conditional mode can only stop at WORKFLOW_MAX_LOOP_TIMES. + if ( + mode === LoopRunModeEnum.conditional && + !hasLoopRunBreakChild(runtimeNodes, childrenNodeIdList) + ) { + return i18nT('workflow:loop_run_conditional_requires_break'); + } + return undefined; + })(); + + if (preCheckError) { + return getNodeErrResponse({ + error: preCheckError, + responseData: { + mergeSignId: node.nodeId, + ...(mode === LoopRunModeEnum.array ? { loopRunInput: inputArray } : {}) + } + }); + } + + // Isolate from parent so concurrent siblings don't mutate our view. + let isolatedNodes = cloneDeep(runtimeNodes); + const isolatedEdges = cloneDeep(runtimeEdges); + + const customOutputInputs = pickCustomOutputInputs(node.inputs, node.outputs); + + let interactiveData = + lastInteractive?.type === 'loopRunInteractive' ? lastInteractive.params : undefined; + + // On resume, the inner loop-body outputs (e.g. loopRunStart.currentIteration) were + // captured into childrenResponse.nodeOutputs by the inner handleInteractiveResult. + // The top-level restore in chat/completions only reads the outer nodeOutputs, which + // doesn't cover the loop body — apply the inner snapshot here so downstream refs + // in the resumed iteration resolve correctly. + if (interactiveData?.childrenResponse) { + isolatedNodes = rewriteNodeOutputByHistories(isolatedNodes, interactiveData.childrenResponse); + } + + const loopHistory: LoopRunHistoryItem[] = interactiveData + ? (interactiveData.loopHistory as LoopRunHistoryItem[]) ?? [] + : []; + const loopResponseDetail: ChatHistoryItemResType[] = []; + const assistantResponses: AIChatItemValueItemType[] = []; + const customFeedbacks: string[] = []; + let totalPoints = 0; + let newVariables: Record = props.variables; + let interactiveResponse: WorkflowInteractiveResponseType | undefined; + // Pre-interrupt children of the in-flight iteration survive across resume here, + // so pushIterationDetail can stitch them back with the resumed iteration's + // flowResponses (also handles multiple interrupts in the same iteration). + let pendingIterationResponses: ChatHistoryItemResType[] = + interactiveData?.pendingIterationResponses ?? []; + + const resumeIteration = interactiveData?.iteration; + let iteration = resumeIteration ?? 1; + // Hit the iteration budget (primarily a conditional-mode guard). Signal via + // `error` on return so the accumulated loopHistory/loopDetail survives for + // user debugging, instead of reject'ing and dropping everything. + let maxIterationsExceeded = false; + + while (true) { + // Check exhaustion before maxLength so `inputArray.length === maxLength` runs cleanly. + const arrayItem = (() => { + if (mode !== LoopRunModeEnum.array) { + return { exhausted: false as const, index: undefined, item: undefined }; + } + const index = iteration - 1; + if (index >= inputArray.length) return { exhausted: true as const }; + return { exhausted: false as const, index, item: inputArray[index] }; + })(); + if (arrayItem.exhausted) break; + const currentIndex = arrayItem.index; + const currentItem = arrayItem.item; + + if (iteration > maxLength) { + maxIterationsExceeded = true; + break; + } + + const isResumeIteration = !!interactiveData && iteration === resumeIteration; + + if (isResumeIteration) { + isolatedNodes.forEach((n) => { + if (interactiveData?.childrenResponse?.entryNodeIds.includes(n.nodeId)) { + n.isEntry = true; + } + }); + } else { + injectLoopRunStart({ + nodes: isolatedNodes, + childrenNodeIdList, + mode, + item: currentItem, + index: currentIndex, + iteration + }); + } + + const response = await runWorkflow({ + ...props, + lastInteractive: interactiveData?.childrenResponse, + variables: newVariables, + runtimeNodes: isolatedNodes, + runtimeEdges: cloneDeep( + storeEdges2RuntimeEdges(isolatedEdges, interactiveData?.childrenResponse) + ) + }); + + // Merge pre-interrupt children into the detail tree so the resumed iteration + // shows the full picture (wall-clock and children both span the whole iteration). + // pushSubWorkflowUsage still uses `response` only — pre-interrupt usage was + // billed in the interrupted request. + const iterationChildrenResponses = isResumeIteration + ? [...pendingIterationResponses, ...response.flowResponses] + : response.flowResponses; + const iterationRunningTime = iterationChildrenResponses.reduce( + (acc, r) => acc + (typeof r.runningTime === 'number' ? r.runningTime : 0), + 0 + ); + assistantResponses.push(...response.assistantResponses); + const iterationTotalPoints = pushSubWorkflowUsage({ + usagePush: props.usagePush, + response, + name, + iteration + }); + totalPoints += iterationTotalPoints; + collectResponseFeedbacks(response, customFeedbacks); + newVariables = { ...newVariables, ...response.newVariables }; + + // Pause: stash accumulated children so the next resume still sees pre-interrupt + // nodes (supports multiple interrupts in the same iteration). + if (response.workflowInteractiveResponse) { + interactiveResponse = response.workflowInteractiveResponse; + pendingIterationResponses = iterationChildrenResponses; + break; + } + + // Apply `finishedNodeIds` over the merged children so pre-interrupt nodes count + // as finished for the customOutputs snapshot. + const finishedNodeIds = extractFinishedNodeIds(iterationChildrenResponses); + const customOutputs = readCustomOutputSnapshot({ + customOutputInputs, + runtimeNodes: isolatedNodes, + variables: newVariables, + finishedNodeIds, + childrenNodeIdList + }); + + // Wrap this iteration as a virtual task node so the whole-response tree + // shows a per-iteration layer (mirrors parallelRun's aggregation). + const pushIterationDetail = (opts: { error?: string }) => { + const wrapper: ChatHistoryItemResType = { + id: `${node.nodeId}_iter_${iteration}`, + nodeId: `${node.nodeId}_iter_${iteration}`, + moduleType: FlowNodeTypeEnum.loopRun, + moduleName: i18nT('workflow:parallel_task'), + moduleNameArgs: { index: iteration }, + runningTime: Math.round(iterationRunningTime * 100) / 100, + totalPoints: iterationTotalPoints, + loopInputValue: mode === LoopRunModeEnum.array ? currentItem : undefined, + loopOutputValue: customOutputs, + error: opts.error, + childrenResponses: iterationChildrenResponses + }; + loopResponseDetail.push(wrapper); + }; + + const errorItem = response.flowResponses.find((r) => r.error); + if (errorItem) { + const errText = getErrText(errorItem.error); + pushIterationDetail({ error: errText }); + loopHistory.push({ + iteration, + customOutputs, + success: false, + error: errText + }); + break; + } + + pushIterationDetail({}); + loopHistory.push({ iteration, customOutputs, success: true }); + + if (isLoopBreakHit(response.flowResponses)) break; + + // Resume state is one-shot; clear so subsequent iterations enter clean. + // injectLoopRunStart only re-sets loopRunStart, so explicitly drop stale + // isEntry flags the resume branch set on other children (e.g. formInput). + if (isResumeIteration) { + isolatedNodes.forEach((n) => { + if (n.flowNodeType !== FlowNodeTypeEnum.loopRunStart) { + n.isEntry = false; + } + }); + } + interactiveData = undefined; + pendingIterationResponses = []; + + iteration++; + } + + const lastEntry = loopHistory[loopHistory.length - 1]; + const lastSnapshot: Record = lastEntry?.customOutputs ?? {}; + const lastFailed = !!lastEntry && lastEntry.success === false; + + const data: Record = { + ...lastSnapshot + }; + + const errorText = maxIterationsExceeded + ? maxIterationsMessage + : lastFailed + ? lastEntry?.error ?? i18nT('workflow:loop_run_iteration_failed') + : undefined; + + return { + data, + [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, + [DispatchNodeResponseKeyEnum.interactive]: interactiveResponse + ? { + type: 'loopRunInteractive', + params: { + loopHistory, + childrenResponse: interactiveResponse, + iteration, + pendingIterationResponses + } + } + : undefined, + [DispatchNodeResponseKeyEnum.newVariables]: newVariables, + [DispatchNodeResponseKeyEnum.nodeResponse]: { + totalPoints, + loopRunInput: mode === LoopRunModeEnum.array ? inputArray : undefined, + loopRunIterations: loopHistory.length, + loopRunHistory: loopHistory, + loopRunDetail: loopResponseDetail, + mergeSignId: node.nodeId, + ...(errorText ? { errorText } : {}) + }, + [DispatchNodeResponseKeyEnum.customFeedbacks]: + customFeedbacks.length > 0 ? customFeedbacks : undefined, + ...(errorText + ? { + error: { [NodeOutputKeyEnum.errorText]: errorText } + } + : {}) + }; +}; diff --git a/packages/service/core/workflow/dispatch/loopRun/runLoopRunBreak.ts b/packages/service/core/workflow/dispatch/loopRun/runLoopRunBreak.ts new file mode 100644 index 0000000000..ce1b87627e --- /dev/null +++ b/packages/service/core/workflow/dispatch/loopRun/runLoopRunBreak.ts @@ -0,0 +1,17 @@ +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { + type DispatchNodeResultType, + type ModuleDispatchProps +} from '@fastgpt/global/core/workflow/runtime/type'; + +type Props = ModuleDispatchProps>; +type Response = DispatchNodeResultType>; + +// 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 => { + return { + data: {}, + [DispatchNodeResponseKeyEnum.nodeResponse]: {} + }; +}; diff --git a/packages/service/core/workflow/dispatch/loopRun/runLoopRunStart.ts b/packages/service/core/workflow/dispatch/loopRun/runLoopRunStart.ts new file mode 100644 index 0000000000..96f160497e --- /dev/null +++ b/packages/service/core/workflow/dispatch/loopRun/runLoopRunStart.ts @@ -0,0 +1,41 @@ +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 => { + const { params } = props; + const mode = params[NodeInputKeyEnum.loopRunMode]; + const rawIndex = params[NodeInputKeyEnum.nestedStartIndex]; + const item = params[NodeInputKeyEnum.nestedStartInput]; + + const data: Record = {}; + 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 + } + }; +}; diff --git a/packages/service/core/workflow/dispatch/loopRun/service.ts b/packages/service/core/workflow/dispatch/loopRun/service.ts new file mode 100644 index 0000000000..b027298082 --- /dev/null +++ b/packages/service/core/workflow/dispatch/loopRun/service.ts @@ -0,0 +1,156 @@ +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; + 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 => { + const ids = new Set(); + 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; + finishedNodeIds?: Set; + childrenNodeIdList?: string[]; +}): Record => { + const nodesMap = new Map(runtimeNodes.map((n) => [n.nodeId, n])); + const childrenSet = childrenNodeIdList ? new Set(childrenNodeIdList) : undefined; + const snapshot: Record = {}; + + 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 + ); +}; diff --git a/packages/service/core/workflow/dispatch/parallelRun/runParallelRun.ts b/packages/service/core/workflow/dispatch/parallelRun/runParallelRun.ts index 52f54ebdd9..6585a9b137 100644 --- a/packages/service/core/workflow/dispatch/parallelRun/runParallelRun.ts +++ b/packages/service/core/workflow/dispatch/parallelRun/runParallelRun.ts @@ -19,7 +19,7 @@ import { aggregateParallelResults, type ParallelFullResultItem } from './service'; -import { safePoints } from '../utils'; +import { pushSubWorkflowUsage } from '../utils'; type Props = ModuleDispatchProps<{ [NodeInputKeyEnum.nestedInputArray]: Array; @@ -87,12 +87,12 @@ export const dispatchParallelRun = async (props: Props): Promise => { }); // 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 }; diff --git a/packages/service/core/workflow/dispatch/parallelRun/service.ts b/packages/service/core/workflow/dispatch/parallelRun/service.ts index 5e99aabb05..57722df5bc 100644 --- a/packages/service/core/workflow/dispatch/parallelRun/service.ts +++ b/packages/service/core/workflow/dispatch/parallelRun/service.ts @@ -1,7 +1,7 @@ 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); } } diff --git a/packages/service/core/workflow/dispatch/utils.ts b/packages/service/core/workflow/dispatch/utils.ts index 8f817b768a..b6f9332210 100644 --- a/packages/service/core/workflow/dispatch/utils.ts +++ b/packages/service/core/workflow/dispatch/utils.ts @@ -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, diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 7c9affb265..edef49626b 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -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'), diff --git a/packages/web/components/common/Icon/icons/core/workflow/inputType/conditional.svg b/packages/web/components/common/Icon/icons/core/workflow/inputType/conditional.svg new file mode 100644 index 0000000000..ccbb96db54 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/inputType/conditional.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/loopRun.svg b/packages/web/components/common/Icon/icons/core/workflow/template/loopRun.svg new file mode 100644 index 0000000000..6ba04d4709 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/loopRun.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreak.svg b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreak.svg new file mode 100644 index 0000000000..a5eae85196 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreak.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreakLinear.tsx b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreakLinear.tsx new file mode 100644 index 0000000000..f6e16dc7a6 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunBreakLinear.tsx @@ -0,0 +1,59 @@ +import React, { useId } from 'react'; + +type LoopRunBreakLinearProps = React.SVGProps; + +const LoopRunBreakLinear: React.FC = (props) => { + const gradientId = useId(); + + return ( + + + + + + + + + + + + + + + ); +}; + +export default LoopRunBreakLinear; diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/loopRunLinear.tsx b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunLinear.tsx new file mode 100644 index 0000000000..3c2478c9f5 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunLinear.tsx @@ -0,0 +1,36 @@ +import React, { useId } from 'react'; + +type LoopRunLinearProps = React.SVGProps; + +const LoopRunLinear: React.FC = (props) => { + const gradientId = useId(); + + return ( + + + + + + + + + + + + ); +}; + +export default LoopRunLinear; diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/loopRunStart.svg b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunStart.svg new file mode 100644 index 0000000000..ca50f34b92 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunStart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/web/components/common/Icon/icons/core/workflow/template/loopRunStartLinear.tsx b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunStartLinear.tsx new file mode 100644 index 0000000000..50be454d54 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/template/loopRunStartLinear.tsx @@ -0,0 +1,41 @@ +import React, { useId } from 'react'; + +type LoopRunStartLinearProps = React.SVGProps; + +const LoopRunStartLinear: React.FC = (props) => { + const gradientId = useId(); + + return ( + + + + + + + + + + ); +}; + +export default LoopRunStartLinear; diff --git a/packages/web/components/common/MySelect/index.tsx b/packages/web/components/common/MySelect/index.tsx index 619e8ce758..366088e3e6 100644 --- a/packages/web/components/common/MySelect/index.tsx +++ b/packages/web/components/common/MySelect/index.tsx @@ -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 = Omit & { ScrollData?: ReturnType['ScrollData']; customOnOpen?: () => void; customOnClose?: () => void; + menuPlacement?: MenuProps['placement']; isInvalid?: boolean; isDisabled?: boolean; @@ -86,6 +87,7 @@ const MySelect = ( ScrollData, customOnOpen, customOnClose, + menuPlacement, isInvalid, isDisabled, ...props @@ -205,6 +207,7 @@ const MySelect = ( onOpen={onOpen} onClose={onClose} strategy={'fixed'} + placement={menuPlacement} // matchWidth > { } 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 ; + 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 ; } if (inputType === InputTypeEnum.multipleSelect) { diff --git a/projects/app/src/components/core/app/formRender/type.ts b/projects/app/src/components/core/app/formRender/type.ts index 0ffcdcfed4..632c7e45d6 100644 --- a/projects/app/src/components/core/app/formRender/type.ts +++ b/projects/app/src/components/core/app/formRender/type.ts @@ -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[]; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx index f2a7286952..72a02b802c 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx @@ -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)), diff --git a/projects/app/src/components/core/chat/components/WholeResponseModal.tsx b/projects/app/src/components/core/chat/components/WholeResponseModal.tsx index 9290d9015e..4cf7b40de0 100644 --- a/projects/app/src/components/core/chat/components/WholeResponseModal.tsx +++ b/projects/app/src/components/core/chat/components/WholeResponseModal.tsx @@ -127,7 +127,7 @@ export const WholeResponseContent = ({ border: '1px solid', borderColor: 'myGray.200', color: 'myGray.900', - bg: '#F7F8FA' + bg: 'myGray.50' })} > )} - - + {/* 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. */} { + 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 */} + + + + {/* loopStart */} { } // 2. Exclude loop start and end nodes - if ( - [FlowNodeTypeEnum.nestedStart, FlowNodeTypeEnum.nestedEnd].includes( - node.data.flowNodeType - ) - ) { + if (isNestedChildSystemNodeType(node.data.flowNodeType)) { return false; } diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx index 8d31f31e9e..e1360a81f9 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx @@ -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(() => { diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx index 02f354a790..621726cdcd 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/useNodeTemplates.tsx @@ -23,10 +23,8 @@ export const useNodeTemplates = () => { const [parentId, setParentId] = useState(''); 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([]); 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((item) => ({ @@ -74,7 +76,7 @@ export const useNodeTemplates = () => { { manual: false, throttleWait: 100, - refreshDeps: [basicNodeTemplates, nodeAmount, hasToolNode, templateType] + refreshDeps: [basicNodeTemplates, nodeAmount, hasToolNode, hasLoopRunNode, templateType] } ); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts index f15dfa9510..4f23b0e49f 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useNestedNode.ts @@ -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; }; -/** - * 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(null); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx index 610aca7fa1..8b7872cdb5 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/hooks/useWorkflow.tsx @@ -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] diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx index d16399b84a..94a537fffe 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/index.tsx @@ -62,6 +62,9 @@ const nodeTypes: Record = { [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')), diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoop.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoop.tsx index bb805e60a5..9dfd0eb3a9 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoop.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoop.tsx @@ -39,7 +39,7 @@ const NodeLoop = ({ data, selected }: NodeProps) => { flex={1} position={'relative'} border={'base'} - bg={'myGray.50'} + bg={'myGray.100'} rounded={'8px'} {...(!isFolded && { minW: nodeWidth, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRun.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRun.tsx new file mode 100644 index 0000000000..af66aef925 --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRun.tsx @@ -0,0 +1,269 @@ +import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { type NodeProps } from 'reactflow'; +import NodeCard from '../render/NodeCard'; +import Container from '../../components/Container'; +import IOTitle from '../../components/IOTitle'; +import { useTranslation } from 'next-i18next'; +import RenderInput from '../render/RenderInput'; +import { Box } from '@chakra-ui/react'; +import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; +import RenderOutput from '../render/RenderOutput'; +import CatchError from '../render/RenderOutput/CatchError'; +import { + NodeInputKeyEnum, + NodeOutputKeyEnum, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; +import { + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; +import { LoopRunBreakNode as LoopRunBreakTemplate } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunBreak'; +import { useNestedNode } from '../../hooks/useNestedNode'; +import { useContextSelector } from 'use-context-selector'; +import { WorkflowActionsContext } from '../../../context/workflowActionsContext'; +import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext'; +import { WorkflowBufferDataContext } from '../../../context/workflowInitContext'; +import { WorkflowInitContext } from '../../../context/workflowInitContext'; +import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils'; +import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; + +const NodeLoopRun = ({ data, selected }: NodeProps) => { + const { t } = useTranslation(); + const { nodeId, inputs, outputs, isFolded, catchError } = data; + const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode); + const splitOutput = useContextSelector(WorkflowUtilsContext, (v) => v.splitOutput); + const { getNodeById, setNodes, childrenNodeIdListMap } = useContextSelector( + WorkflowBufferDataContext, + (v) => v + ); + const childNodeIds = childrenNodeIdListMap[nodeId] ?? []; + const getRawNodeById = useContextSelector(WorkflowInitContext, (v) => v.getRawNodeById); + + const mode = + (inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as + | LoopRunModeEnum + | undefined) ?? LoopRunModeEnum.array; + + // Conditional mode has no array input; skip valueType inference in the hook. + const arrayInputKey = + mode === LoopRunModeEnum.array ? NodeInputKeyEnum.loopRunInputArray : undefined; + + const { nodeWidth, nodeHeight, inputBoxRef } = useNestedNode({ nodeId, inputs, arrayInputKey }); + + const inputAreaInputs = useMemo( + () => + inputs.filter((i) => { + if (i.key === NodeInputKeyEnum.loopCustomOutputs) return false; + if (i.canEdit) return false; + if (mode !== LoopRunModeEnum.array && i.key === NodeInputKeyEnum.loopRunInputArray) { + return false; + } + return true; + }), + [inputs, mode] + ); + + const outputDeclarationInputs = useMemo( + () => inputs.filter((i) => i.key === NodeInputKeyEnum.loopCustomOutputs || !!i.canEdit), + [inputs] + ); + + const { successOutputs, errorOutputs } = useMemoEnhance( + () => splitOutput(outputs), + [splitOutput, outputs] + ); + + // Mode sync is owned by the container, not the start node, because the start + // node doesn't re-render reliably when the parent's mode input changes. + const prevModeRef = useRef(mode); + useEffect(() => { + const prevMode = prevModeRef.current; + prevModeRef.current = mode; + + const startChildId = childNodeIds.find( + (id) => getNodeById(id)?.flowNodeType === FlowNodeTypeEnum.loopRunStart + ); + const startNode = startChildId ? getNodeById(startChildId) : undefined; + + if (startNode) { + const hasIndex = startNode.outputs.some((o) => o.key === NodeOutputKeyEnum.currentIndex); + const hasItem = startNode.outputs.some((o) => o.key === NodeOutputKeyEnum.currentItem); + const hasIteration = startNode.outputs.some( + (o) => o.key === NodeOutputKeyEnum.currentIteration + ); + + // Store i18n keys so downstream `t(label)` stays reactive. + if (mode === LoopRunModeEnum.array) { + if (hasIteration) { + onChangeNode({ + nodeId: startNode.nodeId, + type: 'delOutput', + key: NodeOutputKeyEnum.currentIteration + }); + } + if (!hasIndex) { + onChangeNode({ + nodeId: startNode.nodeId, + type: 'addOutput', + value: { + id: NodeOutputKeyEnum.currentIndex, + key: NodeOutputKeyEnum.currentIndex, + label: 'workflow:current_index', + description: 'workflow:current_index_desc', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.number + } + }); + } + if (!hasItem) { + onChangeNode({ + nodeId: startNode.nodeId, + type: 'addOutput', + value: { + id: NodeOutputKeyEnum.currentItem, + key: NodeOutputKeyEnum.currentItem, + label: 'workflow:current_item', + description: 'workflow:current_item_desc', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.any + } + }); + } + } else { + if (hasIndex) { + onChangeNode({ + nodeId: startNode.nodeId, + type: 'delOutput', + key: NodeOutputKeyEnum.currentIndex + }); + } + if (hasItem) { + onChangeNode({ + nodeId: startNode.nodeId, + type: 'delOutput', + key: NodeOutputKeyEnum.currentItem + }); + } + if (!hasIteration) { + onChangeNode({ + nodeId: startNode.nodeId, + type: 'addOutput', + value: { + id: NodeOutputKeyEnum.currentIteration, + key: NodeOutputKeyEnum.currentIteration, + label: 'workflow:current_iteration', + description: 'workflow:current_iteration_desc', + type: FlowNodeOutputTypeEnum.static, + valueType: WorkflowIOValueTypeEnum.number + } + }); + } + } + } + + // Transition-only, so a user-deleted break node isn't re-created. + if (mode === LoopRunModeEnum.conditional && prevMode !== LoopRunModeEnum.conditional) { + const hasBreak = childNodeIds.some( + (id) => getNodeById(id)?.flowNodeType === FlowNodeTypeEnum.loopRunBreak + ); + if (!hasBreak) { + const startRaw = startChildId ? getRawNodeById(startChildId) : undefined; + const position = startRaw?.position + ? { x: startRaw.position.x + 500, y: startRaw.position.y + 150 } + : { x: 500, y: 400 }; + const breakNode = nodeTemplate2FlowNode({ + template: LoopRunBreakTemplate, + position, + parentNodeId: nodeId, + t + }); + setNodes((state) => state.concat(breakNode)); + } + } + }, [mode, childNodeIds, nodeId, getNodeById, getRawNodeById, onChangeNode, setNodes, t]); + + useEffect(() => { + const declared = inputs.filter((i) => i.canEdit === true); + const currentDynamic = outputs.filter((o) => o.type === FlowNodeOutputTypeEnum.dynamic); + const declaredKeys = new Set(declared.map((i) => i.key)); + + currentDynamic.forEach((o) => { + if (!declaredKeys.has(o.key)) { + onChangeNode({ nodeId, type: 'delOutput', key: o.key }); + } + }); + + declared.forEach((input) => { + const existing = currentDynamic.find((o) => o.key === input.key); + if (!existing) { + onChangeNode({ + nodeId, + type: 'addOutput', + value: { + id: input.key, + key: input.key, + label: input.label || input.key, + type: FlowNodeOutputTypeEnum.dynamic, + valueType: input.valueType + } + }); + } else if ( + existing.valueType !== input.valueType || + existing.label !== (input.label || input.key) + ) { + onChangeNode({ + nodeId, + type: 'updateOutput', + key: input.key, + value: { + ...existing, + label: input.label || input.key, + valueType: input.valueType + } + }); + } + }); + }, [inputs, outputs, nodeId, onChangeNode]); + + return ( + + + + + + + + + <> + + {t('workflow:loop_body')} + + + + + + + + + + + + {catchError && } + + ); +}; + +export default React.memo(NodeLoopRun); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunBreak.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunBreak.tsx new file mode 100644 index 0000000000..b4f3a9de7c --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunBreak.tsx @@ -0,0 +1,21 @@ +import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import React from 'react'; +import { type NodeProps } from 'reactflow'; +import NodeCard from '../render/NodeCard'; + +const NodeLoopRunBreak = ({ data, selected }: NodeProps) => { + return ( + + ); +}; + +export default React.memo(NodeLoopRunBreak); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunStart.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunStart.tsx new file mode 100644 index 0000000000..86749e3538 --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeLoopRunStart.tsx @@ -0,0 +1,110 @@ +import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node'; +import { useTranslation } from 'next-i18next'; +import { type NodeProps } from 'reactflow'; +import NodeCard from '../render/NodeCard'; +import { useContextSelector } from 'use-context-selector'; +import { WorkflowBufferDataContext } from '../../../context/workflowInitContext'; +import { + NodeInputKeyEnum, + NodeOutputKeyEnum, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; +import { Box, Flex, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react'; +import React, { useEffect, useMemo } from 'react'; +import { FlowValueTypeMap } from '@fastgpt/global/core/workflow/node/constant'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { WorkflowActionsContext } from '../../../context/workflowActionsContext'; +import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; + +const arrayItemTypeMap: Partial> = { + [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) => { + 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 ( + + + + + + + + + + + + + {outputs.map((output) => ( + + + {output.valueType && } + + ))} + +
{t('workflow:Variable_name')}{t('common:core.workflow.Value type')}
+ + + {t(output.label as any)} + + {FlowValueTypeMap[output.valueType]?.label}
+
+
+
+
+ ); +}; + +export default React.memo(NodeLoopRunStart); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeParallelRun.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeParallelRun.tsx index 8bef7aebbc..efeea9a57d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeParallelRun.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/Loop/NodeParallelRun.tsx @@ -56,7 +56,7 @@ const NodeParallelRun = ({ data, selected }: NodeProps) => { flex={1} position={'relative'} border={'base'} - bg={'myGray.50'} + bg={'myGray.100'} rounded={'8px'} {...(!isFolded && { minW: nodeWidth, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx index 43b6ebd1af..c45000ceef 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/NodeCard.tsx @@ -327,7 +327,7 @@ const NodeCard = (props: Props) => { {foldedOverlay} {!isFolded && ( - + {/* Header */} {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 ]); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx index 8c28e05c85..499f33ba5b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/CommonInputForm.tsx @@ -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} /> ); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx index 55d271edff..ffd73c60bb 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/DynamicInputs/index.tsx @@ -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 ( - + - {item.label || t('workflow:custom_input')} + {item.label ? t(item.label as any) : t('workflow:custom_input')} {item.description && } {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( diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx index 933f085d34..eb2d0aaf7b 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Reference.tsx @@ -61,15 +61,21 @@ type SelectProps = 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 diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowComputeContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowComputeContext.tsx index 7652e90ff9..15b4dbf3da 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowComputeContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowComputeContext.tsx @@ -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 = diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx index a18a75a7fd..b2ca99124d 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowInitContext.tsx @@ -59,6 +59,7 @@ export type WorkflowDataContextType = { systemConfigNode: StoreNodeItemType | undefined; allNodeFolded: boolean; hasToolNode: boolean; + hasLoopRunNode: boolean; toolNodesMap: Record; nodeIds: string[]; nodeAmount: number; @@ -84,6 +85,7 @@ export const WorkflowBufferDataContext = createContext( 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, diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 256d2db2ee..0a40575f02 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -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; }): 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 diff --git a/projects/app/test/web/core/app/workflow/utils.test.ts b/projects/app/test/web/core/app/workflow/utils.test.ts index 5760b05c70..85d667c1f0 100644 --- a/projects/app/test/web/core/app/workflow/utils.test.ts +++ b/projects/app/test/web/core/app/workflow/utils.test.ts @@ -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 => ({ + 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 => ({ + id, + type: flowNodeType, + data: { + nodeId: id, + flowNodeType, + inputs: [], + outputs: [] + } as any, + position: { x: 0, y: 0 } + }); + + const workflowStart: Node = { + 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(); + }); + }); }); diff --git a/test/cases/global/core/chat/chatUtils.test.ts b/test/cases/global/core/chat/chatUtils.test.ts index a91810f925..ac2eaa5f88 100644 --- a/test/cases/global/core/chat/chatUtils.test.ts +++ b/test/cases/global/core/chat/chatUtils.test.ts @@ -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', () => { diff --git a/test/cases/service/core/chat/saveChat.test.ts b/test/cases/service/core/chat/saveChat.test.ts index 145fe303e5..7a6bcd3eed 100644 --- a/test/cases/service/core/chat/saveChat.test.ts +++ b/test/cases/service/core/chat/saveChat.test.ts @@ -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', () => { diff --git a/test/cases/service/core/workflow/dispatch/loop/service.test.ts b/test/cases/service/core/workflow/dispatch/loop/service.test.ts index 4253963c02..060323d66c 100644 --- a/test/cases/service/core/workflow/dispatch/loop/service.test.ts +++ b/test/cases/service/core/workflow/dispatch/loop/service.test.ts @@ -1,10 +1,10 @@ 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' }]); }); }); diff --git a/test/cases/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts b/test/cases/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts new file mode 100644 index 0000000000..3a7d29f37a --- /dev/null +++ b/test/cases/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts @@ -0,0 +1,799 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; +import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; +import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; +import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; +import type { DispatchFlowResponse } from '@fastgpt/service/core/workflow/dispatch/type'; + +const runWorkflowMock = vi.fn(); + +vi.mock('@fastgpt/service/core/workflow/dispatch', () => ({ + runWorkflow: (args: any) => runWorkflowMock(args) +})); + +// Shrink max iterations so overflow tests run fast. +vi.mock('@fastgpt/service/env', () => ({ + env: { WORKFLOW_MAX_LOOP_TIMES: 5 } +})); + +// Import after mocks so runLoopRun pulls the mocked modules. +import { dispatchLoopRun } from '@fastgpt/service/core/workflow/dispatch/loopRun/runLoopRun'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const makeInput = ( + override: Partial & { key: string } +): FlowNodeInputItemType => + ({ + renderTypeList: [], + valueType: 'any' as any, + label: '', + ...override + }) as FlowNodeInputItemType; + +const makeLoopRunNode = ( + customOutputs: { key: string; ref: [string, string]; valueType?: string }[] = [] +): RuntimeNodeItemType => ({ + nodeId: 'loopRun1', + name: 'LoopRun', + avatar: '', + flowNodeType: FlowNodeTypeEnum.loopRun, + showStatus: true, + isEntry: true, + catchError: false, + inputs: [ + makeInput({ key: NodeInputKeyEnum.loopRunMode, value: LoopRunModeEnum.array }), + makeInput({ key: NodeInputKeyEnum.loopRunInputArray, value: [] }), + makeInput({ key: NodeInputKeyEnum.childrenNodeIdList, value: ['startNode', 'chatNode'] }), + ...customOutputs.map((c) => + makeInput({ + key: c.key, + canEdit: true, + value: c.ref, + valueType: (c.valueType ?? 'string') as any + }) + ) + ], + outputs: customOutputs.map((c) => ({ + id: c.key, + key: c.key, + label: c.key, + type: FlowNodeOutputTypeEnum.dynamic, + valueType: (c.valueType ?? 'string') as any + })) +}); + +const makeRuntimeNodes = ( + childNodeValue?: any, + opts: { withBreak?: boolean } = {} +): RuntimeNodeItemType[] => { + const nodes: RuntimeNodeItemType[] = [ + makeLoopRunNode(), + { + nodeId: 'startNode', + name: 'LoopRunStart', + avatar: '', + flowNodeType: FlowNodeTypeEnum.loopRunStart, + showStatus: false, + isEntry: false, + inputs: [ + makeInput({ key: NodeInputKeyEnum.loopRunMode, value: LoopRunModeEnum.array }), + makeInput({ key: NodeInputKeyEnum.nestedStartInput, value: undefined }), + makeInput({ key: NodeInputKeyEnum.nestedStartIndex, value: undefined }) + ], + outputs: [] + }, + { + nodeId: 'chatNode', + name: 'Chat', + avatar: '', + flowNodeType: FlowNodeTypeEnum.chatNode, + showStatus: true, + isEntry: false, + inputs: [], + outputs: [ + { + id: 'answer', + key: 'answer', + label: '', + type: 'static' as any, + valueType: 'string' as any, + value: childNodeValue + } + ] + } + ]; + if (opts.withBreak) { + nodes.push({ + nodeId: 'breakNode', + name: 'LoopRunBreak', + avatar: '', + flowNodeType: FlowNodeTypeEnum.loopRunBreak, + showStatus: false, + isEntry: false, + inputs: [], + outputs: [] + }); + } + return nodes; +}; + +const makeDispatchFlowResponse = ( + overrides: Partial = {} +): DispatchFlowResponse => + ({ + flowResponses: [], + flowUsages: [], + debugResponse: { memoryEdges: [], memoryNodes: [], entryNodeIds: [], nodeResponses: {} }, + workflowInteractiveResponse: undefined, + [DispatchNodeResponseKeyEnum.toolResponses]: null, + [DispatchNodeResponseKeyEnum.assistantResponses]: [], + [DispatchNodeResponseKeyEnum.runTimes]: 1, + [DispatchNodeResponseKeyEnum.newVariables]: {}, + durationSeconds: 0, + ...overrides + }) as DispatchFlowResponse; + +const makeResponseItem = (nodeId: string, override: Partial = {}) => + ({ + nodeId, + moduleType: FlowNodeTypeEnum.chatNode, + moduleName: nodeId, + ...override + }) as ChatHistoryItemResType; + +const makeProps = ( + params: any, + opts: { withBreak?: boolean; childrenNodeIdList?: string[] } = {} +) => { + const runtimeNodes = makeRuntimeNodes('from-chat', { withBreak: opts.withBreak }); + const node = runtimeNodes[0]; + // Keep childrenNodeIdList in sync with whether a break node exists + const defaultChildren = opts.withBreak + ? ['startNode', 'chatNode', 'breakNode'] + : ['startNode', 'chatNode']; + const finalParams = { + ...params, + [NodeInputKeyEnum.childrenNodeIdList]: + opts.childrenNodeIdList ?? params[NodeInputKeyEnum.childrenNodeIdList] ?? defaultChildren + }; + return { + params: finalParams, + node, + runtimeNodes, + runtimeNodesMap: new Map(runtimeNodes.map((n) => [n.nodeId, n])), + runtimeEdges: [], + variables: {}, + usagePush: vi.fn(), + lastInteractive: undefined + } as any; +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('runLoopRun (integration with mocked runWorkflow)', () => { + beforeEach(() => { + runWorkflowMock.mockReset(); + }); + + it('array mode 数组正常跑完 → loopHistory 全 success, data 含最后一轮快照', async () => { + // Each iteration returns a clean response that writes a new value to chatNode + runWorkflowMock.mockImplementation((args: any) => { + // Simulate chatNode producing an output for this iteration + const chatNode = args.runtimeNodes.find((n: any) => n.nodeId === 'chatNode'); + if (chatNode) + chatNode.outputs[0].value = `v-${ + args.runtimeNodes + .find((n: any) => n.nodeId === 'startNode') + ?.inputs.find((i: any) => i.key === NodeInputKeyEnum.nestedStartInput)?.value + }`; + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ); + }); + + const customOutputs = [{ key: 'answer', ref: ['chatNode', 'answer'] as [string, string] }]; + const runtimeNodes = makeRuntimeNodes(); + const node = makeLoopRunNode(customOutputs); + runtimeNodes[0] = node; + const props = { + params: { + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }, + node, + runtimeNodes, + runtimeNodesMap: new Map(runtimeNodes.map((n) => [n.nodeId, n])), + runtimeEdges: [], + variables: {}, + usagePush: vi.fn(), + lastInteractive: undefined + } as any; + + const result: any = await dispatchLoopRun(props); + + expect(runWorkflowMock).toHaveBeenCalledTimes(3); + const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse]; + expect(nodeResponse.loopRunIterations).toBe(3); + expect(nodeResponse.loopRunHistory).toHaveLength(3); + expect(nodeResponse.loopRunHistory.every((h: any) => h.success)).toBe(true); + // Last snapshot exposed on data + expect(result.data.answer).toBe('v-c'); + expect(result.error).toBeUndefined(); + }); + + it('array mode 第 2 轮节点出错 → 本轮 success:false, 失败轮快照对未跑节点返回 undefined', async () => { + let iter = 0; + runWorkflowMock.mockImplementation((args: any) => { + iter++; + const chatNode = args.runtimeNodes.find((n: any) => n.nodeId === 'chatNode'); + if (iter === 1) { + chatNode.outputs[0].value = 'v1'; + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ); + } + // iter === 2: chatNode 未跑到(startNode 先出错了) + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode', { error: 'boom' })] + }) + ); + }); + + const customOutputs = [{ key: 'answer', ref: ['chatNode', 'answer'] as [string, string] }]; + const runtimeNodes = makeRuntimeNodes(); + const node = makeLoopRunNode(customOutputs); + runtimeNodes[0] = node; + const props = { + params: { + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }, + node, + runtimeNodes, + runtimeNodesMap: new Map(runtimeNodes.map((n) => [n.nodeId, n])), + runtimeEdges: [], + variables: {}, + usagePush: vi.fn(), + lastInteractive: undefined + } as any; + + const result: any = await dispatchLoopRun(props); + + expect(runWorkflowMock).toHaveBeenCalledTimes(2); + const history = result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunHistory; + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ iteration: 1, success: true }); + expect(history[1]).toMatchObject({ iteration: 2, success: false, error: 'boom' }); + // Failure iteration: chatNode didn't finish → `answer` filtered to undefined + expect(history[1].customOutputs.answer).toBeUndefined(); + // Error surfaces through standard node error protocol + expect(result.error?.[NodeOutputKeyEnum.errorText]).toBe('boom'); + expect(result.data.answer).toBeUndefined(); + }); + + it('array mode loopRunBreak 命中 → 后续迭代不再执行', async () => { + let iter = 0; + runWorkflowMock.mockImplementation(() => { + iter++; + const flowResponses = [makeResponseItem('startNode'), makeResponseItem('chatNode')]; + if (iter === 2) { + flowResponses.push( + makeResponseItem('breakNode', { moduleType: FlowNodeTypeEnum.loopRunBreak }) + ); + } + return Promise.resolve(makeDispatchFlowResponse({ flowResponses })); + }); + + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c', 'd'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + + const result: any = await dispatchLoopRun(props); + + expect(runWorkflowMock).toHaveBeenCalledTimes(2); + expect(result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunIterations).toBe(2); + }); + + it('conditional mode - loopRunBreak 第 3 轮命中 → 正常退出', async () => { + let iter = 0; + runWorkflowMock.mockImplementation(() => { + iter++; + const flowResponses = [makeResponseItem('startNode'), makeResponseItem('chatNode')]; + if (iter === 3) { + flowResponses.push( + makeResponseItem('breakNode', { moduleType: FlowNodeTypeEnum.loopRunBreak }) + ); + } + return Promise.resolve(makeDispatchFlowResponse({ flowResponses })); + }); + + const props = makeProps( + { [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.conditional }, + { withBreak: true } + ); + + const result: any = await dispatchLoopRun(props); + expect(iter).toBe(3); + expect(result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunIterations).toBe(3); + }); + + it('conditional mode - 子节点 catchError=false 出错 → 当轮 break, 后续迭代不执行', async () => { + // 对齐 dispatch/index.ts 错误归一化后的 flowResponses 形状: + // dispatcher 返回 `{error}` + catchError=false 会把 error 写回 nodeResponse, + // 所以 flowResponse 项上 `r.error` 必定可见。用户场景:code 节点 iter=2 throw。 + let iter = 0; + runWorkflowMock.mockImplementation(() => { + iter++; + if (iter === 2) { + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [ + makeResponseItem('startNode'), + makeResponseItem('codeNode', { error: '111' }) + ] + }) + ); + } + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('codeNode')] + }) + ); + }); + + const props = makeProps( + { [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.conditional }, + { withBreak: true } + ); + + const result: any = await dispatchLoopRun(props); + expect(iter).toBe(2); + const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse]; + expect(nodeResponse.loopRunIterations).toBe(2); + expect(nodeResponse.loopRunHistory[1]).toMatchObject({ + iteration: 2, + success: false, + error: '111' + }); + expect(result.error?.[NodeOutputKeyEnum.errorText]).toBe('111'); + }); + + it('conditional mode - 无 break 节点 → precheck 返回 errorText 并不执行任何迭代', async () => { + const props = makeProps({ [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.conditional }); + const result: any = await dispatchLoopRun(props); + expect(result.error?.[NodeOutputKeyEnum.errorText]).toBe( + 'workflow:loop_run_conditional_requires_break' + ); + const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse]; + expect(nodeResponse.errorText).toBe('workflow:loop_run_conditional_requires_break'); + expect(nodeResponse.mergeSignId).toBe('loopRun1'); + expect(runWorkflowMock).not.toHaveBeenCalled(); + }); + + it('conditional mode - 有 break 节点但运行中从未命中 → 超过 max → 返回 error 并保留 loopHistory', async () => { + runWorkflowMock.mockImplementation(() => + Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ) + ); + + const props = makeProps( + { [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.conditional }, + { withBreak: true } + ); + + const result: any = await dispatchLoopRun(props); + // 触发 5 次预算后兜底,loopHistory 保留已跑完的每一轮以便排查 + expect(runWorkflowMock).toHaveBeenCalledTimes(5); + expect(result.error?.[NodeOutputKeyEnum.errorText]).toBe( + 'workflow:loop_run_max_iterations_exceeded' + ); + const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse]; + expect(nodeResponse.loopRunIterations).toBe(5); + expect(nodeResponse.loopRunHistory).toHaveLength(5); + expect(nodeResponse.loopRunHistory.every((h: any) => h.success)).toBe(true); + }); + + it('interactive 响应 → 返回 loopInteractive 状态, 不 push 失败 history', async () => { + const interactivePayload: any = { + entryNodeIds: ['userSelectNode'], + memoryEdges: [], + nodeOutputs: [], + interactive: { type: 'userSelect', params: {} } + }; + runWorkflowMock.mockImplementation(() => + Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode')], + workflowInteractiveResponse: interactivePayload + }) + ) + ); + + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + + const result: any = await dispatchLoopRun(props); + const interactive = result[DispatchNodeResponseKeyEnum.interactive]; + expect(interactive).toBeDefined(); + expect(interactive.type).toBe('loopRunInteractive'); + expect(interactive.params.childrenResponse).toBe(interactivePayload); + expect(interactive.params.iteration).toBe(1); + expect(interactive.params.loopHistory).toEqual([]); + // No history written for interactive iteration + expect(result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunHistory).toEqual([]); + }); + + it('lastInteractive 恢复 → 从中断轮次续跑, 保留已累积 loopHistory', async () => { + // Resume at iteration 2 (0-based index 1). Prior history has 1 success entry. + // The mocked runWorkflow returns success with break for iteration 2. + runWorkflowMock.mockImplementationOnce(() => + Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [ + makeResponseItem('startNode'), + makeResponseItem('chatNode'), + makeResponseItem('breakNode', { moduleType: FlowNodeTypeEnum.loopRunBreak }) + ] + }) + ) + ); + + const priorHistory = [{ iteration: 1, customOutputs: {}, success: true }]; + const runtimeNodes = makeRuntimeNodes(); + const node = runtimeNodes[0]; + const props = { + params: { + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }, + node, + runtimeNodes, + runtimeNodesMap: new Map(runtimeNodes.map((n) => [n.nodeId, n])), + runtimeEdges: [], + variables: {}, + usagePush: vi.fn(), + lastInteractive: { + type: 'loopRunInteractive', + params: { + loopHistory: priorHistory, + iteration: 2, + childrenResponse: { entryNodeIds: ['userSelectNode'] } + } + } + } as any; + + const result: any = await dispatchLoopRun(props); + + // Only one runWorkflow call: iteration 2 — then break terminates. + expect(runWorkflowMock).toHaveBeenCalledTimes(1); + const history = result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunHistory; + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ iteration: 1, success: true }); + expect(history[1]).toMatchObject({ iteration: 2, success: true }); + }); + + it('lastInteractive 恢复后续跑多轮 → 恢复后非终止轮不应再携带 lastInteractive', async () => { + // Regression guard: resume state must be cleared after its own iteration. + const interactivePayload: any = { + entryNodeIds: ['userSelectNode'], + memoryEdges: [{ source: 'a', target: 'b', status: 'active' }], + nodeOutputs: [] + }; + + runWorkflowMock.mockImplementation(() => + Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ) + ); + + const priorHistory = [{ iteration: 1, customOutputs: {}, success: true }]; + const runtimeNodes = makeRuntimeNodes(); + const node = runtimeNodes[0]; + const props = { + params: { + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }, + node, + runtimeNodes, + runtimeNodesMap: new Map(runtimeNodes.map((n) => [n.nodeId, n])), + runtimeEdges: [], + variables: {}, + usagePush: vi.fn(), + lastInteractive: { + type: 'loopRunInteractive', + params: { + loopHistory: priorHistory, + iteration: 2, + childrenResponse: interactivePayload + } + } + } as any; + + await dispatchLoopRun(props); + + expect(runWorkflowMock).toHaveBeenCalledTimes(2); + + const resumeCall = runWorkflowMock.mock.calls[0][0]; + expect(resumeCall.lastInteractive).toBe(interactivePayload); + + const nextCall = runWorkflowMock.mock.calls[1][0]; + expect(nextCall.lastInteractive).toBeUndefined(); + expect(nextCall.runtimeEdges).toEqual([]); + }); + + it('array mode 输入非数组 → precheck 返回 errorText', async () => { + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: 'not-array' as any, + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + const result: any = await dispatchLoopRun(props); + expect(result.error?.[NodeOutputKeyEnum.errorText]).toBe('workflow:loop_run_input_not_array'); + expect(runWorkflowMock).not.toHaveBeenCalled(); + }); + + it('array mode 数组长度超上限 → precheck 返回 errorText', async () => { + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: new Array(100).fill('x'), + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + const result: any = await dispatchLoopRun(props); + expect(result.error?.[NodeOutputKeyEnum.errorText]).toBe( + 'workflow:loop_run_max_iterations_exceeded' + ); + expect(runWorkflowMock).not.toHaveBeenCalled(); + }); + + it('成功轮:未跑完的节点引用在快照里过滤为 undefined(避免跨迭代 stale value)', async () => { + // Iteration 1: both startNode & chatNode run. Iteration 2: chatNode skipped + // (e.g. if-else branch). Snapshot for iteration 2 must not leak iteration-1 value. + let iter = 0; + runWorkflowMock.mockImplementation((args: any) => { + iter++; + const chatNode = args.runtimeNodes.find((n: any) => n.nodeId === 'chatNode'); + if (iter === 1) { + if (chatNode) chatNode.outputs[0].value = 'stale-from-iter-1'; + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ); + } + // iter === 2: chatNode skipped, but outputs.value still holds 'stale-from-iter-1' + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode')] + }) + ); + }); + + const customOutputs = [{ key: 'answer', ref: ['chatNode', 'answer'] as [string, string] }]; + const runtimeNodes = makeRuntimeNodes(); + const node = makeLoopRunNode(customOutputs); + runtimeNodes[0] = node; + const props = { + params: { + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }, + node, + runtimeNodes, + runtimeNodesMap: new Map(runtimeNodes.map((n) => [n.nodeId, n])), + runtimeEdges: [], + variables: {}, + usagePush: vi.fn(), + lastInteractive: undefined + } as any; + + const result: any = await dispatchLoopRun(props); + const history = result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunHistory; + expect(history).toHaveLength(2); + expect(history[0]).toMatchObject({ iteration: 1, success: true }); + expect(history[0].customOutputs.answer).toBe('stale-from-iter-1'); + expect(history[1]).toMatchObject({ iteration: 2, success: true }); + // chatNode didn't run this iteration → ref filtered to undefined, not leaked. + expect(history[1].customOutputs.answer).toBeUndefined(); + }); + + it('loopRunDetail 按轮包装为虚拟任务节点,childrenResponses 带本轮子节点', async () => { + runWorkflowMock.mockImplementation(() => + Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ) + ); + + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + + const result: any = await dispatchLoopRun(props); + const detail = result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunDetail; + expect(detail).toHaveLength(2); + expect(detail[0]).toMatchObject({ + moduleType: FlowNodeTypeEnum.loopRun, + moduleName: 'workflow:parallel_task', + moduleNameArgs: { index: 1 }, + loopInputValue: 'a', + error: undefined + }); + expect(detail[0].childrenResponses).toHaveLength(2); + expect(detail[0].childrenResponses[0].nodeId).toBe('startNode'); + expect(detail[1].moduleNameArgs).toEqual({ index: 2 }); + expect(detail[1].loopInputValue).toBe('b'); + }); + + it('loopRunDetail 失败轮包装带 error 字段并包含触发错误的子节点', async () => { + let iter = 0; + runWorkflowMock.mockImplementation(() => { + iter++; + if (iter === 2) { + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [ + makeResponseItem('startNode'), + makeResponseItem('chatNode', { error: 'kaboom' }) + ] + }) + ); + } + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ); + }); + + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + + const result: any = await dispatchLoopRun(props); + const detail = result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunDetail; + expect(detail).toHaveLength(2); + expect(detail[0]).toMatchObject({ moduleNameArgs: { index: 1 }, error: undefined }); + expect(detail[1]).toMatchObject({ + moduleNameArgs: { index: 2 }, + loopInputValue: 'b', + error: 'kaboom' + }); + expect(detail[1].childrenResponses.map((c: any) => c.nodeId)).toEqual([ + 'startNode', + 'chatNode' + ]); + }); + + it('interactive 中断轮:不产出 loopRunDetail 包装节点', async () => { + const interactivePayload: any = { + entryNodeIds: ['userSelectNode'], + memoryEdges: [], + nodeOutputs: [], + interactive: { type: 'userSelect', params: {} } + }; + runWorkflowMock.mockImplementation(() => + Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode')], + workflowInteractiveResponse: interactivePayload + }) + ) + ); + + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + + const result: any = await dispatchLoopRun(props); + expect(result[DispatchNodeResponseKeyEnum.nodeResponse].loopRunDetail).toEqual([]); + }); + + it('array mode 数组长度 === max → 跑满且不报超限(回归:== max 不算超限)', async () => { + runWorkflowMock.mockImplementation(() => + Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ) + ); + + const props = makeProps({ + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c', 'd', 'e'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }); + + const result: any = await dispatchLoopRun(props); + expect(runWorkflowMock).toHaveBeenCalledTimes(5); + const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse]; + expect(nodeResponse.loopRunIterations).toBe(5); + expect(nodeResponse.loopRunHistory.every((h: any) => h.success)).toBe(true); + expect(result.error).toBeUndefined(); + expect(nodeResponse.errorText).toBeUndefined(); + }); + + it('resume 后续迭代:childrenResponse.entryNodeIds 标的节点 isEntry 不应泄漏到下一轮', async () => { + const interactivePayload: any = { + entryNodeIds: ['chatNode'], + memoryEdges: [], + nodeOutputs: [] + }; + // Snapshot chatNode.isEntry when runWorkflow is invoked — isolatedNodes is a + // single array reused across iterations, so reading it after the run would + // always see the post-reset state. + const chatEntryPerCall: boolean[] = []; + runWorkflowMock.mockImplementation((args: any) => { + const chatNode = args.runtimeNodes.find((n: any) => n.nodeId === 'chatNode'); + chatEntryPerCall.push(!!chatNode?.isEntry); + return Promise.resolve( + makeDispatchFlowResponse({ + flowResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')] + }) + ); + }); + + const runtimeNodes = makeRuntimeNodes(); + const node = runtimeNodes[0]; + const props = { + params: { + [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array, + [NodeInputKeyEnum.loopRunInputArray]: ['a', 'b', 'c'], + [NodeInputKeyEnum.childrenNodeIdList]: ['startNode', 'chatNode'] + }, + node, + runtimeNodes, + runtimeNodesMap: new Map(runtimeNodes.map((n) => [n.nodeId, n])), + runtimeEdges: [], + variables: {}, + usagePush: vi.fn(), + lastInteractive: { + type: 'loopRunInteractive', + params: { + loopHistory: [{ iteration: 1, customOutputs: {}, success: true }], + iteration: 2, + childrenResponse: interactivePayload + } + } + } as any; + + await dispatchLoopRun(props); + expect(runWorkflowMock).toHaveBeenCalledTimes(2); + expect(chatEntryPerCall[0]).toBe(true); + expect(chatEntryPerCall[1]).toBe(false); + }); +}); diff --git a/test/cases/service/core/workflow/dispatch/loopRun/service.test.ts b/test/cases/service/core/workflow/dispatch/loopRun/service.test.ts new file mode 100644 index 0000000000..8aee449dcf --- /dev/null +++ b/test/cases/service/core/workflow/dispatch/loopRun/service.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect } from 'vitest'; +import { + FlowNodeOutputTypeEnum, + FlowNodeTypeEnum +} from '@fastgpt/global/core/workflow/node/constant'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun'; +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 { + extractFinishedNodeIds, + hasLoopRunBreakChild, + injectLoopRunStart, + isLoopBreakHit, + pickCustomOutputInputs, + readCustomOutputSnapshot +} from '@fastgpt/service/core/workflow/dispatch/loopRun/service'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const makeInput = ( + override: Partial & { key: string } +): FlowNodeInputItemType => ({ + renderTypeList: [], + valueType: 'any' as any, + label: '', + ...override +}); + +const makeNode = ( + nodeId: string, + flowNodeType: FlowNodeTypeEnum, + opts: { + inputs?: { key: string; value?: any }[]; + outputs?: { id: string; key: string; value?: any }[]; + isEntry?: boolean; + } = {} +): RuntimeNodeItemType => ({ + nodeId, + name: nodeId, + avatar: '', + flowNodeType, + showStatus: false, + isEntry: opts.isEntry ?? false, + inputs: (opts.inputs ?? []).map((i) => makeInput({ key: i.key, value: i.value })), + outputs: (opts.outputs ?? []).map((o) => ({ + id: o.id, + key: o.key, + label: '', + type: 'static' as any, + valueType: 'any' as any, + value: o.value + })) +}); + +const makeResponse = (override: Partial): ChatHistoryItemResType => + ({ + nodeId: 'n', + moduleType: FlowNodeTypeEnum.chatNode, + moduleName: 'n', + ...override + }) as ChatHistoryItemResType; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('loopRun/service', () => { + describe('pickCustomOutputInputs', () => { + const makeDynamicOutput = (key: string): FlowNodeOutputItemType => ({ + id: key, + key, + label: key, + type: FlowNodeOutputTypeEnum.dynamic, + valueType: 'any' as any + }); + + it('只返回同时满足 canEdit=true 且存在 dynamic output 镜像的 input', () => { + const inputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'staticInput' }), + makeInput({ key: 'userField1', canEdit: true }), + makeInput({ key: 'userField2', canEdit: true }), + makeInput({ key: 'anotherStatic' }) + ]; + const outputs = [makeDynamicOutput('userField1'), makeDynamicOutput('userField2')]; + const result = pickCustomOutputInputs(inputs, outputs); + expect(result.map((i) => i.key)).toEqual(['userField1', 'userField2']); + }); + + it('空输入列表返回空数组', () => { + expect(pickCustomOutputInputs([], [])).toEqual([]); + }); + + it('无任何 canEdit 返回空数组', () => { + const inputs = [makeInput({ key: 'a' }), makeInput({ key: 'b' })]; + expect(pickCustomOutputInputs(inputs, [])).toEqual([]); + }); + + it('canEdit 为 true 但没有对应 dynamic output → 排除(避免未来 canEdit 配置项误混入)', () => { + const inputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'configField', canEdit: true }), + makeInput({ key: 'outputField', canEdit: true }) + ]; + const outputs = [makeDynamicOutput('outputField')]; + const result = pickCustomOutputInputs(inputs, outputs); + expect(result.map((i) => i.key)).toEqual(['outputField']); + }); + + it('output 类型非 dynamic(如 static / error)不计入镜像', () => { + const inputs: FlowNodeInputItemType[] = [makeInput({ key: 'userField1', canEdit: true })]; + const outputs: FlowNodeOutputItemType[] = [ + { + id: 'userField1', + key: 'userField1', + label: 'userField1', + type: FlowNodeOutputTypeEnum.static, + valueType: 'any' as any + } + ]; + expect(pickCustomOutputInputs(inputs, outputs)).toEqual([]); + }); + }); + + describe('extractFinishedNodeIds', () => { + it('把 flowResponses 里带 nodeId 的项汇总到 Set', () => { + const responses = [ + makeResponse({ nodeId: 'n1' }), + makeResponse({ nodeId: 'n2' }), + makeResponse({ nodeId: 'n1' }) // 重复 + ]; + const result = extractFinishedNodeIds(responses); + expect(result).toEqual(new Set(['n1', 'n2'])); + }); + + it('空数组返回空 Set', () => { + expect(extractFinishedNodeIds([])).toEqual(new Set()); + }); + }); + + describe('readCustomOutputSnapshot', () => { + const nodeA = makeNode('A', FlowNodeTypeEnum.chatNode, { + outputs: [{ id: 'outA', key: 'outA', value: 'valueFromA' }] + }); + const nodeB = makeNode('B', FlowNodeTypeEnum.chatNode, { + outputs: [{ id: 'outB', key: 'outB', value: 'valueFromB' }] + }); + + it('成功轮(不传 finishedNodeIds)- 所有字段读取引用值', () => { + const customOutputInputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'a', canEdit: true, value: ['A', 'outA'] }), + makeInput({ key: 'b', canEdit: true, value: ['B', 'outB'] }) + ]; + const snapshot = readCustomOutputSnapshot({ + customOutputInputs, + runtimeNodes: [nodeA, nodeB], + variables: {} + }); + expect(snapshot).toEqual({ a: 'valueFromA', b: 'valueFromB' }); + }); + + it('失败轮 - 目标节点未在 finishedNodeIds → undefined;在集合内 → 正常读取', () => { + const customOutputInputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'a', canEdit: true, value: ['A', 'outA'] }), + makeInput({ key: 'b', canEdit: true, value: ['B', 'outB'] }) + ]; + const snapshot = readCustomOutputSnapshot({ + customOutputInputs, + runtimeNodes: [nodeA, nodeB], + variables: {}, + finishedNodeIds: new Set(['A']) // 只有 A 跑过 + }); + expect(snapshot).toEqual({ a: 'valueFromA', b: undefined }); + }); + + it('失败轮 - 全空 finishedNodeIds → 全部 undefined', () => { + const customOutputInputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'a', canEdit: true, value: ['A', 'outA'] }) + ]; + const snapshot = readCustomOutputSnapshot({ + customOutputInputs, + runtimeNodes: [nodeA], + variables: {}, + finishedNodeIds: new Set() + }); + expect(snapshot).toEqual({ a: undefined }); + }); + + it('全局变量引用 VARIABLE_NODE_ID 不受 finishedNodeIds 过滤', () => { + const customOutputInputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'g', canEdit: true, value: ['VARIABLE_NODE_ID', 'globalKey'] }) + ]; + const snapshot = readCustomOutputSnapshot({ + customOutputInputs, + runtimeNodes: [], + variables: { globalKey: 'globalValue' }, + finishedNodeIds: new Set() + }); + expect(snapshot).toEqual({ g: 'globalValue' }); + }); + + it('空声明列表 → 空快照', () => { + const snapshot = readCustomOutputSnapshot({ + customOutputInputs: [], + runtimeNodes: [], + variables: {} + }); + expect(snapshot).toEqual({}); + }); + + it('引用循环体外的节点 - 不在 childrenNodeIdList 内 → 跳过 finishedNodeIds 过滤', () => { + // A: 循环体外的节点(如 代码运行#3),其 output 在迭代中被 变量更新 追加写入 + // B: 循环体内跑过的节点 + const customOutputInputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'a', canEdit: true, value: ['A', 'outA'] }), + makeInput({ key: 'b', canEdit: true, value: ['B', 'outB'] }) + ]; + const snapshot = readCustomOutputSnapshot({ + customOutputInputs, + runtimeNodes: [nodeA, nodeB], + variables: {}, + finishedNodeIds: new Set(['B']), // 只有 B(循环体内)跑过 + childrenNodeIdList: ['B'] // A 在循环体外 + }); + expect(snapshot).toEqual({ a: 'valueFromA', b: 'valueFromB' }); + }); + + it('引用循环体内跳过的节点 - 在 childrenNodeIdList 内但未跑 → undefined', () => { + const customOutputInputs: FlowNodeInputItemType[] = [ + makeInput({ key: 'a', canEdit: true, value: ['A', 'outA'] }), + makeInput({ key: 'b', canEdit: true, value: ['B', 'outB'] }) + ]; + const snapshot = readCustomOutputSnapshot({ + customOutputInputs, + runtimeNodes: [nodeA, nodeB], + variables: {}, + finishedNodeIds: new Set(['B']), + childrenNodeIdList: ['A', 'B'] // 都在循环体内,A 本轮未跑 + }); + expect(snapshot).toEqual({ a: undefined, b: 'valueFromB' }); + }); + }); + + describe('injectLoopRunStart', () => { + const makeLoopStartNode = () => + makeNode('start1', FlowNodeTypeEnum.loopRunStart, { + inputs: [ + { key: NodeInputKeyEnum.loopRunMode }, + { key: NodeInputKeyEnum.nestedStartInput }, + { key: NodeInputKeyEnum.nestedStartIndex } + ] + }); + + it('array 模式 - 注入 item + index(0-based)并 mark entry', () => { + const startNode = makeLoopStartNode(); + const otherNode = makeNode('other', FlowNodeTypeEnum.chatNode); + + injectLoopRunStart({ + nodes: [startNode, otherNode], + childrenNodeIdList: ['start1', 'other'], + mode: LoopRunModeEnum.array, + item: 'hello', + index: 2, + iteration: 3 + }); + + expect(startNode.isEntry).toBe(true); + const inputs = Object.fromEntries(startNode.inputs.map((i) => [i.key, i.value])); + expect(inputs[NodeInputKeyEnum.loopRunMode]).toBe(LoopRunModeEnum.array); + expect(inputs[NodeInputKeyEnum.nestedStartInput]).toBe('hello'); + expect(inputs[NodeInputKeyEnum.nestedStartIndex]).toBe(2); + // 非 loopRunStart 节点不被标记 + expect(otherNode.isEntry).toBe(false); + }); + + it('conditional 模式 - 注入 iteration(1-based),item 为 undefined', () => { + const startNode = makeLoopStartNode(); + injectLoopRunStart({ + nodes: [startNode], + childrenNodeIdList: ['start1'], + mode: LoopRunModeEnum.conditional, + iteration: 5 + }); + const inputs = Object.fromEntries(startNode.inputs.map((i) => [i.key, i.value])); + expect(inputs[NodeInputKeyEnum.loopRunMode]).toBe(LoopRunModeEnum.conditional); + expect(inputs[NodeInputKeyEnum.nestedStartInput]).toBeUndefined(); + expect(inputs[NodeInputKeyEnum.nestedStartIndex]).toBe(5); + }); + + it('不在 childrenNodeIdList 内的 loopRunStart 节点不被触达', () => { + const startNode = makeLoopStartNode(); // nodeId = 'start1' + injectLoopRunStart({ + nodes: [startNode], + childrenNodeIdList: ['other'], + mode: LoopRunModeEnum.array, + item: 'x', + index: 0, + iteration: 1 + }); + expect(startNode.isEntry).toBe(false); + // inputs 原状 + const inputs = Object.fromEntries(startNode.inputs.map((i) => [i.key, i.value])); + expect(inputs[NodeInputKeyEnum.nestedStartInput]).toBeUndefined(); + }); + }); + + describe('hasLoopRunBreakChild', () => { + it('childrenNodeIdList 内有 loopRunBreak 节点 → true', () => { + const nodes = [ + makeNode('loopRun', FlowNodeTypeEnum.loopRun), + makeNode('break1', FlowNodeTypeEnum.loopRunBreak), + makeNode('chat1', FlowNodeTypeEnum.chatNode) + ]; + expect(hasLoopRunBreakChild(nodes, ['break1', 'chat1'])).toBe(true); + }); + + it('childrenNodeIdList 内无 loopRunBreak 节点 → false', () => { + const nodes = [ + makeNode('loopRun', FlowNodeTypeEnum.loopRun), + makeNode('chat1', FlowNodeTypeEnum.chatNode) + ]; + expect(hasLoopRunBreakChild(nodes, ['chat1'])).toBe(false); + }); + + it('loopRunBreak 存在但不在 childrenNodeIdList → false', () => { + const nodes = [ + makeNode('loopRun', FlowNodeTypeEnum.loopRun), + makeNode('break1', FlowNodeTypeEnum.loopRunBreak), // 属于别的 loopRun + makeNode('chat1', FlowNodeTypeEnum.chatNode) + ]; + expect(hasLoopRunBreakChild(nodes, ['chat1'])).toBe(false); + }); + + it('空 childrenNodeIdList → false', () => { + const nodes = [makeNode('break1', FlowNodeTypeEnum.loopRunBreak)]; + expect(hasLoopRunBreakChild(nodes, [])).toBe(false); + }); + }); + + describe('isLoopBreakHit', () => { + it('含 loopRunBreak moduleType 响应 → true', () => { + const responses = [ + makeResponse({ moduleType: FlowNodeTypeEnum.chatNode }), + makeResponse({ moduleType: FlowNodeTypeEnum.loopRunBreak }) + ]; + expect(isLoopBreakHit(responses)).toBe(true); + }); + + it('无 loopRunBreak → false', () => { + expect(isLoopBreakHit([makeResponse({ moduleType: FlowNodeTypeEnum.chatNode })])).toBe(false); + }); + + it('空数组 → false', () => { + expect(isLoopBreakHit([])).toBe(false); + }); + }); +});