Commit 6c7ea32d by Archer Committed by GitHub

fix(workflow): sync container run state updates (#7249)

* fix(workflow): sync container run state updates

* docs: add workflow state sync release notes

* refactor(workflow): consolidate container state snapshots

* fix(workflow): order nested reference sources

* docs: update 4151 notes and assets

* style(chat): reduce response accordion icon size

* doc
parent 61b6134a
...@@ -132,7 +132,7 @@ docker compose up -d ...@@ -132,7 +132,7 @@ docker compose up -d
扫码加入飞书话题群: 扫码加入飞书话题群:
![](https://oss.laf.run/otnvvf-imgs/fastgpt-feishu2.png) ![](https://oss.laf.run/otnvvf-imgs/fastgpt-feishu1.png)
<a href="#readme"> <a href="#readme">
<img src="https://img.shields.io/badge/-返回顶部-7d09f1.svg" alt="#" align="right"> <img src="https://img.shields.io/badge/-返回顶部-7d09f1.svg" alt="#" align="right">
......
...@@ -66,7 +66,10 @@ The Loop Node provides two running modes. ...@@ -66,7 +66,10 @@ The Loop Node provides two running modes.
- The custom outputs on the Loop Node only hold the values from the **last iteration** when the loop exits (it no longer aggregates all outputs into a single array). - The custom outputs on the Loop Node only hold the values from the **last iteration** when the loop exits (it no longer aggregates all outputs into a single array).
- **How to aggregate results from all iterations?** - **How to aggregate results from all iterations?**
If you need to collect and aggregate data from all runs into a list, declare an array variable **outside** the Loop Node as a global variable, and use a **Variable Update** node **inside** the loop body to append the result of each iteration into that global array. If you need to collect and aggregate data from all runs into a list, declare an array variable **outside** the Loop Node as a global variable, and use a **Variable Update** node **inside** the loop body to append the result of each iteration into that global array.
3. **Prevent Infinite Loops** 3. **Variable and External Output Writeback**
- After each successful iteration, global variable changes made inside the loop body are written back to the main flow. If a Variable Update node changes an output on a node outside the loop container, that output is also written back after that iteration succeeds.
- Failed iterations do not commit variable or external output changes from that iteration. When an interactive node pauses execution, the completed changes before the pause are kept as a resumable checkpoint so the loop can continue after the user submits input.
4. **Prevent Infinite Loops**
- For Conditional Loops, ensure that a **Loop Break** node is reachable under certain conditions. - For Conditional Loops, ensure that a **Loop Break** node is reachable under certain conditions.
- The system enforces a maximum iteration limit (default 100). The loop will automatically terminate and throw an error if this limit is reached. - The system enforces a maximum iteration limit (default 100). The loop will automatically terminate and throw an error if this limit is reached.
......
...@@ -66,7 +66,10 @@ description: FastGPT 循环节点介绍和使用(适用于 4.15.0 及以上版 ...@@ -66,7 +66,10 @@ description: FastGPT 循环节点介绍和使用(适用于 4.15.0 及以上版
- 新版循环节点的“自定义输出”在退出时,只包含**最后一轮迭代的值**(不再默认将所有轮次的输出聚合成一个数组)。 - 新版循环节点的“自定义输出”在退出时,只包含**最后一轮迭代的值**(不再默认将所有轮次的输出聚合成一个数组)。
- **如何聚合所有轮次的结果?** - **如何聚合所有轮次的结果?**
如果您需要将每一轮的数据收集并汇总成一个列表,请在循环体**外部**声明一个全局变量数组,并在循环体**内部**使用【变量更新】节点,每次将当前轮次的结果追加到该全局变量中。 如果您需要将每一轮的数据收集并汇总成一个列表,请在循环体**外部**声明一个全局变量数组,并在循环体**内部**使用【变量更新】节点,每次将当前轮次的结果追加到该全局变量中。
3. **条件循环防止死循环** 3. **变量与外部输出回写**
- 每轮成功结束后,循环体内对全局变量的修改会写回主流程;通过【变量更新】修改循环容器外节点输出时,也会在该轮成功结束后写回。
- 失败轮不会提交本轮变量或外部输出变更;遇到交互节点暂停时,会作为可恢复 checkpoint 保留暂停前已完成节点的更新,便于用户提交后继续运行。
4. **条件循环防止死循环**
- 条件循环必须在子流程某个分支能最终触发【循环终止】节点。 - 条件循环必须在子流程某个分支能最终触发【循环终止】节点。
- 系统设定了最大循环次数(默认 100 次),达到上限会自动报错并安全停止。 - 系统设定了最大循环次数(默认 100 次),达到上限会自动报错并安全停止。
......
...@@ -53,7 +53,7 @@ It fits batch tasks where each item is **independent and does not depend on the ...@@ -53,7 +53,7 @@ It fits batch tasks where each item is **independent and does not depend on the
- **No nesting**: a Parallel Run node cannot contain another Parallel Run or a Batch Processing node - **No nesting**: a Parallel Run node cannot contain another Parallel Run or a Batch Processing node
- **No interactive nodes**: form input, user selection, and other interactive nodes cannot run inside the Execution Logic — the editor blocks dropping them in - **No interactive nodes**: form input, user selection, and other interactive nodes cannot run inside the Execution Logic — the editor blocks dropping them in
- **Variable isolation**: changes made to global variables inside the Execution Logic are not carried back to the main flow. Use the End node's output to persist anything you need - **Variable and output races**: global variable changes from successful tasks are written back to the main flow. If a Variable Update node changes an output on a node outside the parallel container, that output is also written back after the task succeeds. When multiple tasks write the same variable or external node output, the final value depends on task completion order and is not guaranteed to be stable. For deterministic results, return values through the End node and use the Parallel Run aggregate outputs
- **Array length cap**: the input array is capped at 100 items by default (adjustable by the deployment — see below) - **Array length cap**: the input array is capped at 100 items by default (adjustable by the deployment — see below)
- **Turn off streaming for AI nodes inside the Execution Logic**: it is strongly recommended to disable **"Return AI content"** on AI Chat nodes placed inside the parallel body. Otherwise multiple tasks will stream to the same chat window at once and the text will interleave into a garbled mess. Usually you only want a final **Specified Reply** node *after* the parallel node to emit the aggregated result. - **Turn off streaming for AI nodes inside the Execution Logic**: it is strongly recommended to disable **"Return AI content"** on AI Chat nodes placed inside the parallel body. Otherwise multiple tasks will stream to the same chat window at once and the text will interleave into a garbled mess. Usually you only want a final **Specified Reply** node *after* the parallel node to emit the aggregated result.
......
...@@ -53,7 +53,7 @@ description: FastGPT 并行执行节点介绍和使用(适用于 4.14.11 及 ...@@ -53,7 +53,7 @@ description: FastGPT 并行执行节点介绍和使用(适用于 4.14.11 及
- **不能嵌套**:并行执行节点里不能再放另一个【并行执行】或【批量运行】节点 - **不能嵌套**:并行执行节点里不能再放另一个【并行执行】或【批量运行】节点
- **不支持交互节点**:表单输入、用户选择等需要和用户互动的节点不能放在执行逻辑内,编辑器会阻止拖入 - **不支持交互节点**:表单输入、用户选择等需要和用户互动的节点不能放在执行逻辑内,编辑器会阻止拖入
- **变量独立**:执行逻辑内对全局变量的修改不会带回主流程,想要保留的结果请通过「结束」节点输出 - **变量与输出竞争**:成功任务内对全局变量的修改会写回主流程;通过【变量更新】修改并行容器外节点输出时,也会在任务成功后写回主流程。多个任务同时写同一个变量或同一个外部节点输出时,最终值取决于任务完成顺序,不保证稳定;需要确定结果时,请通过「结束」节点输出并使用并行节点的汇总结果
- **数组长度**:输入数组最多 100 项(由部署方统一设置,可通过下文环境变量调整) - **数组长度**:输入数组最多 100 项(由部署方统一设置,可通过下文环境变量调整)
- **尽量关闭 AI 节点的流式输出**:执行逻辑内的【AI 对话】等节点建议关闭「返回 AI 内容」(流式输出),否则多个任务的输出会同时往对话窗口里推送,容易出现内容交错、显示混乱。通常只在并行节点**之后**的【指定回复】节点里统一输出汇总结果即可。 - **尽量关闭 AI 节点的流式输出**:执行逻辑内的【AI 对话】等节点建议关闭「返回 AI 内容」(流式输出),否则多个任务的输出会同时往对话窗口里推送,容易出现内容交错、显示混乱。通常只在并行节点**之后**的【指定回复】节点里统一输出汇总结果即可。
......
...@@ -5,20 +5,21 @@ description: 'FastGPT V4.15.1 Release Notes' ...@@ -5,20 +5,21 @@ description: 'FastGPT V4.15.1 Release Notes'
## 📦 Upgrade Guide ## 📦 Upgrade Guide
### Pro Internal API Authentication ### fastgpt-pro Environment Variable Updates
Starting from v4.15.1, the FastGPT app no longer uses `rootkey` when calling Pro/Admin internal APIs. These internal service-to-service calls now use a dedicated `PRO_TOKEN`. If you deploy the Pro edition, configure the same `PRO_TOKEN` in both the FastGPT app and the Pro/Admin service: Starting from v4.15.1, the FastGPT main app no longer uses `rootkey` when calling Pro/Admin internal APIs. These internal service-to-service calls now use a dedicated `PRO_TOKEN`. `FE_DOMAIN` is also required. If you deploy the Pro edition, configure the same `PRO_TOKEN` in both the FastGPT main app and the Pro/Admin service:
```bash ```bash
PRO_TOKEN=your_pro_token_at_least_32_chars PRO_TOKEN=your_pro_token_at_least_32_chars
FE_DOMAIN=fastgpt_domain
``` ```
Notes: Notes:
1. `PRO_TOKEN` must be at least 32 characters long, and the value must be identical in the FastGPT app and Pro/Admin. 1. `PRO_TOKEN` must be at least 32 characters long, and the value must be identical in the FastGPT main app and Pro/Admin.
2. If the FastGPT app is configured with `PRO_URL`, `PRO_TOKEN` is also required. Otherwise, the service fails to start. 2. If the FastGPT main app is configured with `PRO_URL`, `PRO_TOKEN` is also required. Otherwise, the service fails to start.
3. The Pro/Admin service must configure `PRO_TOKEN`; otherwise, internal API authentication fails. 3. The Pro/Admin service must configure `PRO_TOKEN`; otherwise, internal API authentication fails.
4. `rootkey` is no longer used as the credential for FastGPT app calls to Pro/Admin internal APIs. It is only the admin secret for the current system and is used to call `/api/admin/**` APIs, such as the initialization script below. 4. `rootkey` is no longer used as the credential for FastGPT main app calls to Pro/Admin internal APIs. It is only the admin secret for the current system and is used to call `/api/admin/**` APIs, such as the initialization script below.
5. Open-source deployment files do not include `PRO_TOKEN`. For Pro deployments, add it manually in your private deployment environment variables. 5. Open-source deployment files do not include `PRO_TOKEN`. For Pro deployments, add it manually in your private deployment environment variables.
### API Key App Name Initialization ### API Key App Name Initialization
...@@ -37,11 +38,18 @@ The script only fills missing `appName` values. It does not overwrite existing v ...@@ -37,11 +38,18 @@ The script only fills missing `appName` values. It does not overwrite existing v
## 🚀 New Features ## 🚀 New Features
1. Added global API Key tag management and an `appName` display snapshot for historical app-level API Keys, making older API keys compatible and easier to find when they were previously associated with apps. 1. Added global API Key tag management and an `appName` display snapshot for historical app-level API Keys, making older API keys compatible and easier to find when they were previously associated with apps.
2. Pre-extract the skill name and description when publishing a skill to help with generation.
## ⚙️ Improvements ## ⚙️ Improvements
## 🐛 Fixes ## 🐛 Fixes
1. Workflow tool debugging did not show run details. 1. Workflow tool debugging did not show run details.
2. The chat page did not automatically show the login component after credentials expired.
3. Fixed an issue where workflow tools did not initialize variables from the tool app's global variable configuration when running a sub-workflow, causing runtime variables such as default variables and system variables to be read incorrectly.
4. Fixed an issue where updates to global variables or outputs from nodes outside the container through **Variable Update** inside loop nodes and parallel execution nodes were not synchronized back to the main workflow by round or task completion. Successful rounds or tasks now write back their changes, while failed rounds or tasks do not commit their changes.
5. The component did not refresh immediately when retrying all Knowledge Base collections.
## 🛠️ Code Improvements ## 🛠️ Code Improvements
1. Fixed file paths that contain colons to avoid Windows compatibility issues.
...@@ -38,12 +38,17 @@ curl -X POST "{{host}}/api/admin/initv4151" \ ...@@ -38,12 +38,17 @@ curl -X POST "{{host}}/api/admin/initv4151" \
## 🚀 新增内容 ## 🚀 新增内容
1. 增加全局 API Key 标签管理,并为历史应用级 API Key 增加 `appName` 展示快照,便于兼容旧版 API 密钥并查找以前应用关联的密钥。 1. 增加全局 API Key 标签管理,并为历史应用级 API Key 增加 `appName` 展示快照,便于兼容旧版 API 密钥并查找以前应用关联的密钥。
2. 发布技能时,预提取技能名称和描述,便于辅助生成。
## ⚙️ 优化 ## ⚙️ 优化
## 🐛 修复 ## 🐛 修复
1. 工作流工具调试时,运行详情看不到。 1. 工作流工具调试时,运行详情看不到。
2. 对话页,凭证到期不会自动跳出登录组件。
3. 修复工作流工具运行子工作流时未按工具应用的全局变量配置初始化变量,导致默认变量、系统变量等运行态变量读取异常的问题。
4. 修复循环节点和并行执行节点中通过【变量更新】修改全局变量或容器外节点输出时,主流程未按轮次/任务结束同步更新的问题。成功轮次或成功任务会回写本轮变更,失败轮次或失败任务不提交本轮变更。
5. 重试全部知识库集合时,未立即刷新组件。
## 🛠️ 代码优化 ## 🛠️ 代码优化
......
...@@ -79,10 +79,10 @@ ...@@ -79,10 +79,10 @@
"content/guide/build/workflow/nodes/knowledge_base_search_merge.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/workflow/nodes/knowledge_base_search_merge.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/workflow/nodes/loop.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/workflow/nodes/loop.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/workflow/nodes/loop.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/workflow/nodes/loop.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/workflow/nodes/loop_run.en.mdx": "2026-06-12T00:30:58+08:00", "content/guide/build/workflow/nodes/loop_run.en.mdx": "2026-07-03T18:33:25+08:00",
"content/guide/build/workflow/nodes/loop_run.mdx": "2026-06-12T00:30:58+08:00", "content/guide/build/workflow/nodes/loop_run.mdx": "2026-07-03T18:33:25+08:00",
"content/guide/build/workflow/nodes/parallel_run.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/workflow/nodes/parallel_run.en.mdx": "2026-07-03T18:33:25+08:00",
"content/guide/build/workflow/nodes/parallel_run.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/workflow/nodes/parallel_run.mdx": "2026-07-03T18:33:25+08:00",
"content/guide/build/workflow/nodes/question_classify.en.mdx": "2026-06-22T11:01:59+08:00", "content/guide/build/workflow/nodes/question_classify.en.mdx": "2026-06-22T11:01:59+08:00",
"content/guide/build/workflow/nodes/question_classify.mdx": "2026-06-22T11:01:59+08:00", "content/guide/build/workflow/nodes/question_classify.mdx": "2026-06-22T11:01:59+08:00",
"content/guide/build/workflow/nodes/reply.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/build/workflow/nodes/reply.en.mdx": "2026-05-07T15:06:40+08:00",
...@@ -167,8 +167,8 @@ ...@@ -167,8 +167,8 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-07-02T11:54:55+08:00", "content/plugin/system-tool-development.en.mdx": "2026-07-02T11:54:55+08:00",
"content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00", "content/plugin/system-tool-development.mdx": "2026-07-02T11:54:55+08:00",
"content/self-host/config/env.en.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/config/env.en.mdx": "2026-07-02T15:38:53+08:00",
"content/self-host/config/env.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/config/env.mdx": "2026-07-02T15:38:53+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00", "content/self-host/config/model/intro.mdx": "2026-06-04T16:10:15+08:00",
"content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00", "content/self-host/config/model/minimax.en.mdx": "2026-06-03T10:40:17+08:00",
...@@ -179,10 +179,10 @@ ...@@ -179,10 +179,10 @@
"content/self-host/config/object-storage.mdx": "2026-05-21T11:24:48+08:00", "content/self-host/config/object-storage.mdx": "2026-05-21T11:24:48+08:00",
"content/self-host/config/remote-debug-suite.en.mdx": "2026-06-27T22:05:51+08:00", "content/self-host/config/remote-debug-suite.en.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/remote-debug-suite.mdx": "2026-06-27T22:05:51+08:00", "content/self-host/config/remote-debug-suite.mdx": "2026-06-27T22:05:51+08:00",
"content/self-host/config/sandbox/common.en.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/config/sandbox/common.en.mdx": "2026-07-02T15:38:53+08:00",
"content/self-host/config/sandbox/common.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/config/sandbox/common.mdx": "2026-07-02T15:38:53+08:00",
"content/self-host/config/sandbox/opensandbox.en.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/config/sandbox/opensandbox.en.mdx": "2026-07-02T15:38:53+08:00",
"content/self-host/config/sandbox/opensandbox.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/config/sandbox/opensandbox.mdx": "2026-07-02T15:38:53+08:00",
"content/self-host/config/sandbox/sealosdevbox.en.mdx": "2026-06-30T14:56:33+08:00", "content/self-host/config/sandbox/sealosdevbox.en.mdx": "2026-06-30T14:56:33+08:00",
"content/self-host/config/sandbox/sealosdevbox.mdx": "2026-06-30T14:56:33+08:00", "content/self-host/config/sandbox/sealosdevbox.mdx": "2026-06-30T14:56:33+08:00",
"content/self-host/config/signoz.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/config/signoz.en.mdx": "2026-04-26T21:08:47+08:00",
...@@ -275,8 +275,8 @@ ...@@ -275,8 +275,8 @@
"content/self-host/upgrading/4-14/41422.mdx": "2026-05-23T22:47:02+08:00", "content/self-host/upgrading/4-14/41422.mdx": "2026-05-23T22:47:02+08:00",
"content/self-host/upgrading/4-14/41424.en.mdx": "2026-06-10T17:33:23+08:00", "content/self-host/upgrading/4-14/41424.en.mdx": "2026-06-10T17:33:23+08:00",
"content/self-host/upgrading/4-14/41424.mdx": "2026-06-10T17:33:23+08:00", "content/self-host/upgrading/4-14/41424.mdx": "2026-06-10T17:33:23+08:00",
"content/self-host/upgrading/4-14/41425.en.mdx": "2026-06-22T12:20:51+08:00", "content/self-host/upgrading/4-14/41425.en.mdx": "2026-07-03T12:05:55+08:00",
"content/self-host/upgrading/4-14/41425.mdx": "2026-06-22T12:20:51+08:00", "content/self-host/upgrading/4-14/41425.mdx": "2026-07-03T12:05:55+08:00",
"content/self-host/upgrading/4-14/41426.en.mdx": "2026-06-24T23:05:29+08:00", "content/self-host/upgrading/4-14/41426.en.mdx": "2026-06-24T23:05:29+08:00",
"content/self-host/upgrading/4-14/41426.mdx": "2026-06-24T23:05:29+08:00", "content/self-host/upgrading/4-14/41426.mdx": "2026-06-24T23:05:29+08:00",
"content/self-host/upgrading/4-14/41427.en.mdx": "2026-07-01T17:22:47+08:00", "content/self-host/upgrading/4-14/41427.en.mdx": "2026-07-01T17:22:47+08:00",
...@@ -314,8 +314,8 @@ ...@@ -314,8 +314,8 @@
"content/self-host/upgrading/4-15/41506.mdx": "2026-07-01T12:13:58+08:00", "content/self-host/upgrading/4-15/41506.mdx": "2026-07-01T12:13:58+08:00",
"content/self-host/upgrading/4-15/41507.en.mdx": "2026-06-30T17:31:43+08:00", "content/self-host/upgrading/4-15/41507.en.mdx": "2026-06-30T17:31:43+08:00",
"content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T17:31:43+08:00", "content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T17:31:43+08:00",
"content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-02T15:38:53+08:00",
"content/self-host/upgrading/4-15/4151.mdx": "2026-07-02T14:50:15+08:00", "content/self-host/upgrading/4-15/4151.mdx": "2026-07-03T18:54:27+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
import type { StoreEdgeItemType } from '../../workflow/type/edge'; import type { StoreEdgeItemType } from '../../workflow/type/edge';
import type { StoreNodeItemType } from '../../workflow/type/node'; import type { StoreNodeItemType } from '../../workflow/type/node';
import type { WorkflowTemplateType } from '../../workflow/type'; import type { AppChatConfigType } from '../type';
import {
FlowNodeInputItemTypeSchema,
FlowNodeOutputItemTypeSchema,
InputConfigTypeSchema,
type FlowNodeInputItemType,
type FlowNodeOutputItemType
} from '../../workflow/type/io';
import {
PluginStatusSchema,
type PluginStatusType,
type SystemPluginToolTagType
} from '../../plugin/type';
import type { UserTagsType } from '../../../support/user/type';
import { UserTagsSchema } from '../../../support/user/type';
import z from 'zod';
export type AppToolRuntimeType = { export type AppToolRuntimeType = {
id: string; id: string;
...@@ -28,6 +13,7 @@ export type AppToolRuntimeType = { ...@@ -28,6 +13,7 @@ export type AppToolRuntimeType = {
isTool?: boolean; isTool?: boolean;
nodes: StoreNodeItemType[]; nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[]; edges: StoreEdgeItemType[];
chatConfig?: AppChatConfigType;
currentCost?: number; currentCost?: number;
systemKeyCost?: number; systemKeyCost?: number;
hasTokenFee?: boolean; hasTokenFee?: boolean;
......
...@@ -796,6 +796,7 @@ export class SystemToolRepo { ...@@ -796,6 +796,7 @@ export class SystemToolRepo {
return { return {
avatar: tool.customConfig.avatar ?? '', avatar: tool.customConfig.avatar ?? '',
chatConfig: appVersion.chatConfig,
edges: appVersion.edges, edges: appVersion.edges,
id: pluginId, id: pluginId,
name: tool.customConfig.name, name: tool.customConfig.name,
......
...@@ -93,7 +93,7 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse> ...@@ -93,7 +93,7 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse>
responseChatItemId: data.responseChatItemId, responseChatItemId: data.responseChatItemId,
histories: [], histories: [],
uid: data.uid, uid: data.uid,
variablesConfig: chatConfig.variables, variablesConfig: chatConfig.variables ?? [],
inputVariables: customAppVariables, inputVariables: customAppVariables,
externalVariables: externalProvider?.externalWorkflowVariables, externalVariables: externalProvider?.externalWorkflowVariables,
sourceVariableState: variableState sourceVariableState: variableState
...@@ -194,11 +194,12 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon ...@@ -194,11 +194,12 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon
responseChatItemId: data.responseChatItemId, responseChatItemId: data.responseChatItemId,
histories: [], histories: [],
uid: data.uid, uid: data.uid,
variablesConfig: [], variablesConfig: chatConfig.variables ?? [],
inputVariables: {}, inputVariables: {},
externalVariables: externalProvider?.externalWorkflowVariables, externalVariables: externalProvider?.externalWorkflowVariables,
sourceVariableState: variableState sourceVariableState: variableState
}); });
const runtimeVariables = childrenVariableState.toRuntimeRecord();
const runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes)).map( const runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes)).map(
(node) => { (node) => {
// Update plugin input value // Update plugin input value
...@@ -262,7 +263,10 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon ...@@ -262,7 +263,10 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon
variableState: childrenVariableState, variableState: childrenVariableState,
query: serverGetWorkflowToolRunUserQuery({ query: serverGetWorkflowToolRunUserQuery({
pluginInputs: getWorkflowToolInputsFromStoreNodes(nodes), pluginInputs: getWorkflowToolInputsFromStoreNodes(nodes),
variables: customAppVariables variables: {
...runtimeVariables,
...customAppVariables
}
}).value, }).value,
stream: false, stream: false,
workflowStreamResponse: undefined workflowStreamResponse: undefined
...@@ -281,7 +285,7 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon ...@@ -281,7 +285,7 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon
return acc; return acc;
}, {}) }, {})
) )
: 'Run plugin failed'; : 'Run workflow tool failed';
return { return {
response, response,
......
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import {
DispatchNodeResponseKeyEnum,
SseResponseEventEnum
} from '@fastgpt/global/core/workflow/runtime/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { import type {
DispatchNodeResultType, DispatchNodeResultType,
...@@ -33,6 +36,7 @@ import { ...@@ -33,6 +36,7 @@ import {
pickCustomOutputInputs, pickCustomOutputInputs,
readCustomOutputSnapshot readCustomOutputSnapshot
} from './service'; } from './service';
import { createContainerRunStateSnapshot, syncContainerRunState } from '../utils/containerRunState';
type Props = ModuleDispatchProps<{ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum; [NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum;
...@@ -182,8 +186,15 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => { ...@@ -182,8 +186,15 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => {
}); });
} }
const iterationVariableState = props.variableState.clone();
const iterationStateSnapshot = createContainerRunStateSnapshot({
nodes: isolatedNodes,
childrenNodeIdList,
variableState: iterationVariableState
});
const response = await runWorkflow({ const response = await runWorkflow({
...props, ...props,
variableState: iterationVariableState,
lastInteractive: interactiveData?.childrenResponse, lastInteractive: interactiveData?.childrenResponse,
nodeResponseParentId: iterationResponseId, nodeResponseParentId: iterationResponseId,
runtimeNodes: isolatedNodes, runtimeNodes: isolatedNodes,
...@@ -217,7 +228,7 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => { ...@@ -217,7 +228,7 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => {
const customOutputs = readCustomOutputSnapshot({ const customOutputs = readCustomOutputSnapshot({
customOutputInputs, customOutputInputs,
runtimeNodes: isolatedNodes, runtimeNodes: isolatedNodes,
variableState: props.variableState, variableState: iterationVariableState,
finishedNodeIds, finishedNodeIds,
childrenNodeIdList childrenNodeIdList
}); });
...@@ -241,7 +252,18 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => { ...@@ -241,7 +252,18 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => {
}; };
childResponseCount += 1 + (wrapper.childResponseCount || 0); childResponseCount += 1 + (wrapper.childResponseCount || 0);
if (props.nodeResponseWriter) { if (props.nodeResponseWriter) {
await props.nodeResponseWriter.recordWithParent([wrapper], props.nodeResponseParentId); const recordedWrappers = await props.nodeResponseWriter.recordWithParent(
[wrapper],
props.nodeResponseParentId
);
if (props.apiVersion === 'v2') {
recordedWrappers.forEach((item) => {
props.workflowStreamResponse?.({
event: SseResponseEventEnum.flowNodeResponse,
data: item
});
});
}
} }
}; };
...@@ -252,6 +274,15 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => { ...@@ -252,6 +274,15 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => {
if (response.workflowInteractiveResponse) { if (response.workflowInteractiveResponse) {
interactiveResponse = response.workflowInteractiveResponse; interactiveResponse = response.workflowInteractiveResponse;
pendingIterationSummary = iterationSummary; pendingIterationSummary = iterationSummary;
// 交互暂停是可恢复 checkpoint,需要保留暂停前已完成节点的变量和外部 output 变更。
await syncContainerRunState({
sourceNodes: isolatedNodes,
targetNodes: runtimeNodes,
childrenNodeIdList,
stateSnapshot: iterationStateSnapshot,
childVariableState: iterationVariableState,
parentVariableState: props.variableState
});
await pushIterationDetail({}); await pushIterationDetail({});
break; break;
} }
...@@ -270,6 +301,14 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => { ...@@ -270,6 +301,14 @@ export const dispatchLoopRun = async (props: Props): Promise<Response> => {
await pushIterationDetail({}); await pushIterationDetail({});
loopHistory.push({ iteration, customOutputs, success: true }); loopHistory.push({ iteration, customOutputs, success: true });
await syncContainerRunState({
sourceNodes: isolatedNodes,
targetNodes: runtimeNodes,
childrenNodeIdList,
stateSnapshot: iterationStateSnapshot,
childVariableState: iterationVariableState,
parentVariableState: props.variableState
});
if (iterationSummary.hasLoopRunBreak) break; if (iterationSummary.hasLoopRunBreak) break;
......
import { batchRun } from '@fastgpt/global/common/system/utils'; import { batchRun } from '@fastgpt/global/common/system/utils';
import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import {
DispatchNodeResponseKeyEnum,
SseResponseEventEnum
} from '@fastgpt/global/core/workflow/runtime/constants';
import { import {
type DispatchNodeResultType, type DispatchNodeResultType,
type ModuleDispatchProps type ModuleDispatchProps
...@@ -22,6 +25,7 @@ import { ...@@ -22,6 +25,7 @@ import {
type ParallelFullResultItem type ParallelFullResultItem
} from './service'; } from './service';
import { pushSubWorkflowUsage } from '../utils'; import { pushSubWorkflowUsage } from '../utils';
import { createContainerRunStateSnapshot, syncContainerRunState } from '../utils/containerRunState';
type Props = ModuleDispatchProps<{ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.nestedInputArray]: Array<any>; [NodeInputKeyEnum.nestedInputArray]: Array<any>;
...@@ -94,6 +98,11 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -94,6 +98,11 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
try { try {
const taskVariableState = props.variableState.clone(); const taskVariableState = props.variableState.clone();
const taskStateSnapshot = createContainerRunStateSnapshot({
nodes: taskRuntimeNodes,
childrenNodeIdList,
variableState: taskVariableState
});
const response = await runWorkflow({ const response = await runWorkflow({
...props, ...props,
variableState: taskVariableState, variableState: taskVariableState,
...@@ -113,10 +122,14 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -113,10 +122,14 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
const result = parseTaskResponse({ index, response }); const result = parseTaskResponse({ index, response });
if (result.success) { if (result.success) {
const taskVariables = taskVariableState.toRuntimeRecord(); await syncContainerRunState({
for (const [key, value] of Object.entries(taskVariables)) { sourceNodes: taskRuntimeNodes,
await props.variableState.set(key, value); targetNodes: runtimeNodes,
} childrenNodeIdList,
stateSnapshot: taskStateSnapshot,
childVariableState: taskVariableState,
parentVariableState: props.variableState
});
} }
const attemptResult = { const attemptResult = {
...result, ...result,
...@@ -175,7 +188,7 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -175,7 +188,7 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
const rootChildResponseCount = getNodeResponseChildResponseCount(attemptResponseDetails); const rootChildResponseCount = getNodeResponseChildResponseCount(attemptResponseDetails);
if (props.nodeResponseWriter) { if (props.nodeResponseWriter) {
for (const detail of attemptResponseDetails) { for (const detail of attemptResponseDetails) {
await props.nodeResponseWriter.recordWithParent( const recordedWrappers = await props.nodeResponseWriter.recordWithParent(
[ [
{ {
...detail, ...detail,
...@@ -184,6 +197,14 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -184,6 +197,14 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
], ],
props.nodeResponseParentId props.nodeResponseParentId
); );
if (props.apiVersion === 'v2') {
recordedWrappers.forEach((item) => {
props.workflowStreamResponse?.({
event: SseResponseEventEnum.flowNodeResponse,
data: item
});
});
}
} }
} }
......
...@@ -125,6 +125,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -125,6 +125,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
systemKeyCost: 0, systemKeyCost: 0,
nodes: toolVersion.nodes, nodes: toolVersion.nodes,
edges: toolVersion.edges, edges: toolVersion.edges,
chatConfig: toolVersion.chatConfig,
hasTokenFee: false hasTokenFee: false
}; };
} else { } else {
...@@ -146,6 +147,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -146,6 +147,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
systemKeyCost: systemTool.systemKeyCost ?? 0, systemKeyCost: systemTool.systemKeyCost ?? 0,
nodes: systemTool.nodes, nodes: systemTool.nodes,
edges: systemTool.edges, edges: systemTool.edges,
chatConfig: systemTool.chatConfig,
hasTokenFee: !!systemTool.hasTokenFee, hasTokenFee: !!systemTool.hasTokenFee,
associatedPluginId: systemTool.associatedPluginId associatedPluginId: systemTool.associatedPluginId
}; };
...@@ -208,7 +210,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -208,7 +210,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
chatId: props.chatId, chatId: props.chatId,
responseChatItemId: props.responseChatItemId, responseChatItemId: props.responseChatItemId,
histories: props.histories, histories: props.histories,
variablesConfig: [], variablesConfig: workflowTool.chatConfig?.variables ?? [],
inputVariables: {}, inputVariables: {},
externalVariables: externalProvider?.externalWorkflowVariables, externalVariables: externalProvider?.externalWorkflowVariables,
sourceVariableState: props.variableState sourceVariableState: props.variableState
...@@ -248,7 +250,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -248,7 +250,7 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
variables: runtimeVariables, variables: runtimeVariables,
files files
}).value, }).value,
chatConfig: {}, chatConfig: workflowTool.chatConfig ?? {},
runtimeNodes, runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(workflowTool.edges) runtimeEdges: storeEdges2RuntimeEdges(workflowTool.edges)
}); });
......
import { cloneDeep, isEqual } from 'lodash';
import type {
RuntimeNodeItemType,
WorkflowVariableStateLike
} from '@fastgpt/global/core/workflow/runtime/type';
type ExternalOutputSnapshot = Map<string, unknown>;
type VariableSnapshot = Map<string, unknown>;
export type ContainerRunStateSnapshot = {
externalOutputSnapshot: ExternalOutputSnapshot;
variableSnapshot?: VariableSnapshot;
};
const getOutputSnapshotKey = ({ nodeId, outputId }: { nodeId: string; outputId: string }) =>
`${nodeId}:${outputId}`;
const createExternalOutputSnapshot = ({
nodes,
childrenNodeIdList
}: {
nodes: RuntimeNodeItemType[];
childrenNodeIdList: string[];
}): ExternalOutputSnapshot => {
const childrenSet = new Set(childrenNodeIdList);
const snapshot: ExternalOutputSnapshot = new Map();
nodes.forEach((node) => {
if (childrenSet.has(node.nodeId)) return;
node.outputs.forEach((output) => {
snapshot.set(
getOutputSnapshotKey({ nodeId: node.nodeId, outputId: output.id }),
cloneDeep(output.value)
);
});
});
return snapshot;
};
const createVariableSnapshot = ({
variableState
}: {
variableState: WorkflowVariableStateLike;
}): VariableSnapshot => {
const variables = variableState.toRuntimeRecord();
return new Map(Object.entries(variables).map(([key, value]) => [key, cloneDeep(value)]));
};
/** 创建容器子运行开始前的状态快照,用于后续只提交本轮真实发生的副作用。 */
export const createContainerRunStateSnapshot = ({
nodes,
childrenNodeIdList,
variableState
}: {
nodes: RuntimeNodeItemType[];
childrenNodeIdList: string[];
variableState?: WorkflowVariableStateLike;
}): ContainerRunStateSnapshot => ({
externalOutputSnapshot: createExternalOutputSnapshot({
nodes,
childrenNodeIdList
}),
variableSnapshot: variableState ? createVariableSnapshot({ variableState }) : undefined
});
const syncExternalNodeOutputs = ({
sourceNodes,
targetNodes,
childrenNodeIdList,
initialOutputSnapshot
}: {
sourceNodes: RuntimeNodeItemType[];
targetNodes: RuntimeNodeItemType[];
childrenNodeIdList: string[];
initialOutputSnapshot: ExternalOutputSnapshot;
}) => {
const childrenSet = new Set(childrenNodeIdList);
const sourceNodesMap = new Map(sourceNodes.map((node) => [node.nodeId, node]));
targetNodes.forEach((targetNode) => {
if (childrenSet.has(targetNode.nodeId)) return;
const sourceNode = sourceNodesMap.get(targetNode.nodeId);
if (!sourceNode) return;
const sourceOutputMap = new Map(sourceNode.outputs.map((output) => [output.id, output]));
targetNode.outputs.forEach((targetOutput) => {
const sourceOutput = sourceOutputMap.get(targetOutput.id);
if (!sourceOutput) return;
const snapshotKey = getOutputSnapshotKey({
nodeId: targetNode.nodeId,
outputId: targetOutput.id
});
if (isEqual(sourceOutput.value, initialOutputSnapshot.get(snapshotKey))) return;
targetOutput.value = cloneDeep(sourceOutput.value);
});
});
};
/**
* 将容器子运行完成后的副作用回写到父运行态。
*
* loopRun / parallelRun 都会用隔离的 runtimeNodes 或 variableState 运行子流程。
* 这里统一处理两类需要显式提交的副作用:
* - 变量更新节点写到容器外节点 output 的值,只同步相对本轮开始发生变化的 output。
* - 子运行 clone 出来的全局变量状态,只把相对本轮开始发生变化的变量提交回父状态。
*
* 多个并行任务写同一变量或 output 时,调用方按任务完成顺序调用本函数,后完成者覆盖先完成者。
*/
export const syncContainerRunState = async ({
sourceNodes,
targetNodes,
childrenNodeIdList,
stateSnapshot,
childVariableState,
parentVariableState
}: {
sourceNodes: RuntimeNodeItemType[];
targetNodes: RuntimeNodeItemType[];
childrenNodeIdList: string[];
stateSnapshot: ContainerRunStateSnapshot;
childVariableState?: WorkflowVariableStateLike;
parentVariableState?: WorkflowVariableStateLike;
}) => {
syncExternalNodeOutputs({
sourceNodes,
targetNodes,
childrenNodeIdList,
initialOutputSnapshot: stateSnapshot.externalOutputSnapshot
});
if (!childVariableState || !parentVariableState || childVariableState === parentVariableState) {
return;
}
const childVariables = childVariableState.toRuntimeRecord();
for (const [key, value] of Object.entries(childVariables)) {
if (
stateSnapshot.variableSnapshot?.has(key) &&
isEqual(value, stateSnapshot.variableSnapshot.get(key))
) {
continue;
}
await parentVariableState.set(key, cloneDeep(value));
}
};
...@@ -456,6 +456,59 @@ describe('SystemToolRepo.getSystemToolDetail', () => { ...@@ -456,6 +456,59 @@ describe('SystemToolRepo.getSystemToolDetail', () => {
}); });
}); });
describe('SystemToolRepo.getSystemToolWorkflowRuntime', () => {
it('returns workflow app chatConfig for runtime variable initialization', async () => {
mocks.findSystemTool.mockResolvedValue({
pluginId: 'commercial-workflow-tool',
currentCost: 2,
customConfig: {
name: 'Workflow Tool',
avatar: 'workflow.svg',
associatedPluginId: 'app-id'
}
});
mocks.getAppVersionById.mockResolvedValue({
nodes: [],
edges: [],
chatConfig: {
variables: [
{
key: 'counter',
type: 'numberInput',
valueType: WorkflowIOValueTypeEnum.number,
defaultValue: 0
}
]
}
});
const runtime = await SystemToolRepo.getInstance().getSystemToolWorkflowRuntime({
pluginId: 'commercial-workflow-tool',
version: 'version-id'
});
expect(runtime).toMatchObject({
id: 'commercial-workflow-tool',
name: 'Workflow Tool',
avatar: 'workflow.svg',
currentCost: 2,
associatedPluginId: 'app-id',
chatConfig: {
variables: [
{
key: 'counter',
defaultValue: 0
}
]
}
});
expect(mocks.getAppVersionById).toHaveBeenCalledWith({
appId: 'app-id',
versionId: 'version-id'
});
});
});
describe('SystemToolRepo.getSystemToolDisplayInfo', () => { describe('SystemToolRepo.getSystemToolDisplayInfo', () => {
it('returns workflow tool display metadata without loading app version schemas', async () => { it('returns workflow tool display metadata without loading app version schemas', async () => {
mocks.findSystemTool.mockResolvedValue({ mocks.findSystemTool.mockResolvedValue({
......
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
VariableInputEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import { WorkflowVariableState } from '@fastgpt/service/core/workflow/dispatch/utils/variables';
import { summarizeRuntimeNodeResponses } from '@fastgpt/service/core/workflow/dispatch/utils';
const mocks = vi.hoisted(() => ({
runWorkflow: vi.fn(),
authAppByTmbId: vi.fn(),
getAppVersionById: vi.fn(),
serverGetWorkflowToolRunUserQuery: vi.fn()
}));
vi.mock('@fastgpt/service/core/workflow/dispatch', () => ({
runWorkflow: (args: any) => mocks.runWorkflow(args)
}));
vi.mock('@fastgpt/service/support/permission/app/auth', () => ({
authAppByTmbId: mocks.authAppByTmbId
}));
vi.mock('@fastgpt/service/core/app/version/controller', () => ({
getAppVersionById: mocks.getAppVersionById
}));
vi.mock('@fastgpt/service/support/user/team/utils', () => ({
getUserChatInfo: vi.fn().mockResolvedValue({ externalProvider: undefined })
}));
vi.mock('@fastgpt/service/core/app/tool/workflowTool/utils', () => ({
serverGetWorkflowToolRunUserQuery: (args: any) => mocks.serverGetWorkflowToolRunUserQuery(args)
}));
import { dispatchPlugin } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/app';
const createVariableState = () =>
WorkflowVariableState.create({
timezone: 'Asia/Shanghai',
runningAppInfo: {
sourceType: 'app',
sourceId: 'parent-app',
teamId: 'team',
tmbId: 'member',
name: 'parent'
},
uid: 'user',
chatId: 'chat',
responseChatItemId: 'response',
histories: [],
variablesConfig: []
});
describe('agent sub app dispatchPlugin', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.serverGetWorkflowToolRunUserQuery.mockReturnValue({ value: [] });
mocks.runWorkflow.mockResolvedValue({
flowUsages: [],
runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, [
{
id: 'pluginOutputResponse',
nodeId: 'pluginOutput',
moduleName: 'Output',
moduleType: FlowNodeTypeEnum.pluginOutput,
pluginOutput: { result: 'ok' }
}
])
});
});
it('initializes workflow tool variables from child chatConfig', async () => {
mocks.authAppByTmbId.mockResolvedValue({
app: {
_id: 'child-app',
name: 'Child Workflow Tool',
teamId: 'child-team',
tmbId: 'child-member'
}
});
mocks.getAppVersionById.mockResolvedValue({
nodes: [
{
nodeId: 'pluginInput',
name: 'Input',
flowNodeType: FlowNodeTypeEnum.pluginInput,
inputs: [
{
key: 'query',
defaultValue: 'default query',
renderTypeList: []
}
],
outputs: []
},
{
nodeId: 'pluginOutput',
name: 'Output',
flowNodeType: FlowNodeTypeEnum.pluginOutput,
inputs: [{ key: 'result', isToolOutput: true }],
outputs: []
}
],
edges: [],
chatConfig: {
variables: [
{
key: 'counter',
label: 'counter',
type: VariableInputEnum.numberInput,
valueType: WorkflowIOValueTypeEnum.number,
defaultValue: 0,
description: ''
}
]
}
});
await dispatchPlugin({
app: {
id: 'child-app',
name: 'Child Workflow Tool'
},
runningAppInfo: {
sourceType: 'app',
sourceId: 'parent-app',
teamId: 'team',
tmbId: 'member',
name: 'parent'
},
runningUserInfo: {
teamId: 'team',
tmbId: 'member'
},
customAppVariables: {
query: 'hello'
},
userChatInput: '',
timezone: 'Asia/Shanghai',
uid: 'user',
chatId: 'chat',
responseChatItemId: 'response',
histories: [],
variableState: await createVariableState(),
checkIsStopping: vi.fn(() => false),
maxRunTimes: 20,
workflowDispatchDeep: 0
} as any);
expect(mocks.runWorkflow).toHaveBeenCalledTimes(1);
expect(mocks.runWorkflow.mock.calls[0][0].variableState.get('counter')).toBe(0);
expect(mocks.serverGetWorkflowToolRunUserQuery).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({
counter: 0,
query: 'hello'
})
})
);
});
});
...@@ -992,10 +992,17 @@ describe('runWorkflow node response persistence', () => { ...@@ -992,10 +992,17 @@ describe('runWorkflow node response persistence', () => {
const streamedNodeResponses = responseEvents.filter( const streamedNodeResponses = responseEvents.filter(
(item: any) => item?.id && item?.moduleType (item: any) => item?.id && item?.moduleType
) as Array<{ id: string; parentId?: string; moduleType: FlowNodeTypeEnum }>; ) as Array<{ id: string; parentId?: string; nodeId: string; moduleType: FlowNodeTypeEnum }>;
expect(streamedNodeResponses.some((item) => item.moduleType === FlowNodeTypeEnum.loopRun)).toBe( expect(streamedNodeResponses.some((item) => item.moduleType === FlowNodeTypeEnum.loopRun)).toBe(
true true
); );
const streamedIterationWrappers = streamedNodeResponses.filter(
(item) => item.moduleType === FlowNodeTypeEnum.loopRun && item.nodeId === item.id
);
expect(streamedIterationWrappers).toHaveLength(loopItems.length);
expect(streamedIterationWrappers.map((item) => item.parentId)).toEqual(
Array(loopItems.length).fill(rootRow.data.id)
);
expect(streamedNodeResponses.every((item) => item.id)).toBe(true); expect(streamedNodeResponses.every((item) => item.id)).toBe(true);
}); });
}); });
...@@ -273,6 +273,223 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => { ...@@ -273,6 +273,223 @@ describe('runLoopRun (integration with mocked runWorkflow)', () => {
expect(result[DispatchNodeResponseKeyEnum.newVariables]).toBeUndefined(); expect(result[DispatchNodeResponseKeyEnum.newVariables]).toBeUndefined();
}); });
it('每轮成功结束后把 clone 中的全局变量提交回父状态,下一轮可读取上一轮更新', async () => {
const parentVariableState = makeVariableState({ count: 0 });
const originalSet = parentVariableState.set;
parentVariableState.set = vi.fn((key: string, value: unknown) => originalSet(key, value));
let iteration = 0;
runWorkflowMock.mockImplementation(async (args: any) => {
iteration++;
expect(args.variableState).not.toBe(parentVariableState);
if (iteration === 1) {
expect(args.variableState.get('count')).toBe(0);
await args.variableState.set('count', 1);
} else {
expect(args.variableState.get('count')).toBe(1);
await args.variableState.set('count', 2);
}
return makeDispatchFlowResponse({
nodeResponses: [makeResponseItem('startNode'), makeResponseItem('chatNode')]
});
});
const props = makeProps({
[NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array,
[NodeInputKeyEnum.loopRunInputArray]: ['first', 'second']
});
props.variableState = parentVariableState;
await dispatchLoopRun(props);
expect(parentVariableState.get('count')).toBe(2);
expect(parentVariableState.set).toHaveBeenCalledWith('count', 1);
expect(parentVariableState.set).toHaveBeenCalledWith('count', 2);
});
it('循环体内变量更新外部节点输出后,每轮成功结束会同步到父运行态', async () => {
let unchangedExternalNode: RuntimeNodeItemType | undefined;
let props: any;
let iteration = 0;
runWorkflowMock.mockImplementation((args: any) => {
iteration++;
if (iteration === 2) {
const parentExternalNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'externalText'
);
expect(parentExternalNode?.outputs[0].value).toBe('first');
}
const currentItem = args.runtimeNodes
.find((node: any) => node.nodeId === 'startNode')
?.inputs.find((input: any) => input.key === NodeInputKeyEnum.nestedStartInput)?.value;
const externalNode = args.runtimeNodes.find((node: any) => node.nodeId === 'externalText');
const chatNode = args.runtimeNodes.find((node: any) => node.nodeId === 'chatNode');
externalNode.outputs[0].value = currentItem;
chatNode.outputs[0].value = `child-${currentItem}`;
if (unchangedExternalNode) {
unchangedExternalNode.outputs[0].value = 'changed-by-sibling';
}
return Promise.resolve(
makeDispatchFlowResponse({
nodeResponses: [
makeResponseItem('startNode'),
makeResponseItem('chatNode'),
makeResponseItem('variableUpdateNode')
]
})
);
});
props = makeProps({
[NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array,
[NodeInputKeyEnum.loopRunInputArray]: ['first', 'second']
});
props.params[NodeInputKeyEnum.childrenNodeIdList] = [
'startNode',
'chatNode',
'variableUpdateNode'
];
props.runtimeNodes.push({
nodeId: 'externalText',
name: 'External Text',
avatar: '',
flowNodeType: FlowNodeTypeEnum.textEditor,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: [
{
id: 'text',
key: 'text',
label: 'text',
type: FlowNodeOutputTypeEnum.static,
valueType: 'string' as any,
value: 'before-loop'
}
]
});
props.runtimeNodes.push({
nodeId: 'unchangedExternal',
name: 'Unchanged External',
avatar: '',
flowNodeType: FlowNodeTypeEnum.textEditor,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: [
{
id: 'text',
key: 'text',
label: 'text',
type: FlowNodeOutputTypeEnum.static,
valueType: 'string' as any,
value: 'before-loop'
}
]
});
unchangedExternalNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'unchangedExternal'
);
props.runtimeNodes.push({
nodeId: 'variableUpdateNode',
name: 'Variable Update',
avatar: '',
flowNodeType: FlowNodeTypeEnum.variableUpdate,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: []
});
props.runtimeNodesMap = new Map(
props.runtimeNodes.map((node: RuntimeNodeItemType) => [node.nodeId, node])
);
await dispatchLoopRun(props);
const externalNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'externalText'
);
const childNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'chatNode'
);
expect(externalNode?.outputs[0].value).toBe('second');
expect(unchangedExternalNode?.outputs[0].value).toBe('changed-by-sibling');
expect(childNode?.outputs[0].value).toBe('from-chat');
});
it('失败轮不会把 clone 中的全局变量和外部节点 output 提交回父状态', async () => {
const parentVariableState = makeVariableState({ count: 0 });
runWorkflowMock.mockImplementation(async (args: any) => {
const externalNode = args.runtimeNodes.find((node: any) => node.nodeId === 'externalText');
externalNode.outputs[0].value = 'failed-update';
await args.variableState.set('count', 1);
return makeDispatchFlowResponse({
nodeResponses: [makeResponseItem('startNode', { error: 'boom' })]
});
});
const props = makeProps({
[NodeInputKeyEnum.loopRunMode]: LoopRunModeEnum.array,
[NodeInputKeyEnum.loopRunInputArray]: ['first']
});
props.variableState = parentVariableState;
props.params[NodeInputKeyEnum.childrenNodeIdList] = [
'startNode',
'chatNode',
'variableUpdateNode'
];
props.runtimeNodes.push({
nodeId: 'externalText',
name: 'External Text',
avatar: '',
flowNodeType: FlowNodeTypeEnum.textEditor,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: [
{
id: 'text',
key: 'text',
label: 'text',
type: FlowNodeOutputTypeEnum.static,
valueType: 'string' as any,
value: 'before-loop'
}
]
});
props.runtimeNodes.push({
nodeId: 'variableUpdateNode',
name: 'Variable Update',
avatar: '',
flowNodeType: FlowNodeTypeEnum.variableUpdate,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: []
});
await dispatchLoopRun(props);
const externalNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'externalText'
);
expect(externalNode?.outputs[0].value).toBe('before-loop');
expect(parentVariableState.get('count')).toBe(0);
});
it('array mode 第 2 轮节点出错 → 本轮 success:false, 失败轮快照对未跑节点返回 undefined', async () => { it('array mode 第 2 轮节点出错 → 本轮 success:false, 失败轮快照对未跑节点返回 undefined', async () => {
let iter = 0; let iter = 0;
runWorkflowMock.mockImplementation((args: any) => { runWorkflowMock.mockImplementation((args: any) => {
......
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import {
FlowNodeOutputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { import {
NodeInputKeyEnum, NodeInputKeyEnum,
NodeOutputKeyEnum, NodeOutputKeyEnum,
...@@ -241,60 +244,227 @@ describe('dispatchParallelRun', () => { ...@@ -241,60 +244,227 @@ describe('dispatchParallelRun', () => {
}); });
it('成功任务结束后把 clone 中的全局变量提交回父状态', async () => { it('成功任务结束后把 clone 中的全局变量提交回父状态', async () => {
const taskVariableState = makeVariableState({ count: 2 }); const taskVariableState = makeVariableState({ count: 0 });
const parentVariableState = { const parentVariableState = makeVariableState({ count: 0 });
...makeVariableState(), const originalSet = parentVariableState.set;
clone: vi.fn(() => taskVariableState), parentVariableState.clone = vi.fn(() => taskVariableState);
set: vi.fn() parentVariableState.set = vi.fn((key: string, value: unknown) => originalSet(key, value));
}; runWorkflowMock.mockImplementation(async (args: any) => {
runWorkflowMock.mockResolvedValue( await args.variableState.set('count', 2);
makeDispatchFlowResponse({ return makeDispatchFlowResponse({
nodeResponses: [ nodeResponses: [
makeResponseItem('nestedEnd', { makeResponseItem('nestedEnd', {
moduleType: FlowNodeTypeEnum.nestedEnd, moduleType: FlowNodeTypeEnum.nestedEnd,
loopOutputValue: 'done' loopOutputValue: 'done'
}) })
] ]
});
});
await dispatchParallelRun(
makeProps({
variableState: parentVariableState
}) })
); );
expect(parentVariableState.set).toHaveBeenCalledWith('count', 2);
expect(parentVariableState.get('count')).toBe(2);
});
it('成功任务只提交实际变化变量,避免覆盖其他任务已写回的变量', async () => {
const task0VariableState = makeVariableState({ first: 0, second: 0 });
const task1VariableState = makeVariableState({ first: 0, second: 0 });
const parentVariableState = makeVariableState({ first: 0, second: 0 });
parentVariableState.clone = vi
.fn()
.mockReturnValueOnce(task0VariableState)
.mockReturnValueOnce(task1VariableState);
runWorkflowMock.mockImplementation(async (args: any) => {
if (args.nodeResponseParentId.endsWith('_task_0')) {
await args.variableState.set('first', 1);
} else {
await args.variableState.set('second', 2);
}
return makeDispatchFlowResponse({
nodeResponses: [
makeResponseItem('nestedEnd', {
moduleType: FlowNodeTypeEnum.nestedEnd,
loopOutputValue: 'done'
})
]
});
});
await dispatchParallelRun( await dispatchParallelRun(
makeProps({ makeProps({
params: {
loopInputArray: ['a', 'b'],
[NodeInputKeyEnum.childrenNodeIdList]: [],
[NodeInputKeyEnum.parallelRunMaxConcurrency]: 2,
[NodeInputKeyEnum.parallelRunMaxRetryTimes]: 0
},
variableState: parentVariableState variableState: parentVariableState
}) })
); );
expect(parentVariableState.set).toHaveBeenCalledWith('count', 2); expect(parentVariableState.get('first')).toBe(1);
expect(parentVariableState.get('second')).toBe(2);
});
it('成功任务会把变量更新写到外部节点的 output 同步回父运行态', async () => {
let unchangedExternalNode: RuntimeNodeItemType | undefined;
runWorkflowMock.mockImplementation((args: any) => {
const externalNode = args.runtimeNodes.find((node: any) => node.nodeId === 'externalText');
externalNode.outputs[0].value = `updated-${args.nodeResponseParentId}`;
if (unchangedExternalNode) {
unchangedExternalNode.outputs[0].value = 'changed-by-sibling';
}
return Promise.resolve(
makeDispatchFlowResponse({
nodeResponses: [
makeResponseItem('nestedEnd', {
moduleType: FlowNodeTypeEnum.nestedEnd,
loopOutputValue: 'done'
})
]
})
);
});
const props = makeProps();
props.runtimeNodes.push({
nodeId: 'externalText',
name: 'External Text',
avatar: '',
flowNodeType: FlowNodeTypeEnum.textEditor,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: [
{
id: 'text',
key: 'text',
label: 'text',
type: FlowNodeOutputTypeEnum.static,
valueType: 'string' as any,
value: 'before'
}
]
});
props.runtimeNodes.push({
nodeId: 'unchangedExternal',
name: 'Unchanged External',
avatar: '',
flowNodeType: FlowNodeTypeEnum.textEditor,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: [
{
id: 'text',
key: 'text',
label: 'text',
type: FlowNodeOutputTypeEnum.static,
valueType: 'string' as any,
value: 'before'
}
]
});
unchangedExternalNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'unchangedExternal'
);
await dispatchParallelRun(props);
const externalNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'externalText'
);
expect(externalNode?.outputs[0].value).toBe('updated-parallelRun1_task_0');
expect(unchangedExternalNode?.outputs[0].value).toBe('changed-by-sibling');
});
it('失败任务不会把外部节点 output 更新同步回父运行态', async () => {
runWorkflowMock.mockImplementation((args: any) => {
const externalNode = args.runtimeNodes.find((node: any) => node.nodeId === 'externalText');
externalNode.outputs[0].value = 'failed-update';
return Promise.resolve(
makeDispatchFlowResponse({
nodeResponses: [
makeResponseItem('failed-node', {
error: 'failed'
})
]
})
);
});
const props = makeProps();
props.runtimeNodes.push({
nodeId: 'externalText',
name: 'External Text',
avatar: '',
flowNodeType: FlowNodeTypeEnum.textEditor,
showStatus: false,
isEntry: false,
catchError: false,
inputs: [],
outputs: [
{
id: 'text',
key: 'text',
label: 'text',
type: FlowNodeOutputTypeEnum.static,
valueType: 'string' as any,
value: 'before'
}
]
});
await dispatchParallelRun(props);
const externalNode = props.runtimeNodes.find(
(node: RuntimeNodeItemType) => node.nodeId === 'externalText'
);
expect(externalNode?.outputs[0].value).toBe('before');
}); });
it('失败 attempt 不提交全局变量更新,重试成功后只提交成功 attempt 的更新', async () => { it('失败 attempt 不提交全局变量更新,重试成功后只提交成功 attempt 的更新', async () => {
const failedClone = makeVariableState({ count: 1 }); const failedClone = makeVariableState({ count: 0 });
const successClone = makeVariableState({ count: 2 }); const successClone = makeVariableState({ count: 0 });
const parentVariableState = { const parentVariableState = makeVariableState({ count: 0 });
...makeVariableState(), const originalSet = parentVariableState.set;
clone: vi.fn().mockReturnValueOnce(failedClone).mockReturnValueOnce(successClone), parentVariableState.clone = vi
set: vi.fn() .fn()
}; .mockReturnValueOnce(failedClone)
.mockReturnValueOnce(successClone);
parentVariableState.set = vi.fn((key: string, value: unknown) => originalSet(key, value));
runWorkflowMock runWorkflowMock
.mockResolvedValueOnce( .mockImplementationOnce(async (args: any) => {
makeDispatchFlowResponse({ await args.variableState.set('count', 1);
return makeDispatchFlowResponse({
nodeResponses: [ nodeResponses: [
makeResponseItem('failed-node', { makeResponseItem('failed-node', {
error: 'failed' error: 'failed'
}) })
] ]
});
}) })
) .mockImplementationOnce(async (args: any) => {
.mockResolvedValueOnce( await args.variableState.set('count', 2);
makeDispatchFlowResponse({ return makeDispatchFlowResponse({
nodeResponses: [ nodeResponses: [
makeResponseItem('nestedEnd', { makeResponseItem('nestedEnd', {
moduleType: FlowNodeTypeEnum.nestedEnd, moduleType: FlowNodeTypeEnum.nestedEnd,
loopOutputValue: 'done' loopOutputValue: 'done'
}) })
] ]
}) });
); });
await dispatchParallelRun( await dispatchParallelRun(
makeProps({ makeProps({
...@@ -310,5 +480,6 @@ describe('dispatchParallelRun', () => { ...@@ -310,5 +480,6 @@ describe('dispatchParallelRun', () => {
expect(parentVariableState.set).toHaveBeenCalledTimes(1); expect(parentVariableState.set).toHaveBeenCalledTimes(1);
expect(parentVariableState.set).toHaveBeenCalledWith('count', 2); expect(parentVariableState.set).toHaveBeenCalledWith('count', 2);
expect(parentVariableState.get('count')).toBe(2);
}); });
}); });
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import {
NodeOutputKeyEnum,
VariableInputEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import { WorkflowVariableState } from '@fastgpt/service/core/workflow/dispatch/utils/variables'; import { WorkflowVariableState } from '@fastgpt/service/core/workflow/dispatch/utils/variables';
import { summarizeRuntimeNodeResponses } from '@fastgpt/service/core/workflow/dispatch/utils'; import { summarizeRuntimeNodeResponses } from '@fastgpt/service/core/workflow/dispatch/utils';
...@@ -84,6 +88,18 @@ describe('dispatchRunPlugin', () => { ...@@ -84,6 +88,18 @@ describe('dispatchRunPlugin', () => {
} }
], ],
edges: [], edges: [],
chatConfig: {
variables: [
{
key: 'counter',
label: 'counter',
type: VariableInputEnum.numberInput,
valueType: WorkflowIOValueTypeEnum.number,
defaultValue: 0,
description: ''
}
]
},
currentCost: 0, currentCost: 0,
associatedPluginId: 'associated-app' associatedPluginId: 'associated-app'
}); });
...@@ -139,7 +155,10 @@ describe('dispatchRunPlugin', () => { ...@@ -139,7 +155,10 @@ describe('dispatchRunPlugin', () => {
} as any); } as any);
expect(runWorkflowMock).toHaveBeenCalledTimes(1); expect(runWorkflowMock).toHaveBeenCalledTimes(1);
expect(runWorkflowMock.mock.calls[0][0].nodeResponseWriter).toBeUndefined(); const childWorkflowProps = runWorkflowMock.mock.calls[0][0];
expect(childWorkflowProps.nodeResponseWriter).toBeUndefined();
expect(childWorkflowProps.chatConfig.variables).toHaveLength(1);
expect(childWorkflowProps.variableState.get('counter')).toBe(0);
expect(result[DispatchNodeResponseKeyEnum.nodeResponse]).toMatchObject({ expect(result[DispatchNodeResponseKeyEnum.nodeResponse]).toMatchObject({
moduleLogo: 'system-avatar', moduleLogo: 'system-avatar',
childResponseCount: 1 childResponseCount: 1
......
Subproject commit bed1e59a243e8d3062450d6eab799b37d5c718e0 Subproject commit 2b3e11934332dcefae9ee6847f65a2e15d32095a
...@@ -115,8 +115,8 @@ export const ResponseBox = React.memo(function ResponseBox({ ...@@ -115,8 +115,8 @@ export const ResponseBox = React.memo(function ResponseBox({
</Box> </Box>
</Flex> </Flex>
) : ( ) : (
<Box h={'100%'} minH={0} overflow={'hidden'}> <Box h={'100%'} minH={0} overflow={'hidden'} position={'relative'}>
{!isOpenMobileModal && ( <Box h={'100%'} minH={0} overflow={'auto'}>
<WholeResponseSideTab <WholeResponseSideTab
response={sliderResponseList} response={sliderResponseList}
value={currentNodeId} value={currentNodeId}
...@@ -126,9 +126,17 @@ export const ResponseBox = React.memo(function ResponseBox({ ...@@ -126,9 +126,17 @@ export const ResponseBox = React.memo(function ResponseBox({
}} }}
isMobile={true} isMobile={true}
/> />
)} </Box>
{isOpenMobileModal && ( {isOpenMobileModal && (
<Flex flexDirection={'column'} h={'100%'} minH={0}> <Flex
position={'absolute'}
inset={0}
zIndex={1}
bg={'white'}
flexDirection={'column'}
h={'100%'}
minH={0}
>
<Flex <Flex
align={'center'} align={'center'}
justifyContent={'center'} justifyContent={'center'}
......
...@@ -2,7 +2,7 @@ import { type ReactNode } from 'react'; ...@@ -2,7 +2,7 @@ import { type ReactNode } from 'react';
import { Box, Flex, useDisclosure } from '@chakra-ui/react'; import { Box, Flex, useDisclosure } from '@chakra-ui/react';
import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants'; import { moduleTemplatesFlat } from '@fastgpt/global/core/workflow/template/constants';
import Avatar from '@fastgpt/web/components/common/Avatar'; import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
import type { SideTabItemType } from './types'; import type { SideTabItemType } from './types';
...@@ -14,6 +14,8 @@ const SIDE_TAB_ROOT_PADDING = 8; ...@@ -14,6 +14,8 @@ const SIDE_TAB_ROOT_PADDING = 8;
const SIDE_TAB_MAX_CHILD_DEPTH = 3; const SIDE_TAB_MAX_CHILD_DEPTH = 3;
const SIDE_TAB_AVATAR_SIZE = 24; const SIDE_TAB_AVATAR_SIZE = 24;
const SIDE_TAB_CHILD_INDENT = 28; const SIDE_TAB_CHILD_INDENT = 28;
const SIDE_TAB_ACCORDION_BUTTON_SIZE = 24;
const SIDE_TAB_ACCORDION_ICON_SIZE = 16;
const getSideTabLeftPadding = (index: number) => { const getSideTabLeftPadding = (index: number) => {
const safeIndex = Math.min(index, SIDE_TAB_MAX_CHILD_DEPTH); const safeIndex = Math.min(index, SIDE_TAB_MAX_CHILD_DEPTH);
...@@ -93,12 +95,12 @@ const NormalSideTabItem = ({ ...@@ -93,12 +95,12 @@ const NormalSideTabItem = ({
</Box> </Box>
{children && ( {children && (
<Flex <Flex
h={'24px'} h={`${SIDE_TAB_ACCORDION_BUTTON_SIZE}px`}
w={'20px'} w={`${SIDE_TAB_ACCORDION_BUTTON_SIZE}px`}
flexShrink={0} flexShrink={0}
alignItems={'center'} alignItems={'center'}
justifyContent={'center'} justifyContent={'center'}
ml={1} ml={2}
> >
{children} {children}
</Flex> </Flex>
...@@ -131,15 +133,18 @@ const AccordionSideTabItem = ({ ...@@ -131,15 +133,18 @@ const AccordionSideTabItem = ({
onChange={onChange} onChange={onChange}
sideBarItem={sideBarItem} sideBarItem={sideBarItem}
> >
<MyIcon <MyIconButton
h={'20px'} icon={isShowAccordion ? 'core/chat/chevronUp' : 'core/chat/chevronDown'}
w={'20px'} size={`${SIDE_TAB_ACCORDION_ICON_SIZE}px`}
name={isShowAccordion ? 'core/chat/chevronUp' : 'core/chat/chevronDown'} w={`${SIDE_TAB_ACCORDION_BUTTON_SIZE}px`}
h={`${SIDE_TAB_ACCORDION_BUTTON_SIZE}px`}
p={0}
justifyContent={'center'}
hoverBg={'myGray.200'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleShowAccordion(); onToggleShowAccordion();
}} }}
_hover={{ color: 'primary.600', cursor: 'pointer' }}
/> />
</NormalSideTabItem> </NormalSideTabItem>
</Flex> </Flex>
......
...@@ -172,7 +172,7 @@ export const useChatTest = ({ ...@@ -172,7 +172,7 @@ export const useChatTest = ({
const CustomChatContainer = useMemoizedFn(() => const CustomChatContainer = useMemoizedFn(() =>
appDetail.type === AppTypeEnum.workflowTool ? ( appDetail.type === AppTypeEnum.workflowTool ? (
<Box p={5} pb={16} h={'100%'} minH={0} display={'flex'} flexDirection={'column'}> <Box p={5} h={'100%'} minH={0} display={'flex'} flexDirection={'column'}>
<PluginRunBox <PluginRunBox
appId={appId} appId={appId}
chatId={chatId} chatId={chatId}
......
...@@ -475,6 +475,11 @@ export const workflowReferenceValueIsSelectable = ({ ...@@ -475,6 +475,11 @@ export const workflowReferenceValueIsSelectable = ({
}); });
}; };
/**
* 获取当前节点可引用的所有上游节点。
* 结果按工作流入边距离由近到远排列;嵌套节点先取自身入边,再取父容器入边,
* 最后追加全局变量,保证引用选择器优先展示最近的可用输出。
*/
export const getNodeAllSource = ({ export const getNodeAllSource = ({
nodeId, nodeId,
systemConfigNode, systemConfigNode,
...@@ -502,22 +507,36 @@ export const getNodeAllSource = ({ ...@@ -502,22 +507,36 @@ export const getNodeAllSource = ({
const parentId = node.parentNodeId; const parentId = node.parentNodeId;
const sourceNodes = new Map<string, FlowNodeItemType>(); const sourceNodes = new Map<string, FlowNodeItemType>();
// 根据 edge 获取所有的 source 节点(source节点会继续向前递归获取) const searchedTargetNodeIds = new Set<string>();
const findSourceNode = (nodeId: string) => {
const targetEdges = edges.filter((item) => item.target === nodeId || item.target === parentId); // 按入边层级遍历,避免深度优先递归把更远的上游节点排到直接来源前面。
const collectSourceNodesByEdgeDistance = (targetNodeIds: string[]) => {
const queue = targetNodeIds.filter(Boolean);
while (queue.length > 0) {
const targetNodeId = queue.shift();
if (!targetNodeId || searchedTargetNodeIds.has(targetNodeId)) continue;
searchedTargetNodeIds.add(targetNodeId);
const targetEdges = edges.filter((item) => item.target === targetNodeId);
targetEdges.forEach((edge) => { targetEdges.forEach((edge) => {
const sourceNode = getNodeById(edge.source); const sourceNode = getNodeById(edge.source);
if (!sourceNode) return; if (!sourceNode) return;
// 去重 if (!sourceNodes.has(sourceNode.nodeId)) {
if (sourceNodes.has(sourceNode.nodeId)) {
return;
}
sourceNodes.set(sourceNode.nodeId, sourceNode); sourceNodes.set(sourceNode.nodeId, sourceNode);
findSourceNode(sourceNode.nodeId); }
queue.push(sourceNode.nodeId);
}); });
}
}; };
findSourceNode(nodeId);
collectSourceNodesByEdgeDistance([nodeId]);
if (parentId) {
collectSourceNodesByEdgeDistance([parentId]);
}
// 对于嵌套在容器(Loop/ParallelRun)内的节点,容器的 reference 类型输入 // 对于嵌套在容器(Loop/ParallelRun)内的节点,容器的 reference 类型输入
// 是通过引用选择器设置的(存在 input.value = [nodeId, outputId]),不产生 ReactFlow edge。 // 是通过引用选择器设置的(存在 input.value = [nodeId, outputId]),不产生 ReactFlow edge。
...@@ -534,7 +553,7 @@ export const getNodeAllSource = ({ ...@@ -534,7 +553,7 @@ export const getNodeAllSource = ({
const refNode = getNodeById(refNodeId); const refNode = getNodeById(refNodeId);
if (!refNode || sourceNodes.has(refNode.nodeId)) return; if (!refNode || sourceNodes.has(refNode.nodeId)) return;
sourceNodes.set(refNode.nodeId, refNode); sourceNodes.set(refNode.nodeId, refNode);
findSourceNode(refNode.nodeId); collectSourceNodesByEdgeDistance([refNode.nodeId]);
}); });
} }
} }
......
...@@ -17,6 +17,7 @@ import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/l ...@@ -17,6 +17,7 @@ import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/l
import { import {
nodeTemplate2FlowNode, nodeTemplate2FlowNode,
storeNode2FlowNode, storeNode2FlowNode,
getNodeAllSource,
filterWorkflowNodeOutputsByType, filterWorkflowNodeOutputsByType,
filterSelectableWorkflowNodeOutputs, filterSelectableWorkflowNodeOutputs,
workflowReferenceValueIsSelectable, workflowReferenceValueIsSelectable,
...@@ -348,6 +349,141 @@ describe('workflowReferenceValueIsSelectable', () => { ...@@ -348,6 +349,141 @@ describe('workflowReferenceValueIsSelectable', () => {
}); });
}); });
describe('getNodeAllSource', () => {
const makeNode = (nodeId: string, parentNodeId?: string): FlowNodeItemType =>
({
nodeId,
parentNodeId,
name: nodeId,
flowNodeType: FlowNodeTypeEnum.formInput,
inputs: [],
outputs: [
{
id: 'output',
key: 'output',
label: 'output',
type: FlowNodeOutputTypeEnum.static,
valueType: WorkflowIOValueTypeEnum.string
}
]
}) as FlowNodeItemType;
const makeNodeWithoutOutputs = (nodeId: string, parentNodeId?: string): FlowNodeItemType =>
({
...makeNode(nodeId, parentNodeId),
outputs: []
}) as FlowNodeItemType;
const getSourceNodeIds = ({
nodeId,
nodes,
edges
}: {
nodeId: string;
nodes: FlowNodeItemType[];
edges: Edge[];
}) => {
const nodeMap = new Map(nodes.map((node) => [node.nodeId, node]));
return getNodeAllSource({
nodeId,
getNodeById: (nodeId) => (nodeId ? nodeMap.get(nodeId) : undefined),
edges,
chatConfig: {} as any,
t: ((key: string) => key) as any
}).map((node) => node.nodeId);
};
const getSelectableSourceNodeIds = ({
nodeId,
nodes,
edges
}: {
nodeId: string;
nodes: FlowNodeItemType[];
edges: Edge[];
}) => {
const nodeMap = new Map(nodes.map((node) => [node.nodeId, node]));
return getNodeAllSource({
nodeId,
getNodeById: (nodeId) => (nodeId ? nodeMap.get(nodeId) : undefined),
edges,
chatConfig: {} as any,
t: ((key: string) => key) as any
})
.filter(
(node) =>
filterSelectableWorkflowNodeOutputs({
outputs: node.outputs,
valueType: WorkflowIOValueTypeEnum.any,
catchError: node.catchError
}).length > 0
)
.map((node) => node.nodeId);
};
it('orders source nodes by incoming edge distance', () => {
const nodes = [makeNode('target'), makeNode('direct'), makeNode('ancestor')];
const edges = [
{ id: 'ancestor-to-direct', source: 'ancestor', target: 'direct' },
{ id: 'direct-to-target', source: 'direct', target: 'target' }
] as Edge[];
expect(getSourceNodeIds({ nodeId: 'target', nodes, edges })).toEqual([
'direct',
'ancestor',
VARIABLE_NODE_ID
]);
});
it('orders nested node references by nearest edge and keeps global variables last', () => {
const nodes = [
makeNode('reply', 'loop'),
makeNode('loop'),
makeNode('loopStart', 'loop'),
makeNode('textConcat'),
makeNode('pluginStart')
];
const edges = [
{ id: 'plugin-to-text', source: 'pluginStart', target: 'textConcat' },
{ id: 'text-to-loop', source: 'textConcat', target: 'loop' },
{ id: 'loopStart-to-reply', source: 'loopStart', target: 'reply' }
] as Edge[];
expect(getSourceNodeIds({ nodeId: 'reply', nodes, edges })).toEqual([
'loopStart',
'textConcat',
'pluginStart',
VARIABLE_NODE_ID
]);
});
it('keeps same-level upstream references before parent references after filtering empty outputs', () => {
const nodes = [
makeNode('variableUpdate', 'loop'),
makeNodeWithoutOutputs('reply', 'loop'),
makeNode('loop'),
makeNode('loopStart', 'loop'),
makeNode('textConcat'),
makeNode('pluginStart')
];
const edges = [
{ id: 'plugin-to-text', source: 'pluginStart', target: 'textConcat' },
{ id: 'text-to-loop', source: 'textConcat', target: 'loop' },
{ id: 'loopStart-to-reply', source: 'loopStart', target: 'reply' },
{ id: 'reply-to-variable-update', source: 'reply', target: 'variableUpdate' }
] as Edge[];
expect(getSelectableSourceNodeIds({ nodeId: 'variableUpdate', nodes, edges })).toEqual([
'loopStart',
'textConcat',
'pluginStart',
VARIABLE_NODE_ID
]);
});
});
describe('checkWorkflowNodeAndConnection', () => { describe('checkWorkflowNodeAndConnection', () => {
it('should validate nodes and connections', () => { it('should validate nodes and connections', () => {
const nodes: Node[] = [ const nodes: Node[] = [
......
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