Commit 27ebc0ee by DigHuang Committed by GitHub

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

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

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

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

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

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

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

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

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

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

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

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

* fix(workflow): loop run max-length boundary and resume isEntry leak
parent 81dfc589
# 循环节点交互恢复修复
## 背景
循环节点(`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`
# 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 修复。
......@@ -305,6 +305,8 @@ export const getFlatAppResponses = (res: ChatHistoryItemResType[]): ChatHistoryI
...getFlatAppResponses(item.pluginDetail || []),
...getFlatAppResponses(item.toolDetail || []),
...getFlatAppResponses(item.loopDetail || []),
...getFlatAppResponses(item.loopRunDetail || []),
...getFlatAppResponses(item.parallelDetail || []),
...getFlatAppResponses(item.childrenResponses || [])
];
})
......@@ -369,6 +371,14 @@ export const mergeChatResponseData = (
...(existing.loopDetail || []),
...(item.loopDetail || [])
]),
loopRunDetail: mergeChatResponseData([
...(existing.loopRunDetail || []),
...(item.loopRunDetail || [])
]),
parallelDetail: mergeChatResponseData([
...(existing.parallelDetail || []),
...(item.parallelDetail || [])
]),
pluginDetail: mergeChatResponseData([
...(existing.pluginDetail || []),
...(item.pluginDetail || [])
......
......@@ -253,6 +253,11 @@ export enum NodeInputKeyEnum {
parallelRunMaxConcurrency = 'parallelRunMaxConcurrency',
parallelRunMaxRetryTimes = 'parallelRunMaxRetryTimes',
// loopRun
loopRunMode = 'loopRunMode',
loopRunInputArray = 'loopRunInputArray',
loopCustomOutputs = 'loopCustomOutputs',
// form input
userInputForms = 'userInputForms',
......@@ -320,6 +325,11 @@ export enum NodeOutputKeyEnum {
parallelFullResults = 'parallelFullResults',
parallelStatus = 'parallelStatus',
// loopRunStart dynamic outputs
currentIndex = 'currentIndex',
currentItem = 'currentItem',
currentIteration = 'currentIteration',
// form input
formInputResult = 'formInputResult',
......
......@@ -160,6 +160,9 @@ export enum FlowNodeTypeEnum {
nestedStart = 'loopStart',
nestedEnd = 'loopEnd',
parallelRun = 'parallelRun',
loopRun = 'loopRun',
loopRunStart = 'loopRunStart',
loopRunBreak = 'loopRunBreak',
formInput = 'formInput',
tool = 'tool',
toolSet = 'toolSet',
......@@ -303,7 +306,8 @@ export const NodeGradients = {
skyBlue: 'linear-gradient(180deg, rgba(137, 229, 255, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
salmon: 'linear-gradient(180deg, rgba(255, 160, 160, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
gray: 'linear-gradient(180deg, rgba(136, 136, 136, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
emerald: 'linear-gradient(180deg, rgba(20, 168, 70, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)'
emerald: 'linear-gradient(180deg, rgba(20, 168, 70, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)',
loopRun: 'linear-gradient(180deg, rgba(110, 231, 183, 0.20) 0%, rgba(255, 255, 255, 0.00) 100%)'
};
export const NodeBorderColors = {
pink: 'rgba(255, 161, 206, 0.6)',
......@@ -325,7 +329,8 @@ export const NodeBorderColors = {
skyBlue: 'rgba(137, 229, 255, 0.6)',
salmon: 'rgba(255, 160, 160, 0.6)',
gray: 'rgba(136, 136, 136, 0.6)',
emerald: 'rgba(20, 168, 70, 0.6)'
emerald: 'rgba(20, 168, 70, 0.6)',
loopRun: 'rgba(110, 231, 183, 0.6)'
};
export const NodeColorSchemaEnum = [
'pink',
......@@ -347,19 +352,35 @@ export const NodeColorSchemaEnum = [
'skyBlue',
'salmon',
'gray',
'emerald'
'emerald',
'loopRun'
] as const;
/** 返回 true 表示该节点是嵌套父容器(loop / parallelRun)。 */
/** 嵌套父容器节点类型集合(loop / parallelRun / loopRun)。 */
export const NESTED_PARENT_NODE_TYPES: ReadonlySet<FlowNodeTypeEnum> = new Set([
FlowNodeTypeEnum.loop,
FlowNodeTypeEnum.parallelRun,
FlowNodeTypeEnum.loopRun
]);
export const isNestedParentNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean =>
flowNodeType === FlowNodeTypeEnum.loop || flowNodeType === FlowNodeTypeEnum.parallelRun;
NESTED_PARENT_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum);
/** 交互类节点类型集合(在 parallelRun 体内禁止使用)。 */
/** 交互类节点类型集合(在 parallelRun 体内禁止使用;loopRun 允许)。 */
export const INTERACTIVE_NODE_TYPES: ReadonlySet<FlowNodeTypeEnum> = new Set([
FlowNodeTypeEnum.userSelect,
FlowNodeTypeEnum.formInput
]);
/** 返回 true 表示该节点是交互类节点(userSelect / formInput)。 */
export const isInteractiveNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean =>
INTERACTIVE_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum);
/** 嵌套容器的系统子节点类型集合(只能由容器自动创建,不允许从模板面板添加)。 */
export const NESTED_CHILD_SYSTEM_NODE_TYPES: ReadonlySet<FlowNodeTypeEnum> = new Set([
FlowNodeTypeEnum.nestedStart,
FlowNodeTypeEnum.nestedEnd,
FlowNodeTypeEnum.loopRunStart
]);
export const isNestedChildSystemNodeType = (flowNodeType: FlowNodeTypeEnum | string): boolean =>
NESTED_CHILD_SYSTEM_NODE_TYPES.has(flowNodeType as FlowNodeTypeEnum);
......@@ -314,6 +314,18 @@ export const DispatchNodeResponseSchema = z
.optional()
.meta({ description: '成功任务子工作流完整响应列表' }),
// loopRun
loopRunInput: z
.any()
.optional()
.meta({ description: 'loopRun 循环输入(数组或条件模式标记)' }),
loopRunIterations: z.number().optional().meta({ description: 'loopRun 实际执行轮数' }),
loopRunHistory: z.array(z.any()).optional().meta({ description: 'loopRun 每轮快照' }),
loopRunDetail: z
.array(z.any())
.optional()
.meta({ description: 'loopRun 各轮子工作流节点响应聚合' }),
childrenResponses: z.array(z.any()).optional().meta({ description: '子节点响应' }),
// Tools
......
......@@ -30,6 +30,9 @@ import { LafModule } from './system/laf';
import { LoopNode } from './system/loop/loop';
import { LoopEndNode } from './system/loop/loopEnd';
import { LoopStartNode } from './system/loop/loopStart';
import { LoopRunNode } from './system/loopRun/loopRun';
import { LoopRunStartNode } from './system/loopRun/loopRunStart';
import { LoopRunBreakNode } from './system/loopRun/loopRunBreak';
import { ParallelRunNode } from './system/parallelRun/parallelRun';
import { ReadFilesNode } from './system/readFiles';
import { RunToolNode } from './system/runTool';
......@@ -59,7 +62,9 @@ const systemNodes: FlowNodeTemplateType[] = [
VariableUpdateNode,
CodeNode,
LoopNode,
ParallelRunNode
ParallelRunNode,
LoopRunNode,
LoopRunBreakNode
];
/* app flow module templates */
export const appSystemModuleTemplates: FlowNodeTemplateType[] = [
......@@ -91,6 +96,7 @@ export const moduleTemplatesFlat: FlowNodeTemplateType[] = [
RunAppModule,
LoopStartNode,
LoopEndNode,
LoopRunStartNode,
RunToolNode,
RunToolSetNode
];
......@@ -4,7 +4,8 @@ export const isChildInteractive = (type: InteractiveNodeResponseType['type']) =>
if (
type === 'childrenInteractive' ||
type === 'toolChildrenInteractive' ||
type === 'loopInteractive'
type === 'loopInteractive' ||
type === 'loopRunInteractive'
) {
return true;
}
......
......@@ -5,6 +5,7 @@ import { AppFileSelectConfigTypeSchema } from '../../../../app/type/config.schem
import { RuntimeEdgeItemTypeSchema } from '../../../type/edge';
import z from 'zod';
import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type';
import type { ChatHistoryItemResType } from '../../../../chat/type';
export const InteractiveBasicTypeSchema = z.object({
entryNodeIds: z.array(z.string()),
......@@ -67,6 +68,25 @@ export type LoopInteractive = InteractiveNodeType & {
};
};
export const LoopRunInteractiveSchema = z.object({
type: z.literal('loopRunInteractive'),
params: z.object({
loopHistory: z.array(z.any()),
childrenResponse: z.any(),
iteration: z.number(),
pendingIterationResponses: z.array(z.any()).optional()
})
});
export type LoopRunInteractive = InteractiveNodeType & {
type: 'loopRunInteractive';
params: {
loopHistory: any[];
childrenResponse: WorkflowInteractiveResponseType;
iteration: number;
pendingIterationResponses?: ChatHistoryItemResType[];
};
};
// Agent Interactive
export const AgentPlanCheckInteractiveSchema = z.object({
type: z.literal('agentPlanCheck'),
......@@ -146,6 +166,7 @@ export const InteractiveNodeResponseTypeSchema = z.intersection(
ChildrenInteractiveSchema,
ToolCallChildrenInteractiveSchema,
LoopInteractiveSchema,
LoopRunInteractiveSchema,
PaymentPauseInteractiveSchema,
AgentPlanCheckInteractiveSchema,
AgentPlanAskQueryInteractiveSchema
......
import {
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '../../../node/constant';
import { type FlowNodeTemplateType } from '../../../type/node';
import {
FlowNodeTemplateTypeEnum,
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '../../../constants';
import { i18nT } from '../../../../../../web/i18n/utils';
import {
Input_Template_Children_Node_List,
Input_Template_NESTED_NODE_OFFSET,
Input_Template_Node_Height,
Input_Template_Node_Width
} from '../../input';
export enum LoopRunModeEnum {
array = 'array',
conditional = 'conditional'
}
export const LoopRunNode: FlowNodeTemplateType = {
id: FlowNodeTypeEnum.loopRun,
templateType: FlowNodeTemplateTypeEnum.tools,
flowNodeType: FlowNodeTypeEnum.loopRun,
showSourceHandle: true,
showTargetHandle: true,
avatar: 'core/workflow/template/loopRun',
avatarLinear: 'core/workflow/template/loopRunLinear',
colorSchema: 'loopRun',
name: i18nT('workflow:loop_run'),
intro: i18nT('workflow:intro_loop_run'),
showStatus: true,
catchError: false,
inputs: [
{
key: NodeInputKeyEnum.loopRunMode,
renderTypeList: [FlowNodeInputTypeEnum.select],
valueType: WorkflowIOValueTypeEnum.string,
required: true,
label: i18nT('workflow:loop_run_mode'),
description: i18nT('workflow:loop_run_mode_tip'),
list: [
{
label: i18nT('workflow:loop_run_mode_array'),
value: LoopRunModeEnum.array,
icon: 'core/workflow/inputType/array',
description: i18nT('workflow:loop_run_mode_array_desc')
},
{
label: i18nT('workflow:loop_run_mode_conditional'),
value: LoopRunModeEnum.conditional,
icon: 'core/workflow/inputType/conditional',
description: i18nT('workflow:loop_run_mode_conditional_desc')
}
],
value: LoopRunModeEnum.array
},
{
key: NodeInputKeyEnum.loopRunInputArray,
renderTypeList: [FlowNodeInputTypeEnum.reference],
valueType: WorkflowIOValueTypeEnum.arrayAny,
required: true,
label: i18nT('workflow:loop_run_input_array'),
value: []
},
{
key: NodeInputKeyEnum.loopCustomOutputs,
renderTypeList: [FlowNodeInputTypeEnum.addInputParam],
valueType: WorkflowIOValueTypeEnum.dynamic,
label: i18nT('workflow:loop_custom_outputs'),
description: i18nT('workflow:loop_custom_outputs_tip'),
required: false,
customInputConfig: {
selectValueTypeList: Object.values(WorkflowIOValueTypeEnum),
showDescription: false,
showDefaultValue: false,
hideBottomDivider: true
}
},
Input_Template_Children_Node_List,
Input_Template_Node_Width,
Input_Template_Node_Height,
Input_Template_NESTED_NODE_OFFSET
],
outputs: [
{
id: NodeOutputKeyEnum.errorText,
key: NodeOutputKeyEnum.errorText,
label: i18nT('workflow:error_text'),
type: FlowNodeOutputTypeEnum.error,
valueType: WorkflowIOValueTypeEnum.string
}
]
};
import { FlowNodeTypeEnum } from '../../../node/constant';
import { type FlowNodeTemplateType } from '../../../type/node';
import { FlowNodeTemplateTypeEnum } from '../../../constants';
import { i18nT } from '../../../../../../web/i18n/utils';
export const LoopRunBreakNode: FlowNodeTemplateType = {
id: FlowNodeTypeEnum.loopRunBreak,
templateType: FlowNodeTemplateTypeEnum.tools,
flowNodeType: FlowNodeTypeEnum.loopRunBreak,
showSourceHandle: false,
showTargetHandle: true,
avatar: 'core/workflow/template/loopRunBreak',
avatarLinear: 'core/workflow/template/loopRunBreakLinear',
colorSchema: 'loopRun',
name: i18nT('workflow:loop_run_break'),
intro: i18nT('workflow:loop_run_break_tip'),
showStatus: false,
inputs: [],
outputs: []
};
import {
FlowNodeInputTypeEnum,
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '../../../node/constant';
import { type FlowNodeTemplateType } from '../../../type/node';
import {
FlowNodeTemplateTypeEnum,
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '../../../constants';
import { i18nT } from '../../../../../../web/i18n/utils';
import { LoopRunModeEnum } from './loopRun';
export const LoopRunStartNode: FlowNodeTemplateType = {
id: FlowNodeTypeEnum.loopRunStart,
templateType: FlowNodeTemplateTypeEnum.systemInput,
flowNodeType: FlowNodeTypeEnum.loopRunStart,
showSourceHandle: true,
showTargetHandle: false,
avatar: 'core/workflow/template/loopRunStart',
avatarLinear: 'core/workflow/template/loopRunStartLinear',
colorSchema: 'loopRun',
name: i18nT('workflow:loop_run_start'),
unique: true,
forbidDelete: true,
showStatus: false,
inputs: [
{
key: NodeInputKeyEnum.loopRunMode,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.string,
label: '',
value: LoopRunModeEnum.array
},
{
key: NodeInputKeyEnum.nestedStartInput,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.any,
label: '',
value: ''
},
{
key: NodeInputKeyEnum.nestedStartIndex,
renderTypeList: [FlowNodeInputTypeEnum.hidden],
valueType: WorkflowIOValueTypeEnum.number,
label: ''
}
],
outputs: [
{
id: NodeOutputKeyEnum.currentIndex,
key: NodeOutputKeyEnum.currentIndex,
label: i18nT('workflow:current_index'),
description: i18nT('workflow:current_index_desc'),
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
},
{
id: NodeOutputKeyEnum.currentItem,
key: NodeOutputKeyEnum.currentItem,
label: i18nT('workflow:current_item'),
description: i18nT('workflow:current_item_desc'),
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.any
},
{
id: NodeOutputKeyEnum.currentIteration,
key: NodeOutputKeyEnum.currentIteration,
label: i18nT('workflow:current_iteration'),
description: i18nT('workflow:current_iteration_desc'),
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
}
]
};
......@@ -20,7 +20,8 @@ export const CustomFieldConfigTypeSchema = z.object({
// reference
selectValueTypeList: z.array(z.enum(WorkflowIOValueTypeEnum)).optional(), // 可以选哪个数据类型, 只有1个的话,则默认选择
showDefaultValue: z.boolean().optional(),
showDescription: z.boolean().optional()
showDescription: z.boolean().optional(),
hideBottomDivider: z.boolean().optional()
});
export type CustomFieldConfigType = z.infer<typeof CustomFieldConfigTypeSchema>;
......@@ -38,7 +39,16 @@ export const InputComponentPropsTypeSchema = z.object({
placeholder: z.string().optional(), // input,textarea
maxLength: z.number().optional(), // input,textarea
minLength: z.number().optional(), // password
list: z.array(z.object({ label: z.string(), value: z.string() })).optional(), // select
list: z
.array(
z.object({
label: z.string(),
value: z.string(),
icon: z.string().optional(),
description: z.string().optional()
})
)
.optional(), // select
markList: z.array(z.object({ label: z.string(), value: z.number() })).optional(), // slider
step: z.number().optional(), // slider
max: z.number().optional(), // slider, number input
......
......@@ -17,6 +17,9 @@ import { dispatchLoop } from './loop/runLoop';
import { dispatchLoopEnd } from './loop/runLoopEnd';
import { dispatchLoopStart } from './loop/runLoopStart';
import { dispatchParallelRun } from './parallelRun/runParallelRun';
import { dispatchLoopRun } from './loopRun/runLoopRun';
import { dispatchLoopRunStart } from './loopRun/runLoopRunStart';
import { dispatchLoopRunBreak } from './loopRun/runLoopRunBreak';
import { dispatchRunPlugin } from './plugin/run';
import { dispatchRunAppNode } from './child/runApp';
import { dispatchPluginInput } from './plugin/runInput';
......@@ -67,6 +70,9 @@ export const callbackMap: Record<FlowNodeTypeEnum, Function> = {
[FlowNodeTypeEnum.userSelect]: dispatchUserSelect,
[FlowNodeTypeEnum.loop]: dispatchLoop,
[FlowNodeTypeEnum.parallelRun]: dispatchParallelRun,
[FlowNodeTypeEnum.loopRun]: dispatchLoopRun,
[FlowNodeTypeEnum.loopRunStart]: dispatchLoopRunStart,
[FlowNodeTypeEnum.loopRunBreak]: dispatchLoopRunBreak,
[FlowNodeTypeEnum.nestedStart]: dispatchLoopStart,
[FlowNodeTypeEnum.nestedEnd]: dispatchLoopEnd,
[FlowNodeTypeEnum.formInput]: dispatchFormInput,
......
......@@ -920,8 +920,18 @@ export class WorkflowQueue {
if (result.error) {
// Run error and not catch error, skip all edges
if (!node.catchError) {
// Callback returned with `result.error` set instead of throwing;
// mirror the catch-branch convention and copy it onto nodeResponse
// so runLoopRun / parallelRun failure detection and OTel span
// status see `.error` uniformly across both failure paths.
const nodeResponseBase = result[DispatchNodeResponseKeyEnum.nodeResponse];
const errText = nodeResponseBase?.errorText ?? getErrText(result.error as any);
return {
...result,
[DispatchNodeResponseKeyEnum.nodeResponse]: {
...nodeResponseBase,
error: errText
},
[DispatchNodeResponseKeyEnum.skipHandleId]: targetEdges.map(
(item) => item.sourceHandle
)
......
......@@ -14,8 +14,8 @@ import { cloneDeep } from 'lodash';
import { type WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { storeEdges2RuntimeEdges } from '@fastgpt/global/core/workflow/runtime/utils';
import { env } from '../../../../env';
import { getNestedEndOutputValue, pushSubWorkflowUsage, collectResponseFeedbacks } from './service';
import { injectNestedStartInputs } from '../utils';
import { getNestedEndOutputValue } from './service';
import { collectResponseFeedbacks, injectNestedStartInputs, pushSubWorkflowUsage } from '../utils';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.nestedInputArray]: Array<any>;
......@@ -98,7 +98,12 @@ export const dispatchLoop = async (props: Props): Promise<Response> => {
loopResponseDetail.push(...response.flowResponses);
assistantResponses.push(...response.assistantResponses);
totalPoints += pushSubWorkflowUsage({ usagePush: props.usagePush, response, name, index });
totalPoints += pushSubWorkflowUsage({
usagePush: props.usagePush,
response,
name,
iteration: index
});
collectResponseFeedbacks(response, customFeedbacks);
......
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
import type { DispatchFlowResponse } from '../type';
import { safePoints } from '../utils';
// ─── 1. getNestedEndOutputValue ───────────────────────────────────────────────
/**
* Extract the output value produced by the nestedEnd node in a sub-workflow
* response. Returns undefined when the nestedEnd node was never reached
* (e.g. the sub-workflow terminated with an error before completion).
*/
// Returns undefined if nestedEnd was never reached (sub-workflow errored early).
export const getNestedEndOutputValue = (response: DispatchFlowResponse): any =>
response.flowResponses.find((res) => res.moduleType === FlowNodeTypeEnum.nestedEnd)
?.loopOutputValue;
// ─── 2. pushSubWorkflowUsage ─────────────────────────────────────────────────
/**
* Compute the total usage points for a single sub-workflow run, push the entry
* to the parent dispatcher's usage accumulator, and return the computed value
* so the caller can keep a running total.
*
* Pattern shared by runLoop and runParallelRun:
* const pts = pushSubWorkflowUsage({ usagePush: props.usagePush, response, name, index });
* totalPoints += pts;
*/
export const pushSubWorkflowUsage = ({
usagePush,
response,
name,
index
}: {
usagePush: (usages: ChatNodeUsageType[]) => void;
response: DispatchFlowResponse;
name: string;
index: number;
}): number => {
const itemUsagePoint = response.flowUsages.reduce(
(acc, usage) => acc + safePoints(usage.totalPoints),
0
);
usagePush([{ totalPoints: itemUsagePoint, moduleName: `${name}-${index}` }]);
return itemUsagePoint;
};
// ─── 3. collectResponseFeedbacks ─────────────────────────────────────────────
/**
* Append any customFeedbacks from a sub-workflow response into the provided
* accumulator array. Returns the same array for convenience.
*/
export const collectResponseFeedbacks = (
response: DispatchFlowResponse,
target: string[]
): string[] => {
const feedbacks = response[DispatchNodeResponseKeyEnum.customFeedbacks];
if (feedbacks && feedbacks.length > 0) {
target.push(...feedbacks);
}
return target;
};
import { 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<any>;
[NodeInputKeyEnum.childrenNodeIdList]: string[];
}>;
type Response = DispatchNodeResultType<Record<string, any>>;
export const dispatchLoopRun = async (props: Props): Promise<Response> => {
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<string, any> = 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<string, any> = lastEntry?.customOutputs ?? {};
const lastFailed = !!lastEntry && lastEntry.success === false;
const data: Record<string, any> = {
...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 }
}
: {})
};
};
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import {
type DispatchNodeResultType,
type ModuleDispatchProps
} from '@fastgpt/global/core/workflow/runtime/type';
type Props = ModuleDispatchProps<Record<string, never>>;
type Response = DispatchNodeResultType<Record<string, never>>;
// Signal-only node. The parent loopRun detects the moduleType in flowResponses
// to decide whether to terminate the loop.
export const dispatchLoopRunBreak = async (_props: Props): Promise<Response> => {
return {
data: {},
[DispatchNodeResponseKeyEnum.nodeResponse]: {}
};
};
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import {
type DispatchNodeResultType,
type ModuleDispatchProps
} from '@fastgpt/global/core/workflow/runtime/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum;
[NodeInputKeyEnum.nestedStartInput]: any;
[NodeInputKeyEnum.nestedStartIndex]: number;
}>;
type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.currentIndex]?: number;
[NodeOutputKeyEnum.currentItem]?: any;
[NodeOutputKeyEnum.currentIteration]?: number;
}>;
export const dispatchLoopRunStart = async (props: Props): Promise<Response> => {
const { params } = props;
const mode = params[NodeInputKeyEnum.loopRunMode];
const rawIndex = params[NodeInputKeyEnum.nestedStartIndex];
const item = params[NodeInputKeyEnum.nestedStartInput];
const data: Record<string, any> = {};
if (mode === LoopRunModeEnum.array) {
data[NodeOutputKeyEnum.currentIndex] = rawIndex;
data[NodeOutputKeyEnum.currentItem] = item;
} else {
data[NodeOutputKeyEnum.currentIteration] = rawIndex;
}
return {
data,
[DispatchNodeResponseKeyEnum.nodeResponse]: {
loopInputValue: mode === LoopRunModeEnum.array ? item : rawIndex
}
};
};
import {
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum, VARIABLE_NODE_ID } from '@fastgpt/global/core/workflow/constants';
import {
formatVariableValByType,
getReferenceVariableValue
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import type {
FlowNodeInputItemType,
FlowNodeOutputItemType
} from '@fastgpt/global/core/workflow/type/io';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
export type LoopRunHistoryItem = {
iteration: number;
customOutputs: Record<string, any>;
success: boolean;
error?: string;
};
// 自定义输出声明:canEdit 本身是通用的「用户可改 key/type」标记,单独用会把未来
// 新增的 canEdit 输入(如迭代配置项)误当成输出声明。这里通过「是否存在同 key 的
// dynamic output 镜像」二次校验,确保只挑出真正被 NodeLoopRun.useEffect 镜像过
// 的声明项。
export const pickCustomOutputInputs = (
inputs: FlowNodeInputItemType[],
outputs: FlowNodeOutputItemType[]
): FlowNodeInputItemType[] => {
const dynamicOutputKeys = new Set(
outputs.filter((o) => o.type === FlowNodeOutputTypeEnum.dynamic).map((o) => o.key)
);
return inputs.filter((i) => i.canEdit === true && dynamicOutputKeys.has(i.key));
};
export const extractFinishedNodeIds = (flowResponses: ChatHistoryItemResType[]): Set<string> => {
const ids = new Set<string>();
for (const r of flowResponses) {
if (r.nodeId) ids.add(r.nodeId);
}
return ids;
};
/**
* When `finishedNodeIds` is provided (failure iteration), refs whose target
* did not run resolve to undefined so stale values from earlier iterations
* don't leak. Global variable refs and refs targeting nodes *outside* the
* loop body bypass the filter — only in-body nodes are subject to the
* skipped-branch guard.
*/
export const readCustomOutputSnapshot = ({
customOutputInputs,
runtimeNodes,
variables,
finishedNodeIds,
childrenNodeIdList
}: {
customOutputInputs: FlowNodeInputItemType[];
runtimeNodes: RuntimeNodeItemType[];
variables: Record<string, any>;
finishedNodeIds?: Set<string>;
childrenNodeIdList?: string[];
}): Record<string, any> => {
const nodesMap = new Map(runtimeNodes.map((n) => [n.nodeId, n]));
const childrenSet = childrenNodeIdList ? new Set(childrenNodeIdList) : undefined;
const snapshot: Record<string, any> = {};
for (const item of customOutputInputs) {
const refValue = item.value;
if (finishedNodeIds) {
// Single reference: [nodeId, outputId?] — refValue[0] is a string
// Reference array: [[nodeId, outputId?], ...] — refValue[0] is a tuple
const refs: [string, string | undefined][] = !Array.isArray(refValue)
? []
: Array.isArray(refValue[0])
? (refValue as [string, string | undefined][])
: [refValue as [string, string | undefined]];
const allFinished = refs.every(([nodeId]) => {
if (!nodeId) return true;
if (nodeId === VARIABLE_NODE_ID) return true;
// Refs to nodes outside the loop body (e.g. an outer 代码运行 whose
// output is being mutated via 变量更新) aren't in this iteration's
// flowResponses — exempt them from the skipped-branch guard.
if (childrenSet && !childrenSet.has(nodeId)) return true;
return finishedNodeIds.has(nodeId);
});
if (!allFinished) {
snapshot[item.key] = undefined;
continue;
}
}
const resolved = getReferenceVariableValue({
value: refValue,
nodesMap,
variables
});
snapshot[item.key] = formatVariableValByType(resolved, item.valueType);
}
return snapshot;
};
/**
* Array mode injects 0-based index; conditional mode injects 1-based iteration.
* Mutates in place.
*/
export const injectLoopRunStart = ({
nodes,
childrenNodeIdList,
mode,
item,
index,
iteration
}: {
nodes: RuntimeNodeItemType[];
childrenNodeIdList: string[];
mode: LoopRunModeEnum;
item?: any;
index?: number;
iteration: number;
}): void => {
nodes.forEach((node) => {
if (!childrenNodeIdList.includes(node.nodeId)) return;
if (node.flowNodeType !== FlowNodeTypeEnum.loopRunStart) return;
node.isEntry = true;
node.inputs.forEach((input) => {
if (input.key === NodeInputKeyEnum.loopRunMode) {
input.value = mode;
} else if (input.key === NodeInputKeyEnum.nestedStartInput) {
input.value = mode === LoopRunModeEnum.array ? item : undefined;
} else if (input.key === NodeInputKeyEnum.nestedStartIndex) {
input.value = mode === LoopRunModeEnum.array ? index ?? 0 : iteration;
}
});
});
};
export const isLoopBreakHit = (flowResponses: ChatHistoryItemResType[]): boolean =>
flowResponses.some((r) => r.moduleType === FlowNodeTypeEnum.loopRunBreak);
export const hasLoopRunBreakChild = (
runtimeNodes: RuntimeNodeItemType[],
childrenNodeIdList: string[]
): boolean => {
const childSet = new Set(childrenNodeIdList);
return runtimeNodes.some(
(n) => childSet.has(n.nodeId) && n.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
};
......@@ -19,7 +19,7 @@ import {
aggregateParallelResults,
type ParallelFullResultItem
} from './service';
import { safePoints } from '../utils';
import { pushSubWorkflowUsage } from '../utils';
type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.nestedInputArray]: Array<any>;
......@@ -87,12 +87,12 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
});
// Push usage per attempt (resources were consumed regardless of success)
const itemUsagePoint = response.flowUsages.reduce(
(acc, usage) => acc + safePoints(usage.totalPoints),
0
);
accumulatedPoints += itemUsagePoint;
props.usagePush([{ totalPoints: itemUsagePoint, moduleName: `${name}-${index}` }]);
accumulatedPoints += pushSubWorkflowUsage({
usagePush: props.usagePush,
response,
name,
iteration: index
});
const result = parseTaskResponse({ index, response });
if (result.success) return { ...result, totalPoints: accumulatedPoints };
......
import { cloneDeep } from 'lodash';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { ParallelRunStatusEnum } from '@fastgpt/global/core/workflow/constants';
import { injectNestedStartInputs, safePoints } from '../utils';
import { collectResponseFeedbacks, injectNestedStartInputs, safePoints } from '../utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { i18nT } from '../../../../../web/i18n/utils';
......@@ -269,11 +269,7 @@ export const aggregateParallelResults = (
if (result.response) {
const response = result.response;
assistantResponses.push(...(response[DispatchNodeResponseKeyEnum.assistantResponses] || []));
const feedbacks = response[DispatchNodeResponseKeyEnum.customFeedbacks];
if (feedbacks && feedbacks.length > 0) {
customFeedbacks.push(...feedbacks);
}
collectResponseFeedbacks(response, customFeedbacks);
}
}
......
......@@ -2,6 +2,8 @@ import path from 'path';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
import type { DispatchFlowResponse } from './type';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
......@@ -598,19 +600,41 @@ export const getNodeErrResponse = ({
};
};
/**
* Coerce a points value to a finite number, defaulting to 0 for
* NaN / Infinity / null / undefined.
*/
export const safePoints = (val: number | undefined | null): number =>
Number.isFinite(val) ? (val as number) : 0;
/**
* Mutates nodes in-place: sets the nestedStart node as entry and injects the
* current item / 1-based index into its inputs.
*
* Shared by loop and parallelRun dispatchers.
*/
export const pushSubWorkflowUsage = ({
usagePush,
response,
name,
iteration
}: {
usagePush: (usages: ChatNodeUsageType[]) => void;
response: DispatchFlowResponse;
name: string;
iteration: number;
}): number => {
const itemUsagePoint = response.flowUsages.reduce(
(acc, usage) => acc + safePoints(usage.totalPoints),
0
);
usagePush([{ totalPoints: itemUsagePoint, moduleName: `${name}-${iteration}` }]);
return itemUsagePoint;
};
export const collectResponseFeedbacks = (
response: DispatchFlowResponse,
target: string[]
): string[] => {
const feedbacks = response[DispatchNodeResponseKeyEnum.customFeedbacks];
if (feedbacks && feedbacks.length > 0) {
target.push(...feedbacks);
}
return target;
};
// Sets nestedStart as entry and injects current item + 1-based index.
// Shared by loop and parallelRun dispatchers.
export const injectNestedStartInputs = ({
nodes,
childrenNodeIdList,
......
......@@ -251,6 +251,8 @@ export const iconPaths = {
'core/workflow/edgeArrow': () => import('./icons/core/workflow/edgeArrow.svg'),
'core/workflow/edgeArrowBold': () => import('./icons/core/workflow/edgeArrowBold.svg'),
'core/workflow/inputType/array': () => import('./icons/core/workflow/inputType/array.svg'),
'core/workflow/inputType/conditional': () =>
import('./icons/core/workflow/inputType/conditional.svg'),
'core/workflow/inputType/customVariable': () =>
import('./icons/core/workflow/inputType/customVariable.svg'),
'core/workflow/inputType/dynamic': () => import('./icons/core/workflow/inputType/dynamic.svg'),
......@@ -341,6 +343,17 @@ export const iconPaths = {
import('./icons/core/workflow/template/parallelRun.svg'),
'core/workflow/template/parallelRunLinear': () =>
import('./icons/core/workflow/template/parallelRunLinear.tsx'),
'core/workflow/template/loopRun': () => import('./icons/core/workflow/template/loopRun.svg'),
'core/workflow/template/loopRunLinear': () =>
import('./icons/core/workflow/template/loopRunLinear.tsx'),
'core/workflow/template/loopRunStart': () =>
import('./icons/core/workflow/template/loopRunStart.svg'),
'core/workflow/template/loopRunStartLinear': () =>
import('./icons/core/workflow/template/loopRunStartLinear.tsx'),
'core/workflow/template/loopRunBreak': () =>
import('./icons/core/workflow/template/loopRunBreak.svg'),
'core/workflow/template/loopRunBreakLinear': () =>
import('./icons/core/workflow/template/loopRunBreakLinear.tsx'),
'core/workflow/template/mathCall': () => import('./icons/core/workflow/template/mathCall.svg'),
'core/workflow/template/pluginOutput': () =>
import('./icons/core/workflow/template/pluginOutput.svg'),
......
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2.34247 2.25885C1.89753 2.25885 1.425 2.51697 1.34164 3.06164C1.2721 3.6342 1.64747 4.08372 2.23097 4.08372C2.81554 4.08372 3.28702 3.81139 3.28702 3.18414C3.28702 2.66735 2.96686 2.25885 2.34247 2.25885ZM2.25859 5.70407C1.92515 5.70407 1.55031 5.88126 1.52163 6.38437V12.6782C1.52163 13.114 1.84233 13.3191 2.2724 13.3191C2.77254 13.3191 3.06403 13.0872 3.05022 12.583V9.62563C3.03748 8.60464 3.06403 7.55521 3.05022 6.53477C3.05075 5.86706 2.71732 5.70407 2.25859 5.70407ZM9.10296 2.23097C8.83142 2.07522 8.52494 1.99564 8.21416 2.00018C6.04684 2.00018 5.37892 3.44446 5.35132 5.05002V5.79977H4.55967C4.01759 5.79977 3.79619 6.07212 3.80998 6.48007C3.80998 6.90171 3.9905 7.1199 4.49013 7.1199H5.33804C5.33804 8.23713 5.35132 9.25813 5.35132 10.1572V12.6251C5.33804 13.1425 5.69854 13.3333 6.10154 13.3333C6.49072 13.3333 6.83795 13.1282 6.83795 12.6935C6.83795 12.203 6.82468 10.6072 6.82468 10.144C6.82468 9.31281 6.81087 8.31862 6.81087 7.12044L8.15894 7.13358C8.61661 7.13358 8.81147 6.84812 8.79766 6.43905C8.79766 6.01742 8.61661 5.79925 8.08938 5.79925H6.81087C6.81087 5.48534 6.79814 5.22665 6.81087 4.94066C6.82468 4.08372 7.20006 3.32031 7.95028 3.29297C8.29699 3.27929 8.53379 3.37444 8.86722 3.37444C9.15924 3.37444 9.35357 3.04687 9.35357 2.77455C9.35357 2.54486 9.28402 2.33978 9.10296 2.23097ZM11.4349 5.74289C11.0929 5.53072 10.6411 5.63244 10.4266 5.96766C9.88292 6.81474 9.58984 8.34432 9.58984 9.35327C9.58984 10.2917 9.84893 12.0181 10.4266 12.9183C10.5657 13.1348 10.8036 13.2529 11.0467 13.2529C11.1805 13.2529 11.3149 13.2179 11.4349 13.143C11.7778 12.9325 11.8803 12.489 11.6658 12.1538C11.3212 11.6167 11.0536 10.2064 11.0536 9.35328C11.0536 8.54775 11.2995 7.30256 11.6658 6.73164C11.8798 6.39749 11.7773 5.95344 11.4349 5.74289ZM12.8206 5.74289C12.4776 5.95398 12.3752 6.39585 12.5902 6.73218C12.956 7.3031 13.2024 8.54829 13.2024 9.35383C13.2024 10.2075 12.9348 11.6173 12.5902 12.1543C12.3752 12.489 12.4776 12.933 12.8206 13.1436C12.938 13.2157 13.0721 13.2536 13.2087 13.2535C13.4525 13.2535 13.6903 13.1348 13.8294 12.9188C14.4076 12.0187 14.6667 10.2922 14.6667 9.35383C14.6667 8.34432 14.3731 6.81476 13.8294 5.96823C13.6149 5.63245 13.1625 5.53181 12.8206 5.74289Z" fill="#3370FF"/>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="4" fill="url(#paint0_linear_loopRun)"/>
<g transform="translate(4 4)" fill="none">
<path d="M11.3333 1.33325L14 3.99992M14 3.99992L11.3333 6.66658M14 3.99992H4.66667C3.95942 3.99992 3.28115 4.28087 2.78105 4.78097C2.28095 5.28106 2 5.95934 2 6.66658V7.33325M4.66667 14.6666L2 11.9999M2 11.9999L4.66667 9.33325M2 11.9999H11.3333C12.0406 11.9999 12.7189 11.719 13.219 11.2189C13.719 10.7188 14 10.0405 14 9.33325V8.66658" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<linearGradient id="paint0_linear_loopRun" x1="12" y1="24" x2="12" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#6EE7B7"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
</svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="4" fill="url(#paint0_linear_loopRunBreak)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 19.0467C15.8918 19.0467 19.0467 15.8918 19.0467 12C19.0467 8.10823 15.8918 4.95337 12 4.95337C8.10824 4.95337 4.95334 8.10823 4.95334 12C4.95334 15.8918 8.10824 19.0467 12 19.0467ZM10.335 9.66833C9.96678 9.66833 9.6683 9.96681 9.6683 10.335V13.665C9.6683 14.0332 9.96678 14.3317 10.335 14.3317H13.665C14.0332 14.3317 14.3317 14.0332 14.3317 13.665V10.335C14.3317 9.96681 14.0332 9.66833 13.665 9.66833H10.335Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_loopRunBreak" x1="12" y1="24" x2="12" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#6EE7B7"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
</svg>
import React, { useId } from 'react';
type LoopRunBreakLinearProps = React.SVGProps<SVGSVGElement>;
const LoopRunBreakLinear: React.FC<LoopRunBreakLinearProps> = (props) => {
const gradientId = useId();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
{...props}
>
<path
d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
stroke={`url(#${gradientId})`}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M30 18H18V30H30V18Z"
stroke={`url(#${gradientId})`}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<linearGradient
id="paint0_linear_32941_93"
x1="24"
y1="44"
x2="24"
y2="4"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6EE7B7" />
<stop offset="1" stop-color="#2DD4BF" />
</linearGradient>
<linearGradient
id={gradientId}
x1="24"
y1="44"
x2="24"
y2="4"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6EE7B7" />
<stop offset="1" stop-color="#2DD4BF" />
</linearGradient>
</defs>
</svg>
);
};
export default LoopRunBreakLinear;
import React, { useId } from 'react';
type LoopRunLinearProps = React.SVGProps<SVGSVGElement>;
const LoopRunLinear: React.FC<LoopRunLinearProps> = (props) => {
const gradientId = useId();
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" {...props}>
<g transform="translate(4 4) scale(2.5)">
<path
d="M11.3333 1.33325L14 3.99992M14 3.99992L11.3333 6.66658M14 3.99992H4.66667C3.95942 3.99992 3.28115 4.28087 2.78105 4.78097C2.28095 5.28106 2 5.95934 2 6.66658V7.33325M4.66667 14.6666L2 11.9999M2 11.9999L4.66667 9.33325M2 11.9999H11.3333C12.0406 11.9999 12.7189 11.719 13.219 11.2189C13.719 10.7188 14 10.0405 14 9.33325V8.66658"
stroke={`url(#${gradientId})`}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<linearGradient
id={gradientId}
x1="24"
y1="44"
x2="24"
y2="4"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#6EE7B7" />
<stop offset="1" stopColor="#2DD4BF" />
</linearGradient>
</defs>
</svg>
);
};
export default LoopRunLinear;
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="4" fill="url(#paint0_linear_loopRunStart)"/>
<path d="M17.8498 12.1179C18.4125 11.9865 18.7471 11.407 18.5795 10.8539L17.3578 6.82341C17.1039 5.98573 15.9724 5.85545 15.5348 6.61349L14.9437 7.63725C13.9606 7.31824 12.9154 7.20957 11.877 7.3254C10.3437 7.49642 8.9039 8.14811 7.76357 9.1872C6.62324 10.2263 5.84087 11.5995 5.52844 13.1103C5.28383 14.2931 5.3371 15.5127 5.67632 16.6613C5.83275 17.191 6.43226 17.4139 6.93565 17.1867L8.22228 16.606C8.72567 16.3788 8.93516 15.7852 8.84311 15.2406C8.7629 14.7662 8.77073 14.2781 8.86935 13.8012C9.03598 12.9954 9.45323 12.2631 10.0614 11.7089C10.6696 11.1547 11.4375 10.8072 12.2552 10.716C12.5588 10.6821 12.8635 10.6842 13.1632 10.7211L12.6551 11.6013C12.2174 12.3593 12.896 13.274 13.7484 13.0751L17.8498 12.1179Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_loopRunStart" x1="12" y1="24" x2="12" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#6EE7B7"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
</svg>
import React, { useId } from 'react';
type LoopRunStartLinearProps = React.SVGProps<SVGSVGElement>;
const LoopRunStartLinear: React.FC<LoopRunStartLinearProps> = (props) => {
const gradientId = useId();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
{...props}
>
<path
d="M42 14V26M42 26H30M42 26L36 20.6C32.7023 17.6423 28.4298 16.0045 24 16C19.2261 16 14.6477 17.8964 11.2721 21.2721C7.89642 24.6477 6 29.2261 6 34"
stroke={`url(#${gradientId})`}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<defs>
<linearGradient
id={gradientId}
x1="24"
y1="34"
x2="24"
y2="14"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#6EE7B7" />
<stop offset="1" stop-color="#2DD4BF" />
</linearGradient>
</defs>
</svg>
);
};
export default LoopRunStartLinear;
......@@ -18,7 +18,7 @@ import {
Flex,
Input
} from '@chakra-ui/react';
import type { ButtonProps, MenuItemProps } from '@chakra-ui/react';
import type { ButtonProps, MenuItemProps, MenuProps } from '@chakra-ui/react';
import MyIcon from '../Icon';
import { useRequest } from '../../../hooks/useRequest';
import MyDivider from '../MyDivider';
......@@ -54,6 +54,7 @@ export type SelectProps<T = any> = Omit<ButtonProps, 'onChange'> & {
ScrollData?: ReturnType<typeof useScrollPagination>['ScrollData'];
customOnOpen?: () => void;
customOnClose?: () => void;
menuPlacement?: MenuProps['placement'];
isInvalid?: boolean;
isDisabled?: boolean;
......@@ -86,6 +87,7 @@ const MySelect = <T = any,>(
ScrollData,
customOnOpen,
customOnClose,
menuPlacement,
isInvalid,
isDisabled,
...props
......@@ -205,6 +207,7 @@ const MySelect = <T = any,>(
onOpen={onOpen}
onClose={onClose}
strategy={'fixed'}
placement={menuPlacement}
// matchWidth
>
<MenuButton
......
......@@ -394,6 +394,9 @@
"core.chat.response.parallel_input": "Parallel Input Array",
"core.chat.response.parallel_output": "Parallel Success Results",
"core.chat.response.parallel_run_detail": "Parallel Run Details",
"core.chat.response.loop_run_input": "Loop Input",
"core.chat.response.loop_run_iterations": "Iterations",
"core.chat.response.loop_run_history": "Loop History",
"core.chat.response.loop_input_element": "Loop Input Element",
"core.chat.response.loop_output": "Loop Output Array",
"core.chat.response.loop_output_element": "Loop Output Element",
......
......@@ -143,6 +143,31 @@
"parallel_task": "Task {{index}}",
"parallel_task_not_reach_end": "Sub-workflow did not reach the end node",
"parallel_task_interactive_not_supported": "Sub-workflow triggered an interactive response, which is not supported in parallel runs",
"loop_run": "Loop",
"intro_loop_run": "Run a repeating sub-workflow; the loop's outputs decide whether to continue into the next iteration.",
"loop_run_mode": "Loop type",
"loop_run_mode_tip": "Choose how the loop is driven: iterate over array elements, or run until a Loop break node is hit.",
"loop_run_mode_array": "Array loop",
"loop_run_mode_array_desc": "Process each item in the list in order",
"loop_run_mode_conditional": "Conditional loop",
"loop_run_mode_conditional_desc": "Keep running until a specific condition is met",
"loop_run_input_array": "Array",
"loop_custom_outputs": "Custom outputs",
"loop_custom_outputs_tip": "Declare output fields exposed by the loop. Each field references an output of a node inside the sub-workflow.",
"loop_run_break": "Loop break",
"loop_run_break_tip": "This module must be used inside a loop node. When executed, it will force the loop node to stop running.",
"loop_run_break_must_inside_loop_run": "Loop break node can only be placed inside a Loop container",
"loop_run_conditional_requires_break": "Conditional loop must contain at least one Loop break node",
"loop_run_input_not_array": "Loop input is not an array",
"loop_run_iteration_failed": "Loop iteration failed",
"loop_run_max_iterations_exceeded": "Loop reached maximum iteration limit",
"loop_run_start": "Loop start",
"current_index": "Current index",
"current_index_desc": "0-based index of the current element in array mode.",
"current_item": "Current item",
"current_item_desc": "Element being processed in the current iteration (array mode).",
"current_iteration": "Current loop count",
"current_iteration_desc": "1-based iteration counter in conditional mode.",
"max_dialog_rounds": "Maximum Number of Dialog Rounds",
"max_tokens": "Maximum Tokens",
"mouse_priority": "Mouse first\n- Press the left button to drag the canvas\n- Hold down shift and left click to select batches",
......
......@@ -394,6 +394,9 @@
"core.chat.response.parallel_input": "并行输入数组",
"core.chat.response.parallel_output": "并行成功结果",
"core.chat.response.parallel_run_detail": "并行执行明细",
"core.chat.response.loop_run_input": "循环输入",
"core.chat.response.loop_run_iterations": "循环轮数",
"core.chat.response.loop_run_history": "循环历史",
"core.chat.response.loop_input_element": "输入数组元素",
"core.chat.response.loop_output": "输出数组",
"core.chat.response.loop_output_element": "输出数组元素",
......
......@@ -143,6 +143,31 @@
"parallel_task": "任务 {{index}}",
"parallel_task_not_reach_end": "子工作流未到达结束节点",
"parallel_task_interactive_not_supported": "子工作流触发了交互式响应,并行执行节点不支持交互式节点",
"loop_run": "循环节点",
"intro_loop_run": "执行重复性工作流,并由循环结果决定是否进行下一次操作。",
"loop_run_mode": "循环类型",
"loop_run_mode_tip": "选择循环驱动方式:按数组元素依次执行,或持续执行直到命中「循环终止」。",
"loop_run_mode_array": "数组循环",
"loop_run_mode_array_desc": "按顺序处理列表内每一项",
"loop_run_mode_conditional": "条件循环",
"loop_run_mode_conditional_desc": "重复运行直至满足特定条件",
"loop_run_input_array": "数组",
"loop_custom_outputs": "自定义输出",
"loop_custom_outputs_tip": "声明对外暴露的输出字段,每个字段引用子流程内某个节点的输出。",
"loop_run_break": "循环终止",
"loop_run_break_tip": "该模块需要在循环节点内部使用。当该模块被执行时,将强制结束循环节点的运行。",
"loop_run_break_must_inside_loop_run": "循环终止节点只能放在循环执行节点内部",
"loop_run_conditional_requires_break": "条件循环必须至少包含一个循环终止节点",
"loop_run_input_not_array": "循环输入值不是数组",
"loop_run_iteration_failed": "循环执行失败",
"loop_run_max_iterations_exceeded": "循环达最大上限",
"loop_run_start": "循环开始",
"current_index": "当前下标",
"current_index_desc": "数组模式下当前元素的 0-based 下标。",
"current_item": "当前元素",
"current_item_desc": "数组模式下当前迭代处理的元素。",
"current_iteration": "当前循环次数",
"current_iteration_desc": "条件循环模式下的 1-based 迭代次数。",
"max_dialog_rounds": "最多携带多少轮对话记录",
"max_tokens": "最大 Tokens",
"mouse_priority": "鼠标优先\n- 左键按下后可拖动画布\n- 按住 shift 后左键可批量选择",
......
......@@ -390,6 +390,9 @@
"core.chat.response.parallel_input": "並行輸入陣列",
"core.chat.response.parallel_output": "並行成功結果",
"core.chat.response.parallel_run_detail": "並行執行明細",
"core.chat.response.loop_run_input": "迴圈輸入",
"core.chat.response.loop_run_iterations": "迴圈輪數",
"core.chat.response.loop_run_history": "迴圈歷史",
"core.chat.response.loop_input_element": "輸入陣列元素",
"core.chat.response.loop_output": "輸出陣列",
"core.chat.response.loop_output_element": "輸出陣列元素",
......
......@@ -143,6 +143,31 @@
"parallel_task": "任務 {{index}}",
"parallel_task_not_reach_end": "子工作流未到達結束節點",
"parallel_task_interactive_not_supported": "子工作流觸發了互動式回應,並行執行節點不支援互動式節點",
"loop_run": "迴圈節點",
"intro_loop_run": "執行重複性工作流,由迴圈結果決定是否進行下一次操作。",
"loop_run_mode": "迴圈類型",
"loop_run_mode_tip": "選擇迴圈驅動方式:依陣列元素逐一執行,或持續執行直到命中「迴圈終止」。",
"loop_run_mode_array": "陣列迴圈",
"loop_run_mode_array_desc": "依序處理清單內每一項",
"loop_run_mode_conditional": "條件迴圈",
"loop_run_mode_conditional_desc": "重複執行直到滿足特定條件",
"loop_run_input_array": "陣列",
"loop_custom_outputs": "自訂輸出",
"loop_custom_outputs_tip": "宣告對外曝露的輸出欄位,每個欄位引用子流程內某個節點的輸出。",
"loop_run_break": "迴圈終止",
"loop_run_break_tip": "此模組需要在迴圈節點內部使用。當此模組被執行時,將強制結束迴圈節點的執行。",
"loop_run_break_must_inside_loop_run": "迴圈終止節點只能放在迴圈執行節點內部",
"loop_run_conditional_requires_break": "條件迴圈必須至少包含一個迴圈終止節點",
"loop_run_input_not_array": "迴圈輸入值不是陣列",
"loop_run_iteration_failed": "迴圈執行失敗",
"loop_run_max_iterations_exceeded": "迴圈達最大上限",
"loop_run_start": "迴圈開始",
"current_index": "目前索引",
"current_index_desc": "陣列模式下目前元素的 0-based 索引。",
"current_item": "目前元素",
"current_item_desc": "陣列模式下目前迭代處理的元素。",
"current_iteration": "目前迴圈次數",
"current_iteration_desc": "條件迴圈模式下的 1-based 迭代次數。",
"max_dialog_rounds": "最多攜帶幾輪對話紀錄",
"max_tokens": "最大 Token 數",
"mouse_priority": "滑鼠優先\n- 按下左鍵拖曳畫布\n- 按住 Shift 鍵並點選左鍵可批次選取",
......
......@@ -178,9 +178,15 @@ const InputRender = (props: InputRenderProps) => {
}
if (inputType === InputTypeEnum.select) {
const list =
const rawList: { label: string; value: string; icon?: string; description?: string }[] =
props.list || props.enums?.map((item) => ({ label: item.value, value: item.value })) || [];
return <MySelect {...commonProps} list={list} h={10} />;
const list = rawList.map((item) => ({
...item,
label: typeof item.label === 'string' ? t(item.label as any) : item.label,
description:
typeof item.description === 'string' ? t(item.description as any) : item.description
}));
return <MySelect {...commonProps} list={list} h={10} menuPlacement={props.menuPlacement} />;
}
if (inputType === InputTypeEnum.multipleSelect) {
......
......@@ -5,7 +5,7 @@ import type {
import type { InputTypeEnum } from './constant';
import type { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import type { UseFormReturn } from 'react-hook-form';
import type { BoxProps } from '@chakra-ui/react';
import type { BoxProps, MenuProps } from '@chakra-ui/react';
import type { EditorProps } from '@fastgpt/web/components/common/Textarea/PromptEditor/Editor';
import type { SelectedDatasetType } from '@fastgpt/global/core/workflow/type/io';
......@@ -41,8 +41,9 @@ export type SpecificProps = {
// switch - no extra props
// select & multipleSelect
list?: { label: string; value: string }[];
list?: { label: string; value: string; icon?: string; description?: string }[];
enums?: { value: string }[]; // old version
menuPlacement?: MenuProps['placement'];
// selectDataset
datasetOptions?: SelectedDatasetType[];
......
......@@ -900,9 +900,12 @@ const ChatBox = ({
const responseData = mergeChatResponseData(item.responseData || []);
// Check node response error
if (!abortSignal?.signal?.aborted) {
const err =
responseData[responseData.length - 1]?.error ||
responseData[responseData.length - 1]?.errorText;
// `.error` is dispatcher-injected only on uncaught failures — scan all items
// so uncaught errors in nested/mid-workflow nodes still surface. Last-entry
// `.errorText` covers the misconfigured "catchError=true, no handler wired"
// case where only errorText was set.
const uncaughtErr = responseData.find((r) => r.error)?.error;
const err = uncaughtErr ?? responseData[responseData.length - 1]?.errorText;
if (err) {
toast({
title: t(getErrText(err)),
......
......@@ -127,7 +127,7 @@ export const WholeResponseContent = ({
border: '1px solid',
borderColor: 'myGray.200',
color: 'myGray.900',
bg: '#F7F8FA'
bg: 'myGray.50'
})}
>
<Box
......@@ -186,8 +186,10 @@ export const WholeResponseContent = ({
)}
/>
)}
<Row label={t('workflow:response.Error')} value={activeModule?.error} />
<Row label={t('workflow:response.Error')} value={activeModule?.errorText} />
<Row
label={t('workflow:response.Error')}
value={activeModule?.errorText ?? activeModule?.error}
/>
<Row label={t('chat:response.node_inputs')} value={activeModule?.nodeInputs} />
</>
{/* ai chat */}
......@@ -246,8 +248,8 @@ export const WholeResponseContent = ({
role={'group'}
alignItems={'center'}
gap={2}
bg={'myGray.50'}
borderRadius={'8px'}
bg={'myGray.100'}
borderRadius={'6px'}
px={3}
py={2}
cursor={'pointer'}
......@@ -260,7 +262,8 @@ export const WholeResponseContent = ({
flex={'1 0 0'}
w={0}
fontSize={'12px'}
lineHeight={'18px'}
lineHeight={'16px'}
letterSpacing={'0.4px'}
textOverflow={'ellipsis'}
overflow={'hidden'}
whiteSpace={'nowrap'}
......@@ -509,9 +512,22 @@ export const WholeResponseContent = ({
/>
{/* update var */}
{/* `updateVarResult` is `updateList.map(...)` — outer dim = rows in the
variable-update config. Single-row is the common case, where the
outer 1-element wrapper is noise (esp. bad when inner is itself an
array → visual `[[...]]`). Unwrap it for all value types for
consistency, but keep the wrapper if inner is null/undefined:
Row hides rows whose `val` falsey-coerces to undefined, and `[null]`
preserves the "invalid reference" signal this node emits. */}
<Row
label={t('common:core.chat.response.update_var_result')}
value={activeModule?.updateVarResult}
value={(() => {
const r = activeModule?.updateVarResult;
if (Array.isArray(r) && r.length === 1 && r[0] !== null && r[0] !== undefined) {
return r[0];
}
return r;
})()}
/>
{/* loop */}
......@@ -532,6 +548,20 @@ export const WholeResponseContent = ({
value={activeModule?.parallelRunDetail}
/>
{/* loopRun */}
<Row
label={t('common:core.chat.response.loop_run_input')}
value={activeModule?.loopRunInput}
/>
<Row
label={t('common:core.chat.response.loop_run_iterations')}
value={activeModule?.loopRunIterations}
/>
<Row
label={t('common:core.chat.response.loop_run_history')}
value={activeModule?.loopRunHistory}
/>
{/* loopStart */}
<Row
label={t('common:core.chat.response.loop_input_element')}
......@@ -776,6 +806,9 @@ export const ResponseBox = React.memo(function ResponseBox({
if (Array.isArray(item.parallelDetail)) {
helper(item.parallelDetail);
}
if (Array.isArray(item.loopRunDetail)) {
helper(item.loopRunDetail);
}
if (Array.isArray(item.childrenResponses)) {
helper(item.childrenResponses);
}
......@@ -811,6 +844,7 @@ export const ResponseBox = React.memo(function ResponseBox({
if (item?.pluginDetail) children.push(...pretreatmentResponse(item?.pluginDetail));
if (item?.loopDetail) children.push(...pretreatmentResponse(item?.loopDetail));
if (item?.parallelDetail) children.push(...pretreatmentResponse(item?.parallelDetail));
if (item?.loopRunDetail) children.push(...pretreatmentResponse(item?.loopRunDetail));
if (item?.childrenResponses)
children.push(...pretreatmentResponse(item?.childrenResponses));
......
import MyBox from '@fastgpt/web/components/common/MyBox';
import React from 'react';
import { useContextSelector } from 'use-context-selector';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
EDGE_TYPE,
FlowNodeTypeEnum,
isNestedChildSystemNodeType
} from '@fastgpt/global/core/workflow/node/constant';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { type Node } from 'reactflow';
import { WorkflowBufferDataContext } from '../context/workflowInitContext';
......@@ -57,11 +61,7 @@ const NodeTemplatesPopover = () => {
}
// 2. Exclude loop start and end nodes
if (
[FlowNodeTypeEnum.nestedStart, FlowNodeTypeEnum.nestedEnd].includes(
node.data.flowNodeType
)
) {
if (isNestedChildSystemNodeType(node.data.flowNodeType)) {
return false;
}
......
......@@ -40,6 +40,7 @@ import { useWorkflowUtils } from '../../hooks/useUtils';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import { LoopStartNode } from '@fastgpt/global/core/workflow/template/system/loop/loopStart';
import { LoopEndNode } from '@fastgpt/global/core/workflow/template/system/loop/loopEnd';
import { LoopRunStartNode } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunStart';
import { useReactFlow } from 'reactflow';
import type { Node } from 'reactflow';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
......@@ -224,6 +225,7 @@ const NodeTemplateList = ({
const { computedNewNodeName } = useWorkflowUtils();
const { getNodeList, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const handleParams = useContextSelector(WorkflowModalContext, (v) => v.handleParams);
const { getIntersectingNodes } = useReactFlow();
const showSkill = !!feConfigs?.show_skill;
......@@ -278,8 +280,27 @@ const NodeTemplateList = ({
});
const currentNode = getNodeById(handleParams?.nodeId);
// Popover insertion inherits the source node's parent; a dragged
// loopRunBreak with no inherited parent falls back to hit-testing.
let effectiveParentNodeId: string | undefined = currentNode?.parentNodeId;
if (templateNode.flowNodeType === FlowNodeTypeEnum.loopRunBreak && !effectiveParentNodeId) {
const dropLoopRun = getIntersectingNodes({
x: position.x,
y: position.y,
width: 1,
height: 1
}).find((n) => n.type === FlowNodeTypeEnum.loopRun && !n.data?.isFolded);
if (dropLoopRun) {
effectiveParentNodeId = dropLoopRun.id;
}
}
const effectiveParentNode = effectiveParentNodeId
? getNodeById(effectiveParentNodeId)
: undefined;
const isNestedParentNode = isNestedParentNodeType(templateNode.flowNodeType);
if (isNestedParentNode && !!currentNode?.parentNodeId) {
if (isNestedParentNode && !!effectiveParentNodeId) {
toast({
status: 'warning',
title: t('workflow:can_not_loop')
......@@ -287,10 +308,8 @@ const NodeTemplateList = ({
return;
}
// Forbid interactive nodes inside parallelRun
if (currentNode?.parentNodeId && isInteractiveNodeType(templateNode.flowNodeType)) {
const parentNode = getNodeById(currentNode.parentNodeId);
if (parentNode?.flowNodeType === FlowNodeTypeEnum.parallelRun) {
if (effectiveParentNodeId && isInteractiveNodeType(templateNode.flowNodeType)) {
if (effectiveParentNode?.flowNodeType === FlowNodeTypeEnum.parallelRun) {
toast({
status: 'warning',
title: t('workflow:can_not_parallel')
......@@ -299,6 +318,16 @@ const NodeTemplateList = ({
}
}
if (templateNode.flowNodeType === FlowNodeTypeEnum.loopRunBreak) {
if (effectiveParentNode?.flowNodeType !== FlowNodeTypeEnum.loopRun) {
toast({
status: 'warning',
title: t('workflow:loop_run_break_must_inside_loop_run')
});
return;
}
}
const newNode = nodeTemplate2FlowNode({
template: {
...templateNode,
......@@ -318,7 +347,15 @@ const NodeTemplateList = ({
description: input.description ? t(input.description as any) : undefined,
placeholder: input.placeholder ? t(input.placeholder as any) : undefined,
debugLabel: input.debugLabel ? t(input.debugLabel as any) : undefined,
toolDescription: input.toolDescription ? t(input.toolDescription as any) : undefined
toolDescription: input.toolDescription
? t(input.toolDescription as any)
: undefined,
list: Array.isArray(input.list)
? input.list.map((opt: any) => ({
...opt,
label: opt?.label ? t(opt.label as any) : opt?.label
}))
: input.list
})),
outputs: templateNode.outputs
.filter((output) => output.deprecated !== true)
......@@ -331,27 +368,37 @@ const NodeTemplateList = ({
},
position,
selected: true,
parentNodeId: currentNode?.parentNodeId,
parentNodeId: effectiveParentNodeId,
t
});
const newNodes = [newNode];
if (isNestedParentNodeType(templateNode.flowNodeType)) {
const startNode = nodeTemplate2FlowNode({
template: LoopStartNode,
position: { x: position.x + 60, y: position.y + 280 },
parentNodeId: newNode.id,
t
});
const endNode = nodeTemplate2FlowNode({
template: LoopEndNode,
position: { x: position.x + 420, y: position.y + 680 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode, endNode);
// loopRun uses its own Start node and no End node.
if (templateNode.flowNodeType === FlowNodeTypeEnum.loopRun) {
const startNode = nodeTemplate2FlowNode({
template: LoopRunStartNode,
position: { x: position.x + 60, y: position.y + 280 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode);
} else {
const startNode = nodeTemplate2FlowNode({
template: LoopStartNode,
position: { x: position.x + 60, y: position.y + 280 },
parentNodeId: newNode.id,
t
});
const endNode = nodeTemplate2FlowNode({
template: LoopEndNode,
position: { x: position.x + 420, y: position.y + 680 },
parentNodeId: newNode.id,
t
});
newNodes.push(startNode, endNode);
}
}
if (newNodes && newNodes.length > 0) {
......@@ -363,7 +410,16 @@ const NodeTemplateList = ({
console.error('Failed to create node template:', error);
}
},
[computedNewNodeName, getNodeById, handleParams?.nodeId, getNodeList, onAddNode, t, toast]
[
computedNewNodeName,
getNodeById,
handleParams?.nodeId,
getNodeList,
getIntersectingNodes,
onAddNode,
t,
toast
]
);
const formatTemplatesArrayData = useMemo(() => {
......
......@@ -23,10 +23,8 @@ export const useNodeTemplates = () => {
const [parentId, setParentId] = useState<ParentIdType>('');
const appId = useContextSelector(AppContext, (v) => v.appDetail._id);
const { basicNodeTemplates, hasToolNode, getNodeList, nodeAmount } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
const { basicNodeTemplates, hasToolNode, hasLoopRunNode, getNodeList, nodeAmount } =
useContextSelector(WorkflowBufferDataContext, (v) => v);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const { data: toolTags = [] } = useRequest(getPluginToolTags, {
......@@ -59,6 +57,10 @@ export const useNodeTemplates = () => {
) {
return false;
}
// loopRunBreak only shows when a loopRun node exists on the canvas
if (!hasLoopRunNode && item.flowNodeType === FlowNodeTypeEnum.loopRunBreak) {
return false;
}
return true;
})
.map<NodeTemplateListItemType>((item) => ({
......@@ -74,7 +76,7 @@ export const useNodeTemplates = () => {
{
manual: false,
throttleWait: 100,
refreshDeps: [basicNodeTemplates, nodeAmount, hasToolNode, templateType]
refreshDeps: [basicNodeTemplates, nodeAmount, hasToolNode, hasLoopRunNode, templateType]
}
);
......
......@@ -15,7 +15,7 @@ import { isValidArrayReferenceValue } from '@fastgpt/global/core/workflow/utils'
import { type ReferenceArrayValueType } from '@fastgpt/global/core/workflow/type/io';
import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { WorkflowBufferDataContext } from '../../context/workflowInitContext';
import { WorkflowBufferDataContext, WorkflowInitContext } from '../../context/workflowInitContext';
import { WorkflowActionsContext } from '../../context/workflowActionsContext';
import { WorkflowLayoutContext } from '../../context/workflowComputeContext';
import { getWorkflowGlobalVariables } from '@/web/core/workflow/utils';
......@@ -24,6 +24,8 @@ import { AppContext } from '../../../context';
type UseNestedNodeParams = {
nodeId: string;
inputs: FlowNodeInputItemType[];
// Pass `undefined` to skip array valueType inference (loopRun conditional mode).
arrayInputKey?: NodeInputKeyEnum;
};
type UseNestedNodeResult = {
......@@ -32,19 +34,12 @@ type UseNestedNodeResult = {
inputBoxRef: React.RefObject<HTMLDivElement>;
};
/**
* Shared hook for nested-container nodes (Loop & ParallelRun).
*
* Encapsulates five pieces of logic that are identical in both components:
* 1. Read nodeWidth / nodeHeight / nestedInputArray / loopNodeInputHeight from inputs
* 2. Infer array valueType from the referenced output and sync it back
* 3. Maintain childrenNodeIdList and trigger resetParentNodeSizeAndPosition
* 4. Measure the input-box height with useSize and sync nestedNodeInputHeight
* 5. Trigger resetParentNodeSizeAndPosition after height changes
*
* Returns only what the component JSX needs (nodeWidth, nodeHeight, inputBoxRef).
*/
export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNestedNodeResult => {
// Shared hook for nested-container nodes (Loop / ParallelRun / LoopRun).
export const useNestedNode = ({
nodeId,
inputs,
arrayInputKey = NodeInputKeyEnum.nestedInputArray
}: UseNestedNodeParams): UseNestedNodeResult => {
const { getNodeById, nodeIds, childNodeIds, getNodeList, systemConfigNode } = useContextSelector(
WorkflowBufferDataContext,
(v) => {
......@@ -57,6 +52,17 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
};
}
);
// 订阅子节点尺寸变化:ReactFlow 完成测量后会更新 node.width / node.height,
// 把它们压成字符串当 signal,有变化就重算 bounds,避免 50ms 定时器抢跑在测量前。
const childDimensionsSignal = useContextSelector(WorkflowInitContext, (v) => {
let signal = '';
for (const node of v.nodes) {
if (node.data.parentNodeId === nodeId) {
signal += `${node.id}:${node.width ?? 0}x${node.height ?? 0}|`;
}
}
return signal;
});
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const resetParentNodeSizeAndPosition = useContextSelector(
......@@ -73,12 +79,14 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
nodeHeight: Math.round(
Number(inputs.find((input) => input.key === NodeInputKeyEnum.nodeHeight)?.value) || 500
),
nestedInputArray: inputs.find((input) => input.key === NodeInputKeyEnum.nestedInputArray),
nestedInputArray: arrayInputKey
? inputs.find((input) => input.key === arrayInputKey)
: undefined,
loopNodeInputHeight: inputs.find(
(input) => input.key === NodeInputKeyEnum.nestedNodeInputHeight
)
};
}, [inputs]);
}, [inputs, arrayInputKey]);
const nestedInputArray = useMemoEnhance(
() => computedResult.nestedInputArray,
......@@ -117,19 +125,19 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
}, [appDetail.chatConfig, getNodeById, nestedInputArray, nodeIds, systemConfigNode]);
useEffect(() => {
if (!nestedInputArray || nestedInputArray.valueType === newValueType) return;
if (!nestedInputArray || !arrayInputKey || nestedInputArray.valueType === newValueType) return;
onChangeNode({
nodeId,
type: 'updateInput',
key: NodeInputKeyEnum.nestedInputArray,
key: arrayInputKey,
value: {
...nestedInputArray,
valueType: newValueType
}
});
}, [nestedInputArray, newValueType, nodeId, onChangeNode]);
}, [nestedInputArray, newValueType, nodeId, onChangeNode, arrayInputKey]);
// ── 3. Maintain childrenNodeIdList ──────────────────────────────────────────
// ── 3a. Maintain childrenNodeIdList ─────────────────────────────────────────
useEffect(() => {
onChangeNode({
nodeId,
......@@ -140,10 +148,15 @@ export const useNestedNode = ({ nodeId, inputs }: UseNestedNodeParams): UseNeste
value: childNodeIds
}
});
// 等待 ReactFlow 完成新子节点的宽高测量后再计算,否则 bounds 会少算整个新节点
}, [childNodeIds, nodeId, onChangeNode]);
// ── 3b. Trigger layout reset on child id / dimension change ─────────────────
// 依赖 childDimensionsSignal,子节点被 ReactFlow 测量出新的 w/h 后会再触发一次,
// 确保 bounds 计算基于真实尺寸,而不是赶在 50ms 定时器到期时还是 0 的状态。
useEffect(() => {
const timer = setTimeout(() => resetParentNodeSizeAndPosition(nodeId), 50);
return () => clearTimeout(timer);
}, [childNodeIds, nodeId, onChangeNode, resetParentNodeSizeAndPosition]);
}, [childNodeIds, childDimensionsSignal, nodeId, resetParentNodeSizeAndPosition]);
// ── 4 & 5. Measure input-box height, sync and re-layout ────────────────────
const inputBoxRef = useRef<HTMLDivElement>(null);
......
......@@ -19,6 +19,7 @@ import {
FlowNodeTypeEnum,
isNestedParentNodeType
} from '@fastgpt/global/core/workflow/node/constant';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import 'reactflow/dist/style.css';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next';
......@@ -450,7 +451,11 @@ export const useRAF = () => {
export const popoverWidth = 400;
export const popoverHeight = 600;
// 嵌套父容器节点类型集合
const PARENT_NODE_TYPES = new Set([FlowNodeTypeEnum.loop, FlowNodeTypeEnum.parallelRun]);
const PARENT_NODE_TYPES = new Set([
FlowNodeTypeEnum.loop,
FlowNodeTypeEnum.parallelRun,
FlowNodeTypeEnum.loopRun
]);
export const useWorkflow = () => {
const { toast } = useToast();
......@@ -500,11 +505,12 @@ export const useWorkflow = () => {
}
);
// Check if a node is placed on top of a nested parent node (loop / parallelRun)
// Check if a node is placed on top of a nested parent node (loop / parallelRun / loopRun)
const checkNodeOverLoopNode = useMemoizedFn((node: Node) => {
const unSupportedInLoop = [
FlowNodeTypeEnum.workflowStart,
FlowNodeTypeEnum.loop,
FlowNodeTypeEnum.loopRun,
FlowNodeTypeEnum.parallelRun,
FlowNodeTypeEnum.pluginInput,
FlowNodeTypeEnum.pluginOutput,
......@@ -526,6 +532,16 @@ export const useWorkflow = () => {
);
if (parentNode) {
if (
node.type === FlowNodeTypeEnum.loopRunBreak &&
parentNode.type !== FlowNodeTypeEnum.loopRun
) {
return toast({
status: 'warning',
title: t('workflow:loop_run_break_must_inside_loop_run')
});
}
const isParallel = parentNode.type === FlowNodeTypeEnum.parallelRun;
const unSupportedTypes = isParallel ? unSupportedInParallel : unSupportedInLoop;
if (unSupportedTypes.includes(node.type as FlowNodeTypeEnum)) {
......@@ -727,6 +743,9 @@ export const useWorkflow = () => {
);
const handleNodesChange = useMemoizedFn((changes: NodeChange[]) => {
const childChanges: NodeChange[] = [];
const removedIds = new Set(
changes.filter((c): c is NodeRemoveChange => c.type === 'remove').map((c) => c.id)
);
for (const change of changes) {
if (change.type === 'remove') {
......@@ -744,6 +763,35 @@ export const useWorkflow = () => {
});
continue;
}
// Conditional loopRun must retain at least one loopRunBreak child.
if (
node.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak &&
node.data.parentNodeId &&
!parentNodeDeleted
) {
const parent = getRawNodeById(node.data.parentNodeId);
const parentMode = parent?.data.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)
?.value as LoopRunModeEnum | undefined;
if (
parent?.data.flowNodeType === FlowNodeTypeEnum.loopRun &&
parentMode === LoopRunModeEnum.conditional
) {
const remainingBreak = nodes.some(
(n) =>
n.data.parentNodeId === parent.id &&
n.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak &&
!removedIds.has(n.id)
);
if (!remainingBreak) {
toast({
status: 'warning',
title: t('workflow:loop_run_conditional_requires_break')
});
removedIds.delete(change.id);
continue;
}
}
}
handleRemoveNode(change, node.id);
} else if (change.type === 'select') {
handleSelectNode(change);
......@@ -774,6 +822,8 @@ export const useWorkflow = () => {
const onNodeDragStop = useCallback(
(_: any, node: Node) => {
setHelperLineHorizontal(undefined);
setHelperLineVertical(undefined);
checkNodeOverLoopNode(node);
},
[checkNodeOverLoopNode]
......
......@@ -62,6 +62,9 @@ const nodeTypes: Record<FlowNodeTypeEnum, any> = {
[FlowNodeTypeEnum.userSelect]: dynamic(() => import('./nodes/NodeUserSelect')),
[FlowNodeTypeEnum.loop]: dynamic(() => import('./nodes/Loop/NodeLoop')),
[FlowNodeTypeEnum.parallelRun]: dynamic(() => import('./nodes/Loop/NodeParallelRun')),
[FlowNodeTypeEnum.loopRun]: dynamic(() => import('./nodes/Loop/NodeLoopRun')),
[FlowNodeTypeEnum.loopRunStart]: dynamic(() => import('./nodes/Loop/NodeLoopRunStart')),
[FlowNodeTypeEnum.loopRunBreak]: dynamic(() => import('./nodes/Loop/NodeLoopRunBreak')),
[FlowNodeTypeEnum.nestedStart]: dynamic(() => import('./nodes/Loop/NodeLoopStart')),
[FlowNodeTypeEnum.nestedEnd]: dynamic(() => import('./nodes/Loop/NodeLoopEnd')),
[FlowNodeTypeEnum.formInput]: dynamic(() => import('./nodes/NodeFormInput')),
......
......@@ -39,7 +39,7 @@ const NodeLoop = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
flex={1}
position={'relative'}
border={'base'}
bg={'myGray.50'}
bg={'myGray.100'}
rounded={'8px'}
{...(!isFolded && {
minW: nodeWidth,
......
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import React, { useEffect, useMemo, useRef } from 'react';
import { type NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
import Container from '../../components/Container';
import IOTitle from '../../components/IOTitle';
import { useTranslation } from 'next-i18next';
import RenderInput from '../render/RenderInput';
import { Box } from '@chakra-ui/react';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import RenderOutput from '../render/RenderOutput';
import CatchError from '../render/RenderOutput/CatchError';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import {
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { LoopRunBreakNode as LoopRunBreakTemplate } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRunBreak';
import { useNestedNode } from '../../hooks/useNestedNode';
import { useContextSelector } from 'use-context-selector';
import { WorkflowActionsContext } from '../../../context/workflowActionsContext';
import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext';
import { WorkflowBufferDataContext } from '../../../context/workflowInitContext';
import { WorkflowInitContext } from '../../../context/workflowInitContext';
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
const NodeLoopRun = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, inputs, outputs, isFolded, catchError } = data;
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const splitOutput = useContextSelector(WorkflowUtilsContext, (v) => v.splitOutput);
const { getNodeById, setNodes, childrenNodeIdListMap } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
const childNodeIds = childrenNodeIdListMap[nodeId] ?? [];
const getRawNodeById = useContextSelector(WorkflowInitContext, (v) => v.getRawNodeById);
const mode =
(inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined) ?? LoopRunModeEnum.array;
// Conditional mode has no array input; skip valueType inference in the hook.
const arrayInputKey =
mode === LoopRunModeEnum.array ? NodeInputKeyEnum.loopRunInputArray : undefined;
const { nodeWidth, nodeHeight, inputBoxRef } = useNestedNode({ nodeId, inputs, arrayInputKey });
const inputAreaInputs = useMemo(
() =>
inputs.filter((i) => {
if (i.key === NodeInputKeyEnum.loopCustomOutputs) return false;
if (i.canEdit) return false;
if (mode !== LoopRunModeEnum.array && i.key === NodeInputKeyEnum.loopRunInputArray) {
return false;
}
return true;
}),
[inputs, mode]
);
const outputDeclarationInputs = useMemo(
() => inputs.filter((i) => i.key === NodeInputKeyEnum.loopCustomOutputs || !!i.canEdit),
[inputs]
);
const { successOutputs, errorOutputs } = useMemoEnhance(
() => splitOutput(outputs),
[splitOutput, outputs]
);
// Mode sync is owned by the container, not the start node, because the start
// node doesn't re-render reliably when the parent's mode input changes.
const prevModeRef = useRef<LoopRunModeEnum>(mode);
useEffect(() => {
const prevMode = prevModeRef.current;
prevModeRef.current = mode;
const startChildId = childNodeIds.find(
(id) => getNodeById(id)?.flowNodeType === FlowNodeTypeEnum.loopRunStart
);
const startNode = startChildId ? getNodeById(startChildId) : undefined;
if (startNode) {
const hasIndex = startNode.outputs.some((o) => o.key === NodeOutputKeyEnum.currentIndex);
const hasItem = startNode.outputs.some((o) => o.key === NodeOutputKeyEnum.currentItem);
const hasIteration = startNode.outputs.some(
(o) => o.key === NodeOutputKeyEnum.currentIteration
);
// Store i18n keys so downstream `t(label)` stays reactive.
if (mode === LoopRunModeEnum.array) {
if (hasIteration) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'delOutput',
key: NodeOutputKeyEnum.currentIteration
});
}
if (!hasIndex) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'addOutput',
value: {
id: NodeOutputKeyEnum.currentIndex,
key: NodeOutputKeyEnum.currentIndex,
label: 'workflow:current_index',
description: 'workflow:current_index_desc',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
}
});
}
if (!hasItem) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'addOutput',
value: {
id: NodeOutputKeyEnum.currentItem,
key: NodeOutputKeyEnum.currentItem,
label: 'workflow:current_item',
description: 'workflow:current_item_desc',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.any
}
});
}
} else {
if (hasIndex) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'delOutput',
key: NodeOutputKeyEnum.currentIndex
});
}
if (hasItem) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'delOutput',
key: NodeOutputKeyEnum.currentItem
});
}
if (!hasIteration) {
onChangeNode({
nodeId: startNode.nodeId,
type: 'addOutput',
value: {
id: NodeOutputKeyEnum.currentIteration,
key: NodeOutputKeyEnum.currentIteration,
label: 'workflow:current_iteration',
description: 'workflow:current_iteration_desc',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.number
}
});
}
}
}
// Transition-only, so a user-deleted break node isn't re-created.
if (mode === LoopRunModeEnum.conditional && prevMode !== LoopRunModeEnum.conditional) {
const hasBreak = childNodeIds.some(
(id) => getNodeById(id)?.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
if (!hasBreak) {
const startRaw = startChildId ? getRawNodeById(startChildId) : undefined;
const position = startRaw?.position
? { x: startRaw.position.x + 500, y: startRaw.position.y + 150 }
: { x: 500, y: 400 };
const breakNode = nodeTemplate2FlowNode({
template: LoopRunBreakTemplate,
position,
parentNodeId: nodeId,
t
});
setNodes((state) => state.concat(breakNode));
}
}
}, [mode, childNodeIds, nodeId, getNodeById, getRawNodeById, onChangeNode, setNodes, t]);
useEffect(() => {
const declared = inputs.filter((i) => i.canEdit === true);
const currentDynamic = outputs.filter((o) => o.type === FlowNodeOutputTypeEnum.dynamic);
const declaredKeys = new Set(declared.map((i) => i.key));
currentDynamic.forEach((o) => {
if (!declaredKeys.has(o.key)) {
onChangeNode({ nodeId, type: 'delOutput', key: o.key });
}
});
declared.forEach((input) => {
const existing = currentDynamic.find((o) => o.key === input.key);
if (!existing) {
onChangeNode({
nodeId,
type: 'addOutput',
value: {
id: input.key,
key: input.key,
label: input.label || input.key,
type: FlowNodeOutputTypeEnum.dynamic,
valueType: input.valueType
}
});
} else if (
existing.valueType !== input.valueType ||
existing.label !== (input.label || input.key)
) {
onChangeNode({
nodeId,
type: 'updateOutput',
key: input.key,
value: {
...existing,
label: input.label || input.key,
valueType: input.valueType
}
});
}
});
}, [inputs, outputs, nodeId, onChangeNode]);
return (
<NodeCard selected={selected} maxW="full" menuForbid={{ copy: true }} {...data}>
<Container position={'relative'} flex={1}>
<IOTitle text={t('common:Input')} />
<Box mb={6} maxW={'500px'} ref={inputBoxRef}>
<RenderInput nodeId={nodeId} flowInputList={inputAreaInputs} />
</Box>
<>
<FormLabel required fontWeight={'medium'} mb={3} color={'myGray.600'}>
{t('workflow:loop_body')}
</FormLabel>
<Box
flex={1}
position={'relative'}
border={'base'}
bg={'myGray.100'}
rounded={'8px'}
{...(!isFolded && {
minW: nodeWidth,
minH: nodeHeight
})}
/>
</>
</Container>
<Container>
<IOTitle text={t('common:Output')} nodeId={nodeId} catchError={catchError} />
<Box maxW={'600px'}>
<RenderInput nodeId={nodeId} flowInputList={outputDeclarationInputs} />
</Box>
<RenderOutput nodeId={nodeId} flowOutputList={successOutputs} />
</Container>
{catchError && <CatchError nodeId={nodeId} errorOutputs={errorOutputs} />}
</NodeCard>
);
};
export default React.memo(NodeLoopRun);
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import React from 'react';
import { type NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
const NodeLoopRunBreak = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
return (
<NodeCard
selected={selected}
{...data}
w={'420px'}
minH={'168px'}
menuForbid={{
copy: true,
debug: true
}}
/>
);
};
export default React.memo(NodeLoopRunBreak);
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { useTranslation } from 'next-i18next';
import { type NodeProps } from 'reactflow';
import NodeCard from '../render/NodeCard';
import { useContextSelector } from 'use-context-selector';
import { WorkflowBufferDataContext } from '../../../context/workflowInitContext';
import {
NodeInputKeyEnum,
NodeOutputKeyEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import { Box, Flex, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react';
import React, { useEffect, useMemo } from 'react';
import { FlowValueTypeMap } from '@fastgpt/global/core/workflow/node/constant';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { WorkflowActionsContext } from '../../../context/workflowActionsContext';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
const arrayItemTypeMap: Partial<Record<WorkflowIOValueTypeEnum, WorkflowIOValueTypeEnum>> = {
[WorkflowIOValueTypeEnum.arrayString]: WorkflowIOValueTypeEnum.string,
[WorkflowIOValueTypeEnum.arrayNumber]: WorkflowIOValueTypeEnum.number,
[WorkflowIOValueTypeEnum.arrayBoolean]: WorkflowIOValueTypeEnum.boolean,
[WorkflowIOValueTypeEnum.arrayObject]: WorkflowIOValueTypeEnum.object,
[WorkflowIOValueTypeEnum.arrayAny]: WorkflowIOValueTypeEnum.any
};
const NodeLoopRunStart = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, outputs } = data;
const { getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const startNode = getNodeById(nodeId);
const parentNode = getNodeById(startNode?.parentNodeId);
const parentMode =
(parentNode?.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined) ?? LoopRunModeEnum.array;
const currentItemType = useMemo(() => {
if (parentMode !== LoopRunModeEnum.array) return undefined;
const parentArrayInput = parentNode?.inputs.find(
(i) => i.key === NodeInputKeyEnum.loopRunInputArray
);
return arrayItemTypeMap[parentArrayInput?.valueType as keyof typeof arrayItemTypeMap];
}, [parentNode?.inputs, parentMode]);
// Output add/remove on mode switches lives in NodeLoopRun; this effect only
// keeps currentItem.valueType in sync with the inferred parent array type.
useEffect(() => {
if (parentMode !== LoopRunModeEnum.array || !currentItemType) return;
const currentItem = startNode?.outputs.find((o) => o.key === NodeOutputKeyEnum.currentItem);
if (currentItem && currentItem.valueType !== currentItemType) {
onChangeNode({
nodeId,
type: 'updateOutput',
key: NodeOutputKeyEnum.currentItem,
value: { ...currentItem, valueType: currentItemType }
});
}
}, [parentMode, currentItemType, nodeId, onChangeNode, startNode?.outputs]);
return (
<NodeCard
selected={selected}
{...data}
menuForbid={{
copy: true,
delete: true,
debug: true
}}
>
<Box px={4} pt={2} w={'420px'}>
<Box bg={'white'} borderRadius={'md'} overflow={'hidden'} border={'base'}>
<TableContainer>
<Table bg={'white'} variant={'workflow'}>
<Thead>
<Tr>
<Th>{t('workflow:Variable_name')}</Th>
<Th>{t('common:core.workflow.Value type')}</Th>
</Tr>
</Thead>
<Tbody>
{outputs.map((output) => (
<Tr key={output.id}>
<Td>
<Flex alignItems={'center'}>
<MyIcon
name={'core/workflow/inputType/array'}
w={'14px'}
mr={1}
color={'primary.600'}
/>
{t(output.label as any)}
</Flex>
</Td>
{output.valueType && <Td>{FlowValueTypeMap[output.valueType]?.label}</Td>}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
</Box>
</NodeCard>
);
};
export default React.memo(NodeLoopRunStart);
......@@ -56,7 +56,7 @@ const NodeParallelRun = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
flex={1}
position={'relative'}
border={'base'}
bg={'myGray.50'}
bg={'myGray.100'}
rounded={'8px'}
{...(!isFolded && {
minW: nodeWidth,
......
......@@ -327,7 +327,7 @@ const NodeCard = (props: Props) => {
{foldedOverlay}
{!isFolded && (
<Box bg={'white'} borderRadius={'lg'}>
<Box bg={'white'} borderRadius={'lg'} flex={1} display={'flex'} flexDirection={'column'}>
{/* Header */}
<Box position={'relative'}>
{gradient && (
......@@ -686,11 +686,9 @@ const MenuRender = React.memo(function MenuRender({
}) {
const { t } = useTranslation();
const { openDebugNode, DebugInputModal } = useDebug();
const { setNodes, setEdges, getNodeList, getNodeById } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
const { setNodes, getNodeById } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const onChangeNode = useContextSelector(WorkflowActionsContext, (v) => v.onChangeNode);
const { deleteElements } = useReactFlow();
const { computedNewNodeName } = useWorkflowUtils();
......@@ -772,30 +770,6 @@ const MenuRender = React.memo(function MenuRender({
},
[computedNewNodeName, setNodes, t]
);
const onDelNode = useCallback(
(nodeId: string) => {
// Remove node and its child nodes
setNodes((state) =>
state.filter((item) => item.data.nodeId !== nodeId && item.data.parentNodeId !== nodeId)
);
// Remove edges connected to the node and its child nodes
const childNodeIds = getNodeList()
.filter((node) => node.parentNodeId === nodeId)
.map((node) => node.nodeId);
setEdges((state) =>
state.filter(
(edge) =>
edge.source !== nodeId &&
edge.target !== nodeId &&
!childNodeIds.includes(edge.target) &&
!childNodeIds.includes(edge.source)
)
);
},
[getNodeList, setEdges, setNodes]
);
const Render = useMemo(() => {
const menuList = [
...(menuForbid?.fold
......@@ -842,7 +816,7 @@ const MenuRender = React.memo(function MenuRender({
icon: 'delete',
label: t('common:Delete'),
variant: 'whiteDanger',
onClick: () => onDelNode(nodeId)
onClick: () => deleteElements({ nodes: [{ id: nodeId }] })
}
])
];
......@@ -891,7 +865,7 @@ const MenuRender = React.memo(function MenuRender({
openDebugNode,
nodeId,
onCopyNode,
onDelNode,
deleteElements,
isFolded,
onChangeNode
]);
......
......@@ -11,6 +11,7 @@ import { getEditorVariables } from '@/pageComponents/app/detail/WorkflowComponen
import { InputTypeEnum } from '@/components/core/app/formRender/constant';
import { getWebDefaultLLMModel } from '@/web/common/system/utils';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { isNestedParentNodeType } from '@fastgpt/global/core/workflow/node/constant';
import OptimizerPopover from '@/components/common/PromptEditor/OptimizerPopover';
import { WorkflowActionsContext } from '@/pageComponents/app/detail/WorkflowComponents/context/workflowActionsContext';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
......@@ -78,6 +79,13 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => {
const inputType = nodeInputTypeToInputType(item.renderTypeList);
// 嵌套容器节点(loop/parallelRun/loopRun)里的 select 下拉向上展开,避免被子节点覆盖。
const menuPlacement = useMemo(() => {
const node = getNodeById(nodeId);
if (!node) return undefined;
return isNestedParentNodeType(node.flowNodeType) ? ('top-start' as const) : undefined;
}, [getNodeById, nodeId]);
// 添加默认值处理的效果
useEffect(() => {
if (inputType === InputTypeEnum.selectLLMModel && item.value === undefined && defaultModel) {
......@@ -110,6 +118,7 @@ const CommonInputForm = ({ item, nodeId }: RenderInputProps) => {
variableLabels={editorVariables}
modelList={llmModelList}
ExtensionPopover={canOptimizePrompt ? [OptimizerPopverComponent] : undefined}
menuPlacement={menuPlacement}
{...item}
/>
);
......
......@@ -38,11 +38,13 @@ const DynamicInputs = ({ item, inputs = [], nodeId }: RenderInputProps) => {
const dynamicInputs = useMemoEnhance(() => inputs.filter((item) => item.canEdit), [inputs]);
const existsKeys = useMemoEnhance(() => inputs.map((item) => item.key), [inputs]);
const hideBottomDivider = item.customInputConfig?.hideBottomDivider;
return (
<Box borderBottom={'base'} pb={3}>
<Box borderBottom={hideBottomDivider ? undefined : 'base'} pb={hideBottomDivider ? 0 : 3}>
<HStack className="nodrag" cursor={'default'} position={'relative'}>
<HStack spacing={1} position={'relative'} fontWeight={'medium'} color={'myGray.600'}>
<Box>{item.label || t('workflow:custom_input')}</Box>
<Box>{item.label ? t(item.label as any) : t('workflow:custom_input')}</Box>
{item.description && <QuestionTip label={t(item.description as any)} />}
{item.deprecated && (
......@@ -134,7 +136,9 @@ const Reference = ({
const { referenceList } = useReference({
nodeId,
valueType: WorkflowIOValueTypeEnum.any
valueType: WorkflowIOValueTypeEnum.any,
// Container nodes (loopRun) need to reference outputs from their sub-workflow.
includeChildren: true
});
const onlBlurLabel = useCallback(
......
......@@ -61,15 +61,21 @@ type SelectProps<T extends boolean> = CommonSelectProps & {
export const useReference = ({
nodeId,
valueType = WorkflowIOValueTypeEnum.any
valueType = WorkflowIOValueTypeEnum.any,
includeChildren
}: {
nodeId: string;
valueType?: WorkflowIOValueTypeEnum;
// Include the container's own children as reference sources.
includeChildren?: boolean;
}) => {
const { t } = useTranslation();
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const edges = useContextSelector(WorkflowBufferDataContext, (v) => v.edges);
const { getNodeById, systemConfigNode } = useContextSelector(WorkflowBufferDataContext, (v) => v);
const { getNodeById, systemConfigNode, childrenNodeIdListMap } = useContextSelector(
WorkflowBufferDataContext,
(v) => v
);
// 获取可选的变量列表
const referenceList = useMemoEnhance(() => {
......@@ -79,7 +85,9 @@ export const useReference = ({
getNodeById,
edges: edges,
chatConfig: appDetail.chatConfig,
t
t,
includeChildren,
childrenNodeIdListMap
});
const isArray = valueType?.includes('array');
......@@ -114,7 +122,17 @@ export const useReference = ({
.filter((item) => item.children.length > 0);
return list;
}, [nodeId, systemConfigNode, getNodeById, edges, appDetail.chatConfig, t, valueType]);
}, [
nodeId,
systemConfigNode,
getNodeById,
edges,
appDetail.chatConfig,
t,
valueType,
includeChildren,
childrenNodeIdListMap
]);
return {
referenceList
......
......@@ -78,6 +78,10 @@ export const WorkflowComputeProvider = ({ children }: { children: React.ReactNod
);
if (!loopNode) return;
if (childNodes.length === 0) return;
// 任一子节点尚未被 ReactFlow 测量(width/height 未定义),直接放弃本次计算,
// 由上游的 dimensionsSignal 监听在尺寸到齐后再触发一次。
if (childNodes.some((n) => !n.width || !n.height)) return;
const loopChilWidth =
loopNode.data.inputs.find((node) => node.key === NodeInputKeyEnum.nodeWidth)?.value ?? 0;
const loopChilHeight =
......
......@@ -59,6 +59,7 @@ export type WorkflowDataContextType = {
systemConfigNode: StoreNodeItemType | undefined;
allNodeFolded: boolean;
hasToolNode: boolean;
hasLoopRunNode: boolean;
toolNodesMap: Record<string, boolean>;
nodeIds: string[];
nodeAmount: number;
......@@ -84,6 +85,7 @@ export const WorkflowBufferDataContext = createContext<WorkflowDataContextType>(
systemConfigNode: undefined,
allNodeFolded: false,
hasToolNode: false,
hasLoopRunNode: false,
toolNodesMap: {},
nodeIds: [],
nodeAmount: 0,
......@@ -140,6 +142,7 @@ const WorkflowInitContextProvider = ({
let systemConfigNode: StoreNodeItemType | undefined = undefined;
let allNodeFolded = true;
let hasToolNode = false;
let hasLoopRunNode = false;
let llmMaxQuoteContext = 0;
nodes.forEach((node) => {
......@@ -213,6 +216,9 @@ const WorkflowInitContextProvider = ({
if (flowNodeType === FlowNodeTypeEnum.toolCall) {
hasToolNode = true;
}
if (flowNodeType === FlowNodeTypeEnum.loopRun) {
hasLoopRunNode = true;
}
});
return {
......@@ -225,6 +231,7 @@ const WorkflowInitContextProvider = ({
systemConfigNode,
allNodeFolded,
hasToolNode,
hasLoopRunNode,
llmMaxQuoteContext,
foldedNodesMap,
compareNodeList
......@@ -261,6 +268,7 @@ const WorkflowInitContextProvider = ({
);
const allNodeFolded = nodeFormat.allNodeFolded;
const hasToolNode = nodeFormat.hasToolNode;
const hasLoopRunNode = nodeFormat.hasLoopRunNode;
const llmMaxQuoteContext = nodeFormat.llmMaxQuoteContext;
const getNodeList = useMemoizedFn(() => nodeList);
......@@ -365,6 +373,7 @@ const WorkflowInitContextProvider = ({
systemConfigNode,
allNodeFolded,
hasToolNode,
hasLoopRunNode,
toolNodesMap,
foldedNodesMap,
getNodeById,
......@@ -387,6 +396,7 @@ const WorkflowInitContextProvider = ({
systemConfigNode,
allNodeFolded,
hasToolNode,
hasLoopRunNode,
toolNodesMap,
foldedNodesMap,
getNodeById,
......
......@@ -28,6 +28,7 @@ import {
type ReferenceItemValueType
} from '@fastgpt/global/core/workflow/type/io';
import { type IfElseListItemType } from '@fastgpt/global/core/workflow/template/system/ifElse/type';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import { VariableConditionEnum } from '@fastgpt/global/core/workflow/template/system/ifElse/constant';
import { type AppChatConfigType } from '@fastgpt/global/core/app/type';
import { cloneDeep, isEqual } from 'lodash';
......@@ -355,7 +356,9 @@ export const getNodeAllSource = ({
getNodeById,
edges,
chatConfig,
t
t,
includeChildren,
childrenNodeIdListMap
}: {
nodeId: string;
systemConfigNode?: StoreNodeItemType;
......@@ -363,6 +366,8 @@ export const getNodeAllSource = ({
edges: Edge[];
chatConfig: AppChatConfigType;
t: TFunction;
includeChildren?: boolean;
childrenNodeIdListMap?: Record<string, string[]>;
}): FlowNodeItemType[] => {
// get current node
const node = getNodeById(nodeId);
......@@ -409,6 +414,17 @@ export const getNodeAllSource = ({
}
}
// Edge traversal only reaches upstream; children must be added explicitly.
if (includeChildren && childrenNodeIdListMap) {
const childIds = childrenNodeIdListMap[nodeId] ?? [];
childIds.forEach((childId) => {
if (sourceNodes.has(childId)) return;
const childNode = getNodeById(childId);
if (!childNode) return;
sourceNodes.set(childId, childNode);
});
}
sourceNodes.set(
'system_global_variable',
getGlobalVariableNode({
......@@ -499,6 +515,24 @@ export const checkWorkflowNodeAndConnection = ({
return [data.nodeId];
}
}
if (data.flowNodeType === FlowNodeTypeEnum.loopRun) {
const mode = inputs.find((input) => input.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined;
if (mode === LoopRunModeEnum.conditional) {
const children =
(inputs.find((input) => input.key === NodeInputKeyEnum.childrenNodeIdList)
?.value as string[]) ?? [];
const childSet = new Set(children);
const hasBreak = nodes.some(
(n) =>
childSet.has(n.data.nodeId) && n.data.flowNodeType === FlowNodeTypeEnum.loopRunBreak
);
if (!hasBreak) {
return [data.nodeId];
}
}
}
if (data.flowNodeType === FlowNodeTypeEnum.toolCall) {
const toolConnections = edges.filter(
(edge) =>
......@@ -512,9 +546,21 @@ export const checkWorkflowNodeAndConnection = ({
}
}
// check node input
if (
inputs.some((input) => {
// Conditional loopRun hides loopRunInputArray in the UI; its required flag is
// only meaningful in array mode, so skip it here to avoid spurious failures.
if (input.key === NodeInputKeyEnum.loopRunInputArray) {
const loopRunMode =
data.flowNodeType === FlowNodeTypeEnum.loopRun
? (inputs.find((i) => i.key === NodeInputKeyEnum.loopRunMode)?.value as
| LoopRunModeEnum
| undefined)
: undefined;
if (loopRunMode === LoopRunModeEnum.conditional) {
return false;
}
}
if (
!input.valueType ||
[WorkflowIOValueTypeEnum.any, WorkflowIOValueTypeEnum.boolean].includes(input.valueType)
......@@ -640,7 +686,10 @@ export const checkWorkflowNodeAndConnection = ({
};
dfsFromStart(startNode.data.nodeId);
nodes.forEach((node) => {
if (node.data.flowNodeType === FlowNodeTypeEnum.nestedStart) {
if (
node.data.flowNodeType === FlowNodeTypeEnum.nestedStart ||
node.data.flowNodeType === FlowNodeTypeEnum.loopRunStart
) {
dfsFromStart(node.data.nodeId);
}
});
......@@ -666,7 +715,8 @@ export const checkWorkflowNodeAndConnection = ({
const isStartNode = [
FlowNodeTypeEnum.workflowStart,
FlowNodeTypeEnum.pluginInput,
FlowNodeTypeEnum.nestedStart
FlowNodeTypeEnum.nestedStart,
FlowNodeTypeEnum.loopRunStart
].includes(nodeType);
// Check if node is reachable from start
......
......@@ -13,6 +13,7 @@ import {
} from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
import {
nodeTemplate2FlowNode,
storeNode2FlowNode,
......@@ -236,4 +237,161 @@ describe('checkWorkflowNodeAndConnection', () => {
const result = checkWorkflowNodeAndConnection({ nodes: [], edges: [] });
expect(result).toBeUndefined();
});
describe('loopRun conditional mode', () => {
const makeLoopRunNode = (
mode: LoopRunModeEnum | undefined,
children: string[]
): Node<FlowNodeItemType> => ({
id: 'loop1',
type: FlowNodeTypeEnum.loopRun,
data: {
nodeId: 'loop1',
flowNodeType: FlowNodeTypeEnum.loopRun,
inputs: [
{
key: NodeInputKeyEnum.loopRunMode,
value: mode,
valueType: WorkflowIOValueTypeEnum.string,
renderTypeList: [FlowNodeInputTypeEnum.select]
} as any,
{
// 模板里这个字段永远 required: true + value: [],
// 条件循环模式下不该因此被判无效
key: NodeInputKeyEnum.loopRunInputArray,
value: [],
required: true,
valueType: WorkflowIOValueTypeEnum.arrayAny,
renderTypeList: [FlowNodeInputTypeEnum.reference]
} as any,
{
key: NodeInputKeyEnum.childrenNodeIdList,
value: children,
renderTypeList: [FlowNodeInputTypeEnum.hidden]
} as any
],
outputs: []
} as any,
position: { x: 0, y: 0 }
});
const makeChild = (id: string, flowNodeType: FlowNodeTypeEnum): Node<FlowNodeItemType> => ({
id,
type: flowNodeType,
data: {
nodeId: id,
flowNodeType,
inputs: [],
outputs: []
} as any,
position: { x: 0, y: 0 }
});
const workflowStart: Node<FlowNodeItemType> = {
id: 'ws',
type: FlowNodeTypeEnum.workflowStart,
data: {
nodeId: 'ws',
flowNodeType: FlowNodeTypeEnum.workflowStart,
inputs: [],
outputs: []
} as any,
position: { x: 0, y: 0 }
};
const wsToLoop: Edge = {
id: 'e-ws-loop',
source: 'ws',
target: 'loop1',
type: EDGE_TYPE
};
// 通用「节点必须有边」校验针对画布上的每个节点,给循环子节点挂上占位边
const stubEdge = (nodeId: string): Edge => ({
id: `e-stub-${nodeId}`,
source: nodeId,
target: '__stub__',
type: EDGE_TYPE
});
it('条件循环无 loopRunBreak → 返回该 loopRun 为无效', () => {
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart)
];
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, stubEdge('start1')]
});
expect(result).toEqual(['loop1']);
});
it('条件循环含 loopRunBreak → 有效', () => {
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1', 'break1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart),
makeChild('break1', FlowNodeTypeEnum.loopRunBreak)
];
const startToBreak: Edge = {
id: 'e-start-break',
source: 'start1',
target: 'break1',
type: EDGE_TYPE
};
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, startToBreak]
});
expect(result).toBeUndefined();
});
it('break 节点不在 childrenNodeIdList 内 → 视为无 break', () => {
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart),
makeChild('break1', FlowNodeTypeEnum.loopRunBreak) // 属于别的 loopRun
];
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, stubEdge('start1'), stubEdge('break1')]
});
expect(result).toEqual(['loop1']);
});
it('数组模式不强制要求 loopRunBreak', () => {
const loop = makeLoopRunNode(LoopRunModeEnum.array, ['start1']);
// 数组模式下 loopRunInputArray 必填,填个非空 value 走通用校验
const arrInput = loop.data.inputs.find((i) => i.key === NodeInputKeyEnum.loopRunInputArray)!;
arrInput.value = ['ws', 'userChatInput'];
const nodes = [workflowStart, loop, makeChild('start1', FlowNodeTypeEnum.loopRunStart)];
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, stubEdge('start1')]
});
expect(result).toBeUndefined();
});
it('条件循环下 loopRunInputArray 必填标记被忽略', () => {
// 模板静态定义里 loopRunInputArray 永远 required: true + value: [];
// 条件循环模式下这个字段被 UI 隐藏,不应该因此拦校验。
const nodes = [
workflowStart,
makeLoopRunNode(LoopRunModeEnum.conditional, ['start1', 'break1']),
makeChild('start1', FlowNodeTypeEnum.loopRunStart),
makeChild('break1', FlowNodeTypeEnum.loopRunBreak)
];
const startToBreak: Edge = {
id: 'e-start-break-2',
source: 'start1',
target: 'break1',
type: EDGE_TYPE
};
const result = checkWorkflowNodeAndConnection({
nodes,
edges: [wsToLoop, startToBreak]
});
expect(result).toBeUndefined();
});
});
});
......@@ -345,6 +345,62 @@ describe('getFlatAppResponses', () => {
expect(result).toHaveLength(3);
});
it('should recurse into loopRunDetail and parallelDetail', () => {
const responses: ChatHistoryItemResType[] = [
{
id: 'loopRunParent',
nodeId: 'loopRunParent',
moduleName: 'LoopRun',
moduleType: FlowNodeTypeEnum.loopRun,
loopRunDetail: [
{
id: 'iter1',
nodeId: 'iter1',
moduleName: 'Iter 1',
moduleType: FlowNodeTypeEnum.loopRun,
childrenResponses: [
{
id: 'ds1',
nodeId: 'ds1',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode
}
]
}
]
},
{
id: 'parallelParent',
nodeId: 'parallelParent',
moduleName: 'Parallel',
moduleType: FlowNodeTypeEnum.parallelRun,
parallelDetail: [
{
id: 'task1',
nodeId: 'task1',
moduleName: 'Task 1',
moduleType: FlowNodeTypeEnum.parallelRun,
childrenResponses: [
{
id: 'ds2',
nodeId: 'ds2',
moduleName: 'Dataset Search',
moduleType: FlowNodeTypeEnum.datasetSearchNode
}
]
}
]
}
];
const result = getFlatAppResponses(responses);
const ids = result.map((item) => item.id);
expect(ids).toContain('ds1');
expect(ids).toContain('ds2');
expect(result).toHaveLength(6);
});
});
describe('checkInteractiveResponseStatus', () => {
......
......@@ -475,6 +475,94 @@ describe('pushChatRecords', () => {
}
}
});
it('should collect citeCollectionIds from dataset search nested in loopRun / parallelRun', async () => {
const makeQuote = (id: string, collectionId: string) => ({
id,
chunkIndex: 0,
datasetId: 'dataset-1',
collectionId,
sourceId: `src-${collectionId}`,
sourceName: `${collectionId}.pdf`,
score: [{ type: 'embedding', value: 0.9, index: 0 }],
q: 'q',
a: 'a',
updateTime: new Date()
});
const makeDatasetSearch = (collectionId: string) => ({
nodeId: `ds-${collectionId}`,
id: `ds-${collectionId}`,
moduleType: FlowNodeTypeEnum.datasetSearchNode,
moduleName: 'Dataset Search',
runningTime: 0.1,
totalPoints: 1,
quoteList: [makeQuote(`quote-${collectionId}`, collectionId)]
});
const props = createMockProps(
{
aiContent: {
obj: ChatRoleEnum.AI,
value: [],
responseData: [
{
nodeId: 'loopRun-1',
id: 'loopRun-1',
moduleType: FlowNodeTypeEnum.loopRun,
moduleName: 'LoopRun',
runningTime: 0.5,
totalPoints: 2,
loopRunDetail: [
{
nodeId: 'loopRun-1_iter_1',
id: 'loopRun-1_iter_1',
moduleType: FlowNodeTypeEnum.loopRun,
moduleName: 'Iter 1',
runningTime: 0.2,
totalPoints: 1,
childrenResponses: [makeDatasetSearch('collection-loop')]
}
]
},
{
nodeId: 'parallelRun-1',
id: 'parallelRun-1',
moduleType: FlowNodeTypeEnum.parallelRun,
moduleName: 'ParallelRun',
runningTime: 0.5,
totalPoints: 2,
parallelDetail: [
{
nodeId: 'parallelRun-1_task_0',
id: 'parallelRun-1_task_0',
moduleType: FlowNodeTypeEnum.parallelRun,
moduleName: 'Task 1',
runningTime: 0.2,
totalPoints: 1,
childrenResponses: [makeDatasetSearch('collection-parallel')]
}
]
}
]
}
},
{ appId: testAppId, teamId: testTeamId, tmbId: testTmbId }
);
await pushChatRecords(props);
const aiItem = await MongoChatItem.findOne({
appId: testAppId,
chatId: props.chatId,
obj: ChatRoleEnum.AI
});
if (!aiItem || !('citeCollectionIds' in aiItem)) {
throw new Error('aiItem does not have citeCollectionIds');
}
expect(aiItem.citeCollectionIds).toContain('collection-loop');
expect(aiItem.citeCollectionIds).toContain('collection-parallel');
});
});
describe('prepared chat round lifecycle', () => {
......
import { describe, it, expect, vi } from 'vitest';
import { getNestedEndOutputValue } from '@fastgpt/service/core/workflow/dispatch/loop/service';
import {
getNestedEndOutputValue,
pushSubWorkflowUsage,
collectResponseFeedbacks
} from '@fastgpt/service/core/workflow/dispatch/loop/service';
import { injectNestedStartInputs } from '@fastgpt/service/core/workflow/dispatch/utils';
collectResponseFeedbacks,
injectNestedStartInputs,
pushSubWorkflowUsage
} from '@fastgpt/service/core/workflow/dispatch/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
......@@ -177,7 +177,7 @@ describe('loop/service', () => {
{ totalPoints: 5, moduleName: 'b' } as any
]
});
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'myNode', index: 0 });
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'myNode', iteration: 0 });
expect(pts).toBe(15);
});
......@@ -186,7 +186,7 @@ describe('loop/service', () => {
const response = makeDispatchFlowResponse({
flowUsages: [{ totalPoints: 7, moduleName: 'x' } as any]
});
pushSubWorkflowUsage({ usagePush, response, name: 'loopNode', index: 3 });
pushSubWorkflowUsage({ usagePush, response, name: 'loopNode', iteration: 3 });
expect(usagePush).toHaveBeenCalledOnce();
expect(usagePush).toHaveBeenCalledWith([{ totalPoints: 7, moduleName: 'loopNode-3' }]);
});
......@@ -194,17 +194,17 @@ describe('loop/service', () => {
it('flowUsages 为空时返回 0', () => {
const usagePush = vi.fn();
const response = makeDispatchFlowResponse({ flowUsages: [] });
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'node', index: 0 });
const pts = pushSubWorkflowUsage({ usagePush, response, name: 'node', iteration: 0 });
expect(pts).toBe(0);
expect(usagePush).toHaveBeenCalledWith([{ totalPoints: 0, moduleName: 'node-0' }]);
});
it('index 正确拼接到 moduleName', () => {
it('iteration 正确拼接到 moduleName', () => {
const usagePush = vi.fn();
const response = makeDispatchFlowResponse({
flowUsages: [{ totalPoints: 1, moduleName: 'z' } as any]
});
pushSubWorkflowUsage({ usagePush, response, name: 'parallel', index: 99 });
pushSubWorkflowUsage({ usagePush, response, name: 'parallel', iteration: 99 });
expect(usagePush).toHaveBeenCalledWith([{ totalPoints: 1, moduleName: 'parallel-99' }]);
});
});
......
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<FlowNodeInputItemType> & { 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> = {}
): 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<ChatHistoryItemResType> = {}) =>
({
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);
});
});
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<FlowNodeInputItemType> & { 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>): 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);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment