Commit 3a908e79 by Archer Committed by GitHub

perf: rewrite node response storage (#7040)

* perf: rewrite node respose storage

perf: tool call

fix: include file tool input in node response

refactor: simplify workflow node response writer

fix: update pro workflow dispatch config

* fix chat interactive response data ids

* refactor workflow stream response context

* fix skill debug sandbox identity

* submodule

* fix ci failures for node response workflow

* fix template list empty type query

* docs: clarify node response child persistence contract

* perf: ui

* fix: batch ru

* fix: system tool

* fix: toolset rewrite

* fix: child response

* fix: test
parent bd6eab3f
...@@ -215,11 +215,10 @@ enum RuntimeEdgeStatusEnum { ...@@ -215,11 +215,10 @@ enum RuntimeEdgeStatusEnum {
```typescript ```typescript
interface DispatchFlowResponse { interface DispatchFlowResponse {
flowResponses: ChatHistoryItemResType[];
flowUsages: ChatNodeUsageType[]; flowUsages: ChatNodeUsageType[];
debugResponse: WorkflowDebugResponse; debugResponse: WorkflowDebugResponse;
workflowInteractiveResponse?: WorkflowInteractiveResponseType; workflowInteractiveResponse?: WorkflowInteractiveResponseType;
toolResponses: ToolRunResponseItemType; toolResponse: ToolRunResponseItemType;
assistantResponses: AIChatItemValueItemType[]; assistantResponses: AIChatItemValueItemType[];
runTimes: number; runTimes: number;
newVariables: Record<string, string>; newVariables: Record<string, string>;
...@@ -594,4 +593,4 @@ interface NodeTemplateListItemType { ...@@ -594,4 +593,4 @@ interface NodeTemplateListItemType {
- **节点样式**: 可定制的节点外观 - **节点样式**: 可定制的节点外观
- **连线样式**: 自定义连线类型和颜色 - **连线样式**: 自定义连线类型和颜色
- **布局配置**: 多种布局算法支持 - **布局配置**: 多种布局算法支持
- **国际化**: 多语言界面支持 - **国际化**: 多语言界面支持
\ No newline at end of file
...@@ -226,7 +226,7 @@ export const dispatchYourNode = async (props: Props): Promise<YourNodeResponse> ...@@ -226,7 +226,7 @@ export const dispatchYourNode = async (props: Props): Promise<YourNodeResponse>
}, },
// 移除当前交互的历史记录(最后2条) // 移除当前交互的历史记录(最后2条)
[DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2),
[DispatchNodeResponseKeyEnum.toolResponses]: userInputVal, [DispatchNodeResponseKeyEnum.toolResponse]: userInputVal,
[DispatchNodeResponseKeyEnum.nodeResponse]: { [DispatchNodeResponseKeyEnum.nodeResponse]: {
yourResult: userInputVal yourResult: userInputVal
} }
......
# Workflow NodeResponse 流式持久化与平铺存储
日期:2026-05-30
基线:`/Volumes/code/FastGPT-worktrees/upstream-main`
## 最终摘要
本轮工作将 workflow 运行期的 `nodeResponses/responseData` 从“内存累计,结束后一次保存”调整为“节点完成后通过请求级 writer 分批写入数据库”。workflow 子流程、loop、parallel、toolcall 等大子树默认在 `chat_item_responses` 中平铺保存,通过 `data.id/data.parentId` 还原父子关系;少数可控、小规模且语义上绑定当前节点的 child 详情允许继续内联保留。
客户端详情结构保持稳定:接口返回和流式展示仍是嵌套结构,正式 child 字段继续使用既有 `childrenResponses`;旧 detail 字段 `pluginDetail/toolDetail/loopDetail/loopRunDetail/parallelDetail` 仅作为历史读取来源,不再作为新链路的通用写入结构。
## 已确认设计
- 复用 `chat_item_responses` 表,不新增版本字段。
- 每条 row 的 `data` 就是完整的当前节点 `nodeResponse` 数据;父子识别只使用 `data.id``data.parentId`
- 不新增 `sequence/depth/rootResponseId/runId/childRuntime`;展示顺序依赖同一个 writer 串行写入后的数据库插入顺序。
- 父节点记录 `childTotalPoints``childResponseCount`,统计完整 child 树;节点自身只保留当前节点运行时间。
- SSE `flowNodeResponse` 透出 `id/parentId`,客户端可在流式过程中合并父子节点。
- 新数据不再写入 `chat_items.responseData``saveChat` 只保存 AI 消息主体,引用、错误数和积分日志来自 writer 的 `nodeResponseSummary`
- 旧数据兼容保留:如果 `chat_items.responseData` 已存在,说明该条 AI 消息仍是历史内联详情,读取时优先使用它,不用 `chat_item_responses` 覆盖。
## 数据结构
row 核心字段:
```ts
type ChatItemResponseSchema = {
teamId: ObjectId;
appId: ObjectId;
chatId: string;
chatItemDataId: string;
// 完整的当前节点 nodeResponse 数据。
// data.id 是节点响应实例 ID;data.parentId 指向父节点 data.id。
// data.nodeId/moduleType/childTotalPoints/childResponseCount 都只保存在 data 内。
// 大子流程通过 parentId 平铺落库,读取时再拼回 childrenResponses。
// 少数可控、小规模的节点内联 child 可保留在 data 内。
data: ChatHistoryItemResType;
time: Date;
};
```
新增索引:
- `{ appId, chatId, chatItemDataId, _id }`,用于详情读取并按写入顺序排序
- `{ appId, chatId, chatItemDataId, data.id }`,unique
- `{ teamId, time: -1 }`
## 写入流程
实现位置:
- [nodeResponseStorage.ts](/Volumes/code/FastGPT-worktrees/upstream-main/packages/service/core/chat/nodeResponseStorage.ts)
- [dispatch/index.ts](/Volumes/code/FastGPT-worktrees/upstream-main/packages/service/core/workflow/dispatch/index.ts)
关键行为:
1. root chat v2 workflow 创建一个 `WorkflowNodeResponseWriter`,子 workflow、loop、parallel、plugin、runApp、toolcall 复用同一个 writer。
2. 节点执行前生成节点响应实例 ID 并写入 `data.id`;子 workflow 的 `nodeResponseParentId` 指向父节点或虚拟 wrapper 的 `data.id`,落库和 SSE 时表现为 `parentId`
3. 节点完成后调用 `record()` 写入轻量化后的 rows;大子流程 child 通过独立 row 平铺保存,父节点只保留统计信息。
4. 模块返回的内部 `nodeResponses` 如果没有显式 `parentId`,会默认挂到当前节点响应下;父节点缺少 child 统计时按这些 child 自动补 `childTotalPoints/childResponseCount`
5. writer 默认 `batchSize = 5`,buffer 满或 root close 时 flush。这里按 row 计数,大子流程会展开成多条 row;可控小 child 可能作为当前节点的内联详情随同一 row 写入。
6. flush 使用同一 Mongo transaction,先删除本批同 `data.id` 旧 row,再一次性 `create(rows[])``create` 设置 `ordered: true`,同一 flush 内全部成功或全部回滚。写入前不再用 `JSON.stringify` 预估体积,BSON 大小和不可序列化字段统一交给 Mongo 写入校验。
7. 普通写入失败重试 3 次;仍失败则将 rows 瘦身为节点名、类型、头像、运行时间、消耗统计、父子统计等关键字段后再写 1 次。
8. 瘦身写仍失败时丢弃本批 rows,记录日志并继续 workflow;失败 rows 不回退到内存,也不交给 `saveChat` 兜底。
9. 详情 rows 被丢弃时,writer 已累计的 summary 仍保留,`saveChat` 仍可保存引用、错误数和积分日志。
10. `record()` 返回规范化后的响应。大子流程 child 已经通过独立 row 交给 writer,调用链可释放原始完整 `nodeResponse`;可控小 child 仍可作为当前节点内联详情保留。
11. parallel retry 通过 `deleteResponses()` 清理失败 attempt 及其 descendants。
12. 新回合使用 replace;interactive resume 使用 append。replace 的旧详情删除延迟到第一次 flush transaction 中执行,避免新写失败先清空旧详情。
## 读取规则
实现位置:
- [controller.ts](/Volumes/code/FastGPT-worktrees/upstream-main/packages/service/core/chat/controller.ts)
- [getResData.ts](/Volumes/code/FastGPT-worktrees/upstream-main/projects/app/src/pages/api/core/chat/record/getResData.ts)
- [utils.ts](/Volumes/code/FastGPT-worktrees/upstream-main/packages/global/core/chat/utils.ts)
读取规则:
- 对旧 AI 消息,如果 `chat_items.responseData` 已存在,直接返回内联详情,避免被独立表空结果覆盖。
- 对新 AI 消息,`chat_items.responseData` 不存在,详情接口和 chat history 从 `chat_item_responses` 读取 node response rows。
- rows 按 `_id: 1` 读取,通过 `data.parentId` 拼回 `childrenResponses`
- child row 早于 parent row 写入也能还原。
- ResponseTags、WholeResponseModal、SSE resume 都递归读取 `childrenResponses`,同时读取旧 detail 字段。
## 已完成改动
- 新增 `nodeResponseStorage.ts`,封装 flat row 生成、详情拼接、writer、summary、失败降级与 retry 清理。
- `MongoChatItemResponse` schema 收敛为所有节点详情都在 `data` 内,唯一性由同一 chat item 下的 `data.id` 保证。
- workflow dispatch 接入请求级 writer,root runtime 不再累计完整 `nodeResponses`
- loop/parallel/runApp/plugin/toolcall/agent 子流程复用 writer,并补齐父节点 child 统计。
- 修正子流程返回语义:持久化和 SSE 使用完整轻量响应,workflow 队列内部避免同一节点同时从 `responseData/nodeResponses` 重复进入 `flowResponses`
- `saveChat` 改为只使用 `nodeResponseSummary`,并显式丢弃误传的内联 `responseData`
- `pushChatLog` 只从持久化 rows 读取详情计算 `responseTime`
- `MongoChatItem` 的 deprecated `responseData` schema path 改为 `default: undefined`,避免新 chat item 自动带空数组。
- 前端流式合并支持 child 先到、parent 后到、重复 `id` 更新,以及详情弹窗递归展示。
## 验证覆盖
重点覆盖:
- rows 写入、child stats、受控 child/detail 字段保留、quote q/a 裁剪。
- 详情读取、旧 `responseData` 字段存在时优先保留、child 先到、append 合并。
- writer 串行顺序、批量 ordered create、session 复用、重复 `data.id` 覆盖、3 次重试、瘦身写入、最终失败丢弃。
- 写入失败丢弃 rows 后 summary 仍保留,`saveChat` 不依赖失败 rows。
- root workflow writer close、loop/parallel/toolcall 子响应写入、parallel retry 清理。
- 真实 `runWorkflow` 集成测试覆盖 loop 子 workflow 平铺落库、模块内部 child 自动挂当前节点、中途 batch flush 后的 `childrenResponses` 拼接、SSE `id/parentId`
- 前端 SSE parent/child 合并、child 先到、重复更新、ResponseTags/WholeResponseModal 递归读取。
最近验证命令:
- `pnpm --filter @fastgpt/service test test/core/chat/nodeResponseStorage.test.ts test/core/workflow/dispatch/loopRun/runLoopRun.test.ts`:2 个文件、39 个测试通过。
- `pnpm --filter @fastgpt/service test test/core/chat/controller.test.ts`:1 个文件、56 个测试通过。
- `pnpm --filter @fastgpt/app test test/api/core/chat/record/getResData.test.ts`:1 个文件、4 个测试通过。
- `pnpm --filter @fastgpt/app test test/components/core/chat/ChatContainer/ChatBox/resume.test.ts test/components/core/chat/ChatContainer/ChatBox/utils.test.ts test/global/core/chat/utils.test.ts`:3 个文件、36 个测试通过。
- `pnpm --filter @fastgpt/global test test/core/chat/chatUtils.test.ts`:1 个文件、53 个测试通过。
- `pnpm --filter @fastgpt/service test`:170 个文件通过、2 个文件跳过;2554 个测试通过、29 个跳过。
- `pnpm --filter @fastgpt/global test`:72 个文件、1626 个测试通过。
- `pnpm --filter @fastgpt/app test`:103 个文件、796 个测试通过。
- `pnpm --filter @fastgpt/app run typecheck`:通过。
- `pnpm exec eslint <changed ts/tsx files>`:通过。
- `git diff --check`
## 生产检查结论
- 当前实现已满足本轮确认的核心约束:单 writer 保序、大子流程平铺落库、允许部分可控节点保留小规模内联 child、按批次批量写入、失败重试与瘦身降级、失败后释放详情 rows、`saveChat` 仅依赖 summary、新数据不再落 `chat_items.responseData`、接口和前端继续返回 `childrenResponses` 嵌套结构。
- 仍建议上线后对真实大工作流采集 heap/rss、Mongo 写入耗时、接口耗时和 fallback 日志数量,用于确认生产数据分布下的默认 `batchSize` 是否需要调优。
# 需求设计文档 # 需求设计文档
...@@ -191,7 +191,7 @@ ...@@ -191,7 +191,7 @@
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| `/api/core/dataset/searchTest` | POST | `authDataset` Read | `text?: string``queryImageUrls?: string[]`、原搜索参数 | 复用现有 `SearchDatasetTestResponseSchema`,可扩展返回图片数量/归一化参数 | 文本和图片同时为空、图片超过 10 张、上传图片读取失败、模型不支持图片向量 | `packages/global/openapi/core/dataset/api.ts``projects/app/src/pages/api/core/dataset/searchTest.ts` | | `/api/core/dataset/searchTest` | POST | `authDataset` Read | `text?: string``queryImageUrls?: string[]`、原搜索参数 | 复用现有 `SearchDatasetTestResponseSchema`,可扩展返回图片数量/归一化参数 | 文本和图片同时为空、图片超过 10 张、上传图片读取失败、模型不支持图片向量 | `packages/global/openapi/core/dataset/api.ts``projects/app/src/pages/api/core/dataset/searchTest.ts` |
| 搜索测试图片上传入口 | POST | 团队/知识库读权限或临时上传权限 | `multipart/form-data` 图片文件,不支持普通文件 | 可被搜索测试读取的图片 URL/S3 key/文件信息,上传对象 3 小时过期 | 非图片文件直接过滤;图片格式/大小跟随系统限制,超出直接过滤;上传失败;未写入 TTL | 可复用现有文件上传能力或新增临时接口 | | 搜索测试图片上传入口 | POST | 团队/知识库读权限或临时上传权限 | `multipart/form-data` 图片文件,不支持普通文件 | 可被搜索测试读取的图片 URL/S3 key/文件信息,上传对象 3 小时过期 | 非图片文件直接过滤;图片格式/大小跟随系统限制,超出直接过滤;上传失败;未写入 TTL | 可复用现有文件上传能力或新增临时接口 |
| 工作流 dataset search dispatch | internal | 工作流运行权限 | `userChatInput?: string \| string[]`,其中数组可含文本和文件链接 | `quoteQA``nodeResponse``toolResponses`,可附带过滤统计 | 检索内容为空、图片文件解析失败、非图片文件链接被过滤 | `packages/service/core/workflow/dispatch/dataset/search.ts` | | 工作流 dataset search dispatch | internal | 工作流运行权限 | `userChatInput?: string \| string[]`,其中数组可含文本和文件链接 | `quoteQA``nodeResponse``toolResponse`,可附带过滤统计 | 检索内容为空、图片文件解析失败、非图片文件链接被过滤 | `packages/service/core/workflow/dispatch/dataset/search.ts` |
搜索测试请求示例: 搜索测试请求示例:
......
# 普通知识库搜索开启问题优化后耗时较长分析
## 背景
用户反馈:普通知识库搜索流程中,开启“问题优化”后搜索耗时明显变长,需要分析原因。
这里的“问题优化”对应当前代码里的 `datasetSearchUsingExtensionQuery` / `query extension`,不是 Deep RAG。普通搜索入口包括:
- 搜索测试 API:`projects/app/src/pages/api/core/dataset/searchTest.ts`
- 工作流知识库搜索节点:`packages/service/core/workflow/dispatch/dataset/search.ts`
- 搜索统一入口:`packages/service/core/dataset/search/index.ts`
- 默认召回实现:`packages/service/core/dataset/search/defaultRecall/*`
## 当前调用链
### 搜索测试入口
`/api/core/dataset/searchTest` 在完成权限、余额、图片 key 校验后,构造 `searchData` 并进入普通搜索:
```ts
await defaultSearchDatasetData({
...searchData,
datasetSearchUsingExtensionQuery,
datasetSearchExtensionModel,
datasetSearchExtensionBg
});
```
如果启用 Deep RAG,则走 `deepRagSearch`,不在本文“普通搜索”范围内。
### 工作流知识库搜索入口
工作流节点会先从 `userChatInput``datasetSearchInput` 归一化出 `textQueries` / `imageQueries`,再进入同一个普通搜索入口:
```ts
await defaultSearchDatasetData({
...searchData,
datasetSearchUsingExtensionQuery,
datasetSearchExtensionModel,
datasetSearchExtensionBg,
userKey: externalProvider.openaiAccount
});
```
因此搜索测试和工作流节点的慢点基本共用,区别是工作流会携带历史记录 `histories`,问题优化 prompt 可能更长。
## 开启问题优化后的额外步骤
普通搜索未开启问题优化时,大致流程是:
1. 文本 query 直接进入 `searchDatasetData`
2. 图片 query 如有,先做图片 caption
3. embedding/full-text 多路召回
4. 可选 rerank
5. 去重、相似度过滤、token 上限过滤
开启问题优化后,在第 1 步前新增串行前置链路:
1. `datasetSearchQueryExtension`
2. `queryExtension`
3. LLM 生成候选检索词,默认 `generateCount = 10`
4. 对原问题和候选检索词再做一次 embedding
5. 用 lazy greedy 从候选里选最多 3 条
6. 原问题 + 最多 3 条扩展问题一起进入召回
也就是说,开启后不是“多一次轻量改写”,而是“LLM 改写 + embedding 筛选 + 多 query 召回放大”。
## 耗时来源拆解
### 1. LLM 改写是串行前置阻塞
`defaultSearchDatasetData` 里会先 `await datasetSearchQueryExtension(...)`,之后才调用 `searchDatasetData(...)`。因此 LLM 改写耗时会完整叠加到搜索总耗时上,不能和后续召回并行抵消。
`queryExtension` 使用 `createLLMResponse({ stream: true })` 获取完整 `answerText` 后才继续解析。即使底层是 stream,请求方仍需要等完整 JSON 数组返回才能进入下一步。
工作流场景还会把历史记录压缩后放进 prompt。历史越长,LLM 输入 token 越多,首 token 和完整输出耗时都可能增加。
### 2. 默认生成 10 个候选,随后还要 embedding 筛选
`queryExtension` 默认 `generateCount = 10`,prompt 明确要求模型输出最多对应数量的 JSON 字符串数组。拿到候选后会调用 `lazyGreedyQuerySelection`
`lazyGreedyQuerySelection` 会对 `[原问题, ...候选问题]` 一起调用 embedding。候选 10 条时,这里是 11 条 embedding 输入。
因此问题优化固定增加至少一次 LLM 调用和一次 embedding 调用。对小知识库或原始召回很快的场景,这两个前置调用会成为主要耗时。
### 3. 扩展 query 会放大后续召回工作量
`datasetSearchQueryExtension` 会把原问题和扩展问题拼到 `queries` 里,最多可能形成 4 条文本 query:
- 原问题 1 条
- lazy greedy 选出的扩展问题最多 3 条
后续 `searchDatasetData` 使用扩展后的 `textQueries`。在 embedding 召回里,所有文本 query 会一起生成向量,然后每个向量都会调用一次 `recallFromVectorStore`
如果搜索模式是混合检索,full-text 也会对每条 query 跑一次 Mongo text aggregate。虽然 embedding 召回和 full-text 召回之间是并行的,但每条链路内部的任务数已经被扩展 query 放大。
### 4. 混合检索和 rerank 会叠加放大
混合检索下:
- embedding 每条 query 召回最多 80 条候选
- full-text 每条 query 召回最多 60 条候选
问题优化把文本 query 从 1 条放大到最多 4 条后,候选集合更大,后续 RRF 融合、去重、相似度过滤和 token 统计都会增加工作量。
如果同时启用 rerank,`reRankQuery` 会变成原问题和扩展问题的多行拼接,rerank 文档来自文本召回结果去重后的候选集。扩展 query 越多,进入 rerank 的候选越可能变多,rerank 输入 token 和模型耗时也会增加。
### 5. 现有返回只暴露总耗时,缺少分段耗时
搜索测试 API 返回 `duration`,这是从 API handler 开始到响应前的总耗时。query extension 结果里有 `seconds`,但当前 `SearchDatasetTestResponseSchema` 只返回 `queryExtensionModel`,没有把 `queryExtensionResult.seconds`、召回耗时、rerank 耗时分别返回。
这会导致现象上只能看到“搜索慢”,但无法直接判断慢在:
- LLM query extension
- query extension embedding 筛选
- vector store 召回
- Mongo full-text
- rerank
- token filter
## 初步结论
开启问题优化后耗时变长是当前链路设计的直接结果,主要原因是:
1. LLM 改写是搜索前串行阻塞,必须完成后才能召回。
2. 默认生成 10 个候选,并额外做一次 embedding 筛选。
3. 最终最多把 4 条文本 query 送入召回,放大 embedding/full-text/rerank 的输入规模。
4. 混合检索和 rerank 同时开启时,放大效应更明显。
5. 当前 API 响应缺少分段耗时,容易把 LLM 前置耗时误判为知识库本身召回慢。
## 建议排查方式
先不要直接改召回策略,建议加临时或正式分段日志验证实际瓶颈:
1.`defaultSearchDatasetData` 记录 query extension 总耗时、扩展 query 数、LLM seconds、query extension embeddingTokens。
2.`searchDatasetData` 记录 image caption、multiQueryRecall、rerank、token filter 的分段耗时。
3.`multiQueryRecall` 记录 text query 数、embedding task 数、full-text task 数。
4. 对比四组配置:
- 单 query + embedding 检索 + 不开 rerank
- 开问题优化 + embedding 检索 + 不开 rerank
- 开问题优化 + mixed 检索 + 不开 rerank
- 开问题优化 + mixed 检索 + 开 rerank
如果实际瓶颈集中在 query extension LLM,可以优先考虑减少候选数、换更快模型、加缓存或对短问题/无历史问题跳过扩展。
如果瓶颈集中在召回放大,可以考虑限制进入召回的扩展 query 数、按搜索模式动态调低每路 recall limit,或只让扩展 query 参与 embedding、不参与 full-text。
如果瓶颈集中在 rerank,可以考虑对 rerank 前候选数做上限裁剪,或让 rerank query 使用原问题而不是原问题 + 扩展问题的拼接文本。
## 相关代码证据
- `packages/service/core/dataset/search/index.ts`:普通搜索入口先 await query extension,再调用 `searchDatasetData`
- `packages/service/core/dataset/search/utils.ts``datasetSearchQueryExtension` 把原问题和扩展问题合并后下发。
- `packages/service/core/ai/functions/queryExtension.ts`:默认生成 10 个候选,LLM 完成后再做 lazy greedy。
- `packages/service/core/ai/hooks/useTextCosine.ts`:lazy greedy 会对原问题和全部候选调用 embedding。
- `packages/service/core/dataset/search/defaultRecall/embeddingRecall.ts`:每个 query 向量都会触发一次 vector store recall。
- `packages/service/core/dataset/search/defaultRecall/fullTextRecall.ts`:每个文本 query 都会触发一次 Mongo text aggregate。
- `packages/service/core/dataset/search/defaultRecall/rerank.ts`:rerank 对文本召回候选去重后调用 rerank 模型。
---
title: 'V4.14.24'
description: 'FastGPT V4.14.24 release notes'
---
## Upgrade Guide
### 1. Update image tags
- Update the fastgpt-app image tag (FastGPT main service): v4.14.24
- Update the fastgpt-pro image tag (FastGPT commercial edition): v4.14.24
## Changes
1. Improved the `v1/completions` abort condition to reduce false aborts caused by socket reconnections, which could occasionally terminate workflow API calls.
2. Added an upload API for admin deployments without an S3 external URL configured.
---
title: 'V4.14.24'
description: 'FastGPT V4.14.24 更新说明'
---
## 升级指南
### 1. 更新镜像 tag
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.14.24
- 更新 fastgpt-pro(fastgpt 商业版) 镜像 tag: v4.14.24
## 变更说明
1. 优化 v1/completions abort 条件判断,减少 socket 重连导致误判中断,导致 API 调用工作流时不时终止。
2. 补充 admin 无 s3 external URL 时的上传接口
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
"title": "4.14.x", "title": "4.14.x",
"description": "", "description": "",
"pages": [ "pages": [
"41424",
"41422", "41422",
"41421", "41421",
"41420", "41420",
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
"title": "4.14.x", "title": "4.14.x",
"description": "", "description": "",
"pages": [ "pages": [
"41424",
"41422", "41422",
"41421", "41421",
"41420", "41420",
......
...@@ -48,12 +48,15 @@ fastgpt-plugin: ...@@ -48,12 +48,15 @@ fastgpt-plugin:
5. 工作流数组引用类型增强校验,避免刚好与二维数据冲突。 5. 工作流数组引用类型增强校验,避免刚好与二维数据冲突。
6. 知识库被删除后,应用编排时优雅提示。 6. 知识库被删除后,应用编排时优雅提示。
7. PDF 解析,将 PDFJs 替换成 `liteparse`,速度提高 3 倍。 7. PDF 解析,将 PDFJs 替换成 `liteparse`,速度提高 3 倍。
8. 工作流运行,nodeResponse 扁平化存储优化,避免大的嵌套工作流保存失败。
9. xlsx 解析,自动去除空行空列,补充合并单元格。
## 🐛 修复 ## 🐛 修复
1. 模型获取多模态文件链接异常。 1. 模型获取多模态文件链接异常。
2. 修复 training 接口存在的潜在越权风险。 2. 修复 training 接口存在的潜在越权风险。
3. HTTP tool parse 的 SSRF 风险。 3. HTTP tool parse 的 SSRF 风险。
4. 交互节点后的工具调用,展开 MCP 工具异常。
## 🛠️ 代码优化 ## 🛠️ 代码优化
......
...@@ -125,6 +125,7 @@ description: FastGPT Toc ...@@ -125,6 +125,7 @@ description: FastGPT Toc
- [/en/self-host/upgrading/4-14/41420](/en/self-host/upgrading/4-14/41420) - [/en/self-host/upgrading/4-14/41420](/en/self-host/upgrading/4-14/41420)
- [/en/self-host/upgrading/4-14/41421](/en/self-host/upgrading/4-14/41421) - [/en/self-host/upgrading/4-14/41421](/en/self-host/upgrading/4-14/41421)
- [/en/self-host/upgrading/4-14/41422](/en/self-host/upgrading/4-14/41422) - [/en/self-host/upgrading/4-14/41422](/en/self-host/upgrading/4-14/41422)
- [/en/self-host/upgrading/4-14/41424](/en/self-host/upgrading/4-14/41424)
- [/en/self-host/upgrading/4-14/4143](/en/self-host/upgrading/4-14/4143) - [/en/self-host/upgrading/4-14/4143](/en/self-host/upgrading/4-14/4143)
- [/en/self-host/upgrading/4-14/4144](/en/self-host/upgrading/4-14/4144) - [/en/self-host/upgrading/4-14/4144](/en/self-host/upgrading/4-14/4144)
- [/en/self-host/upgrading/4-14/4145](/en/self-host/upgrading/4-14/4145) - [/en/self-host/upgrading/4-14/4145](/en/self-host/upgrading/4-14/4145)
......
...@@ -127,6 +127,7 @@ description: FastGPT 文档目录 ...@@ -127,6 +127,7 @@ description: FastGPT 文档目录
- [/self-host/upgrading/4-14/41420](/self-host/upgrading/4-14/41420) - [/self-host/upgrading/4-14/41420](/self-host/upgrading/4-14/41420)
- [/self-host/upgrading/4-14/41421](/self-host/upgrading/4-14/41421) - [/self-host/upgrading/4-14/41421](/self-host/upgrading/4-14/41421)
- [/self-host/upgrading/4-14/41422](/self-host/upgrading/4-14/41422) - [/self-host/upgrading/4-14/41422](/self-host/upgrading/4-14/41422)
- [/self-host/upgrading/4-14/41424](/self-host/upgrading/4-14/41424)
- [/self-host/upgrading/4-14/4143](/self-host/upgrading/4-14/4143) - [/self-host/upgrading/4-14/4143](/self-host/upgrading/4-14/4143)
- [/self-host/upgrading/4-14/4144](/self-host/upgrading/4-14/4144) - [/self-host/upgrading/4-14/4144](/self-host/upgrading/4-14/4144)
- [/self-host/upgrading/4-14/4145](/self-host/upgrading/4-14/4145) - [/self-host/upgrading/4-14/4145](/self-host/upgrading/4-14/4145)
......
...@@ -141,12 +141,12 @@ ...@@ -141,12 +141,12 @@
"content/openapi/share.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/share.mdx": "2026-04-26T21:08:47+08:00",
"content/plugin/index.en.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/index.en.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/index.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/index.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/intro.en.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/intro.en.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/intro.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/intro.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/model-presets.en.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/model-presets.en.mdx": "2026-06-04T16:10:15+08:00",
"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-06-04T16:10:15+08:00", "content/plugin/system-tool-development.en.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/system-tool-development.mdx": "2026-06-04T16:10:15+08:00", "content/plugin/system-tool-development.mdx": "2026-06-09T16:03:58+08:00",
"content/self-host/config/env.en.mdx": "2026-05-27T12:17:46+08:00", "content/self-host/config/env.en.mdx": "2026-05-27T12:17:46+08:00",
"content/self-host/config/env.mdx": "2026-05-27T12:17:46+08:00", "content/self-host/config/env.mdx": "2026-05-27T12:17:46+08:00",
"content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00",
...@@ -271,13 +271,13 @@ ...@@ -271,13 +271,13 @@
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00", "content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00", "content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-07T17:54:48+08:00", "content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-07T17:54:48+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-07T17:54:48+08:00", "content/self-host/upgrading/4-15/41504.mdx": "2026-06-09T17:46:40+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",
"content/self-host/upgrading/outdated/41.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4100.en.mdx": "2026-05-24T00:53:50+08:00", "content/self-host/upgrading/outdated/4100.en.mdx": "2026-06-09T16:03:58+08:00",
"content/self-host/upgrading/outdated/4100.mdx": "2026-05-24T00:53:50+08:00", "content/self-host/upgrading/outdated/4100.mdx": "2026-06-09T16:03:58+08:00",
"content/self-host/upgrading/outdated/4101.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4101.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4101.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4101.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/4110.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4110.en.mdx": "2026-04-26T21:08:47+08:00",
...@@ -412,6 +412,6 @@ ...@@ -412,6 +412,6 @@
"content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00",
"content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
"content/toc.en.mdx": "2026-06-04T16:10:15+08:00", "content/toc.en.mdx": "2026-06-09T16:03:58+08:00",
"content/toc.mdx": "2026-06-04T16:10:15+08:00" "content/toc.mdx": "2026-06-09T16:03:58+08:00"
} }
\ No newline at end of file
...@@ -31,6 +31,8 @@ export type AppToolRuntimeType = { ...@@ -31,6 +31,8 @@ export type AppToolRuntimeType = {
currentCost?: number; currentCost?: number;
systemKeyCost?: number; systemKeyCost?: number;
hasTokenFee?: boolean; hasTokenFee?: boolean;
/** 系统工具关联的真实 workflow app。存在时按系统级 workflow tool 处理。 */
associatedPluginId?: string;
}; };
// // System tool // // System tool
......
...@@ -24,8 +24,10 @@ import { ObjectIdSchema } from '../../common/type/mongo'; ...@@ -24,8 +24,10 @@ import { ObjectIdSchema } from '../../common/type/mongo';
export const ChatHistoryItemResSchema = DispatchNodeResponseSchema.extend({ export const ChatHistoryItemResSchema = DispatchNodeResponseSchema.extend({
nodeId: z.string(), nodeId: z.string(),
id: z.string(), id: z.string(),
parentId: z.string().optional(),
moduleType: z.enum(FlowNodeTypeEnum), moduleType: z.enum(FlowNodeTypeEnum),
moduleName: z.string() moduleName: z.string(),
childResponseCount: z.number().optional()
}); });
export type ChatHistoryItemResType = z.infer<typeof ChatHistoryItemResSchema>; export type ChatHistoryItemResType = z.infer<typeof ChatHistoryItemResSchema>;
...@@ -290,6 +292,7 @@ export const ChatItemResponseSchema = z.object({ ...@@ -290,6 +292,7 @@ export const ChatItemResponseSchema = z.object({
appId: z.string(), appId: z.string(),
chatId: z.string(), chatId: z.string(),
chatItemDataId: z.string(), chatItemDataId: z.string(),
time: z.coerce.date().optional(),
data: ChatHistoryItemResSchema data: ChatHistoryItemResSchema
}); });
export type ChatItemResponseSchemaType = z.infer<typeof ChatItemResponseSchema>; export type ChatItemResponseSchemaType = z.infer<typeof ChatItemResponseSchema>;
......
...@@ -52,7 +52,7 @@ export enum DispatchNodeResponseKeyEnum { ...@@ -52,7 +52,7 @@ export enum DispatchNodeResponseKeyEnum {
nodeResponse = 'responseData', // run node response nodeResponse = 'responseData', // run node response
nodeResponses = 'nodeResponses', // node responses nodeResponses = 'nodeResponses', // node responses
childrenResponses = 'childrenResponses', // Some nodes make recursive calls that need to be returned childrenResponses = 'childrenResponses', // Some nodes make recursive calls that need to be returned
toolResponses = 'toolResponses', // The result is passed back to the tool node for use toolResponse = 'toolResponse', // The result is passed back to the tool node for use
assistantResponses = 'assistantResponses', // assistant response assistantResponses = 'assistantResponses', // assistant response
rewriteHistories = 'rewriteHistories', // If have the response, workflow histories will be rewrite rewriteHistories = 'rewriteHistories', // If have the response, workflow histories will be rewrite
interactive = 'INTERACTIVE', // is interactive interactive = 'INTERACTIVE', // is interactive
......
...@@ -76,6 +76,8 @@ export type ChatDispatchProps = { ...@@ -76,6 +76,8 @@ export type ChatDispatchProps = {
tmbId: string; // App tmbId tmbId: string; // App tmbId
name: string; name: string;
isChildApp?: boolean; isChildApp?: boolean;
/** 显式指定本轮工作流使用的 sandbox 资源,避免由 appId/userId/chatId 重新生成。 */
sandboxId?: string;
}; };
runningUserInfo: { runningUserInfo: {
username: string; username: string;
...@@ -88,7 +90,8 @@ export type ChatDispatchProps = { ...@@ -88,7 +90,8 @@ export type ChatDispatchProps = {
uid: string; // Who run this workflow uid: string; // Who run this workflow
chatId: string; chatId: string;
responseChatItemId?: string; /** 当前 AI 回复 chat item 的 dataId;workflow 运行期必须有值,外部入口缺省时由 dispatchWorkFlow 补齐。 */
responseChatItemId: string;
histories: ChatItemMiniType[]; histories: ChatItemMiniType[];
variableState: WorkflowVariableStateLike; // global variable state variableState: WorkflowVariableStateLike; // global variable state
query: UserChatItemValueItemType[]; // trigger query query: UserChatItemValueItemType[]; // trigger query
...@@ -106,6 +109,7 @@ export type ChatDispatchProps = { ...@@ -106,6 +109,7 @@ export type ChatDispatchProps = {
responseAllData?: boolean; responseAllData?: boolean;
responseDetail?: boolean; responseDetail?: boolean;
nodeResponseParentId?: string; // 传递给 child,用于设置 nodeResponse 的 parentId
// TODO: 移除 // TODO: 移除
usageId?: string; usageId?: string;
...@@ -184,6 +188,7 @@ export const DispatchNodeResponseSchema = z ...@@ -184,6 +188,7 @@ export const DispatchNodeResponseSchema = z
nodeInputs: z.record(z.string(), z.any()).optional().meta({ description: '节点输入' }), nodeInputs: z.record(z.string(), z.any()).optional().meta({ description: '节点输入' }),
nodeOutputs: z.record(z.string(), z.any()).optional().meta({ description: '节点输出' }), nodeOutputs: z.record(z.string(), z.any()).optional().meta({ description: '节点输出' }),
mergeSignId: z.string().optional().meta({ description: '合并签名 ID' }), mergeSignId: z.string().optional().meta({ description: '合并签名 ID' }),
parentId: z.string().optional().meta({ description: '父节点响应实例 ID' }),
// bill // bill
tokens: z.number().optional().meta({ description: '总 token' }), tokens: z.number().optional().meta({ description: '总 token' }),
...@@ -193,6 +198,7 @@ export const DispatchNodeResponseSchema = z ...@@ -193,6 +198,7 @@ export const DispatchNodeResponseSchema = z
contextTotalLen: z.number().optional().meta({ description: '上下文总长度' }), contextTotalLen: z.number().optional().meta({ description: '上下文总长度' }),
totalPoints: z.number().optional().meta({ description: '总积分' }), totalPoints: z.number().optional().meta({ description: '总积分' }),
childTotalPoints: z.number().optional().meta({ description: '子节点总积分' }), childTotalPoints: z.number().optional().meta({ description: '子节点总积分' }),
childResponseCount: z.number().optional().meta({ description: '子节点响应数量' }),
// LLM chat // LLM chat
temperature: z.number().optional().meta({ description: '温度' }), temperature: z.number().optional().meta({ description: '温度' }),
...@@ -321,7 +327,7 @@ export const DispatchNodeResponseSchema = z ...@@ -321,7 +327,7 @@ export const DispatchNodeResponseSchema = z
parallelDetail: z parallelDetail: z
.array(z.any()) .array(z.any())
.optional() .optional()
.meta({ description: '成功任务子工作流完整响应列表' }), .meta({ description: '成功任务子工作流完整响应列表', deprecated: true }),
// loopRun // loopRun
loopRunInput: z loopRunInput: z
...@@ -333,8 +339,7 @@ export const DispatchNodeResponseSchema = z ...@@ -333,8 +339,7 @@ export const DispatchNodeResponseSchema = z
loopRunDetail: z loopRunDetail: z
.array(z.any()) .array(z.any())
.optional() .optional()
.meta({ description: 'loopRun 各轮子工作流节点响应聚合' }), .meta({ description: 'loopRun 各轮子工作流节点响应聚合', deprecated: true }),
childrenResponses: z.array(z.any()).optional().meta({ description: '子节点响应' }), childrenResponses: z.array(z.any()).optional().meta({ description: '子节点响应' }),
// Tools // Tools
...@@ -349,10 +354,17 @@ export const DispatchNodeResponseSchema = z ...@@ -349,10 +354,17 @@ export const DispatchNodeResponseSchema = z
type Tmp_DispatchNodeResponseType = z.infer<typeof DispatchNodeResponseSchema>; type Tmp_DispatchNodeResponseType = z.infer<typeof DispatchNodeResponseSchema>;
export type DispatchNodeResponseType = Omit< export type DispatchNodeResponseType = Omit<
Tmp_DispatchNodeResponseType, Tmp_DispatchNodeResponseType,
'childrenResponses' | 'loopDetail' | 'pluginDetail' | 'toolDetail' | 'childrenResponses'
| 'loopDetail'
| 'loopRunDetail'
| 'parallelDetail'
| 'pluginDetail'
| 'toolDetail'
> & { > & {
childrenResponses?: DispatchNodeResponseType[]; childrenResponses?: DispatchNodeResponseType[];
loopDetail?: DispatchNodeResponseType[]; loopDetail?: DispatchNodeResponseType[];
loopRunDetail?: DispatchNodeResponseType[];
parallelDetail?: DispatchNodeResponseType[];
pluginDetail?: DispatchNodeResponseType[]; pluginDetail?: DispatchNodeResponseType[];
toolDetail?: DispatchNodeResponseType[]; toolDetail?: DispatchNodeResponseType[];
}; };
...@@ -367,7 +379,7 @@ export type DispatchNodeResultType< ...@@ -367,7 +379,7 @@ export type DispatchNodeResultType<
[DispatchNodeResponseKeyEnum.nodeResponse]?: DispatchNodeResponseType; // The node response detail [DispatchNodeResponseKeyEnum.nodeResponse]?: DispatchNodeResponseType; // The node response detail
[DispatchNodeResponseKeyEnum.nodeResponses]?: ChatHistoryItemResType[]; // Node responses [DispatchNodeResponseKeyEnum.nodeResponses]?: ChatHistoryItemResType[]; // Node responses
[DispatchNodeResponseKeyEnum.childrenResponses]?: DispatchNodeResultType[]; // Children node response [DispatchNodeResponseKeyEnum.childrenResponses]?: DispatchNodeResultType[]; // Children node response
[DispatchNodeResponseKeyEnum.toolResponses]?: ToolRunResponseItemType; // Tool response [DispatchNodeResponseKeyEnum.toolResponse]?: ToolRunResponseItemType; // Tool response
[DispatchNodeResponseKeyEnum.assistantResponses]?: AIChatItemValueItemType[]; // Assistant response(Store to db) [DispatchNodeResponseKeyEnum.assistantResponses]?: AIChatItemValueItemType[]; // Assistant response(Store to db)
[DispatchNodeResponseKeyEnum.rewriteHistories]?: ChatItemMiniType[]; [DispatchNodeResponseKeyEnum.rewriteHistories]?: ChatItemMiniType[];
[DispatchNodeResponseKeyEnum.runTimes]?: number; [DispatchNodeResponseKeyEnum.runTimes]?: number;
......
...@@ -5,7 +5,6 @@ import { AppFileSelectConfigTypeSchema } from '../../../../app/type/config.schem ...@@ -5,7 +5,6 @@ import { AppFileSelectConfigTypeSchema } from '../../../../app/type/config.schem
import { RuntimeEdgeItemTypeSchema } from '../../../type/edge'; import { RuntimeEdgeItemTypeSchema } from '../../../type/edge';
import z from 'zod'; import z from 'zod';
import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type'; import { ChatCompletionMessageParamSchema } from '../../../../ai/llm/type';
import type { ChatHistoryItemResType } from '../../../../chat/type';
export const InteractiveBasicTypeSchema = z.object({ export const InteractiveBasicTypeSchema = z.object({
entryNodeIds: z.array(z.string()), entryNodeIds: z.array(z.string()),
...@@ -74,7 +73,7 @@ export const LoopRunInteractiveSchema = z.object({ ...@@ -74,7 +73,7 @@ export const LoopRunInteractiveSchema = z.object({
loopHistory: z.array(z.any()), loopHistory: z.array(z.any()),
childrenResponse: z.any(), childrenResponse: z.any(),
iteration: z.number(), iteration: z.number(),
pendingIterationResponses: z.array(z.any()).optional() pendingIterationSummary: z.any().optional()
}) })
}); });
export type LoopRunInteractive = InteractiveNodeType & { export type LoopRunInteractive = InteractiveNodeType & {
...@@ -83,7 +82,7 @@ export type LoopRunInteractive = InteractiveNodeType & { ...@@ -83,7 +82,7 @@ export type LoopRunInteractive = InteractiveNodeType & {
loopHistory: any[]; loopHistory: any[];
childrenResponse: WorkflowInteractiveResponseType; childrenResponse: WorkflowInteractiveResponseType;
iteration: number; iteration: number;
pendingIterationResponses?: ChatHistoryItemResType[]; pendingIterationSummary?: Record<string, any>;
}; };
}; };
......
...@@ -21,11 +21,14 @@ export const ListAppTemplateQuerySchema = z.object({ ...@@ -21,11 +21,14 @@ export const ListAppTemplateQuerySchema = z.object({
description: '随机返回数量' description: '随机返回数量'
}), }),
type: z type: z
.union([z.enum(AppTypeEnum), z.literal('all')]) .preprocess(
(value) => (value === '' ? 'all' : value),
z.union([z.enum(AppTypeEnum), z.literal('all')])
)
.optional() .optional()
.meta({ .meta({
example: 'all', example: 'all',
description: '应用类型' description: '应用类型;空字符串按 all 处理'
}), }),
excludeIds: z.string().optional().meta({ excludeIds: z.string().optional().meta({
example: '["template-a","template-b"]', example: '["template-a","template-b"]',
......
import { describe, expect, it } from 'vitest';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { ListAppTemplateQuerySchema } from '@fastgpt/global/openapi/core/app/template/api';
describe('ListAppTemplateQuerySchema', () => {
it('should treat empty template type query as all templates', () => {
expect(ListAppTemplateQuerySchema.parse({ type: '' }).type).toBe('all');
});
it('should keep omitted type as unspecified for handler defaults', () => {
expect(ListAppTemplateQuerySchema.parse({})).toEqual({});
});
it('should accept app type and all filters', () => {
expect(ListAppTemplateQuerySchema.parse({ type: AppTypeEnum.workflow }).type).toBe(
AppTypeEnum.workflow
);
expect(ListAppTemplateQuerySchema.parse({ type: 'all' }).type).toBe('all');
});
});
...@@ -144,6 +144,30 @@ export async function findSandboxAppIdBySandboxId(sandboxId: string) { ...@@ -144,6 +144,30 @@ export async function findSandboxAppIdBySandboxId(sandboxId: string) {
} }
/** /**
* 按显式 sandboxId 查询单条 sandbox 实例。
*
* 适用于调用方已经拥有稳定 sandboxId 的场景,避免再把资源定位退回
* appId/userId/chatId 推导规则。
*/
export async function findSandboxInstanceBySandboxId(params: {
provider?: SandboxProviderType;
sandboxId: string;
appId?: string;
type?: SandboxTypeEnum;
status?: SandboxStatusType;
}) {
const { provider, sandboxId, appId, type, status } = params;
return MongoSandboxInstance.findOne({
...(provider ? { provider } : {}),
sandboxId,
...(appId ? { appId } : {}),
...(type ? { type } : {}),
...(status ? { status } : {})
});
}
/**
* 按 app/chat/type 查询单条 sandbox 实例。 * 按 app/chat/type 查询单条 sandbox 实例。
* *
* provider 可选是为了兼容“当前 provider 查询”和“只按业务归属查询”两类场景。 * provider 可选是为了兼容“当前 provider 查询”和“只按业务归属查询”两类场景。
......
...@@ -152,7 +152,15 @@ export class SandboxClient { ...@@ -152,7 +152,15 @@ export class SandboxClient {
} }
} }
function resolveSandboxId(props: { sandboxId: string } | UnionIdType): string { type ExplicitSandboxIdType = {
sandboxId: string;
appId?: string;
userId?: string;
chatId?: string;
teamId?: string;
};
function resolveSandboxId(props: ExplicitSandboxIdType | UnionIdType): string {
if ('sandboxId' in props) { if ('sandboxId' in props) {
return props.sandboxId; return props.sandboxId;
} }
...@@ -168,12 +176,7 @@ function resolveSandboxId(props: { sandboxId: string } | UnionIdType): string { ...@@ -168,12 +176,7 @@ function resolveSandboxId(props: { sandboxId: string } | UnionIdType): string {
* 返回前会准备 volume 配置并确保 sandbox 可用。 * 返回前会准备 volume 配置并确保 sandbox 可用。
*/ */
export const getSandboxClient = async ( export const getSandboxClient = async (
props: props: ExplicitSandboxIdType | UnionIdType,
| {
sandboxId: string;
teamId?: string;
}
| UnionIdType,
opts: { opts: {
providerName?: SandboxProviderType; providerName?: SandboxProviderType;
resourceLimits?: ResourceLimits; resourceLimits?: ResourceLimits;
......
...@@ -38,6 +38,7 @@ export const runSandboxTools = async ({ ...@@ -38,6 +38,7 @@ export const runSandboxTools = async ({
appId, appId,
userId, userId,
chatId, chatId,
sandboxId,
toolName, toolName,
args, args,
sandboxClient sandboxClient
...@@ -45,6 +46,7 @@ export const runSandboxTools = async ({ ...@@ -45,6 +46,7 @@ export const runSandboxTools = async ({
appId: string; appId: string;
userId: string; userId: string;
chatId: string; chatId: string;
sandboxId?: string;
toolName: string; toolName: string;
args: string; args: string;
sandboxClient?: SandboxClient; sandboxClient?: SandboxClient;
...@@ -73,7 +75,11 @@ export const runSandboxTools = async ({ ...@@ -73,7 +75,11 @@ export const runSandboxTools = async ({
}; };
} }
const instance = sandboxClient ?? (await getSandboxClient({ appId, userId, chatId })); const instance =
sandboxClient ??
(await getSandboxClient(
sandboxId ? { sandboxId, appId, userId, chatId } : { appId, userId, chatId }
));
const result = await tool.execute({ const result = await tool.execute({
appId, appId,
userId, userId,
...@@ -99,14 +105,18 @@ export const injectSandboxFiles = async ({ ...@@ -99,14 +105,18 @@ export const injectSandboxFiles = async ({
appId, appId,
userId, userId,
chatId, chatId,
sandboxId,
files files
}: { }: {
appId: string; appId: string;
userId: string; userId: string;
chatId: string; chatId: string;
sandboxId?: string;
files: { path: string; url: string }[]; files: { path: string; url: string }[];
}) => { }) => {
const instance = await getSandboxClient({ appId, userId, chatId }); const instance = await getSandboxClient(
sandboxId ? { sandboxId, appId, userId, chatId } : { appId, userId, chatId }
);
await instance.ensureAvailable(); await instance.ensureAvailable();
await writeUrlFilesToSandbox(instance.provider, files); await writeUrlFilesToSandbox(instance.provider, files);
}; };
......
...@@ -426,7 +426,8 @@ export class SystemToolRepo { ...@@ -426,7 +426,8 @@ export class SystemToolRepo {
id: pluginId, id: pluginId,
name: tool.customConfig.name, name: tool.customConfig.name,
nodes: appVersion.nodes, nodes: appVersion.nodes,
currentCost: tool.currentCost currentCost: tool.currentCost,
associatedPluginId
}; };
} }
} }
......
...@@ -35,8 +35,19 @@ const ChatItemResponseSchema = new Schema({ ...@@ -35,8 +35,19 @@ const ChatItemResponseSchema = new Schema({
} }
}); });
// Get response/Delete // 按 chat item 拉取完整 nodeResponse rows;复合索引包含 _id,避免详情读取时额外排序。
ChatItemResponseSchema.index({ appId: 1, chatId: 1, chatItemDataId: 1 }); ChatItemResponseSchema.index({ appId: 1, chatId: 1, chatItemDataId: 1, _id: 1 });
ChatItemResponseSchema.index(
{
appId: 1,
chatId: 1,
chatItemDataId: 1,
'data.id': 1
},
{
unique: true
}
);
// Clear expired response // Clear expired response
ChatItemResponseSchema.index({ teamId: 1, time: -1 }); ChatItemResponseSchema.index({ teamId: 1, time: -1 });
......
...@@ -86,8 +86,11 @@ const ChatItemSchema = new Schema({ ...@@ -86,8 +86,11 @@ const ChatItemSchema = new Schema({
default: null default: null
}, },
/** @deprecated */ /** @deprecated nodeResponses 已迁移到 chat_item_responses;保留 schema path 仅避免历史数据被误清理。 */
[DispatchNodeResponseKeyEnum.nodeResponse]: Array [DispatchNodeResponseKeyEnum.nodeResponse]: {
type: Array,
default: undefined
}
}); });
/* /*
......
import type { ChatHistoryItemResType, ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import { MongoChatItem } from './chatItemSchema'; import { MongoChatItem } from './chatItemSchema';
import { MongoChat } from './chatSchema'; import { MongoChat } from './chatSchema';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
...@@ -6,12 +6,21 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; ...@@ -6,12 +6,21 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { MongoChatItemResponse } from './chatItemResponseSchema'; import { MongoChatItemResponse } from './chatItemResponseSchema';
import type { ClientSession } from '../../common/mongo'; import type { ClientSession } from '../../common/mongo';
import { Types } from '../../common/mongo'; import { Types } from '../../common/mongo';
import { mongoSessionRun } from '../../common/mongo/sessionRun';
import { UserError } from '@fastgpt/global/common/error/utils'; import { UserError } from '@fastgpt/global/common/error/utils';
import { getLogger, LogCategories } from '../../common/logger'; import { getLogger, LogCategories } from '../../common/logger';
import { composeChatItemResponseData } from './nodeResponseStorage';
const logger = getLogger(LogCategories.MODULE.CHAT.HISTORY); const logger = getLogger(LogCategories.MODULE.CHAT.HISTORY);
/**
* 判断 AI 消息是否仍带旧的 chat_items.responseData 内联详情。
*
* 历史数据可能带空数组 responseData;只用 `.length` 会把这类旧记录误判成新记录,
* 进而用独立表的空结果覆盖原字段。新链路默认不写该字段,所以以字段是否存在作为边界。
*/
const hasInlineNodeResponses = (item: object) =>
Object.prototype.hasOwnProperty.call(item, DispatchNodeResponseKeyEnum.nodeResponse);
export async function getChatItems({ export async function getChatItems({
includeDeleted = false, includeDeleted = false,
appId, appId,
...@@ -166,30 +175,37 @@ export async function getChatItems({ ...@@ -166,30 +175,37 @@ export async function getChatItems({
// Add node responses field // Add node responses field
if (field.includes(DispatchNodeResponseKeyEnum.nodeResponse) && histories.length > 0) { if (field.includes(DispatchNodeResponseKeyEnum.nodeResponse) && histories.length > 0) {
const chatItemDataIds = histories const chatItemDataIds = histories
.filter((item) => item.obj === ChatRoleEnum.AI && !item.responseData?.length) // 旧记录的 responseData 已内联在 chat_items,存在时不能被独立表的空结果覆盖。
.filter((item) => item.obj === ChatRoleEnum.AI && !hasInlineNodeResponses(item))
.map((item) => item.dataId); .map((item) => item.dataId);
if (chatItemDataIds.length > 0) { if (chatItemDataIds.length > 0) {
const chatItemResponsesMap = await MongoChatItemResponse.find( const chatItemResponsesMap = await MongoChatItemResponse.find(
{ appId, chatId, chatItemDataId: { $in: chatItemDataIds } }, { appId, chatId, chatItemDataId: { $in: chatItemDataIds } },
{ chatItemDataId: 1, data: 1 } {
chatItemDataId: 1,
data: 1
}
) )
.sort({ _id: 1 })
.lean() .lean()
.then((res) => { .then((res) => {
const map = new Map<string, ChatHistoryItemResType[]>(); const map = new Map<string, typeof res>();
res.forEach((item) => { res.forEach((item) => {
const val = map.get(item.chatItemDataId) || []; const val = map.get(item.chatItemDataId) || [];
val.push(item.data); val.push(item);
map.set(item.chatItemDataId, val); map.set(item.chatItemDataId, val);
}); });
return map; return map;
}); });
histories.forEach((item) => { histories.forEach((item) => {
const val = chatItemResponsesMap.get(String(item.dataId)); if (item.obj !== ChatRoleEnum.AI) return;
if (item.obj === ChatRoleEnum.AI && val) { if (hasInlineNodeResponses(item)) return;
item.responseData = val;
} item.responseData = composeChatItemResponseData({
rows: chatItemResponsesMap.get(String(item.dataId)) || []
});
}); });
} }
} }
......
...@@ -8,7 +8,7 @@ type ValidateChatRoundDataIdsParams = { ...@@ -8,7 +8,7 @@ type ValidateChatRoundDataIdsParams = {
appId: string; appId: string;
chatId: string; chatId: string;
userContent: UserChatItemType & { dataId?: string }; userContent: UserChatItemType & { dataId?: string };
responseChatItemId: string; responseChatItemId?: string;
}; };
const getValidDataIds = (dataIds: Array<string | undefined>) => const getValidDataIds = (dataIds: Array<string | undefined>) =>
......
import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { checkInteractiveResponseStatus } from '@fastgpt/global/core/chat/utils';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { UserChatItemType } from '@fastgpt/global/core/chat/type';
import { MongoChatItem } from './chatItemSchema';
/**
* 解析本轮 workflow 写入 nodeResponse 时应该归属的 AI chat item dataId。
*
* 交互 submit 会把结果追加到数据库最后一条 AI 消息,而不是新建 AI 消息;因此 runtime
* 写 nodeResponse 前就必须使用旧 AI 消息的 dataId。交互 query 和普通对话仍使用客户端
* 本轮 responseChatItemId,因为它们会新建 AI 消息。
*/
export const getInteractiveResponseStatus = ({
interactive,
userContent
}: {
interactive?: WorkflowInteractiveResponseType;
userContent: UserChatItemType;
}) => {
if (!interactive) return;
const { text } = chatValue2RuntimePrompt(userContent.value);
return checkInteractiveResponseStatus({
interactive,
input: text
});
};
export const resolveResponseChatItemId = async ({
appId,
chatId,
responseChatItemId,
interactive,
userContent
}: {
appId: string;
chatId?: string;
responseChatItemId: string;
interactive?: WorkflowInteractiveResponseType;
userContent: UserChatItemType;
}) => {
if (!interactive || !chatId) return responseChatItemId;
const status = getInteractiveResponseStatus({ interactive, userContent });
if (status === 'query') return responseChatItemId;
const chatItem = await MongoChatItem.findOne(
{
appId,
chatId,
obj: ChatRoleEnum.AI
},
'dataId'
)
.sort({ _id: -1 })
.lean();
return chatItem?.dataId || responseChatItemId;
};
import { MongoChatItem } from './chatItemSchema'; import { MongoChatItem } from './chatItemSchema';
import { MongoChat } from './chatSchema'; import { MongoChat } from './chatSchema';
import { axios } from '../../common/api/axios'; import { axios } from '../../common/api/axios';
import { type AIChatItemType, type UserChatItemType } from '@fastgpt/global/core/chat/type'; import {
type AIChatItemType,
type ChatItemDBSchemaType,
type UserChatItemType
} from '@fastgpt/global/core/chat/type';
import { getLogger, LogCategories } from '../../common/logger'; import { getLogger, LogCategories } from '../../common/logger';
import { serviceEnv } from '../../env'; import { serviceEnv } from '../../env';
import { getChatItemResponseData } from './nodeResponseStorage';
const logger = getLogger(LogCategories.MODULE.CHAT.RECORD); const logger = getLogger(LogCategories.MODULE.CHAT.RECORD);
...@@ -74,8 +79,12 @@ const pushChatLogInternal = async ({ ...@@ -74,8 +79,12 @@ const pushChatLogInternal = async ({
}) => { }) => {
try { try {
const [chatItemHuman, chatItemAi] = await Promise.all([ const [chatItemHuman, chatItemAi] = await Promise.all([
MongoChatItem.findById(chatItemIdHuman).lean() as Promise<UserChatItemType>, MongoChatItem.findById(chatItemIdHuman).lean() as Promise<
MongoChatItem.findById(chatItemIdAi).lean() as Promise<AIChatItemType> (UserChatItemType & ChatItemDBSchemaType) | null
>,
MongoChatItem.findById(chatItemIdAi).lean() as Promise<
(AIChatItemType & ChatItemDBSchemaType) | null
>
]); ]);
if (!chatItemHuman || !chatItemAi) { if (!chatItemHuman || !chatItemAi) {
...@@ -147,10 +156,12 @@ ${JSON.stringify(item.interactive, null, 2)} ...@@ -147,10 +156,12 @@ ${JSON.stringify(item.interactive, null, 2)}
return; return;
} }
// computed response time const responseData = await getChatItemResponseData({
const responseData = chatItemAi.responseData; appId,
const responseTime = chatId,
responseData?.reduce((acc, item) => acc + (item?.runningTime ?? 0), 0) || 0; chatItemDataId: chatItemAi.dataId
});
const responseTime = responseData.reduce((acc, item) => acc + (item?.runningTime ?? 0), 0) || 0;
const sourceIdPrefix = serviceEnv.CHAT_LOG_SOURCE_ID_PREFIX; const sourceIdPrefix = serviceEnv.CHAT_LOG_SOURCE_ID_PREFIX;
......
...@@ -82,7 +82,7 @@ export const dispatchAppRequest = async (props: Props): Promise<Response> => { ...@@ -82,7 +82,7 @@ export const dispatchAppRequest = async (props: Props): Promise<Response> => {
sourceVariableState: variableState sourceVariableState: variableState
}); });
const { flowResponses, flowUsages, assistantResponses, system_memories } = await runWorkflow({ const { assistantResponses, system_memories, runtimeNodeResponseSummary } = await runWorkflow({
...props, ...props,
runningAppInfo: childRunningAppInfo, runningAppInfo: childRunningAppInfo,
runtimeNodes: storeNodes2RuntimeNodes( runtimeNodes: storeNodes2RuntimeNodes(
...@@ -124,7 +124,7 @@ export const dispatchAppRequest = async (props: Props): Promise<Response> => { ...@@ -124,7 +124,7 @@ export const dispatchAppRequest = async (props: Props): Promise<Response> => {
moduleLogo: appData.avatar, moduleLogo: appData.avatar,
query: userChatInput, query: userChatInput,
textOutput: text, textOutput: text,
totalPoints: flowResponses.reduce((sum, item) => sum + (item.totalPoints || 0), 0) totalPoints: runtimeNodeResponseSummary.totalPoints
} }
}; };
}; };
...@@ -7,10 +7,7 @@ import { ...@@ -7,10 +7,7 @@ import {
} from '@fastgpt/global/core/workflow/runtime/type'; } from '@fastgpt/global/core/workflow/runtime/type';
import { runWorkflow } from '..'; import { runWorkflow } from '..';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
type AIChatItemValueItemType,
type ChatHistoryItemResType
} from '@fastgpt/global/core/chat/type';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { type WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import { type WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { storeEdges2RuntimeEdges } from '@fastgpt/global/core/workflow/runtime/utils'; import { storeEdges2RuntimeEdges } from '@fastgpt/global/core/workflow/runtime/utils';
...@@ -52,8 +49,7 @@ export const dispatchLoop = async (props: Props): Promise<Response> => { ...@@ -52,8 +49,7 @@ export const dispatchLoop = async (props: Props): Promise<Response> => {
let lastIndex = interactiveData?.currentIndex; let lastIndex = interactiveData?.currentIndex;
const outputValueArr = interactiveData ? interactiveData.loopResult : []; const outputValueArr = interactiveData ? interactiveData.loopResult : [];
const loopResponseDetail: ChatHistoryItemResType[] = []; const assistantResponses: AIChatItemValueItemType[] = [];
let assistantResponses: AIChatItemValueItemType[] = [];
const customFeedbacks: string[] = []; const customFeedbacks: string[] = [];
let totalPoints = 0; let totalPoints = 0;
let interactiveResponse: WorkflowInteractiveResponseType | undefined = undefined; let interactiveResponse: WorkflowInteractiveResponseType | undefined = undefined;
...@@ -98,7 +94,6 @@ export const dispatchLoop = async (props: Props): Promise<Response> => { ...@@ -98,7 +94,6 @@ export const dispatchLoop = async (props: Props): Promise<Response> => {
if (!response.workflowInteractiveResponse) { if (!response.workflowInteractiveResponse) {
outputValueArr.push(getNestedEndOutputValue(response)); outputValueArr.push(getNestedEndOutputValue(response));
} }
loopResponseDetail.push(...response.flowResponses);
assistantResponses.push(...response.assistantResponses); assistantResponses.push(...response.assistantResponses);
totalPoints += pushSubWorkflowUsage({ totalPoints += pushSubWorkflowUsage({
...@@ -140,7 +135,6 @@ export const dispatchLoop = async (props: Props): Promise<Response> => { ...@@ -140,7 +135,6 @@ export const dispatchLoop = async (props: Props): Promise<Response> => {
totalPoints, totalPoints,
loopInput: loopInputArray, loopInput: loopInputArray,
loopResult: outputValueArr, loopResult: outputValueArr,
loopDetail: loopResponseDetail,
mergeSignId: props.node.nodeId mergeSignId: props.node.nodeId
}, },
[DispatchNodeResponseKeyEnum.customFeedbacks]: [DispatchNodeResponseKeyEnum.customFeedbacks]:
......
...@@ -48,6 +48,9 @@ export const createWorkflowAgentLoopRuntime = ({ ...@@ -48,6 +48,9 @@ export const createWorkflowAgentLoopRuntime = ({
workflowStreamResponse, workflowStreamResponse,
assistantResponses = [], assistantResponses = [],
nodeResponses = [], nodeResponses = [],
appendNodeResponse = (nodeResponse) => {
nodeResponses.push(nodeResponse);
},
executeToolFactory = getExecuteTool executeToolFactory = getExecuteTool
}: { }: {
context: WorkflowAgentLoopRuntimeContext; context: WorkflowAgentLoopRuntimeContext;
...@@ -55,6 +58,7 @@ export const createWorkflowAgentLoopRuntime = ({ ...@@ -55,6 +58,7 @@ export const createWorkflowAgentLoopRuntime = ({
workflowStreamResponse?: WorkflowResponseType; workflowStreamResponse?: WorkflowResponseType;
assistantResponses?: AIChatItemValueItemType[]; assistantResponses?: AIChatItemValueItemType[];
nodeResponses?: ChatHistoryItemResType[]; nodeResponses?: ChatHistoryItemResType[];
appendNodeResponse?: (nodeResponse: ChatHistoryItemResType) => void;
executeToolFactory?: typeof getExecuteTool; executeToolFactory?: typeof getExecuteTool;
}): { }): {
runtime: AgentLoopRuntime; runtime: AgentLoopRuntime;
...@@ -91,6 +95,7 @@ export const createWorkflowAgentLoopRuntime = ({ ...@@ -91,6 +95,7 @@ export const createWorkflowAgentLoopRuntime = ({
const { cacheToolResult, appendToolNodeResponse } = useToolNodeResponse({ const { cacheToolResult, appendToolNodeResponse } = useToolNodeResponse({
node: context.node, node: context.node,
nodeResponses: artifacts.nodeResponses, nodeResponses: artifacts.nodeResponses,
appendNodeResponse,
toolCatalog, toolCatalog,
getSubAppInfo: context.getSubAppInfo getSubAppInfo: context.getSubAppInfo
}); });
...@@ -117,7 +122,7 @@ export const createWorkflowAgentLoopRuntime = ({ ...@@ -117,7 +122,7 @@ export const createWorkflowAgentLoopRuntime = ({
reasoningText: event.reasoningText, reasoningText: event.reasoningText,
...(event.error ? { errorText: getErrText(event.error) } : {}) ...(event.error ? { errorText: getErrText(event.error) } : {})
}; };
artifacts.nodeResponses.push(agentResponse); appendNodeResponse(agentResponse);
}; };
// Message 压缩是独立内部 LLM 调用,需要作为顶层运行详情展示。 // Message 压缩是独立内部 LLM 调用,需要作为顶层运行详情展示。
...@@ -142,7 +147,7 @@ export const createWorkflowAgentLoopRuntime = ({ ...@@ -142,7 +147,7 @@ export const createWorkflowAgentLoopRuntime = ({
}; };
}; };
const appendMessageCompressNodeResponse = (event: MessageCompressNodeResponseInput) => { const appendMessageCompressNodeResponse = (event: MessageCompressNodeResponseInput) => {
artifacts.nodeResponses.push(createMessageCompressNodeResponse(event)); appendNodeResponse(createMessageCompressNodeResponse(event));
}; };
return { return {
......
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { stripChildTotalPoints } from '@fastgpt/global/core/chat/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
import type { AgentLoopEvent, AgentLoopToolCatalog } from '../../../../../ai/llm/agentLoop'; import type { AgentLoopEvent, AgentLoopToolCatalog } from '../../../../../ai/llm/agentLoop';
...@@ -19,9 +20,6 @@ type PendingToolResult = { ...@@ -19,9 +20,6 @@ type PendingToolResult = {
const getUsageTotalPoints = (usages: ChatNodeUsageType[] = []) => const getUsageTotalPoints = (usages: ChatNodeUsageType[] = []) =>
usages.reduce((sum, item) => sum + (item.totalPoints || 0), 0); usages.reduce((sum, item) => sum + (item.totalPoints || 0), 0);
const getChildrenTotalPoints = (childrenResponses: ChatHistoryItemResType[] = []) =>
childrenResponses.reduce((sum, item) => sum + (item.totalPoints || 0), 0);
/** /**
* 维护 agent-loop 工具调用产生的 workflow nodeResponse。 * 维护 agent-loop 工具调用产生的 workflow nodeResponse。
* 包含普通工具、plan/ask 内部工具,以及工具响应压缩 child 的挂载。 * 包含普通工具、plan/ask 内部工具,以及工具响应压缩 child 的挂载。
...@@ -29,6 +27,9 @@ const getChildrenTotalPoints = (childrenResponses: ChatHistoryItemResType[] = [] ...@@ -29,6 +27,9 @@ const getChildrenTotalPoints = (childrenResponses: ChatHistoryItemResType[] = []
export const useToolNodeResponse = ({ export const useToolNodeResponse = ({
node, node,
nodeResponses, nodeResponses,
appendNodeResponse = (nodeResponse) => {
nodeResponses.push(nodeResponse);
},
toolCatalog, toolCatalog,
getSubAppInfo getSubAppInfo
}: { }: {
...@@ -37,6 +38,7 @@ export const useToolNodeResponse = ({ ...@@ -37,6 +38,7 @@ export const useToolNodeResponse = ({
flowNodeType: FlowNodeTypeEnum; flowNodeType: FlowNodeTypeEnum;
}; };
nodeResponses: ChatHistoryItemResType[]; nodeResponses: ChatHistoryItemResType[];
appendNodeResponse?: (nodeResponse: ChatHistoryItemResType) => void;
toolCatalog: AgentLoopToolCatalog; toolCatalog: AgentLoopToolCatalog;
getSubAppInfo: GetSubAppInfoFnType; getSubAppInfo: GetSubAppInfoFnType;
}) => { }) => {
...@@ -90,20 +92,8 @@ export const useToolNodeResponse = ({ ...@@ -90,20 +92,8 @@ export const useToolNodeResponse = ({
}; };
}; };
const withChildTotalPoints = (nodeResponse: ChatHistoryItemResType): ChatHistoryItemResType => { const stripNodeResponseChildTotalPoints = (nodeResponse: ChatHistoryItemResType) =>
/** stripChildTotalPoints(nodeResponse);
* childrenResponses 可能来自原工具 nodeResponse,也可能是本 hook 追加的压缩 child。
* 每次返回前重算 childTotalPoints,避免沿用旧值导致父节点消耗展示不准。
*/
const restNodeResponse = { ...nodeResponse };
delete restNodeResponse.childTotalPoints;
const childTotalPoints = getChildrenTotalPoints(nodeResponse.childrenResponses);
return {
...restNodeResponse,
...(childTotalPoints > 0 ? { childTotalPoints } : {})
};
};
const getUpdatePlanStatus = (call: ToolResponseEvent['call']): AgentPlanStatus => { const getUpdatePlanStatus = (call: ToolResponseEvent['call']): AgentPlanStatus => {
/** /**
...@@ -190,8 +180,8 @@ export const useToolNodeResponse = ({ ...@@ -190,8 +180,8 @@ export const useToolNodeResponse = ({
: undefined; : undefined;
const childrenResponses = compressNodeResponse ? [compressNodeResponse] : []; const childrenResponses = compressNodeResponse ? [compressNodeResponse] : [];
nodeResponses.push( appendNodeResponse(
withChildTotalPoints({ stripNodeResponseChildTotalPoints({
id: `${node.nodeId}-plan-${call.id}`, id: `${node.nodeId}-plan-${call.id}`,
nodeId: `${node.nodeId}-plan-${call.id}`, nodeId: `${node.nodeId}-plan-${call.id}`,
moduleName: AgentNodeResponseDisplay.plan.moduleName, moduleName: AgentNodeResponseDisplay.plan.moduleName,
...@@ -232,7 +222,7 @@ export const useToolNodeResponse = ({ ...@@ -232,7 +222,7 @@ export const useToolNodeResponse = ({
...(compressNodeResponse ? [compressNodeResponse] : []) ...(compressNodeResponse ? [compressNodeResponse] : [])
]; ];
return withChildTotalPoints({ return stripNodeResponseChildTotalPoints({
...toolNodeResponse, ...toolNodeResponse,
runningTime: toolNodeResponse.runningTime ?? event.seconds, runningTime: toolNodeResponse.runningTime ?? event.seconds,
toolRes: toolNodeResponse.toolRes ?? event.response, toolRes: toolNodeResponse.toolRes ?? event.response,
...@@ -259,7 +249,7 @@ export const useToolNodeResponse = ({ ...@@ -259,7 +249,7 @@ export const useToolNodeResponse = ({
} }
const toolNodeResponse = createToolNodeResponse(event); const toolNodeResponse = createToolNodeResponse(event);
nodeResponses.push(toolNodeResponse); appendNodeResponse(toolNodeResponse);
pendingToolResultMap.delete(event.call.id); pendingToolResultMap.delete(event.call.id);
}; };
......
...@@ -34,6 +34,9 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils'; ...@@ -34,6 +34,9 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { InteractiveNodeResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { useSandbox } from './sub/sandbox'; import { useSandbox } from './sub/sandbox';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
import type { RuntimeNodeResponseSummary } from '../../type';
import { createAgentNodeResponseCollector } from './nodeResponseCollector';
export type DispatchAgentModuleProps = ModuleDispatchProps<{ export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.history]?: ChatItemMiniType[]; [NodeInputKeyEnum.history]?: ChatItemMiniType[];
...@@ -55,11 +58,15 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{ ...@@ -55,11 +58,15 @@ export type DispatchAgentModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.datasetParams]?: AppFormEditFormType['dataset']; [NodeInputKeyEnum.datasetParams]?: AppFormEditFormType['dataset'];
[NodeInputKeyEnum.useAgentSandbox]?: boolean; [NodeInputKeyEnum.useAgentSandbox]?: boolean;
}>; }> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
};
type Response = DispatchNodeResultType<{ type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.answerText]: string; [NodeOutputKeyEnum.answerText]: string;
}>; }> & {
runtimeNodeResponseSummary?: RuntimeNodeResponseSummary;
};
/** /**
* 将主 loop 的 ask_agent 追问转换成 workflow interactive 响应,交给前端展示并等待用户回答。 * 将主 loop 的 ask_agent 追问转换成 workflow interactive 响应,交给前端展示并等待用户回答。
...@@ -91,10 +98,16 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -91,10 +98,16 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
return dispatchPiAgent(props); return dispatchPiAgent(props);
} }
// 这些数组会贯穿整轮 dispatch,并由 adapter 持续写入。 // assistantResponses 仍按原逻辑累计给 chat.value;nodeResponses 在 writer 模式下不再累计,
// 最终统一作为 workflow 节点的 assistantResponses 和 nodeResponses 返回 // 由 collector 即时写库并只向外返回 runtimeNodeResponseSummary
const assistantResponses: AIChatItemValueItemType[] = []; const assistantResponses: AIChatItemValueItemType[] = [];
const childNodeResponses: ChatHistoryItemResType[] = []; const childResponses: ChatHistoryItemResType[] = [];
const nodeResponseCollector = createAgentNodeResponseCollector({
nodeResponseWriter: props.nodeResponseWriter,
// Agent 节点本身不返回当前节点 nodeResponse;内部模型/工具详情按旧语义作为 root 展示。
nodeResponseParentId: undefined,
nodeResponses: childResponses
});
const { const {
node: { nodeId, inputs }, node: { nodeId, inputs },
...@@ -165,6 +178,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -165,6 +178,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
appId: runningAppInfo.id, appId: runningAppInfo.id,
userId: uid, userId: uid,
chatId, chatId,
sandboxId: runningAppInfo.sandboxId,
teamId: runningAppInfo.teamId, teamId: runningAppInfo.teamId,
useAgentSandbox, useAgentSandbox,
skillIds, skillIds,
...@@ -249,7 +263,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -249,7 +263,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
usagePush, usagePush,
workflowStreamResponse, workflowStreamResponse,
assistantResponses, assistantResponses,
nodeResponses: childNodeResponses nodeResponses: childResponses,
appendNodeResponse: nodeResponseCollector.appendNodeResponse
}); });
// ask_agent 追问会把 pendingMainContext 写入 memory。 // ask_agent 追问会把 pendingMainContext 写入 memory。
...@@ -296,7 +311,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -296,7 +311,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
} }
return { return {
[DispatchNodeResponseKeyEnum.nodeResponses]: childNodeResponses, [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponseCollector.getNodeResponses(),
runtimeNodeResponseSummary: nodeResponseCollector.getRuntimeNodeResponseSummary(),
[DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses,
[DispatchNodeResponseKeyEnum.memories]: buildWorkflowAgentLoopMemories({ [DispatchNodeResponseKeyEnum.memories]: buildWorkflowAgentLoopMemories({
nodeId, nodeId,
...@@ -355,7 +371,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -355,7 +371,8 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
memory: {} memory: {}
}), }),
[DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponses]: childNodeResponses [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponseCollector.getNodeResponses(),
runtimeNodeResponseSummary: nodeResponseCollector.getRuntimeNodeResponseSummary()
}; };
} catch (error) { } catch (error) {
// dispatch 层兜底:异常仍要清理 pending memory,并把已有 assistantResponses/nodeResponses 返回给前端恢复。 // dispatch 层兜底:异常仍要清理 pending memory,并把已有 assistantResponses/nodeResponses 返回给前端恢复。
...@@ -363,11 +380,12 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -363,11 +380,12 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
error error
}); });
const errorText = getErrText(error); const errorText = getErrText(error);
return { return {
error: { error: {
[NodeOutputKeyEnum.errorText]: errorText [NodeOutputKeyEnum.errorText]: errorText
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: { [DispatchNodeResponseKeyEnum.toolResponse]: {
error: errorText error: errorText
}, },
[DispatchNodeResponseKeyEnum.memories]: buildWorkflowAgentLoopMemories({ [DispatchNodeResponseKeyEnum.memories]: buildWorkflowAgentLoopMemories({
...@@ -375,7 +393,10 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise ...@@ -375,7 +393,10 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise
memory: {} memory: {}
}), }),
[DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponses]: childNodeResponses [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponseCollector.getNodeResponses(),
runtimeNodeResponseSummary: nodeResponseCollector.getRuntimeNodeResponseSummary()
}; };
} finally {
await nodeResponseCollector.flush();
} }
}; };
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
import type { RuntimeNodeResponseSummary } from '../../type';
import { createRuntimeNodeResponseSummary, summarizeRuntimeNodeResponses } from '../../utils';
/**
* 收集 Agent 内部持续产生的 nodeResponse。
*
* 普通 Agent 和 PiAgent 都会在一次节点运行中产生多条内部详情。业务链路存在 root writer
* 时,这些详情应立即写库并释放,只向父 workflow 返回运行期 summary;无 writer 只保留给
* 不落库的调试/单测路径,继续返回旧的内存数组。
*/
export const createAgentNodeResponseCollector = ({
nodeResponseWriter,
nodeResponseParentId,
nodeResponses
}: {
nodeResponseWriter?: WorkflowNodeResponseWriter;
nodeResponseParentId?: string;
nodeResponses: ChatHistoryItemResType[];
}) => {
let runtimeNodeResponseSummary: RuntimeNodeResponseSummary = createRuntimeNodeResponseSummary();
let writeQueue = Promise.resolve();
const appendNodeResponse = (nodeResponse: ChatHistoryItemResType) => {
if (!nodeResponseWriter) {
nodeResponses.push(nodeResponse);
return;
}
runtimeNodeResponseSummary = summarizeRuntimeNodeResponses(runtimeNodeResponseSummary, [
nodeResponse
]);
// Agent runtime 可能连续同步 append 多条详情,这里串行交给共享 writer,避免乱序。
writeQueue = writeQueue
.then(() => nodeResponseWriter.recordWithParent([nodeResponse], nodeResponseParentId))
.then(
() => undefined,
() => undefined
);
};
return {
appendNodeResponse,
flush: () => writeQueue,
getNodeResponses: () => (nodeResponseWriter ? undefined : nodeResponses),
getRuntimeNodeResponseSummary: () =>
nodeResponseWriter ? runtimeNodeResponseSummary : undefined
};
};
...@@ -270,6 +270,9 @@ export type PiAgentWorkflowRuntimeArtifacts = { ...@@ -270,6 +270,9 @@ export type PiAgentWorkflowRuntimeArtifacts = {
export const createPiAgentWorkflowRuntime = ({ export const createPiAgentWorkflowRuntime = ({
props, props,
nodeResponses, nodeResponses,
appendNodeResponse = (nodeResponse) => {
nodeResponses.push(nodeResponse);
},
workflowStreamResponse, workflowStreamResponse,
usagePush, usagePush,
completionTools, completionTools,
...@@ -277,6 +280,7 @@ export const createPiAgentWorkflowRuntime = ({ ...@@ -277,6 +280,7 @@ export const createPiAgentWorkflowRuntime = ({
}: { }: {
props: DispatchAgentModuleProps; props: DispatchAgentModuleProps;
nodeResponses: ChatHistoryItemResType[]; nodeResponses: ChatHistoryItemResType[];
appendNodeResponse?: (nodeResponse: ChatHistoryItemResType) => void;
workflowStreamResponse?: WorkflowResponseType; workflowStreamResponse?: WorkflowResponseType;
usagePush: DispatchAgentModuleProps['usagePush']; usagePush: DispatchAgentModuleProps['usagePush'];
completionTools?: ChatCompletionTool[]; completionTools?: ChatCompletionTool[];
...@@ -291,7 +295,7 @@ export const createPiAgentWorkflowRuntime = ({ ...@@ -291,7 +295,7 @@ export const createPiAgentWorkflowRuntime = ({
const usedUserOpenAIKey = !!props.externalProvider.openaiAccount?.key; const usedUserOpenAIKey = !!props.externalProvider.openaiAccount?.key;
const appendChildNodeResponse = (nodeResponse: ChatHistoryItemResType) => { const appendChildNodeResponse = (nodeResponse: ChatHistoryItemResType) => {
nodeResponses.push(nodeResponse); appendNodeResponse(nodeResponse);
}; };
const saveRequestRecord = ({ const saveRequestRecord = ({
...@@ -379,7 +383,7 @@ export const createPiAgentWorkflowRuntime = ({ ...@@ -379,7 +383,7 @@ export const createPiAgentWorkflowRuntime = ({
...(errorText ? { errorText: getErrText(errorText) } : {}) ...(errorText ? { errorText: getErrText(errorText) } : {})
}; };
nodeResponses.push(agentResponse); appendNodeResponse(agentResponse);
}; };
return { return {
......
...@@ -24,10 +24,14 @@ import { ...@@ -24,10 +24,14 @@ import {
} from './adapter/runtime'; } from './adapter/runtime';
import { buildPiModel, getModelApiKey, getPiThinkingLevel } from './modelBridge'; import { buildPiModel, getModelApiKey, getPiThinkingLevel } from './modelBridge';
import { buildAgentTools, createPiAgentToolEventHandler } from './toolAdapter'; import { buildAgentTools, createPiAgentToolEventHandler } from './toolAdapter';
import type { RuntimeNodeResponseSummary } from '../../../type';
import { createAgentNodeResponseCollector } from '../nodeResponseCollector';
type Response = DispatchNodeResultType<{ type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.answerText]: string; [NodeOutputKeyEnum.answerText]: string;
}>; }> & {
runtimeNodeResponseSummary?: RuntimeNodeResponseSummary;
};
export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<Response> => { export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<Response> => {
const { const {
...@@ -67,6 +71,13 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -67,6 +71,13 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
const assistantResponses: AIChatItemValueItemType[] = []; const assistantResponses: AIChatItemValueItemType[] = [];
const nodeResponses: ChatHistoryItemResType[] = []; const nodeResponses: ChatHistoryItemResType[] = [];
const piToolResponseIds = new Set<string>();
const nodeResponseCollector = createAgentNodeResponseCollector({
nodeResponseWriter: props.nodeResponseWriter,
// Pi Agent 与 unified Agent 一样没有当前节点 wrapper,内部详情保持 root 语义。
nodeResponseParentId: undefined,
nodeResponses
});
let agent: InstanceType<typeof Agent> | undefined; let agent: InstanceType<typeof Agent> | undefined;
let piRuntime: PiAgentWorkflowRuntimeArtifacts | undefined; let piRuntime: PiAgentWorkflowRuntimeArtifacts | undefined;
let stopPoller: ReturnType<typeof setInterval> | undefined; let stopPoller: ReturnType<typeof setInterval> | undefined;
...@@ -179,6 +190,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -179,6 +190,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
piRuntime = createPiAgentWorkflowRuntime({ piRuntime = createPiAgentWorkflowRuntime({
props, props,
nodeResponses, nodeResponses,
appendNodeResponse: nodeResponseCollector.appendNodeResponse,
workflowStreamResponse, workflowStreamResponse,
usagePush, usagePush,
completionTools: agentCompletionTools completionTools: agentCompletionTools
...@@ -201,14 +213,19 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -201,14 +213,19 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
const piTools = await buildAgentTools({ const piTools = await buildAgentTools({
ctx: toolCtx, ctx: toolCtx,
assistantResponses, assistantResponses,
appendChildNodeResponse: piRuntime.appendChildNodeResponse, appendChildNodeResponse: (nodeResponse) => {
if (nodeResponse.id) {
piToolResponseIds.add(nodeResponse.id);
}
piRuntime?.appendChildNodeResponse(nodeResponse);
},
usagePush usagePush
}); });
const handlePiToolEvent = createPiAgentToolEventHandler({ const handlePiToolEvent = createPiAgentToolEventHandler({
ctx: toolCtx, ctx: toolCtx,
assistantResponses, assistantResponses,
appendChildNodeResponse: piRuntime.appendChildNodeResponse, appendChildNodeResponse: piRuntime.appendChildNodeResponse,
nodeResponses appendedNodeResponseIds: piToolResponseIds
}); });
// 6. 恢复上一轮 PiAgent messages。只从当前节点 memory 恢复,保持 PiAgent 独立 loop 的连续性。 // 6. 恢复上一轮 PiAgent messages。只从当前节点 memory 恢复,保持 PiAgent 独立 loop 的连续性。
...@@ -276,7 +293,8 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -276,7 +293,8 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
[piMessagesKey]: agent.state.messages [piMessagesKey]: agent.state.messages
}, },
[DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponses [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponseCollector.getNodeResponses(),
runtimeNodeResponseSummary: nodeResponseCollector.getRuntimeNodeResponseSummary()
}; };
} catch (error) { } catch (error) {
getLogger(LogCategories.MODULE.AI.AGENT).error(`[piAgent] dispatchPiAgent error`, { error }); getLogger(LogCategories.MODULE.AI.AGENT).error(`[piAgent] dispatchPiAgent error`, { error });
...@@ -297,7 +315,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -297,7 +315,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
error: { error: {
[NodeOutputKeyEnum.errorText]: errorText [NodeOutputKeyEnum.errorText]: errorText
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: { [DispatchNodeResponseKeyEnum.toolResponse]: {
error: errorText error: errorText
}, },
...(memories ...(memories
...@@ -306,9 +324,11 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< ...@@ -306,9 +324,11 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise<
} }
: {}), : {}),
[DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponses [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponseCollector.getNodeResponses(),
runtimeNodeResponseSummary: nodeResponseCollector.getRuntimeNodeResponseSummary()
}; };
} finally { } finally {
if (stopPoller) clearInterval(stopPoller); if (stopPoller) clearInterval(stopPoller);
await nodeResponseCollector.flush();
} }
}; };
...@@ -124,13 +124,18 @@ export const createPiAgentToolEventHandler = ({ ...@@ -124,13 +124,18 @@ export const createPiAgentToolEventHandler = ({
ctx, ctx,
assistantResponses, assistantResponses,
appendChildNodeResponse, appendChildNodeResponse,
nodeResponses appendedNodeResponseIds
}: { }: {
ctx: ToolDispatchContext; ctx: ToolDispatchContext;
assistantResponses: AIChatItemValueItemType[]; assistantResponses: AIChatItemValueItemType[];
appendChildNodeResponse: (nodeResponse: ChatHistoryItemResType) => void; appendChildNodeResponse: (nodeResponse: ChatHistoryItemResType) => void;
nodeResponses: ChatHistoryItemResType[]; /**
* PiAgent 事件流和 buildAgentTools 都可能为同一次工具调用补 nodeResponse。
* 业务链路下 nodeResponse 会被 writer 立即释放,不能再依赖完整数组做去重。
*/
appendedNodeResponseIds?: Set<string>;
}) => { }) => {
const recordedNodeResponseIds = appendedNodeResponseIds || new Set<string>();
const toolStarts = new Map< const toolStarts = new Map<
string, string,
{ {
...@@ -200,9 +205,10 @@ export const createPiAgentToolEventHandler = ({ ...@@ -200,9 +205,10 @@ export const createPiAgentToolEventHandler = ({
toolName: string; toolName: string;
response: string; response: string;
}) => { }) => {
if (!callId || nodeResponses.some((item) => item.id === callId || item.nodeId === callId)) { if (!callId || recordedNodeResponseIds.has(callId)) {
return; return;
} }
recordedNodeResponseIds.add(callId);
const started = toolStarts.get(callId); const started = toolStarts.get(callId);
const subAppInfo = ctx.getSubAppInfo(toolName); const subAppInfo = ctx.getSubAppInfo(toolName);
......
...@@ -20,6 +20,7 @@ import { getWorkflowToolInputsFromStoreNodes } from '@fastgpt/global/core/app/to ...@@ -20,6 +20,7 @@ import { getWorkflowToolInputsFromStoreNodes } from '@fastgpt/global/core/app/to
import type { RunWorkflowProps } from '../../../../../../../core/workflow/dispatch'; import type { RunWorkflowProps } from '../../../../../../../core/workflow/dispatch';
import { anyValueDecrypt } from '../../../../../../../common/secret/utils'; import { anyValueDecrypt } from '../../../../../../../common/secret/utils';
import { WorkflowVariableState } from '../../../../utils/variables'; import { WorkflowVariableState } from '../../../../utils/variables';
import { getRuntimeNodeResponseSummary } from '../../../../utils';
type Props = Pick< type Props = Pick<
RunWorkflowProps, RunWorkflowProps,
...@@ -39,6 +40,8 @@ type Props = Pick< ...@@ -39,6 +40,8 @@ type Props = Pick<
| 'workflowDispatchDeep' | 'workflowDispatchDeep'
| 'responseAllData' | 'responseAllData'
| 'responseDetail' | 'responseDetail'
| 'nodeResponseWriter'
| 'nodeResponseParentId'
| 'variableState' | 'variableState'
> & { > & {
app: { app: {
...@@ -99,7 +102,7 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse> ...@@ -99,7 +102,7 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse>
); );
const runtimeEdges = storeEdges2RuntimeEdges(edges); const runtimeEdges = storeEdges2RuntimeEdges(edges);
const { assistantResponses, flowUsages } = await runWorkflow({ const { assistantResponses, flowUsages, runtimeNodeResponseSummary } = await runWorkflow({
...data, ...data,
runningAppInfo: { runningAppInfo: {
id: String(appData._id), id: String(appData._id),
...@@ -126,6 +129,9 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse> ...@@ -126,6 +129,9 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse>
}); });
const { text } = chatValue2RuntimePrompt(assistantResponses); const { text } = chatValue2RuntimePrompt(assistantResponses);
const runtimeSummary = getRuntimeNodeResponseSummary({
runtimeNodeResponseSummary
});
return { return {
response: text, response: text,
...@@ -138,7 +144,8 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse> ...@@ -138,7 +144,8 @@ export const dispatchApp = async (props: Props): Promise<DispatchSubAppResponse>
userChatInput, userChatInput,
...customAppVariables ...customAppVariables
}, },
toolRes: text toolRes: text,
childResponseCount: runtimeSummary.childResponseCount
} }
}; };
}; };
...@@ -153,6 +160,8 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon ...@@ -153,6 +160,8 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon
userChatInput, userChatInput,
...data ...data
} = props; } = props;
// plugin 子应用不接收普通 userChatInput;这里解构只为了避免透传给 runWorkflow。
void userChatInput;
// Auth the app by tmbId(Not the user, but the workflow user) // Auth the app by tmbId(Not the user, but the workflow user)
const { app: appData } = await authAppByTmbId({ const { app: appData } = await authAppByTmbId({
...@@ -230,7 +239,7 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon ...@@ -230,7 +239,7 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon
return acc; return acc;
}, {}) ?? {}; }, {}) ?? {};
const { flowResponses, flowUsages, runTimes } = await runWorkflow({ const { flowUsages, runtimeNodeResponseSummary } = await runWorkflow({
...data, ...data,
runningAppInfo: { runningAppInfo: {
id: String(appData._id), id: String(appData._id),
...@@ -254,13 +263,16 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon ...@@ -254,13 +263,16 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon
workflowStreamResponse: undefined workflowStreamResponse: undefined
}); });
const output = flowResponses.find((item) => item.moduleType === FlowNodeTypeEnum.pluginOutput); const runtimeSummary = getRuntimeNodeResponseSummary({
const response = output?.pluginOutput runtimeNodeResponseSummary
});
const pluginOutput = runtimeSummary.pluginOutput;
const response = pluginOutput
? JSON.stringify( ? JSON.stringify(
Object.keys(output.pluginOutput) Object.keys(pluginOutput)
.filter((key) => outputFilterMap[key]) .filter((key) => outputFilterMap[key])
.reduce<Record<string, any>>((acc, key) => { .reduce<Record<string, any>>((acc, key) => {
acc[key] = output.pluginOutput![key]; acc[key] = pluginOutput[key];
return acc; return acc;
}, {}) }, {})
) )
...@@ -274,7 +286,8 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon ...@@ -274,7 +286,8 @@ export const dispatchPlugin = async (props: Props): Promise<DispatchSubAppRespon
moduleName: app.name, moduleName: app.name,
moduleLogo: app.avatar, moduleLogo: app.avatar,
toolInput: customAppVariables, toolInput: customAppVariables,
toolRes: output?.pluginOutput || {} toolRes: pluginOutput || {},
childResponseCount: runtimeSummary.childResponseCount
} }
}; };
}; };
...@@ -10,13 +10,15 @@ type FileReadParams = { ...@@ -10,13 +10,15 @@ type FileReadParams = {
teamId: string; teamId: string;
tmbId: string; tmbId: string;
customPdfParse?: boolean; customPdfParse?: boolean;
usageId?: string;
}; };
export const dispatchFileRead = async ({ export const dispatchFileRead = async ({
files, files,
teamId, teamId,
tmbId, tmbId,
customPdfParse customPdfParse,
usageId
}: FileReadParams): Promise<DispatchSubAppResponse> => { }: FileReadParams): Promise<DispatchSubAppResponse> => {
try { try {
const readFilesResult = await Promise.all( const readFilesResult = await Promise.all(
...@@ -26,7 +28,8 @@ export const dispatchFileRead = async ({ ...@@ -26,7 +28,8 @@ export const dispatchFileRead = async ({
url, url,
teamId, teamId,
tmbId, tmbId,
customPdfParse customPdfParse,
usageId
}); });
return { return {
......
...@@ -12,6 +12,7 @@ export const dispatchSandboxTool = async ({ ...@@ -12,6 +12,7 @@ export const dispatchSandboxTool = async ({
appId, appId,
userId, userId,
chatId, chatId,
sandboxId,
lang, lang,
sandboxClient sandboxClient
}: { }: {
...@@ -20,6 +21,7 @@ export const dispatchSandboxTool = async ({ ...@@ -20,6 +21,7 @@ export const dispatchSandboxTool = async ({
appId: string; appId: string;
userId: string; userId: string;
chatId: string; chatId: string;
sandboxId?: string;
lang?: localeType; lang?: localeType;
sandboxClient?: SandboxClient; sandboxClient?: SandboxClient;
}): Promise<DispatchSubAppResponse> => { }): Promise<DispatchSubAppResponse> => {
...@@ -29,6 +31,7 @@ export const dispatchSandboxTool = async ({ ...@@ -29,6 +31,7 @@ export const dispatchSandboxTool = async ({
appId, appId,
userId, userId,
chatId, chatId,
sandboxId,
sandboxClient sandboxClient
}); });
......
...@@ -15,6 +15,7 @@ type UseSandboxParams = { ...@@ -15,6 +15,7 @@ type UseSandboxParams = {
appId: string; appId: string;
userId: string; userId: string;
chatId: string; chatId: string;
sandboxId?: string;
teamId: string; teamId: string;
useAgentSandbox: boolean; useAgentSandbox: boolean;
skillIds: string[]; skillIds: string[];
...@@ -80,6 +81,7 @@ export async function useSandbox({ ...@@ -80,6 +81,7 @@ export async function useSandbox({
appId, appId,
userId, userId,
chatId, chatId,
sandboxId,
teamId, teamId,
useAgentSandbox, useAgentSandbox,
skillIds, skillIds,
...@@ -105,7 +107,9 @@ export async function useSandbox({ ...@@ -105,7 +107,9 @@ export async function useSandbox({
} }
// 确认使用沙盒,启动沙盒实例 // 确认使用沙盒,启动沙盒实例
const sandboxClient = await getSandboxClient({ appId, userId, chatId }); const sandboxClient = await getSandboxClient(
sandboxId ? { sandboxId, appId, userId, chatId } : { appId, userId, chatId }
);
const getSkillsInfo = async () => { const getSkillsInfo = async () => {
// 编辑模式下复用编辑器已经写入 skills 目录的 skill 文件,避免工作区其他 SKILL.md 进入 prompt。 // 编辑模式下复用编辑器已经写入 skills 目录的 skill 文件,避免工作区其他 SKILL.md 进入 prompt。
......
...@@ -20,6 +20,7 @@ import type { WorkflowResponseItemType } from '../../type'; ...@@ -20,6 +20,7 @@ import type { WorkflowResponseItemType } from '../../type';
import { dispatchApp, dispatchPlugin } from './sub/app'; import { dispatchApp, dispatchPlugin } from './sub/app';
import type { SandboxClient } from '../../../../ai/sandbox/service/runtime'; import type { SandboxClient } from '../../../../ai/sandbox/service/runtime';
import { SystemToolRepo } from '../../../../app/tool/systemTool/systemTool.repo'; import { SystemToolRepo } from '../../../../app/tool/systemTool/systemTool.repo';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
/** /**
* 收集 Agent 节点可用的系统工具和用户选择的子应用工具。 * 收集 Agent 节点可用的系统工具和用户选择的子应用工具。
...@@ -97,6 +98,8 @@ export type ToolDispatchContext = Pick< ...@@ -97,6 +98,8 @@ export type ToolDispatchContext = Pick<
| 'runningUserInfo' | 'runningUserInfo'
| 'runningAppInfo' | 'runningAppInfo'
| 'chatId' | 'chatId'
| 'responseChatItemId'
| 'usageId'
| 'uid' | 'uid'
| 'variableState' | 'variableState'
| 'externalProvider' | 'externalProvider'
...@@ -110,6 +113,8 @@ export type ToolDispatchContext = Pick< ...@@ -110,6 +113,8 @@ export type ToolDispatchContext = Pick<
| 'params' | 'params'
| 'stream' | 'stream'
> & { > & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
nodeResponseParentId?: string;
systemPrompt?: string; systemPrompt?: string;
getSubAppInfo: GetSubAppInfoFnType; getSubAppInfo: GetSubAppInfoFnType;
getSubApp: (id: string) => SubAppRuntimeType | undefined; getSubApp: (id: string) => SubAppRuntimeType | undefined;
...@@ -133,6 +138,8 @@ export const getExecuteTool = ({ ...@@ -133,6 +138,8 @@ export const getExecuteTool = ({
runningUserInfo, runningUserInfo,
runningAppInfo, runningAppInfo,
chatId, chatId,
responseChatItemId,
usageId,
uid, uid,
variableState, variableState,
externalProvider, externalProvider,
...@@ -148,7 +155,8 @@ export const getExecuteTool = ({ ...@@ -148,7 +155,8 @@ export const getExecuteTool = ({
timezone, timezone,
retainDatasetCite, retainDatasetCite,
maxRunTimes, maxRunTimes,
workflowDispatchDeep workflowDispatchDeep,
nodeResponseWriter
}: ToolDispatchContext) => { }: ToolDispatchContext) => {
/** /**
* 执行单次工具调用,并补齐节点响应的 id、运行时间和计费信息。 * 执行单次工具调用,并补齐节点响应的 id、运行时间和计费信息。
...@@ -175,6 +183,7 @@ export const getExecuteTool = ({ ...@@ -175,6 +183,7 @@ export const getExecuteTool = ({
appId: runningAppInfo.id, appId: runningAppInfo.id,
userId: uid, userId: uid,
chatId, chatId,
sandboxId: runningAppInfo.sandboxId,
lang, lang,
sandboxClient sandboxClient
}); });
...@@ -205,7 +214,8 @@ export const getExecuteTool = ({ ...@@ -205,7 +214,8 @@ export const getExecuteTool = ({
files, files,
teamId: runningUserInfo.teamId, teamId: runningUserInfo.teamId,
tmbId: runningUserInfo.tmbId, tmbId: runningUserInfo.tmbId,
customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse,
usageId
}); });
return { return {
...@@ -291,12 +301,15 @@ export const getExecuteTool = ({ ...@@ -291,12 +301,15 @@ export const getExecuteTool = ({
timezone, timezone,
externalProvider, externalProvider,
chatId, chatId,
responseChatItemId,
uid, uid,
runningAppInfo, runningAppInfo,
runningUserInfo, runningUserInfo,
retainDatasetCite, retainDatasetCite,
maxRunTimes, maxRunTimes,
workflowDispatchDeep, workflowDispatchDeep,
nodeResponseWriter,
nodeResponseParentId: callId,
variableState variableState
}); });
...@@ -337,12 +350,15 @@ export const getExecuteTool = ({ ...@@ -337,12 +350,15 @@ export const getExecuteTool = ({
timezone, timezone,
externalProvider, externalProvider,
chatId, chatId,
responseChatItemId,
uid, uid,
runningAppInfo, runningAppInfo,
runningUserInfo, runningUserInfo,
retainDatasetCite, retainDatasetCite,
maxRunTimes, maxRunTimes,
workflowDispatchDeep, workflowDispatchDeep,
nodeResponseWriter,
nodeResponseParentId: callId,
variableState variableState
}); });
...@@ -367,10 +383,11 @@ export const getExecuteTool = ({ ...@@ -367,10 +383,11 @@ export const getExecuteTool = ({
if (!nodeResponse) return undefined; if (!nodeResponse) return undefined;
const subInfo = getSubAppInfo(toolId); const subInfo = getSubAppInfo(toolId);
const childTotalPoints = (nodeResponse.childrenResponses || []).reduce( const childResponseCount =
(sum, item) => sum + (item.totalPoints || 0), nodeResponse.childResponseCount ??
0 (nodeResponse.childrenResponses?.length
); ? nodeResponse.childrenResponses.length
: undefined);
return { return {
...nodeResponse, ...nodeResponse,
moduleType: nodeResponse.moduleType || FlowNodeTypeEnum.tool, moduleType: nodeResponse.moduleType || FlowNodeTypeEnum.tool,
...@@ -380,7 +397,7 @@ export const getExecuteTool = ({ ...@@ -380,7 +397,7 @@ export const getExecuteTool = ({
id: callId, id: callId,
runningTime: +((Date.now() - startTime) / 1000).toFixed(2), runningTime: +((Date.now() - startTime) / 1000).toFixed(2),
totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0), totalPoints: usages?.reduce((sum, item) => sum + item.totalPoints, 0),
...(childTotalPoints > 0 ? { childTotalPoints } : {}) ...(childResponseCount !== undefined ? { childResponseCount } : {})
}; };
})(); })();
......
...@@ -293,7 +293,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp ...@@ -293,7 +293,7 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
finishReason: finish_reason, finishReason: finish_reason,
llmRequestIds: [requestId] // 记录 LLM 请求追踪 ID llmRequestIds: [requestId] // 记录 LLM 请求追踪 ID
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: answerText [DispatchNodeResponseKeyEnum.toolResponse]: answerText
}; };
} catch (error) { } catch (error) {
return getNodeErrResponse({ error }); return getNodeErrResponse({ error });
......
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import type { ChildResponseItemType } from './type'; import type { ChildResponseItemType } from './type';
import { SANDBOX_SHELL_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools'; import { SANDBOX_SHELL_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools';
import { summarizeRuntimeNodeResponses } from '../../utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
export const getSandboxToolWorkflowResponse = ({ export const getSandboxToolWorkflowResponse = ({
name, name,
...@@ -18,21 +19,24 @@ export const getSandboxToolWorkflowResponse = ({ ...@@ -18,21 +19,24 @@ export const getSandboxToolWorkflowResponse = ({
response: string; response: string;
durationSeconds: number; durationSeconds: number;
}): ChildResponseItemType => { }): ChildResponseItemType => {
const flowResponses = [
{
moduleName: name,
moduleType: FlowNodeTypeEnum.tool,
moduleLogo: logo,
toolId,
toolInput: input,
toolRes: response,
totalPoints: 0,
id: getNanoid(),
nodeId: getNanoid(),
runningTime: durationSeconds
}
];
return { return {
flowResponses: [ runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, flowResponses),
{ builtinNodeResponses: flowResponses,
moduleName: name,
moduleType: FlowNodeTypeEnum.tool,
moduleLogo: logo,
toolId,
toolInput: input,
toolRes: response,
totalPoints: 0,
id: getNanoid(),
nodeId: getNanoid(),
runningTime: durationSeconds
}
],
flowUsages: [], flowUsages: [],
runTimes: 0 runTimes: 0
}; };
......
...@@ -80,7 +80,8 @@ export const useToolCatalog = async ({ ...@@ -80,7 +80,8 @@ export const useToolCatalog = async ({
lang, lang,
appId, appId,
userId, userId,
chatId chatId,
sandboxId
}: { }: {
messages: ChatCompletionMessageParam[]; messages: ChatCompletionMessageParam[];
toolNodes: ToolNodeItemType[]; toolNodes: ToolNodeItemType[];
...@@ -90,6 +91,7 @@ export const useToolCatalog = async ({ ...@@ -90,6 +91,7 @@ export const useToolCatalog = async ({
appId: string; appId: string;
userId: string; userId: string;
chatId: string; chatId: string;
sandboxId?: string;
}) => { }) => {
let finalMessages = messages; let finalMessages = messages;
const toolNodesMap = new Map<string, ToolNodeItemType>(); const toolNodesMap = new Map<string, ToolNodeItemType>();
...@@ -124,6 +126,7 @@ export const useToolCatalog = async ({ ...@@ -124,6 +126,7 @@ export const useToolCatalog = async ({
appId, appId,
userId, userId,
chatId, chatId,
sandboxId,
files: currentInputFiles.map((file) => ({ files: currentInputFiles.map((file) => ({
path: file.sandboxPath!, path: file.sandboxPath!,
url: file.url url: file.url
......
...@@ -9,11 +9,13 @@ import type { AgentLoopChildrenInteractiveParams } from '../../../../../ai/llm/a ...@@ -9,11 +9,13 @@ import type { AgentLoopChildrenInteractiveParams } from '../../../../../ai/llm/a
import { runSandboxTools } from '../../../../../ai/sandbox/toolCall'; import { runSandboxTools } from '../../../../../ai/sandbox/toolCall';
import { parseJsonArgs } from '../../../../../ai/utils'; import { parseJsonArgs } from '../../../../../ai/utils';
import { runWorkflow } from '../../../index'; import { runWorkflow } from '../../../index';
import { getRuntimeNodeResponseSummary } from '../../../utils';
import type { DispatchFlowResponse } from '../../../type'; import type { DispatchFlowResponse } from '../../../type';
import { formatToolResponse } from '../../utils';
import { getSandboxToolWorkflowResponse } from '../constants'; import { getSandboxToolWorkflowResponse } from '../constants';
import type { ChildResponseItemType, DispatchToolModuleProps, FileInputType } from '../type'; import type { ChildResponseItemType, DispatchToolModuleProps, FileInputType } from '../type';
import { dispatchReadFileTool, ReadFileToolParamsSchema } from '../tools/file'; import { dispatchReadFileTool, ReadFileToolParamsSchema } from '../tools/file';
import { formatToolResponse, initToolCallEdges, initToolNodes } from '../utils'; import { initToolCallEdges, initToolNodes } from '../utils';
import type { ToolInfo } from './useToolCatalog'; import type { ToolInfo } from './useToolCatalog';
import { checkTeamSandboxPermission } from '../../../../../../support/permission/teamLimit'; import { checkTeamSandboxPermission } from '../../../../../../support/permission/teamLimit';
...@@ -85,7 +87,7 @@ export const useToolRunner = ({ ...@@ -85,7 +87,7 @@ export const useToolRunner = ({
fileUrls = [], fileUrls = [],
getToolInfo, getToolInfo,
cacheToolFlowResponse, cacheToolFlowResponse,
appendToolFlowResponse, appendInteractiveToolSummary,
streamToolResponse streamToolResponse
}: { }: {
workflowProps: WorkflowProps; workflowProps: WorkflowProps;
...@@ -98,7 +100,7 @@ export const useToolRunner = ({ ...@@ -98,7 +100,7 @@ export const useToolRunner = ({
call: ChatCompletionMessageToolCall; call: ChatCompletionMessageToolCall;
flowResponse?: ChildResponseItemType; flowResponse?: ChildResponseItemType;
}) => void; }) => void;
appendToolFlowResponse: (flowResponse: ChildResponseItemType) => void; appendInteractiveToolSummary: (flowResponse: ChildResponseItemType) => void;
streamToolResponse: (args: { toolCallId: string; response?: string }) => void; streamToolResponse: (args: { toolCallId: string; response?: string }) => void;
}) => { }) => {
const runTool = async ({ call }: { call: ChatCompletionMessageToolCall }) => { const runTool = async ({ call }: { call: ChatCompletionMessageToolCall }) => {
...@@ -127,7 +129,7 @@ export const useToolRunner = ({ ...@@ -127,7 +129,7 @@ export const useToolRunner = ({
if (toolInfo.type === 'sandbox') { if (toolInfo.type === 'sandbox') {
try { try {
await checkTeamSandboxPermission(workflowProps.runningUserInfo.teamId); await checkTeamSandboxPermission(workflowProps.runningUserInfo.teamId);
} catch (err) { } catch {
throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。'); throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。');
} }
...@@ -136,7 +138,8 @@ export const useToolRunner = ({ ...@@ -136,7 +138,8 @@ export const useToolRunner = ({
args: call.function.arguments ?? '', args: call.function.arguments ?? '',
appId: workflowProps.runningAppInfo.id, appId: workflowProps.runningAppInfo.id,
userId: workflowProps.uid, userId: workflowProps.uid,
chatId: workflowProps.chatId chatId: workflowProps.chatId,
sandboxId: workflowProps.runningAppInfo.sandboxId
}); });
const flowResponse = getSandboxToolWorkflowResponse({ const flowResponse = getSandboxToolWorkflowResponse({
...@@ -188,16 +191,21 @@ export const useToolRunner = ({ ...@@ -188,16 +191,21 @@ export const useToolRunner = ({
runtimeNodes, runtimeNodes,
isToolCall: true isToolCall: true
}); });
const toolRuntimeSummary = getRuntimeNodeResponseSummary(toolRunResponse);
const stringToolResponse = formatToolResponse(toolRunResponse.toolResponses); const stringToolResponse = formatToolResponse(toolRunResponse.toolResponse);
return { return {
response: stringToolResponse, response: stringToolResponse,
flowResponse: toolRunResponse, flowResponse: {
runtimeNodeResponseSummary: toolRuntimeSummary,
flowUsages: toolRunResponse.flowUsages,
runTimes: toolRunResponse.runTimes
},
assistantMessages: getAssistantMessages(toolRunResponse.assistantResponses), assistantMessages: getAssistantMessages(toolRunResponse.assistantResponses),
usages: toolRunResponse.flowUsages, usages: toolRunResponse.flowUsages,
interactive: toolRunResponse.workflowInteractiveResponse, interactive: toolRunResponse.workflowInteractiveResponse,
stop: toolRunResponse.flowResponses?.some((item) => item.toolStop) stop: toolRuntimeSummary.hasToolStop
}; };
})(); })();
...@@ -237,21 +245,26 @@ export const useToolRunner = ({ ...@@ -237,21 +245,26 @@ export const useToolRunner = ({
runtimeEdges, runtimeEdges,
isToolCall: true isToolCall: true
}); });
const stringToolResponse = formatToolResponse(toolRunResponse.toolResponses); const toolRuntimeSummary = getRuntimeNodeResponseSummary(toolRunResponse);
const stringToolResponse = formatToolResponse(toolRunResponse.toolResponse);
streamToolResponse({ streamToolResponse({
toolCallId: toolParams.toolCallId, toolCallId: toolParams.toolCallId,
response: stringToolResponse response: stringToolResponse
}); });
appendToolFlowResponse(toolRunResponse); appendInteractiveToolSummary({
runtimeNodeResponseSummary: toolRuntimeSummary,
flowUsages: toolRunResponse.flowUsages,
runTimes: toolRunResponse.runTimes
});
return { return {
response: stringToolResponse, response: stringToolResponse,
assistantMessages: getAssistantMessages(toolRunResponse.assistantResponses), assistantMessages: getAssistantMessages(toolRunResponse.assistantResponses),
usages: toolRunResponse.flowUsages, usages: toolRunResponse.flowUsages,
interactive: toolRunResponse.workflowInteractiveResponse, interactive: toolRunResponse.workflowInteractiveResponse,
stop: toolRunResponse.flowResponses?.some((item) => item.toolStop) stop: toolRuntimeSummary.hasToolStop
}; };
}; };
......
...@@ -47,7 +47,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< ...@@ -47,7 +47,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
if (useAgentSandbox && global.feConfigs?.show_agent_sandbox) { if (useAgentSandbox && global.feConfigs?.show_agent_sandbox) {
try { try {
await checkTeamSandboxPermission(runningUserInfo.teamId); await checkTeamSandboxPermission(runningUserInfo.teamId);
} catch (err) { } catch {
throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。'); throw new Error('当前应用未配置虚拟机,暂时无法使用相关功能,请联系管理员配置。');
} }
} }
...@@ -108,7 +108,9 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< ...@@ -108,7 +108,9 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
const { const {
toolWorkflowInteractiveResponse, toolWorkflowInteractiveResponse,
toolDispatchFlowResponses, // 工具子流程运行详情 runtimeNodeResponseSummary: toolRuntimeSummary, // 工具子流程运行期摘要;完整详情由 writer 持久化。
toolTotalPoints,
runTimes,
toolCallInputTokens, toolCallInputTokens,
toolCallOutputTokens, toolCallOutputTokens,
toolCallTotalPoints, toolCallTotalPoints,
...@@ -138,8 +140,6 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< ...@@ -138,8 +140,6 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
}); });
})(); })();
const runTimes = toolDispatchFlowResponses.reduce((sum, item) => sum + item.runTimes, 0);
const toolDetail = toolDispatchFlowResponses.map((item) => item.flowResponses).flat();
const historyPreview = getHistoryPreview( const historyPreview = getHistoryPreview(
GPTMessages2Chats({ messages: completeMessages, reserveTool: false }), GPTMessages2Chats({ messages: completeMessages, reserveTool: false }),
10000, 10000,
...@@ -148,21 +148,16 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< ...@@ -148,21 +148,16 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
const modelName = toolModel.name; const modelName = toolModel.name;
const modelTotalPoints = toolCallTotalPoints; const modelTotalPoints = toolCallTotalPoints;
const toolTotalPoints = toolDispatchFlowResponses
.map((item) => item.flowUsages)
.flat()
.reduce((sum, item) => sum + item.totalPoints, 0);
const totalPointsUsage = modelTotalPoints + toolTotalPoints; const totalPointsUsage = modelTotalPoints + toolTotalPoints;
const previewAssistantResponses = filterToolResponseToPreview(assistantResponses); const previewAssistantResponses = filterToolResponseToPreview(assistantResponses);
const nodeResponse: Record<string, any> = { const nodeResponse: Record<string, any> = {
totalPoints: totalPointsUsage, totalPoints: totalPointsUsage,
toolCallInputTokens, toolCallInputTokens,
toolCallOutputTokens, toolCallOutputTokens,
childTotalPoints: toolTotalPoints, childResponseCount: toolRuntimeSummary.childResponseCount,
model: modelName, model: modelName,
query: userChatInput, query: userChatInput,
historyPreview, historyPreview,
toolDetail,
mergeSignId: nodeId, mergeSignId: nodeId,
finishReason: finish_reason, finishReason: finish_reason,
llmRequestIds: requestIds llmRequestIds: requestIds
......
...@@ -2,9 +2,10 @@ import type { ...@@ -2,9 +2,10 @@ import type {
ChatCompletionMessageParam, ChatCompletionMessageParam,
CompletionFinishReason CompletionFinishReason
} from '@fastgpt/global/core/ai/llm/type'; } from '@fastgpt/global/core/ai/llm/type';
import type { ChildResponseItemType, DispatchToolModuleProps } from './type'; import type { DispatchToolModuleProps } from './type';
import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; import { GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt';
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import type { RuntimeNodeResponseSummary } from '../../type';
import { runAgentLoop } from '../../../../ai/llm/agentLoop'; import { runAgentLoop } from '../../../../ai/llm/agentLoop';
import type { import type {
ToolCallChildrenInteractive, ToolCallChildrenInteractive,
...@@ -18,7 +19,9 @@ import { useToolRunner } from './hooks/useToolRunner'; ...@@ -18,7 +19,9 @@ import { useToolRunner } from './hooks/useToolRunner';
type ResponseType = { type ResponseType = {
requestIds: string[]; requestIds: string[];
error?: string; error?: string;
toolDispatchFlowResponses: ChildResponseItemType[]; runtimeNodeResponseSummary: RuntimeNodeResponseSummary;
toolTotalPoints: number;
runTimes: number;
toolCallInputTokens: number; toolCallInputTokens: number;
toolCallOutputTokens: number; toolCallOutputTokens: number;
toolCallTotalPoints: number; // 每次 LLM 调用单独计价后的累计价格(用于梯度计费) toolCallTotalPoints: number; // 每次 LLM 调用单独计价后的累计价格(用于梯度计费)
...@@ -75,19 +78,21 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo ...@@ -75,19 +78,21 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
lang: workflowProps.lang, lang: workflowProps.lang,
appId: workflowProps.runningAppInfo.id, appId: workflowProps.runningAppInfo.id,
userId: workflowProps.uid, userId: workflowProps.uid,
chatId: workflowProps.chatId chatId: workflowProps.chatId,
sandboxId: workflowProps.runningAppInfo.sandboxId
}); });
// ToolCall 的一次运行会横跨 LLM loop、真实工具执行、SSE 预览和运行详情落库。 // ToolCall 的一次运行会横跨 LLM loop、真实工具执行、SSE 预览和运行详情落库。
// 这里按职责拆成 hook,toolCall.ts 只保留主流程编排。 // 这里按职责拆成 hook,toolCall.ts 只保留主流程编排。
const { const {
toolRunResponses, toolDispatchSummary,
cacheToolFlowResponse, cacheToolFlowResponse,
appendToolNodeResponse, appendToolNodeResponse,
appendToolFlowResponse, appendInteractiveToolSummary,
appendContextCompressNodeResponse appendContextCompressNodeResponse
} = useToolNodeResponse({ } = useToolNodeResponse({
moduleType: workflowProps.node.flowNodeType, moduleType: workflowProps.node.flowNodeType,
getToolInfo nodeResponseWriter: props.nodeResponseWriter,
nodeResponseParentId: workflowProps.nodeResponseParentId
}); });
const { streamReasoning, streamAnswer, streamToolCall, streamToolParams, streamToolResponse } = const { streamReasoning, streamAnswer, streamToolCall, streamToolParams, streamToolResponse } =
useToolStreamResponse({ useToolStreamResponse({
...@@ -104,7 +109,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo ...@@ -104,7 +109,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
fileUrls: fileUrlList, fileUrls: fileUrlList,
getToolInfo, getToolInfo,
cacheToolFlowResponse, cacheToolFlowResponse,
appendToolFlowResponse, appendInteractiveToolSummary,
streamToolResponse streamToolResponse
}); });
...@@ -202,7 +207,9 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo ...@@ -202,7 +207,9 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
return { return {
requestIds, requestIds,
error, error,
toolDispatchFlowResponses: toolRunResponses, runtimeNodeResponseSummary: toolDispatchSummary.runtimeNodeResponseSummary,
toolTotalPoints: toolDispatchSummary.toolTotalPoints,
runTimes: toolDispatchSummary.runTimes,
toolCallInputTokens: inputTokens, toolCallInputTokens: inputTokens,
toolCallOutputTokens: outputTokens, toolCallOutputTokens: outputTokens,
toolCallTotalPoints: llmTotalPoints, toolCallTotalPoints: llmTotalPoints,
......
...@@ -6,8 +6,10 @@ import { getLogger } from '@fastgpt-sdk/otel/logger'; ...@@ -6,8 +6,10 @@ import { getLogger } from '@fastgpt-sdk/otel/logger';
import { LogCategories } from '../../../../../../common/logger'; import { LogCategories } from '../../../../../../common/logger';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { i18nT } from '@fastgpt/global/common/i18n/utils';
import { sliceStrStartEnd } from '@fastgpt/global/common/string/tools';
import z from 'zod'; import z from 'zod';
import type { ChildResponseItemType } from '../type'; import type { ChildResponseItemType } from '../type';
import { summarizeRuntimeNodeResponses } from '../../../utils';
const logger = getLogger(LogCategories.MODULE.AI.TOOL_CALL); const logger = getLogger(LogCategories.MODULE.AI.TOOL_CALL);
...@@ -57,22 +59,32 @@ export const dispatchReadFileTool = async ({ ...@@ -57,22 +59,32 @@ export const dispatchReadFileTool = async ({
}: FileReadParams) => { }: FileReadParams) => {
const startTime = Date.now(); const startTime = Date.now();
const usages: ChatNodeUsageType[] = []; const usages: ChatNodeUsageType[] = [];
const getFlowResponse = (nodeResponse: Record<string, any> = {}): ChildResponseItemType => ({ const toolInput = {
flowResponses: [ ids: files.map((file) => file.id)
};
const getFlowResponse = (nodeResponse: Record<string, any> = {}): ChildResponseItemType => {
const flowResponses = [
{ {
...nodeResponse, ...nodeResponse,
moduleType: FlowNodeTypeEnum.readFiles, moduleType: FlowNodeTypeEnum.readFiles,
moduleName: i18nT('chat:read_file'), moduleName: i18nT('chat:read_file'),
moduleLogo: ReadFileTooData.avatar, moduleLogo: ReadFileTooData.avatar,
toolId: ReadFileTooData.id,
toolInput,
id: toolCallId, id: toolCallId,
nodeId: toolCallId, nodeId: toolCallId,
runningTime: +((Date.now() - startTime) / 1000).toFixed(2), runningTime: +((Date.now() - startTime) / 1000).toFixed(2),
totalPoints: usages.reduce((sum, item) => sum + item.totalPoints, 0) totalPoints: usages.reduce((sum, item) => sum + item.totalPoints, 0)
} }
], ];
flowUsages: usages,
runTimes: 0 return {
}); runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, flowResponses),
builtinNodeResponses: flowResponses,
flowUsages: usages,
runTimes: 0
};
};
try { try {
const readFilesResult = await Promise.all( const readFilesResult = await Promise.all(
...@@ -110,16 +122,27 @@ export const dispatchReadFileTool = async ({ ...@@ -110,16 +122,27 @@ export const dispatchReadFileTool = async ({
</file>` </file>`
) )
.join('\n'); .join('\n');
const readFilesResultPreview = readFilesResult
.map((file) => `## ${file.name}\n${sliceStrStartEnd(file.content, 1000, 1000)}`)
.join('\n\n');
return { return {
response, response,
usages, usages,
flowResponse: getFlowResponse() flowResponse: getFlowResponse({
toolRes: response,
readFiles: readFilesResult.map((file, index) => ({
name: file.name,
url: files[index]?.url ?? ''
})),
readFilesResult: readFilesResultPreview
})
}; };
} catch (error) { } catch (error) {
logger.error('[File Read] Compression failed, using original content', { error }); logger.error('[File Read] Compression failed, using original content', { error });
const response = `Failed to read file: ${getErrText(error)}`; const response = `Failed to read file: ${getErrText(error)}`;
const nodeResponse = { const nodeResponse = {
toolRes: response,
errorText: response errorText: response
}; };
......
...@@ -2,13 +2,15 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/typ ...@@ -2,13 +2,15 @@ import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/typ
import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import type { DispatchFlowResponse } from '../../type'; import type { RuntimeNodeResponseSummary } from '../../type';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import type { ChatHistoryItemResType, ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type'; import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema';
import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema'; import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema';
import type { ReasoningEffort } from '@fastgpt/global/core/ai/llm/type'; import type { ReasoningEffort } from '@fastgpt/global/core/ai/llm/type';
import type { AgentLoopChildrenInteractiveParams } from '../../../../ai/llm/agentLoop'; import type { AgentLoopChildrenInteractiveParams } from '../../../../ai/llm/agentLoop';
import type { WorkflowNodeResponseWriter } from '../../../../chat/nodeResponseStorage';
export type DispatchToolModuleProps = ModuleDispatchProps<{ export type DispatchToolModuleProps = ModuleDispatchProps<{
[NodeInputKeyEnum.history]?: ChatItemMiniType[]; [NodeInputKeyEnum.history]?: ChatItemMiniType[];
...@@ -39,6 +41,11 @@ export type DispatchToolModuleProps = ModuleDispatchProps<{ ...@@ -39,6 +41,11 @@ export type DispatchToolModuleProps = ModuleDispatchProps<{
allFiles: Map<string, FileInputType>; allFiles: Map<string, FileInputType>;
currentInputFiles: FileInputType[]; currentInputFiles: FileInputType[];
fileUrls?: string[]; fileUrls?: string[];
/**
* 工作流入口统一创建的 nodeResponse writer。ToolCall 内部会产生多个工具子流程详情,
* 必须复用同一个 writer 及时写库,避免完整 nodeResponse 在 LLM loop 中长期驻留。
*/
nodeResponseWriter?: WorkflowNodeResponseWriter;
}; };
export type ToolNodeItemType = { export type ToolNodeItemType = {
...@@ -54,9 +61,35 @@ export type ToolNodeItemType = { ...@@ -54,9 +61,35 @@ export type ToolNodeItemType = {
}; };
export type ChildResponseItemType = { export type ChildResponseItemType = {
flowResponses: DispatchFlowResponse['flowResponses']; /**
runTimes: DispatchFlowResponse['runTimes']; * ToolCall 运行期只保留 summary 做父节点统计和后续运行判断。
flowUsages: DispatchFlowResponse['flowUsages']; * 工具详情由 child workflow 复用共享 writer 直接写库,ToolCall 不缓存/重写第一层响应。
*/
runtimeNodeResponseSummary?: RuntimeNodeResponseSummary;
/**
* 仅内置工具使用。
*
* sandbox/file 不是 child workflow,不会天然复用 runWorkflow 的 writer 写库,因此它们需要
* 在 afterToolCall 阶段把自己构造的单层 nodeResponse 写到 ToolCall 父节点下。
* 普通 child workflow 工具禁止填充该字段,避免重新引入第一层响应缓存和重复写入。
*/
builtinNodeResponses?: ChatHistoryItemResType[];
runTimes?: number;
/**
* 子 workflow 完整返回时会携带 usage;fallback、空响应或局部工具结构可能没有。
* ToolCall 只在本地折算 totalPoints,对外不再返回该数组。
*/
flowUsages?: ChatNodeUsageType[];
};
export type ToolDispatchSummaryType = {
/**
* ToolCall 子流程的运行期摘要。完整 nodeResponse 由 writer 写库,这里只保留父节点统计、
* 运行控制和费用展示需要的轻量字段。
*/
runtimeNodeResponseSummary: RuntimeNodeResponseSummary;
runTimes: number;
toolTotalPoints: number;
}; };
export type FileInputType = { export type FileInputType = {
......
...@@ -43,17 +43,6 @@ export const filterToolResponseToPreview = (response: AIChatItemValueItemType[]) ...@@ -43,17 +43,6 @@ export const filterToolResponseToPreview = (response: AIChatItemValueItemType[])
}); });
}; };
export const formatToolResponse = (toolResponses: any) => {
if (typeof toolResponses === 'object') {
return JSON.stringify(toolResponses, null, 2);
}
/**
* 非对象的空结果给 LLM 一个稳定字符串,避免 undefined 被拼进上下文后语义不清。
*/
return toolResponses ? String(toolResponses) : 'none';
};
/** /**
* 这些 runtime edge/node 是为一次 tool workflow 派生出的副本。 * 这些 runtime edge/node 是为一次 tool workflow 派生出的副本。
* 初始化阶段直接在副本上标记入口状态,避免后续调度还要维护额外的 entry 集合。 * 初始化阶段直接在副本上标记入口状态,避免后续调度还要维护额外的 entry 集合。
......
...@@ -41,12 +41,12 @@ export const filterMemoryMessages = (messages: ChatCompletionMessageParam[]) => ...@@ -41,12 +41,12 @@ export const filterMemoryMessages = (messages: ChatCompletionMessageParam[]) =>
return messages.filter((item) => item.role !== ChatCompletionRequestMessageRoleEnum.System); return messages.filter((item) => item.role !== ChatCompletionRequestMessageRoleEnum.System);
}; };
export const formatToolResponse = (toolResponses: any) => { export const formatToolResponse = (toolResponse: any) => {
if (typeof toolResponses === 'object') { if (typeof toolResponse === 'object') {
return JSON.stringify(toolResponses, null, 2); return JSON.stringify(toolResponse, null, 2);
} }
return toolResponses ? String(toolResponses) : 'none'; return toolResponse ? String(toolResponse) : 'none';
}; };
/* /*
......
...@@ -23,6 +23,8 @@ import { getAppVersionById } from '../../../app/version/controller'; ...@@ -23,6 +23,8 @@ import { getAppVersionById } from '../../../app/version/controller';
import { parseUrlToFileType } from '../../utils/context'; import { parseUrlToFileType } from '../../utils/context';
import { getUserChatInfo } from '../../../../support/user/team/utils'; import { getUserChatInfo } from '../../../../support/user/team/utils';
import { getRunningUserInfoByTmbId } from '../../../../support/user/team/utils'; import { getRunningUserInfoByTmbId } from '../../../../support/user/team/utils';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { getRuntimeNodeResponseSummary } from '../utils';
type Props = ModuleDispatchProps<{ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.userChatInput]: string; [NodeInputKeyEnum.userChatInput]: string;
...@@ -30,7 +32,9 @@ type Props = ModuleDispatchProps<{ ...@@ -30,7 +32,9 @@ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.fileUrlList]?: string[]; [NodeInputKeyEnum.fileUrlList]?: string[];
[NodeInputKeyEnum.forbidStream]?: boolean; [NodeInputKeyEnum.forbidStream]?: boolean;
[NodeInputKeyEnum.fileUrlList]?: string[]; [NodeInputKeyEnum.fileUrlList]?: string[];
}>; }> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
};
type Response = DispatchNodeResultType<{ type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.answerText]: string; [NodeOutputKeyEnum.answerText]: string;
[NodeOutputKeyEnum.history]: ChatItemMiniType[]; [NodeOutputKeyEnum.history]: ChatItemMiniType[];
...@@ -140,13 +144,13 @@ export const dispatchRunAppNode = async (props: Props): Promise<Response> => { ...@@ -140,13 +144,13 @@ export const dispatchRunAppNode = async (props: Props): Promise<Response> => {
: runtimePrompt2ChatsValue({ files: userInputFiles, text: userChatInput }); : runtimePrompt2ChatsValue({ files: userInputFiles, text: userChatInput });
const { const {
flowResponses,
flowUsages, flowUsages,
assistantResponses, assistantResponses,
runTimes, runTimes,
workflowInteractiveResponse, workflowInteractiveResponse,
system_memories, system_memories,
customFeedbacks customFeedbacks,
runtimeNodeResponseSummary
} = await runWorkflow({ } = await runWorkflow({
...props, ...props,
lastInteractive: childrenInteractive, lastInteractive: childrenInteractive,
...@@ -193,6 +197,10 @@ export const dispatchRunAppNode = async (props: Props): Promise<Response> => { ...@@ -193,6 +197,10 @@ export const dispatchRunAppNode = async (props: Props): Promise<Response> => {
totalPoints: usagePoints totalPoints: usagePoints
} }
]); ]);
const runtimeSummary = getRuntimeNodeResponseSummary({
runtimeNodeResponseSummary
});
const childResponseCount = runtimeSummary.childResponseCount;
return { return {
data: { data: {
...@@ -216,10 +224,10 @@ export const dispatchRunAppNode = async (props: Props): Promise<Response> => { ...@@ -216,10 +224,10 @@ export const dispatchRunAppNode = async (props: Props): Promise<Response> => {
totalPoints: usagePoints, totalPoints: usagePoints,
query: userChatInput, query: userChatInput,
textOutput: text, textOutput: text,
pluginDetail: appData.permission.hasWritePer ? flowResponses : undefined, childResponseCount,
mergeSignId: props.node.nodeId mergeSignId: props.node.nodeId
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: text, [DispatchNodeResponseKeyEnum.toolResponse]: text,
[DispatchNodeResponseKeyEnum.customFeedbacks]: customFeedbacks [DispatchNodeResponseKeyEnum.customFeedbacks]: customFeedbacks
}; };
} catch (error) { } catch (error) {
......
...@@ -150,13 +150,27 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -150,13 +150,27 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
toolRes: res.error, toolRes: res.error,
moduleLogo: avatar moduleLogo: avatar
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: res.error [DispatchNodeResponseKeyEnum.toolResponse]: res.error
}; };
} }
logger.error('Tool Run Error', { error: res.error }); // String error(Common error, not custom)
throw res.error; if (typeof res.error === 'string') {
} logger.error('Tool Run Error', { error: res.error });
throw new Error(res.error);
}
// Custom error field
return {
error: res.error,
[DispatchNodeResponseKeyEnum.nodeResponse]: {
toolInput,
error: res.error,
moduleLogo: avatar
},
[DispatchNodeResponseKeyEnum.toolResponse]: res.error
};
}
const usagePoints = (() => { const usagePoints = (() => {
if ( if (
...@@ -193,7 +207,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -193,7 +207,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
moduleLogo: avatar, moduleLogo: avatar,
totalPoints: usagePoints totalPoints: usagePoints
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: result [DispatchNodeResponseKeyEnum.toolResponse]: result
}; };
} else if (toolConfig?.mcpTool?.toolId) { } else if (toolConfig?.mcpTool?.toolId) {
// pluginId: toolSetAppId/toolsetName/toolName // pluginId: toolSetAppId/toolsetName/toolName
...@@ -229,7 +243,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -229,7 +243,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
toolRes: result, toolRes: result,
moduleLogo: avatar moduleLogo: avatar
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: result [DispatchNodeResponseKeyEnum.toolResponse]: result
}; };
} else if (toolConfig?.httpTool?.toolId) { } else if (toolConfig?.httpTool?.toolId) {
const { parentId, toolName } = parseToolId(toolConfig.httpTool.toolId); const { parentId, toolName } = parseToolId(toolConfig.httpTool.toolId);
...@@ -275,7 +289,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -275,7 +289,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
toolRes: errorMsg, toolRes: errorMsg,
moduleLogo: avatar moduleLogo: avatar
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: errorMsg [DispatchNodeResponseKeyEnum.toolResponse]: errorMsg
}; };
} }
throw new Error(errorMsg); throw new Error(errorMsg);
...@@ -288,7 +302,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -288,7 +302,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
toolRes: data, toolRes: data,
moduleLogo: avatar moduleLogo: avatar
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: data [DispatchNodeResponseKeyEnum.toolResponse]: data
}; };
} else { } else {
// mcp tool (old version compatible) // mcp tool (old version compatible)
...@@ -315,7 +329,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo ...@@ -315,7 +329,7 @@ export const dispatchRunTool = async (props: RunToolProps): Promise<RunToolRespo
toolRes: result, toolRes: result,
moduleLogo: avatar moduleLogo: avatar
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: result [DispatchNodeResponseKeyEnum.toolResponse]: result
}; };
} }
} catch (error) { } catch (error) {
......
...@@ -105,7 +105,7 @@ export async function dispatchDatasetSearch( ...@@ -105,7 +105,7 @@ export async function dispatchDatasetSearch(
limit, limit,
searchMode searchMode
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: [] [DispatchNodeResponseKeyEnum.toolResponse]: []
}; };
const searchQueries = userChatInput ? [userChatInput] : datasetSearchInput; const searchQueries = userChatInput ? [userChatInput] : datasetSearchInput;
...@@ -289,10 +289,6 @@ export async function dispatchDatasetSearch( ...@@ -289,10 +289,6 @@ export async function dispatchDatasetSearch(
} }
} }
const totalPoints = nodeUsages.reduce((acc, item) => acc + item.totalPoints, 0); const totalPoints = nodeUsages.reduce((acc, item) => acc + item.totalPoints, 0);
const childTotalPoints = childrenResponses.reduce(
(sum, item) => sum + (item.totalPoints || 0),
0
);
props.usagePush(nodeUsages); props.usagePush(nodeUsages);
return { return {
...@@ -318,11 +314,10 @@ export async function dispatchDatasetSearch( ...@@ -318,11 +314,10 @@ export async function dispatchDatasetSearch(
searchUsingReRank, searchUsingReRank,
deepSearchResult, deepSearchResult,
...(childrenResponses.length > 0 ? { childrenResponses } : {}), ...(childrenResponses.length > 0 ? { childrenResponses } : {}),
...(childTotalPoints > 0 ? { childTotalPoints } : {}),
// Results // Results
quoteList: searchRes quoteList: searchRes
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: [DispatchNodeResponseKeyEnum.toolResponse]:
searchRes.length > 0 searchRes.length > 0
? { ? {
prompt: getDatasetSearchToolResponsePrompt(), prompt: getDatasetSearchToolResponsePrompt(),
......
...@@ -109,7 +109,7 @@ export const dispatchFormInput = async (props: Props): Promise<FormInputResponse ...@@ -109,7 +109,7 @@ export const dispatchFormInput = async (props: Props): Promise<FormInputResponse
[NodeOutputKeyEnum.formInputResult]: userInputVal [NodeOutputKeyEnum.formInputResult]: userInputVal
}, },
[DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), // Removes the current session record as the history of subsequent nodes [DispatchNodeResponseKeyEnum.rewriteHistories]: histories.slice(0, -2), // Removes the current session record as the history of subsequent nodes
[DispatchNodeResponseKeyEnum.toolResponses]: userInputVal, [DispatchNodeResponseKeyEnum.toolResponse]: userInputVal,
[DispatchNodeResponseKeyEnum.nodeResponse]: { [DispatchNodeResponseKeyEnum.nodeResponse]: {
formInputResult: userInputVal formInputResult: userInputVal
} }
......
...@@ -64,6 +64,6 @@ export const dispatchUserSelect = async (props: Props): Promise<UserSelectRespon ...@@ -64,6 +64,6 @@ export const dispatchUserSelect = async (props: Props): Promise<UserSelectRespon
[DispatchNodeResponseKeyEnum.nodeResponse]: { [DispatchNodeResponseKeyEnum.nodeResponse]: {
userSelectResult: userSelectedVal userSelectResult: userSelectedVal
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: userSelectedVal [DispatchNodeResponseKeyEnum.toolResponse]: userSelectedVal
}; };
}; };
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { DispatchFlowResponse } from '../type'; import type { DispatchFlowResponse } from '../type';
import { getRuntimeNodeResponseSummary } from '../utils';
// Returns undefined if nestedEnd was never reached (sub-workflow errored early). // Returns undefined if nestedEnd was never reached (sub-workflow errored early).
export const getNestedEndOutputValue = (response: DispatchFlowResponse): any => export const getNestedEndOutputValue = (response: DispatchFlowResponse): any =>
response.flowResponses.find((res) => res.moduleType === FlowNodeTypeEnum.nestedEnd) getRuntimeNodeResponseSummary(response).nestedEndOutput;
?.loopOutputValue;
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type';
type DispatchNodeResultType,
type ModuleDispatchProps
} from '@fastgpt/global/core/workflow/runtime/type';
type Props = ModuleDispatchProps<Record<string, never>>;
type Response = DispatchNodeResultType<Record<string, never>>; type Response = DispatchNodeResultType<Record<string, never>>;
// Signal-only node. The parent loopRun detects the moduleType in flowResponses // Signal-only node. The workflow runtime records its moduleType into
// to decide whether to terminate the loop. // runtimeNodeResponseSummary so the parent loopRun can terminate the loop.
export const dispatchLoopRunBreak = async (_props: Props): Promise<Response> => { export const dispatchLoopRunBreak = async (): Promise<Response> => {
return { return {
data: {}, data: {},
[DispatchNodeResponseKeyEnum.nodeResponse]: {} [DispatchNodeResponseKeyEnum.nodeResponse]: {}
......
...@@ -15,7 +15,6 @@ import type { ...@@ -15,7 +15,6 @@ import type {
FlowNodeInputItemType, FlowNodeInputItemType,
FlowNodeOutputItemType FlowNodeOutputItemType
} from '@fastgpt/global/core/workflow/type/io'; } 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'; import { LoopRunModeEnum } from '@fastgpt/global/core/workflow/template/system/loopRun/loopRun';
export type LoopRunHistoryItem = { export type LoopRunHistoryItem = {
...@@ -39,14 +38,6 @@ export const pickCustomOutputInputs = ( ...@@ -39,14 +38,6 @@ export const pickCustomOutputInputs = (
return inputs.filter((i) => i.canEdit === true && dynamicOutputKeys.has(i.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 * When `finishedNodeIds` is provided (failure iteration), refs whose target
* did not run resolve to undefined so stale values from earlier iterations * did not run resolve to undefined so stale values from earlier iterations
...@@ -87,8 +78,8 @@ export const readCustomOutputSnapshot = ({ ...@@ -87,8 +78,8 @@ export const readCustomOutputSnapshot = ({
if (!nodeId) return true; if (!nodeId) return true;
if (nodeId === VARIABLE_NODE_ID) return true; if (nodeId === VARIABLE_NODE_ID) return true;
// Refs to nodes outside the loop body (e.g. an outer 代码运行 whose // Refs to nodes outside the loop body (e.g. an outer 代码运行 whose
// output is being mutated via 变量更新) aren't in this iteration's // output is being mutated via 变量更新) aren't part of this iteration's
// flowResponses — exempt them from the skipped-branch guard. // finished-node summary — exempt them from the skipped-branch guard.
if (childrenSet && !childrenSet.has(nodeId)) return true; if (childrenSet && !childrenSet.has(nodeId)) return true;
return finishedNodeIds.has(nodeId); return finishedNodeIds.has(nodeId);
}); });
...@@ -139,15 +130,12 @@ export const injectLoopRunStart = ({ ...@@ -139,15 +130,12 @@ export const injectLoopRunStart = ({
} else if (input.key === NodeInputKeyEnum.nestedStartInput) { } else if (input.key === NodeInputKeyEnum.nestedStartInput) {
input.value = mode === LoopRunModeEnum.array ? item : undefined; input.value = mode === LoopRunModeEnum.array ? item : undefined;
} else if (input.key === NodeInputKeyEnum.nestedStartIndex) { } else if (input.key === NodeInputKeyEnum.nestedStartIndex) {
input.value = mode === LoopRunModeEnum.array ? index ?? 0 : iteration; input.value = mode === LoopRunModeEnum.array ? (index ?? 0) : iteration;
} }
}); });
}); });
}; };
export const isLoopBreakHit = (flowResponses: ChatHistoryItemResType[]): boolean =>
flowResponses.some((r) => r.moduleType === FlowNodeTypeEnum.loopRunBreak);
export const hasLoopRunBreakChild = ( export const hasLoopRunBreakChild = (
runtimeNodes: RuntimeNodeItemType[], runtimeNodes: RuntimeNodeItemType[],
childrenNodeIdList: string[] childrenNodeIdList: string[]
......
...@@ -9,6 +9,8 @@ import { ...@@ -9,6 +9,8 @@ import {
import { serviceEnv } from '../../../../env'; import { serviceEnv } from '../../../../env';
import { runWorkflow } from '..'; import { runWorkflow } from '..';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { getNodeResponseChildResponseCount } from '../../../chat/nodeResponseStorage';
import { import {
clampParallelConcurrency, clampParallelConcurrency,
clampParallelRetryTimes, clampParallelRetryTimes,
...@@ -16,6 +18,7 @@ import { ...@@ -16,6 +18,7 @@ import {
parseTaskResponse, parseTaskResponse,
parseTaskError, parseTaskError,
aggregateParallelResults, aggregateParallelResults,
type ParallelTaskResult,
type ParallelFullResultItem type ParallelFullResultItem
} from './service'; } from './service';
import { pushSubWorkflowUsage } from '../utils'; import { pushSubWorkflowUsage } from '../utils';
...@@ -25,7 +28,10 @@ type Props = ModuleDispatchProps<{ ...@@ -25,7 +28,10 @@ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.childrenNodeIdList]: string[]; [NodeInputKeyEnum.childrenNodeIdList]: string[];
[NodeInputKeyEnum.parallelRunMaxConcurrency]?: number; [NodeInputKeyEnum.parallelRunMaxConcurrency]?: number;
[NodeInputKeyEnum.parallelRunMaxRetryTimes]?: number; [NodeInputKeyEnum.parallelRunMaxRetryTimes]?: number;
}>; }> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
nodeResponseParentId?: string;
};
type Response = DispatchNodeResultType<{ type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.parallelSuccessResults]: Array<any>; [NodeOutputKeyEnum.parallelSuccessResults]: Array<any>;
...@@ -59,6 +65,8 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -59,6 +65,8 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
); );
const maxRetryAttempts = clampParallelRetryTimes(userRetryTimes); const maxRetryAttempts = clampParallelRetryTimes(userRetryTimes);
const attemptResults: ParallelTaskResult[] = [];
const taskResponseIdPrefix = props.nodeResponseParentId || node.nodeId;
const taskResults = await batchRun( const taskResults = await batchRun(
loopInputArray, loopInputArray,
...@@ -79,33 +87,57 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -79,33 +87,57 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
item, item,
index index
}); });
const taskResponseId =
maxRetryAttempts > 0
? `${taskResponseIdPrefix}_task_${index}_attempt_${attempt}`
: `${taskResponseIdPrefix}_task_${index}`;
try { try {
const response = await runWorkflow({ const response = await runWorkflow({
...props, ...props,
variableState: props.variableState.clone(), variableState: props.variableState.clone(),
nodeResponseParentId: taskResponseId,
runtimeNodes: taskRuntimeNodes, runtimeNodes: taskRuntimeNodes,
runtimeEdges: taskRuntimeEdges runtimeEdges: taskRuntimeEdges
}); });
// Push usage per attempt (resources were consumed regardless of success) // Push usage per attempt (resources were consumed regardless of success)
accumulatedPoints += pushSubWorkflowUsage({ const attemptPoints = pushSubWorkflowUsage({
usagePush: props.usagePush, usagePush: props.usagePush,
response, response,
name, name,
iteration: index iteration: index
}); });
accumulatedPoints += attemptPoints;
const result = parseTaskResponse({ index, response }); const result = parseTaskResponse({ index, response });
if (result.success) return { ...result, totalPoints: accumulatedPoints }; const attemptResult = {
...result,
taskResponseId,
totalPoints: attemptPoints
};
attemptResults.push(attemptResult);
if (result.success) {
return {
...result,
taskResponseId,
totalPoints: accumulatedPoints
};
}
// Non-retryable: interactive response will never succeed on retry // Non-retryable: interactive response will never succeed on retry
if (response.workflowInteractiveResponse) if (response.workflowInteractiveResponse)
return { ...result, totalPoints: accumulatedPoints }; return { ...result, taskResponseId, totalPoints: accumulatedPoints };
lastResult = { ...result, totalPoints: accumulatedPoints }; lastResult = { ...result, taskResponseId, totalPoints: accumulatedPoints };
} catch (err) { } catch (err) {
lastResult = { ...parseTaskError(index, err), totalPoints: accumulatedPoints }; const attemptResult = {
...parseTaskError(index, err),
taskResponseId,
totalPoints: 0
};
attemptResults.push(attemptResult);
lastResult = { ...attemptResult, totalPoints: accumulatedPoints };
} }
// taskRuntimeNodes / taskRuntimeEdges go out of scope → GC // taskRuntimeNodes / taskRuntimeEdges go out of scope → GC
} }
...@@ -121,16 +153,32 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -121,16 +153,32 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
fullDetail, fullDetail,
status, status,
totalPoints, totalPoints,
responseDetails, attemptResponseDetails,
assistantResponses, assistantResponses,
customFeedbacks customFeedbacks
} = aggregateParallelResults( } = aggregateParallelResults(
taskResults.filter((item) => item !== undefined), taskResults.filter((item) => item !== undefined),
{ {
taskInputs: loopInputArray, taskInputs: loopInputArray,
parentNodeId: node.nodeId parentNodeId: node.nodeId,
attemptResults
} }
); );
// 任务包装节点只通过 writer/event 输出;父 parallelRun 只保留轻量统计和业务摘要。
const rootChildResponseCount = getNodeResponseChildResponseCount(attemptResponseDetails);
if (props.nodeResponseWriter) {
for (const detail of attemptResponseDetails) {
await props.nodeResponseWriter.recordWithParent(
[
{
...detail,
childrenResponses: undefined
}
],
props.nodeResponseParentId
);
}
}
return { return {
data: { data: {
...@@ -144,7 +192,7 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => { ...@@ -144,7 +192,7 @@ export const dispatchParallelRun = async (props: Props): Promise<Response> => {
parallelInput: loopInputArray, parallelInput: loopInputArray,
parallelResult: filteredArray, parallelResult: filteredArray,
parallelRunDetail: fullDetail, parallelRunDetail: fullDetail,
parallelDetail: responseDetails, childResponseCount: rootChildResponseCount,
mergeSignId: node.nodeId mergeSignId: node.nodeId
}, },
[DispatchNodeResponseKeyEnum.customFeedbacks]: [DispatchNodeResponseKeyEnum.customFeedbacks]:
......
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { ParallelRunStatusEnum } from '@fastgpt/global/core/workflow/constants'; import { ParallelRunStatusEnum } from '@fastgpt/global/core/workflow/constants';
import { collectResponseFeedbacks, injectNestedStartInputs, safePoints } from '../utils'; import {
collectResponseFeedbacks,
getRuntimeNodeResponseSummary,
injectNestedStartInputs
} from '../utils';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { i18nT } from '@fastgpt/global/common/i18n/utils';
...@@ -101,13 +105,21 @@ export const buildTaskRuntimeContext = ( ...@@ -101,13 +105,21 @@ export const buildTaskRuntimeContext = (
// ─── 4. parseTaskResponse & parseTaskError ──────────────────────────────────── // ─── 4. parseTaskResponse & parseTaskError ────────────────────────────────────
export type ParallelTaskResult = export type ParallelTaskResult =
| { success: true; index: number; data: any; response: DispatchFlowResponse; totalPoints: number } | {
success: true;
index: number;
data: any;
response: DispatchFlowResponse;
totalPoints: number;
taskResponseId?: string;
}
| { | {
success: false; success: false;
index: number; index: number;
error?: string; error?: string;
response?: DispatchFlowResponse; response?: DispatchFlowResponse;
totalPoints: number; totalPoints: number;
taskResponseId?: string;
}; };
/** /**
...@@ -116,8 +128,7 @@ export type ParallelTaskResult = ...@@ -116,8 +128,7 @@ export type ParallelTaskResult =
* *
* Note: runWorkflow always resolves (never rejects), so node-level errors are * Note: runWorkflow always resolves (never rejects), so node-level errors are
* detected here by checking whether the nestedEnd node was actually reached. * detected here by checking whether the nestedEnd node was actually reached.
* If nestedEnd is absent from flowResponses, the sub-workflow terminated early * 新 writer 链路不会再返回完整 nodeResponse 列表,因此运行判断只读 runtimeNodeResponseSummary。
* (e.g. a node threw an error), and the task is considered failed.
*/ */
export const parseTaskResponse = (params: { export const parseTaskResponse = (params: {
index: number; index: number;
...@@ -136,25 +147,27 @@ export const parseTaskResponse = (params: { ...@@ -136,25 +147,27 @@ export const parseTaskResponse = (params: {
}; };
} }
const loopEndResponse = response.flowResponses.find( const runtimeNodeResponseSummary = getRuntimeNodeResponseSummary(response);
(r) => r.moduleType === FlowNodeTypeEnum.nestedEnd const hasNestedEnd = runtimeNodeResponseSummary.hasNestedEnd;
); const nestedEndOutput = runtimeNodeResponseSummary.nestedEndOutput;
// nestedEnd was not reached → sub-workflow terminated with an error // nestedEnd was not reached → sub-workflow terminated with an error
if (!loopEndResponse) { if (!hasNestedEnd) {
const errorResponse = response.flowResponses.find((r) => r.error);
const err = errorResponse?.error;
return { return {
success: false, success: false,
index, index,
error: getErrText(err, i18nT('workflow:parallel_task_not_reach_end')), error: getErrText(
runtimeNodeResponseSummary.errorText,
i18nT('workflow:parallel_task_not_reach_end')
),
response, response,
totalPoints: 0 totalPoints: 0
}; };
} }
const totalPoints = response.flowResponses.reduce((acc, r) => acc + safePoints(r.totalPoints), 0); // 保持 main 分支旧口径:成功任务的 totalPoints 是子流程各 nodeResponse.totalPoints 之和。
return { success: true, index, data: loopEndResponse.loopOutputValue, response, totalPoints }; const totalPoints = runtimeNodeResponseSummary.totalPoints ?? 0;
return { success: true, index, data: nestedEndOutput, response, totalPoints };
}; };
/** /**
...@@ -194,10 +207,42 @@ export type AggregatedParallelResults = { ...@@ -194,10 +207,42 @@ export type AggregatedParallelResults = {
status: ParallelRunStatusEnum; status: ParallelRunStatusEnum;
totalPoints: number; totalPoints: number;
responseDetails: ChatHistoryItemResType[]; responseDetails: ChatHistoryItemResType[];
/** 每次 attempt 的展示 wrapper,包含失败重试记录;业务输出仍只使用每个 input 的最终结果。 */
attemptResponseDetails: ChatHistoryItemResType[];
assistantResponses: AIChatItemValueItemType[]; assistantResponses: AIChatItemValueItemType[];
customFeedbacks: string[]; customFeedbacks: string[];
}; };
const buildParallelTaskWrapper = ({
result,
input,
parentNodeId
}: {
result: ParallelTaskResult;
input: any;
parentNodeId: string;
}): ChatHistoryItemResType => {
const runtimeSummary = result.response
? getRuntimeNodeResponseSummary(result.response)
: undefined;
const runningTime = runtimeSummary?.runningTime || 0;
const taskNodeId = result.taskResponseId || `${parentNodeId}_task_${result.index}`;
return {
id: taskNodeId,
nodeId: taskNodeId,
moduleType: FlowNodeTypeEnum.parallelRun,
moduleName: i18nT('workflow:parallel_task'),
moduleNameArgs: { index: result.index + 1 },
runningTime: Math.round(runningTime * 100) / 100,
totalPoints: result.totalPoints,
loopInputValue: input,
loopOutputValue: result.success ? result.data : undefined,
error: result.success ? undefined : result.error,
childResponseCount: runtimeSummary?.childResponseCount
};
};
/** /**
* Aggregate all parallel task results: * Aggregate all parallel task results:
* - filteredArray: only successful items * - filteredArray: only successful items
...@@ -205,8 +250,8 @@ export type AggregatedParallelResults = { ...@@ -205,8 +250,8 @@ export type AggregatedParallelResults = {
* - totalPoints, responseDetails, assistantResponses, customFeedbacks: merged from successful tasks * - totalPoints, responseDetails, assistantResponses, customFeedbacks: merged from successful tasks
* *
* responseDetails 返回"按任务聚合"的虚拟节点列表:每次任务包装成一个 * responseDetails 返回"按任务聚合"的虚拟节点列表:每次任务包装成一个
* ChatHistoryItemResType,子工作流节点挂在 childrenResponses 下, * ChatHistoryItemResType,并只保留 childResponseCount 等轻量结构统计。
* 方便 UI 按任务维度折叠展示(而非平铺所有子节点) * 完整子节点详情由 writer 写入 DB,详情接口再按 parentId 拼回 childrenResponses
*/ */
export const aggregateParallelResults = ( export const aggregateParallelResults = (
taskResults: ParallelTaskResult[], taskResults: ParallelTaskResult[],
...@@ -215,6 +260,8 @@ export const aggregateParallelResults = ( ...@@ -215,6 +260,8 @@ export const aggregateParallelResults = (
taskInputs: any[]; taskInputs: any[];
/** 并行节点 nodeId,用于生成任务虚拟节点的唯一 id */ /** 并行节点 nodeId,用于生成任务虚拟节点的唯一 id */
parentNodeId: string; parentNodeId: string;
/** 所有 attempt 的运行结果;传入时用于展示失败重试记录,业务输出仍只看 taskResults。 */
attemptResults?: ParallelTaskResult[];
} }
): AggregatedParallelResults => { ): AggregatedParallelResults => {
// Sort by input index so all output arrays are in input order // Sort by input index so all output arrays are in input order
...@@ -226,6 +273,18 @@ export const aggregateParallelResults = ( ...@@ -226,6 +273,18 @@ export const aggregateParallelResults = (
let totalPoints = 0; let totalPoints = 0;
let successCount = 0; let successCount = 0;
const responseDetails: ChatHistoryItemResType[] = []; const responseDetails: ChatHistoryItemResType[] = [];
const attemptResponseDetails = [...(opts.attemptResults || taskResults)]
.sort((a, b) => {
if (a.index !== b.index) return a.index - b.index;
return (a.taskResponseId || '').localeCompare(b.taskResponseId || '');
})
.map((result) =>
buildParallelTaskWrapper({
result,
input: opts.taskInputs[result.index],
parentNodeId: opts.parentNodeId
})
);
const assistantResponses: AIChatItemValueItemType[] = []; const assistantResponses: AIChatItemValueItemType[] = [];
const customFeedbacks: string[] = []; const customFeedbacks: string[] = [];
...@@ -243,28 +302,14 @@ export const aggregateParallelResults = ( ...@@ -243,28 +302,14 @@ export const aggregateParallelResults = (
// totalPoints is pre-accumulated across all retry attempts in the caller // totalPoints is pre-accumulated across all retry attempts in the caller
totalPoints += result.totalPoints; totalPoints += result.totalPoints;
const childrenResponses: ChatHistoryItemResType[] = result.response?.flowResponses ?? []; responseDetails.push(
const runningTime = childrenResponses.reduce( buildParallelTaskWrapper({
(acc, r) => acc + (typeof r.runningTime === 'number' ? r.runningTime : 0), result,
0 input: opts.taskInputs[result.index],
parentNodeId: opts.parentNodeId
})
); );
const taskNodeId = `${opts.parentNodeId}_task_${result.index}`;
const taskWrapper: ChatHistoryItemResType = {
id: taskNodeId,
nodeId: taskNodeId,
moduleType: FlowNodeTypeEnum.parallelRun,
moduleName: i18nT('workflow:parallel_task'),
moduleNameArgs: { index: result.index + 1 },
runningTime: Math.round(runningTime * 100) / 100,
totalPoints: result.totalPoints,
loopInputValue: opts.taskInputs[result.index],
loopOutputValue: result.success ? result.data : undefined,
error: result.success ? undefined : result.error,
childrenResponses
};
responseDetails.push(taskWrapper);
if (result.response) { if (result.response) {
const response = result.response; const response = result.response;
assistantResponses.push(...(response[DispatchNodeResponseKeyEnum.assistantResponses] || [])); assistantResponses.push(...(response[DispatchNodeResponseKeyEnum.assistantResponses] || []));
...@@ -287,6 +332,7 @@ export const aggregateParallelResults = ( ...@@ -287,6 +332,7 @@ export const aggregateParallelResults = (
status, status,
totalPoints, totalPoints,
responseDetails, responseDetails,
attemptResponseDetails,
assistantResponses, assistantResponses,
customFeedbacks customFeedbacks
}; };
......
...@@ -32,11 +32,15 @@ import { getAppVersionById } from '../../../app/version/controller'; ...@@ -32,11 +32,15 @@ import { getAppVersionById } from '../../../app/version/controller';
import { parseI18nString } from '@fastgpt/global/common/i18n/utils'; import { parseI18nString } from '@fastgpt/global/common/i18n/utils';
import { WorkflowVariableState } from '../utils/variables'; import { WorkflowVariableState } from '../utils/variables';
import { SystemToolRepo } from '../../../app/tool/systemTool/systemTool.repo'; import { SystemToolRepo } from '../../../app/tool/systemTool/systemTool.repo';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { getRuntimeNodeResponseSummary } from '../utils';
type RunPluginProps = ModuleDispatchProps<{ type RunPluginProps = ModuleDispatchProps<{
[NodeInputKeyEnum.forbidStream]?: boolean; [NodeInputKeyEnum.forbidStream]?: boolean;
[key: string]: any; [key: string]: any;
}>; }> & {
nodeResponseWriter?: WorkflowNodeResponseWriter;
};
type RunPluginResponse = DispatchNodeResultType< type RunPluginResponse = DispatchNodeResultType<
{ {
[key: string]: any; [key: string]: any;
...@@ -141,7 +145,8 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -141,7 +145,8 @@ 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,
hasTokenFee: !!systemTool.hasTokenFee hasTokenFee: !!systemTool.hasTokenFee,
associatedPluginId: systemTool.associatedPluginId
}; };
} }
...@@ -207,15 +212,18 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -207,15 +212,18 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
sourceVariableState: props.variableState sourceVariableState: props.variableState
}); });
const runtimeVariables = childVariableState.toRuntimeRecord(); const runtimeVariables = childVariableState.toRuntimeRecord();
const shouldStoreChildNodeResponses = !workflowTool.associatedPluginId;
const { const {
flowResponses,
flowUsages, flowUsages,
assistantResponses, assistantResponses,
runTimes, runTimes,
system_memories, system_memories,
runtimeNodeResponseSummary,
[DispatchNodeResponseKeyEnum.customFeedbacks]: customFeedbacks [DispatchNodeResponseKeyEnum.customFeedbacks]: customFeedbacks
} = await runWorkflow({ } = await runWorkflow({
...props, ...props,
// 系统级 workflow tool 只保留工具节点自身的响应,不展开保存其内部 workflow 详情。
...(shouldStoreChildNodeResponses ? {} : { nodeResponseWriter: undefined }),
// Rewrite stream mode // Rewrite stream mode
...(system_forbid_stream ...(system_forbid_stream
? { ? {
...@@ -241,12 +249,15 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -241,12 +249,15 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
runtimeNodes, runtimeNodes,
runtimeEdges: storeEdges2RuntimeEdges(workflowTool.edges) runtimeEdges: storeEdges2RuntimeEdges(workflowTool.edges)
}); });
const output = flowResponses.find((item) => item.moduleType === FlowNodeTypeEnum.pluginOutput); const runtimeSummary = getRuntimeNodeResponseSummary({
runtimeNodeResponseSummary
});
const pluginOutput = runtimeSummary.pluginOutput;
const usagePoints = await computedAppToolUsage({ const usagePoints = await computedAppToolUsage({
plugin: workflowTool, plugin: workflowTool,
childrenUsage: flowUsages, childrenUsage: flowUsages,
error: !!output?.pluginOutput?.error error: !!pluginOutput?.error
}); });
// Child run not push usage // Child run not push usage
props.usagePush([ props.usagePush([
...@@ -255,9 +266,10 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -255,9 +266,10 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
totalPoints: usagePoints totalPoints: usagePoints
} }
]); ]);
const childResponseCount = runtimeSummary.childResponseCount;
return { return {
data: output ? output.pluginOutput : {}, data: pluginOutput || {},
// 嵌套运行时,如果 childApp stream=false,实际上不会有任何内容输出给用户,所以不需要存储 // 嵌套运行时,如果 childApp stream=false,实际上不会有任何内容输出给用户,所以不需要存储
assistantResponses: system_forbid_stream ? [] : assistantResponses, assistantResponses: system_forbid_stream ? [] : assistantResponses,
system_memories, system_memories,
...@@ -267,16 +279,14 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi ...@@ -267,16 +279,14 @@ export const dispatchRunPlugin = async (props: RunPluginProps): Promise<RunPlugi
moduleLogo: workflowTool.avatar, moduleLogo: workflowTool.avatar,
totalPoints: usagePoints, totalPoints: usagePoints,
toolInput: data, toolInput: data,
pluginOutput: output?.pluginOutput, pluginOutput,
pluginDetail: toolData?.permission?.hasWritePer // Not system workflowTool childResponseCount
? flowResponses
: undefined
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: output?.pluginOutput [DispatchNodeResponseKeyEnum.toolResponse]: pluginOutput
? Object.keys(output.pluginOutput) ? Object.keys(pluginOutput)
.filter((key) => outputFilterMap[key]) .filter((key) => outputFilterMap[key])
.reduce<Record<string, any>>((acc, key) => { .reduce<Record<string, any>>((acc, key) => {
acc[key] = output.pluginOutput![key]; acc[key] = pluginOutput[key];
return acc; return acc;
}, {}) }, {})
: undefined, : undefined,
......
...@@ -5,12 +5,13 @@ import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runti ...@@ -5,12 +5,13 @@ import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runti
export type PluginOutputProps = ModuleDispatchProps<{ export type PluginOutputProps = ModuleDispatchProps<{
[key: string]: any; [key: string]: any;
}>; }>;
export type PluginOutputResponse = DispatchNodeResultType<{}>; export type PluginOutputResponse = DispatchNodeResultType<Record<string, any>>;
export const dispatchPluginOutput = (props: PluginOutputProps): PluginOutputResponse => { export const dispatchPluginOutput = (props: PluginOutputProps): PluginOutputResponse => {
const { params } = props; const { params } = props;
return { return {
[DispatchNodeResponseKeyEnum.toolResponse]: params,
[DispatchNodeResponseKeyEnum.nodeResponse]: { [DispatchNodeResponseKeyEnum.nodeResponse]: {
pluginOutput: params pluginOutput: params
} }
......
...@@ -37,6 +37,6 @@ export const dispatchAnswer = (props: Record<string, any>): AnswerResponse => { ...@@ -37,6 +37,6 @@ export const dispatchAnswer = (props: Record<string, any>): AnswerResponse => {
[DispatchNodeResponseKeyEnum.nodeResponse]: { [DispatchNodeResponseKeyEnum.nodeResponse]: {
textOutput: formatText textOutput: formatText
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: responseText [DispatchNodeResponseKeyEnum.toolResponse]: responseText
}; };
}; };
...@@ -59,7 +59,7 @@ export const dispatchCodeSandbox = async (props: RunCodeType): Promise<RunCodeRe ...@@ -59,7 +59,7 @@ export const dispatchCodeSandbox = async (props: RunCodeType): Promise<RunCodeRe
customOutputs: codeReturn, customOutputs: codeReturn,
codeLog: log codeLog: log
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: codeReturn [DispatchNodeResponseKeyEnum.toolResponse]: codeReturn
}; };
} catch (error) { } catch (error) {
const text = getErrText(error, 'Request code sandbox failed'); const text = getErrText(error, 'Request code sandbox failed');
......
...@@ -62,7 +62,7 @@ type HttpResponse = DispatchNodeResultType< ...@@ -62,7 +62,7 @@ type HttpResponse = DispatchNodeResultType<
const UNDEFINED_SIGN = 'UNDEFINED_SIGN'; const UNDEFINED_SIGN = 'UNDEFINED_SIGN';
export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<HttpResponse> => { export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<HttpResponse> => {
let { const {
runningAppInfo: { id: appId }, runningAppInfo: { id: appId },
chatId, chatId,
responseChatItemId, responseChatItemId,
...@@ -71,21 +71,21 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H ...@@ -71,21 +71,21 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
runtimeNodesMap, runtimeNodesMap,
histories, histories,
params: { params: {
system_httpMethod: httpMethod = 'POST',
system_httpReqUrl: httpReqUrl, system_httpReqUrl: httpReqUrl,
system_httpHeader: httpHeader = [], system_httpHeader: httpHeader = [],
system_httpParams: httpParams = [], system_httpParams: httpParams = [],
system_httpJsonBody: httpJsonBody = '', system_httpJsonBody: httpJsonBody = '',
system_httpFormBody: httpFormBody = [], system_httpFormBody: httpFormBody = [],
system_httpContentType: httpContentType = ContentTypes.json,
system_httpTimeout: httpTimeout = 60,
system_header_secret: headerSecret,
[NodeInputKeyEnum.addInputParam]: dynamicInput,
...body ...body
} }
} = props; } = props;
const httpMethod = props.params.system_httpMethod || 'POST';
const httpContentType = props.params.system_httpContentType || ContentTypes.json;
const httpTimeout = props.params.system_httpTimeout || 60;
const headerSecret = props.params.system_header_secret;
let requestUrl = httpReqUrl;
if (!httpReqUrl) { if (!requestUrl) {
return Promise.reject('Http url is empty'); return Promise.reject('Http url is empty');
} }
...@@ -101,8 +101,8 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H ...@@ -101,8 +101,8 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
...systemVariables ...systemVariables
}; };
const allVariables: Record<string, any> = { const allVariables: Record<string, any> = {
[NodeInputKeyEnum.addInputParam]: concatVariables, ...concatVariables,
...concatVariables [NodeInputKeyEnum.addInputParam]: concatVariables
}; };
// General data for variable substitution(Exclude: json body) // General data for variable substitution(Exclude: json body)
...@@ -114,22 +114,22 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H ...@@ -114,22 +114,22 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
}); });
}; };
httpReqUrl = replaceStringVariables(httpReqUrl); requestUrl = replaceStringVariables(requestUrl);
const publicHeaders = await (async () => { const publicHeaders = await (async () => {
try { try {
const contentType = contentTypeMap[httpContentType]; const contentType = contentTypeMap[httpContentType];
if (contentType) { const requestHeaders = contentType
httpHeader = [{ key: 'Content-Type', value: contentType, type: 'string' }, ...httpHeader]; ? [{ key: 'Content-Type', value: contentType, type: 'string' }, ...httpHeader]
} : httpHeader;
return httpHeader.reduce((acc: Record<string, string>, item) => { return requestHeaders.reduce((acc: Record<string, string>, item) => {
const key = replaceStringVariables(item.key); const key = replaceStringVariables(item.key);
const value = replaceStringVariables(item.value); const value = replaceStringVariables(item.value);
acc[key] = valueTypeFormat(value, WorkflowIOValueTypeEnum.string); acc[key] = valueTypeFormat(value, WorkflowIOValueTypeEnum.string);
return acc; return acc;
}, {}); }, {});
} catch (error) { } catch {
return Promise.reject('Header 为非法 JSON 格式'); return Promise.reject('Header 为非法 JSON 格式');
} }
})(); })();
...@@ -149,46 +149,46 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H ...@@ -149,46 +149,46 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
try { try {
if (httpContentType === ContentTypes.formData) { if (httpContentType === ContentTypes.formData) {
if (!Array.isArray(httpFormBody)) return {}; if (!Array.isArray(httpFormBody)) return {};
httpFormBody = httpFormBody.map((item) => ({ const formBody = httpFormBody.map((item) => ({
key: replaceStringVariables(item.key), key: replaceStringVariables(item.key),
type: item.type, type: item.type,
value: replaceStringVariables(item.value) value: replaceStringVariables(item.value)
})); }));
const formData = new FormData(); const formData = new FormData();
for (const { key, value } of httpFormBody) { for (const { key, value } of formBody) {
formData.append(key, value); formData.append(key, value);
} }
return formData; return formData;
} }
if (httpContentType === ContentTypes.xWwwFormUrlencoded) { if (httpContentType === ContentTypes.xWwwFormUrlencoded) {
if (!Array.isArray(httpFormBody)) return {}; if (!Array.isArray(httpFormBody)) return {};
httpFormBody = httpFormBody.map((item) => ({ const formBody = httpFormBody.map((item) => ({
key: replaceStringVariables(item.key), key: replaceStringVariables(item.key),
type: item.type, type: item.type,
value: replaceStringVariables(item.value) value: replaceStringVariables(item.value)
})); }));
const urlSearchParams = new URLSearchParams(); const urlSearchParams = new URLSearchParams();
for (const { key, value } of httpFormBody) { for (const { key, value } of formBody) {
urlSearchParams.append(key, value); urlSearchParams.append(key, value);
} }
return urlSearchParams; return urlSearchParams;
} }
if (!httpJsonBody) return {}; if (!httpJsonBody) return {};
if (httpContentType === ContentTypes.json) { if (httpContentType === ContentTypes.json) {
httpJsonBody = replaceJsonBodyString( const jsonBody = replaceJsonBodyString(
{ text: httpJsonBody }, { text: httpJsonBody },
{ {
allVariables, allVariables,
runtimeNodesMap runtimeNodesMap
} }
); );
return json5.parse(httpJsonBody); return json5.parse(jsonBody);
} }
// Raw text, xml // Raw text, xml
httpJsonBody = replaceStringVariables(httpJsonBody); const rawBody = replaceStringVariables(httpJsonBody);
return httpJsonBody.replaceAll(UNDEFINED_SIGN, 'null'); return rawBody.replaceAll(UNDEFINED_SIGN, 'null');
} catch (error) { } catch {
return Promise.reject(`Invalid JSON body: ${httpJsonBody}`); return Promise.reject(`Invalid JSON body: ${httpJsonBody}`);
} }
})(); })();
...@@ -213,7 +213,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H ...@@ -213,7 +213,7 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
const { formatResponse, rawResponse } = await (async () => { const { formatResponse, rawResponse } = await (async () => {
return fetchData({ return fetchData({
method: httpMethod, method: httpMethod,
url: httpReqUrl, url: requestUrl,
headers: { ...sensitiveHeaders, ...publicHeaders }, headers: { ...sensitiveHeaders, ...publicHeaders },
body: requestBody, body: requestBody,
params, params,
...@@ -264,11 +264,11 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H ...@@ -264,11 +264,11 @@ export const dispatchHttp468Request = async (props: HttpRequestProps): Promise<H
headers: Object.keys(publicHeaders).length > 0 ? publicHeaders : undefined, headers: Object.keys(publicHeaders).length > 0 ? publicHeaders : undefined,
httpResult: rawResponse httpResult: rawResponse
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: [DispatchNodeResponseKeyEnum.toolResponse]:
Object.keys(results).length > 0 ? results : rawResponse Object.keys(results).length > 0 ? results : rawResponse
}; };
} catch (error) { } catch (error) {
logger.warn('HTTP tool request failed', { error, httpReqUrl }); logger.warn('HTTP tool request failed', { error, httpReqUrl: requestUrl });
// @adapt // @adapt
if (node.catchError === undefined) { if (node.catchError === undefined) {
...@@ -356,7 +356,7 @@ export const replaceJsonBodyString = ( ...@@ -356,7 +356,7 @@ export const replaceJsonBodyString = (
try { try {
JSON.parse(val); JSON.parse(val);
return val; return val;
} catch (error) { } catch {
const str = JSON.stringify(val); const str = JSON.stringify(val);
return str.startsWith('"') && str.endsWith('"') ? str.slice(1, -1) : str; return str.startsWith('"') && str.endsWith('"') ? str.slice(1, -1) : str;
} }
......
...@@ -73,7 +73,7 @@ export const dispatchReadFiles = async (props: Props): Promise<Response> => { ...@@ -73,7 +73,7 @@ export const dispatchReadFiles = async (props: Props): Promise<Response> => {
})), })),
readFilesResult: getPreviewResponse readFilesResult: getPreviewResponse
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: { [DispatchNodeResponseKeyEnum.toolResponse]: {
fileContent: text fileContent: text
} }
}; };
......
...@@ -25,7 +25,7 @@ const UNDEFINED_SIGN = 'UNDEFINED_SIGN'; ...@@ -25,7 +25,7 @@ const UNDEFINED_SIGN = 'UNDEFINED_SIGN';
const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS); const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS);
export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafResponse> => { export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafResponse> => {
let { const {
runningAppInfo: { id: appId }, runningAppInfo: { id: appId },
chatId, chatId,
responseChatItemId, responseChatItemId,
...@@ -39,8 +39,9 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes ...@@ -39,8 +39,9 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes
} }
} = props; } = props;
const variables = variableState.toRuntimeRecord(); const variables = variableState.toRuntimeRecord();
let requestUrl = httpReqUrl;
if (!httpReqUrl) { if (!requestUrl) {
return Promise.reject('Http url is empty'); return Promise.reject('Http url is empty');
} }
...@@ -54,7 +55,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes ...@@ -54,7 +55,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes
histories: histories?.slice(-10) || [] histories: histories?.slice(-10) || []
}; };
httpReqUrl = replaceVariable(httpReqUrl, concatVariables); requestUrl = replaceVariable(requestUrl, concatVariables);
const requestBody = { const requestBody = {
systemParams: { systemParams: {
...@@ -71,7 +72,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes ...@@ -71,7 +72,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes
try { try {
const { formatResponse, rawResponse } = await fetchData({ const { formatResponse, rawResponse } = await fetchData({
method: 'POST', method: 'POST',
url: httpReqUrl, url: requestUrl,
body: requestBody body: requestBody
}); });
...@@ -94,7 +95,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes ...@@ -94,7 +95,7 @@ export const dispatchLafRequest = async (props: LafRequestProps): Promise<LafRes
body: Object.keys(requestBody).length > 0 ? requestBody : undefined, body: Object.keys(requestBody).length > 0 ? requestBody : undefined,
httpResult: rawResponse httpResult: rawResponse
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: rawResponse [DispatchNodeResponseKeyEnum.toolResponse]: rawResponse
}; };
} catch (error) { } catch (error) {
logger.warn('Laf tool request failed', { error }); logger.warn('Laf tool request failed', { error });
...@@ -191,25 +192,6 @@ function replaceVariable(text: string, obj: Record<string, any>) { ...@@ -191,25 +192,6 @@ function replaceVariable(text: string, obj: Record<string, any>) {
} }
return text || ''; return text || '';
} }
function removeUndefinedSign(obj: Record<string, any>) {
for (const key in obj) {
if (obj[key] === UNDEFINED_SIGN) {
obj[key] = undefined;
} else if (Array.isArray(obj[key])) {
obj[key] = obj[key].map((item: any) => {
if (item === UNDEFINED_SIGN) {
return undefined;
} else if (typeof item === 'object') {
removeUndefinedSign(item);
}
return item;
});
} else if (typeof obj[key] === 'object') {
removeUndefinedSign(obj[key]);
}
}
return obj;
}
function formatHttpError(error: any) { function formatHttpError(error: any) {
return { return {
message: error?.message, message: error?.message,
......
...@@ -14,8 +14,51 @@ import type { ...@@ -14,8 +14,51 @@ import type {
} from '@fastgpt/global/core/workflow/template/system/interactive/type'; } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { type RuntimeEdgeItemType } from '@fastgpt/global/core/workflow/type/edge'; import { type RuntimeEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type';
import type { NodeResponseWriteSummary } from '../../chat/nodeResponseStorage';
import z from 'zod'; import z from 'zod';
/**
* workflow 内部运行期使用的 nodeResponse 摘要。
*
* 启用 `WorkflowNodeResponseWriter` 后,完整 nodeResponse 会在节点完成时立即写入
* `chat_item_responses`,并且不再通过 `runWorkflow` 返回。父 workflow 仍需要少量
* child 节点信号来继续调度、聚合虚拟节点和处理重试,因此这些字段会在每次节点写库后
* 由 `summarizeRuntimeNodeResponses` 从本批 nodeResponse 中提取,并在 `WorkflowQueue`
* 上持续合并。
*
* 这个结构只服务于“本次运行过程”,不作为最终详情返回给客户端;详情展示仍从 DB rows
* 重新拼 `childrenResponses`。少数局部工具结构没有该字段时,
* `getRuntimeNodeResponseSummary` 可以从传入的临时 nodeResponse 数组归纳同样的信息。
*/
export type RuntimeNodeResponseSummary = {
/** 本次 workflow 已写入/产生的 response id。父 workflow 用它定位子流程运行期响应。 */
responseIds: string[];
/** 已完成的 nodeId。loopRun 读取自定义输出时用它判断哪些子节点真的执行过。 */
finishedNodeIds: string[];
/** child workflow 是否出现节点错误。parallel/loopRun 用它判断任务失败。 */
hasError: boolean;
/** 最近一次错误文本,供父节点包装虚拟任务错误和 catch 分支判断。 */
errorText?: string;
/** loopRunBreak 节点是否命中。父 loopRun 用它提前结束循环。 */
hasLoopRunBreak: boolean;
/** stopTool 是否命中。ToolCall/Agent 工具链用它停止后续工具调用。 */
hasToolStop: boolean;
/** nestedEnd 节点是否到达。parallel/旧 loop 用它判断子流程是否正常结束。 */
hasNestedEnd: boolean;
/** nestedEnd 输出值。parallel task 成功时把它作为该 task 的结果。 */
nestedEndOutput?: any;
/** pluginOutput 输出值。插件调用用它替代从完整 nodeResponse 列表里查 pluginOutput 节点。 */
pluginOutput?: Record<string, any>;
/** 子 workflow 节点运行时间总和。parallel/loopRun 虚拟包装节点展示用。 */
runningTime: number;
/** 子 workflow 顶层节点自身 totalPoints 总和。当前主要用于兜底统计。 */
totalPoints?: number;
/** 子 workflow 所有响应的积分总和,仅作为运行期费用聚合中间态,不写入 responseData。 */
childTotalPoints?: number;
/** 子 workflow 响应数量,包含嵌套 childResponseCount。父节点展示 child 数量用。 */
childResponseCount?: number;
};
export type WorkflowDebugResponse = { export type WorkflowDebugResponse = {
memoryEdges: RuntimeEdgeItemType[]; memoryEdges: RuntimeEdgeItemType[];
memoryNodes: RuntimeNodeItemType[]; memoryNodes: RuntimeNodeItemType[];
...@@ -32,16 +75,19 @@ export type WorkflowDebugResponse = { ...@@ -32,16 +75,19 @@ export type WorkflowDebugResponse = {
skipNodeQueue?: { id: string; skippedNodeIdList: string[] }[]; // Cache skipNodeQueue?: { id: string; skippedNodeIdList: string[] }[]; // Cache
}; };
export type DispatchFlowResponse = { export type DispatchFlowResponse = {
flowResponses: ChatHistoryItemResType[];
flowUsages: ChatNodeUsageType[]; flowUsages: ChatNodeUsageType[];
debugResponse: WorkflowDebugResponse; debugResponse: WorkflowDebugResponse;
workflowInteractiveResponse?: WorkflowInteractiveResponseType; workflowInteractiveResponse?: WorkflowInteractiveResponseType;
[DispatchNodeResponseKeyEnum.toolResponses]: ToolRunResponseItemType; [DispatchNodeResponseKeyEnum.toolResponse]: ToolRunResponseItemType;
[DispatchNodeResponseKeyEnum.assistantResponses]: AIChatItemValueItemType[]; [DispatchNodeResponseKeyEnum.assistantResponses]: AIChatItemValueItemType[];
[DispatchNodeResponseKeyEnum.runTimes]: number; [DispatchNodeResponseKeyEnum.runTimes]: number;
[DispatchNodeResponseKeyEnum.memories]?: Record<string, any>; [DispatchNodeResponseKeyEnum.memories]?: Record<string, any>;
[DispatchNodeResponseKeyEnum.customFeedbacks]?: string[]; [DispatchNodeResponseKeyEnum.customFeedbacks]?: string[];
[DispatchNodeResponseKeyEnum.newVariables]: Record<string, any>; [DispatchNodeResponseKeyEnum.newVariables]: Record<string, any>;
nodeResponseSummary?: NodeResponseWriteSummary;
/** 请求内保留的 flat nodeResponses;只有业务入口显式开启 retainInMemory 时才返回。 */
flatNodeResponses?: ChatHistoryItemResType[];
runtimeNodeResponseSummary: RuntimeNodeResponseSummary;
durationSeconds: number; durationSeconds: number;
}; };
......
import type { ChatDispatchProps } from '@fastgpt/global/core/workflow/runtime/type';
import type { WorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
import { createWorkflowNodeResponseWriter } from '../../../chat/nodeResponseStorage';
export type WorkflowNodeResponseWriteConfig = {
/** 是否把本轮 nodeResponse rows 持久化到 chat_item_responses。 */
persistToDb: boolean;
/** 是否在请求内保留 flat nodeResponses,供接口最终返回 responseData。 */
retainInMemory: boolean;
};
/**
* 创建 workflow 入口级 nodeResponse writer。
*
* 写 DB 和保留内存的策略由业务入口显式传入,dispatch 层不再根据 mode/chatId/detail
* 推断。子 workflow 只复用这个 writer,不关心当前请求到底落库还是仅保留请求内 flat 数据。
*/
export const createWorkflowEntryNodeResponseWriter = async ({
lastInteractive,
teamId,
appId,
chatId,
chatItemDataId,
nodeResponseWriteConfig
}: {
lastInteractive?: ChatDispatchProps['lastInteractive'];
teamId: string;
appId: string;
chatId: string;
chatItemDataId: string;
nodeResponseWriteConfig: WorkflowNodeResponseWriteConfig;
}): Promise<{
nodeResponseWriter: WorkflowNodeResponseWriter;
}> => {
return {
nodeResponseWriter: await createWorkflowNodeResponseWriter({
mode: lastInteractive ? 'append' : 'replace',
teamId,
appId,
chatId,
chatItemDataId,
persistToDb: nodeResponseWriteConfig.persistToDb,
retainInMemory: nodeResponseWriteConfig.retainInMemory
})
};
};
import { trace } from '@opentelemetry/api';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
export type WorkflowObservedStepResult = {
node: RuntimeNodeItemType;
runStatus: 'run';
result: {
[DispatchNodeResponseKeyEnum.nodeResponse]?: ChatHistoryItemResType;
error?: {
system_error_text?: string;
};
[key: string]: unknown;
};
};
const tracedWorkflowStepTypes = new Set<FlowNodeTypeEnum>([
FlowNodeTypeEnum.appModule,
FlowNodeTypeEnum.pluginModule,
FlowNodeTypeEnum.agent,
FlowNodeTypeEnum.chatNode,
FlowNodeTypeEnum.datasetSearchNode,
FlowNodeTypeEnum.classifyQuestion,
FlowNodeTypeEnum.contentExtract,
FlowNodeTypeEnum.queryExtension,
FlowNodeTypeEnum.toolCall,
FlowNodeTypeEnum.httpRequest468,
FlowNodeTypeEnum.lafModule,
FlowNodeTypeEnum.code,
FlowNodeTypeEnum.readFiles,
FlowNodeTypeEnum.tool
]);
/**
* 判断当前节点是否需要创建独立 OTel span。
*
* 高频或轻量工具节点只追加 step event,避免 trace 里出现大量低价值 span;核心模型、
* 工具、知识库等节点才提升为独立 span。
*/
export const shouldTraceWorkflowStep = (nodeType: FlowNodeTypeEnum) =>
tracedWorkflowStepTypes.has(nodeType);
/**
* 从节点执行结果提取指标/trace 状态。
*
* `nodeResponse.error` 和 dispatcher 顶层 `error` 都代表当前节点异常,二者口径需要一致,
* 否则 metrics 和 OTel span 会出现一个成功一个失败的分裂状态。
*/
export const getWorkflowStepStatus = (result: WorkflowObservedStepResult): 'ok' | 'error' => {
const nodeResponse = result.result[DispatchNodeResponseKeyEnum.nodeResponse];
return nodeResponse?.error || result.result.error ? 'error' : 'ok';
};
/**
* 在当前 active span 上追加轻量 step event。
*
* 未创建独立 span 的节点仍需要在父 workflow span 里留下开始/结束事件,便于串联查看
* 节点耗时和失败位置;没有 active span 时静默跳过。
*/
export const addWorkflowStepEvent = ({
eventName,
nodeType,
mode,
status,
durationMs
}: {
eventName: 'workflow.step.start' | 'workflow.step.end';
nodeType: FlowNodeTypeEnum;
mode: string;
status?: 'ok' | 'error';
durationMs?: number;
}) => {
const activeSpan = trace.getActiveSpan();
if (!activeSpan) return;
const attributes: Record<string, string | number> = {
'fastgpt.workflow.node.type': nodeType,
'fastgpt.workflow.mode': mode
};
if (status) {
attributes['fastgpt.workflow.step.status'] = status;
}
if (typeof durationMs === 'number') {
attributes['fastgpt.workflow.step.duration_ms'] = durationMs;
}
activeSpan.addEvent(eventName, attributes);
};
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { import {
getWorkflowEntryNodeIds, getWorkflowEntryNodeIds,
getMaxHistoryLimitFromNodes, getMaxHistoryLimitFromNodes,
...@@ -13,7 +12,6 @@ import { MongoApp } from '../../../core/app/schema'; ...@@ -13,7 +12,6 @@ import { MongoApp } from '../../../core/app/schema';
import { getChatItems } from '../../../core/chat/controller'; import { getChatItems } from '../../../core/chat/controller';
import { pushChatRecords } from '../../../core/chat/saveChat'; import { pushChatRecords } from '../../../core/chat/saveChat';
import { dispatchWorkFlow } from '../../../core/workflow/dispatch'; import { dispatchWorkFlow } from '../../../core/workflow/dispatch';
import { getUserChatInfo } from '../../../support/user/team/utils';
import { getRunningUserInfoByTmbId } from '../../../support/user/team/utils'; import { getRunningUserInfoByTmbId } from '../../../support/user/team/utils';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import type { NextApiResponse } from 'next'; import type { NextApiResponse } from 'next';
...@@ -105,10 +103,9 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({ ...@@ -105,10 +103,9 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
try { try {
// Get app workflow config // Get app workflow config
const [app, { nodes, chatConfig, edges }, { timezone, externalProvider }] = await Promise.all([ const [app, { nodes, chatConfig, edges }] = await Promise.all([
MongoApp.findById(outLinkConfig.appId).lean(), MongoApp.findById(outLinkConfig.appId).lean(),
getAppLatestVersion(outLinkConfig.appId), getAppLatestVersion(outLinkConfig.appId)
getUserChatInfo(outLinkConfig.tmbId)
]); ]);
if (!nodes || !chatConfig || !app) { if (!nodes || !chatConfig || !app) {
...@@ -155,7 +152,6 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({ ...@@ -155,7 +152,6 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
const workflowStreamResponse = enableStreaming const workflowStreamResponse = enableStreaming
? async ({ ? async ({
write,
event, event,
data data
}: { }: {
...@@ -194,14 +190,15 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({ ...@@ -194,14 +190,15 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
// Merge global variables from database // Merge global variables from database
const variables = chatDetail?.variables ?? {}; const variables = chatDetail?.variables ?? {};
const responseChatItemId = getNanoid(24);
const { const {
assistantResponses, assistantResponses,
newVariables, newVariables,
flowResponses,
flowUsages, flowUsages,
durationSeconds, durationSeconds,
system_memories system_memories,
nodeResponseSummary
} = await dispatchWorkFlow({ } = await dispatchWorkFlow({
apiVersion: 'v2', apiVersion: 'v2',
res, res,
...@@ -216,6 +213,7 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({ ...@@ -216,6 +213,7 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
runningUserInfo: await getRunningUserInfoByTmbId(app.tmbId), runningUserInfo: await getRunningUserInfoByTmbId(app.tmbId),
uid: chatUserId || outLinkConfig.tmbId, uid: chatUserId || outLinkConfig.tmbId,
chatId, chatId,
responseChatItemId,
variables, variables,
histories, histories,
query: query, query: query,
...@@ -225,7 +223,11 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({ ...@@ -225,7 +223,11 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
runtimeEdges: storeEdges2RuntimeEdges(edges), runtimeEdges: storeEdges2RuntimeEdges(edges),
runtimeNodes: storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes)), runtimeNodes: storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes)),
maxRunTimes: WORKFLOW_MAX_RUN_TIMES, maxRunTimes: WORKFLOW_MAX_RUN_TIMES,
retainDatasetCite: false retainDatasetCite: false,
nodeResponseWriteConfig: {
persistToDb: true,
retainInMemory: false
}
}); });
// Format results // Format results
...@@ -278,14 +280,15 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({ ...@@ -278,14 +280,15 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
value: query value: query
}, },
aiContent: { aiContent: {
dataId: responseChatItemId,
obj: ChatRoleEnum.AI, obj: ChatRoleEnum.AI,
value: assistantResponses, value: assistantResponses,
[DispatchNodeResponseKeyEnum.nodeResponse]: flowResponses,
memories: system_memories memories: system_memories
}, },
metadata: {}, metadata: {},
durationSeconds, durationSeconds,
errorMsg: replyResult?.success ? undefined : replyResult?.errmsg errorMsg: replyResult?.success ? undefined : replyResult?.errmsg,
nodeResponseSummary
}); });
const totalPoints = flowUsages.reduce((sum, item) => sum + (item.totalPoints || 0), 0); const totalPoints = flowUsages.reduce((sum, item) => sum + (item.totalPoints || 0), 0);
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
findInactiveRunningSandboxResources, findInactiveRunningSandboxResources,
findSandboxAppIdBySandboxId, findSandboxAppIdBySandboxId,
findSandboxInstanceByAppChatType, findSandboxInstanceByAppChatType,
findSandboxInstanceBySandboxId,
findSandboxInstanceBySandboxIdAndTeam, findSandboxInstanceBySandboxIdAndTeam,
findSandboxResourceBySandboxIdAndTeam, findSandboxResourceBySandboxIdAndTeam,
findSandboxResourcesByAppChatType, findSandboxResourcesByAppChatType,
...@@ -149,6 +150,14 @@ describe('sandbox instance helpers', () => { ...@@ -149,6 +150,14 @@ describe('sandbox instance helpers', () => {
undefined undefined
); );
await expect( await expect(
findSandboxInstanceBySandboxId({
provider: 'opensandbox',
sandboxId,
appId,
type: SandboxTypeEnum.editDebug
})
).resolves.toMatchObject({ sandboxId });
await expect(
findSandboxResourcesByAppChatType({ findSandboxResourcesByAppChatType({
provider: 'opensandbox', provider: 'opensandbox',
appId, appId,
......
...@@ -2,12 +2,14 @@ import { describe, expect, it, beforeEach } from 'vitest'; ...@@ -2,12 +2,14 @@ import { describe, expect, it, beforeEach } from 'vitest';
import { getChatItems, updateChatFeedbackCount } from '@fastgpt/service/core/chat/controller'; import { getChatItems, updateChatFeedbackCount } from '@fastgpt/service/core/chat/controller';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema'; import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema'; import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { MongoChatItemResponse } from '@fastgpt/service/core/chat/chatItemResponseSchema';
import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum, ChatSourceEnum } from '@fastgpt/global/core/chat/constants';
import { getUser } from '@test/datas/users'; import { getUser } from '@test/datas/users';
import { MongoApp } from '@fastgpt/service/core/app/schema'; import { MongoApp } from '@fastgpt/service/core/app/schema';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import type { ChatItemSchema } from '@fastgpt/global/core/chat/type'; import type { ChatItemSchema } from '@fastgpt/global/core/chat/type';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
describe('getChatItems', () => { describe('getChatItems', () => {
let testUser: Awaited<ReturnType<typeof getUser>>; let testUser: Awaited<ReturnType<typeof getUser>>;
...@@ -174,7 +176,7 @@ describe('getChatItems', () => { ...@@ -174,7 +176,7 @@ describe('getChatItems', () => {
it('should include custom fields when specified', async () => { it('should include custom fields when specified', async () => {
// Create AI items to support customFeedbacks // Create AI items to support customFeedbacks
const aiItem = await MongoChatItem.create({ await MongoChatItem.create({
teamId: testUser.teamId, teamId: testUser.teamId,
tmbId: testUser.tmbId, tmbId: testUser.tmbId,
userId: testUser.userId, userId: testUser.userId,
...@@ -716,6 +718,153 @@ describe('getChatItems', () => { ...@@ -716,6 +718,153 @@ describe('getChatItems', () => {
expect(result.histories.every((h) => h.dataId !== items[5].dataId)).toBe(true); expect(result.histories.every((h) => h.dataId !== items[5].dataId)).toBe(true);
}); });
}); });
describe('Node Response Detail', () => {
it('composes v2 flat chat item responses into childrenResponses', async () => {
const aiItem = await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
appId,
chatId,
dataId: 'ai-data-id',
obj: ChatRoleEnum.AI,
value: []
});
await MongoChatItemResponse.create([
{
teamId: testUser.teamId,
appId,
chatId,
chatItemDataId: aiItem.dataId,
data: {
id: 'root-response',
nodeId: 'root-node',
moduleName: 'Agent',
moduleType: FlowNodeTypeEnum.agent,
childTotalPoints: 2,
childResponseCount: 1
}
},
{
teamId: testUser.teamId,
appId,
chatId,
chatItemDataId: aiItem.dataId,
data: {
id: 'child-response',
parentId: 'root-response',
nodeId: 'child-node',
moduleName: 'Dataset',
moduleType: FlowNodeTypeEnum.datasetSearchNode,
totalPoints: 2
}
}
]);
const result = await getChatItems({
appId,
chatId,
offset: 0,
limit: 10,
field: 'obj value responseData'
});
expect(result.histories).toHaveLength(1);
expect(result.histories[0].responseData?.[0]).toMatchObject({
id: 'root-response',
childResponseCount: 1
});
expect(result.histories[0].responseData?.[0].childTotalPoints).toBeUndefined();
expect(result.histories[0].responseData?.[0].childrenResponses?.[0]).toMatchObject({
id: 'child-response',
parentId: 'root-response',
moduleType: FlowNodeTypeEnum.datasetSearchNode
});
});
it('keeps inline chatItem responseData for legacy records', async () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
appId,
chatId,
dataId: 'fallback-ai-data-id',
obj: ChatRoleEnum.AI,
value: [],
responseData: [
{
id: 'fallback-root',
nodeId: 'fallback-root',
moduleName: 'Fallback',
moduleType: FlowNodeTypeEnum.chatNode
}
]
});
await MongoChatItemResponse.create({
teamId: testUser.teamId,
appId,
chatId,
chatItemDataId: 'fallback-ai-data-id',
data: {
id: 'persisted-root',
nodeId: 'persisted-root',
moduleName: 'Persisted',
moduleType: FlowNodeTypeEnum.agent
}
});
const result = await getChatItems({
appId,
chatId,
offset: 0,
limit: 10,
field: 'obj value responseData'
});
expect(result.histories[0].responseData?.map((item) => item.id)).toEqual(['fallback-root']);
});
it('does not overwrite legacy chatItem responseData even when it is empty', async () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
userId: testUser.userId,
appId,
chatId,
dataId: 'empty-inline-ai-data-id',
obj: ChatRoleEnum.AI,
value: [],
responseData: []
});
await MongoChatItemResponse.create({
teamId: testUser.teamId,
appId,
chatId,
chatItemDataId: 'empty-inline-ai-data-id',
data: {
id: 'persisted-root',
nodeId: 'persisted-root',
moduleName: 'Persisted',
moduleType: FlowNodeTypeEnum.agent
}
});
const result = await getChatItems({
appId,
chatId,
offset: 0,
limit: 10,
field: 'obj value responseData'
});
expect(result.histories[0].responseData).toEqual([]);
});
});
}); });
describe('updateChatFeedbackCount', () => { describe('updateChatFeedbackCount', () => {
......
...@@ -112,4 +112,28 @@ describe('chat dataId validation', () => { ...@@ -112,4 +112,28 @@ describe('chat dataId validation', () => {
}) })
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
it('should allow validating only the current human dataId for interactive submit', async () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
dataId: 'existing-ai',
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'old answer' } }]
});
await expect(
validateChatRoundDataIds({
appId,
chatId,
userContent: {
obj: ChatRoleEnum.Human,
dataId: 'new-human',
value: [{ text: { content: 'hello' } }]
}
})
).resolves.toBeUndefined();
});
}); });
import { beforeEach, describe, expect, it } from 'vitest';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import { resolveResponseChatItemId } from '@fastgpt/service/core/chat/interactiveResponseDataId';
const base = {
teamId: '654a4107c32f3bf5f998452f',
tmbId: '654a4107c32f3bf5f9984530',
appId: '67e0d5535c02d1d5cdede71f',
chatId: 'interactive-chat-id'
};
describe('resolveResponseChatItemId', () => {
beforeEach(async () => {
await MongoChatItem.deleteMany({ appId: base.appId, chatId: base.chatId });
});
it('uses the existing AI dataId for interactive submit responses', async () => {
await MongoChatItem.create({
...base,
obj: ChatRoleEnum.AI,
dataId: 'existing-ai-data-id',
value: []
});
await expect(
resolveResponseChatItemId({
appId: base.appId,
chatId: base.chatId,
responseChatItemId: 'client-response-id',
interactive: {
type: 'userSelect',
params: {
description: '',
userSelectOptions: [{ key: 'a', value: 'A' }]
},
entryNodeIds: [],
memoryEdges: [],
nodeOutputs: []
},
userContent: {
obj: ChatRoleEnum.Human,
value: [{ text: { content: 'A' } }]
}
})
).resolves.toBe('existing-ai-data-id');
});
it('keeps the client responseChatItemId for interactive query responses', async () => {
await MongoChatItem.create({
...base,
obj: ChatRoleEnum.AI,
dataId: 'existing-ai-data-id',
value: []
});
await expect(
resolveResponseChatItemId({
appId: base.appId,
chatId: base.chatId,
responseChatItemId: 'client-response-id',
interactive: {
type: 'agentPlanAskQuery',
planId: 'plan-id',
params: {
query: 'Need more input'
},
entryNodeIds: [],
memoryEdges: [],
nodeOutputs: []
},
userContent: {
obj: ChatRoleEnum.Human,
value: [{ text: { content: 'next question' } }]
}
})
).resolves.toBe('client-response-id');
});
});
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum, WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants'; import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { WorkflowVariableState } from '../../../../../core/workflow/dispatch/utils/variables'; import { WorkflowVariableState } from '../../../../../core/workflow/dispatch/utils/variables';
import { summarizeRuntimeNodeResponses } from '../../../../../core/workflow/dispatch/utils';
const runWorkflowMock = vi.fn(); const runWorkflowMock = vi.fn();
const authAppByTmbIdMock = vi.fn(); const authAppByTmbIdMock = vi.fn();
...@@ -81,11 +83,20 @@ describe('abandoned dispatchAppRequest', () => { ...@@ -81,11 +83,20 @@ describe('abandoned dispatchAppRequest', () => {
flowResponses: [], flowResponses: [],
flowUsages: [], flowUsages: [],
assistantResponses: [], assistantResponses: [],
system_memories: [] system_memories: [],
runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, [
{
id: 'child-root',
moduleType: FlowNodeTypeEnum.chatNode,
totalPoints: 2,
childTotalPoints: 3,
childResponseCount: 1
} as any
])
}; };
}); });
await dispatchAppRequest({ const result = await dispatchAppRequest({
runningAppInfo: { runningAppInfo: {
id: 'parent-app', id: 'parent-app',
teamId: 'team', teamId: 'team',
...@@ -117,5 +128,6 @@ describe('abandoned dispatchAppRequest', () => { ...@@ -117,5 +128,6 @@ describe('abandoned dispatchAppRequest', () => {
expect(childInitialValue).toBe('parent-value'); expect(childInitialValue).toBe('parent-value');
expect(childVariableState.get('shared')).toBe('child-value'); expect(childVariableState.get('shared')).toBe('child-value');
expect(parentVariableState.get('shared')).toBe('parent-value'); expect(parentVariableState.get('shared')).toBe('parent-value');
expect(result.responseData?.totalPoints).toBe(2);
}); });
}); });
...@@ -505,7 +505,6 @@ describe('createWorkflowAgentLoopRuntime', () => { ...@@ -505,7 +505,6 @@ describe('createWorkflowAgentLoopRuntime', () => {
moduleName: 'chat:plan_agent', moduleName: 'chat:plan_agent',
runningTime: 0.08, runningTime: 0.08,
agentPlanStatus: 'update_plan', agentPlanStatus: 'update_plan',
childTotalPoints: 0.2,
childrenResponses: [ childrenResponses: [
expect.objectContaining({ expect.objectContaining({
moduleName: 'chat:tool_response_compress', moduleName: 'chat:tool_response_compress',
...@@ -807,7 +806,6 @@ describe('createWorkflowAgentLoopRuntime', () => { ...@@ -807,7 +806,6 @@ describe('createWorkflowAgentLoopRuntime', () => {
expect.objectContaining({ expect.objectContaining({
id: 'call_search', id: 'call_search',
runningTime: 0.77, runningTime: 0.77,
childTotalPoints: 0.3,
childrenResponses: [ childrenResponses: [
expect.objectContaining({ expect.objectContaining({
moduleName: 'chat:tool_response_compress', moduleName: 'chat:tool_response_compress',
......
...@@ -117,7 +117,6 @@ describe('agent adapter useToolNodeResponse', () => { ...@@ -117,7 +117,6 @@ describe('agent adapter useToolNodeResponse', () => {
id: 'call_search', id: 'call_search',
runningTime: 0.8, runningTime: 0.8,
toolRes: 'tool response', toolRes: 'tool response',
childTotalPoints: 1,
childrenResponses: [ childrenResponses: [
expect.objectContaining({ expect.objectContaining({
id: 'existing_child', id: 'existing_child',
...@@ -233,7 +232,6 @@ describe('agent adapter useToolNodeResponse', () => { ...@@ -233,7 +232,6 @@ describe('agent adapter useToolNodeResponse', () => {
runningTime: 0.3, runningTime: 0.3,
agentPlanStatus: 'ask_question', agentPlanStatus: 'ask_question',
textOutput: 'need more info', textOutput: 'need more info',
childTotalPoints: 0.1,
childrenResponses: [ childrenResponses: [
expect.objectContaining({ expect.objectContaining({
moduleName: 'chat:tool_response_compress', moduleName: 'chat:tool_response_compress',
......
...@@ -137,8 +137,7 @@ describe('PiAgent tool adapter', () => { ...@@ -137,8 +137,7 @@ describe('PiAgent tool adapter', () => {
const handler = createPiAgentToolEventHandler({ const handler = createPiAgentToolEventHandler({
ctx, ctx,
assistantResponses, assistantResponses,
appendChildNodeResponse, appendChildNodeResponse
nodeResponses: []
}); });
handler({ handler({
...@@ -208,8 +207,7 @@ describe('PiAgent tool adapter', () => { ...@@ -208,8 +207,7 @@ describe('PiAgent tool adapter', () => {
const handler = createPiAgentToolEventHandler({ const handler = createPiAgentToolEventHandler({
ctx, ctx,
assistantResponses, assistantResponses,
appendChildNodeResponse: vi.fn(), appendChildNodeResponse: vi.fn()
nodeResponses: []
}); });
handler({ handler({
...@@ -242,4 +240,37 @@ describe('PiAgent tool adapter', () => { ...@@ -242,4 +240,37 @@ describe('PiAgent tool adapter', () => {
}) })
); );
}); });
it('deduplicates fallback node responses without relying on retained nodeResponses', () => {
const ctx = createContext();
const assistantResponses: any[] = [];
const appendChildNodeResponse = vi.fn();
const appendedNodeResponseIds = new Set<string>(['call_search']);
const handler = createPiAgentToolEventHandler({
ctx,
assistantResponses,
appendChildNodeResponse,
appendedNodeResponseIds
});
handler({
type: 'tool_execution_start',
toolCallId: 'call_search',
toolName: 'search',
args: {
q: 'FastGPT'
}
} as any);
handler({
type: 'tool_execution_end',
toolCallId: 'call_search',
toolName: 'search',
result: {
content: [{ type: 'text', text: 'Validation failed' }]
},
isError: true
} as any);
expect(appendChildNodeResponse).not.toHaveBeenCalled();
});
}); });
...@@ -4,10 +4,13 @@ import { getSubapps, getExecuteTool } from '@fastgpt/service/core/workflow/dispa ...@@ -4,10 +4,13 @@ import { getSubapps, getExecuteTool } from '@fastgpt/service/core/workflow/dispa
import { readFileTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file/utils'; import { readFileTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file/utils';
import { datasetSearchTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset/utils'; import { datasetSearchTool } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset/utils';
const { dispatchAgentDatasetSearchMock, dispatchFileReadMock } = vi.hoisted(() => ({ const { dispatchAgentDatasetSearchMock, dispatchAppMock, dispatchFileReadMock } = vi.hoisted(
dispatchAgentDatasetSearchMock: vi.fn(), () => ({
dispatchFileReadMock: vi.fn() dispatchAgentDatasetSearchMock: vi.fn(),
})); dispatchAppMock: vi.fn(),
dispatchFileReadMock: vi.fn()
})
);
vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file', () => ({ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file', () => ({
dispatchFileRead: dispatchFileReadMock dispatchFileRead: dispatchFileReadMock
...@@ -21,6 +24,11 @@ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset', () => ({ ...@@ -21,6 +24,11 @@ vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset', () => ({
dispatchAgentDatasetSearch: dispatchAgentDatasetSearchMock dispatchAgentDatasetSearch: dispatchAgentDatasetSearchMock
})); }));
vi.mock('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/app', () => ({
dispatchApp: dispatchAppMock,
dispatchPlugin: vi.fn()
}));
describe('Agent read_files tool protocol', () => { describe('Agent read_files tool protocol', () => {
it('exposes read_files with ids parameter', async () => { it('exposes read_files with ids parameter', async () => {
const { completionTools } = await getSubapps({ const { completionTools } = await getSubapps({
...@@ -198,4 +206,73 @@ describe('Agent read_files tool protocol', () => { ...@@ -198,4 +206,73 @@ describe('Agent read_files tool protocol', () => {
}) })
); );
}); });
it('passes shared nodeResponseWriter and callId parent to workflow sub apps', async () => {
const nodeResponseWriter = { record: vi.fn() } as any;
dispatchAppMock.mockResolvedValue({
response: 'workflow result',
usages: [],
nodeResponse: {
moduleName: 'Sub Workflow'
}
});
const executeTool = getExecuteTool({
checkIsStopping: vi.fn(),
chatConfig: {},
runningUserInfo: {
teamId: 'team_1',
tmbId: 'tmb_1'
},
runningAppInfo: {
id: 'app_1'
},
chatId: 'chat_1',
uid: 'user_1',
variableState: {} as any,
externalProvider: {
openaiAccount: undefined
} as any,
lang: 'zh-CN',
requestOrigin: '',
mode: 'chat',
timezone: 'Asia/Shanghai',
retainDatasetCite: false,
maxRunTimes: 10,
workflowDispatchDeep: 1,
nodeResponseWriter,
nodeResponseParentId: 'agent-parent',
params: {
model: 'gpt-4'
},
stream: false,
getSubAppInfo: () => ({
name: 'Sub Workflow',
avatar: '',
toolDescription: ''
}),
getSubApp: () => ({
type: 'workflow',
id: 'workflow-tool',
name: 'Sub Workflow',
avatar: '',
params: {}
}),
completionTools: [],
filesMap: {}
} as any);
await executeTool({
callId: 'call_workflow',
toolId: 'workflow-tool',
args: '{"userChatInput":"hello"}'
});
expect(dispatchAppMock).toHaveBeenCalledWith(
expect.objectContaining({
nodeResponseWriter,
nodeResponseParentId: 'call_workflow'
})
);
});
}); });
...@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; ...@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { useToolRunner } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner'; import { useToolRunner } from '@fastgpt/service/core/workflow/dispatch/ai/toolcall/hooks/useToolRunner';
import { summarizeRuntimeNodeResponses } from '@fastgpt/service/core/workflow/dispatch/utils';
const { dispatchReadFileToolMock, runSandboxToolsMock, runWorkflowMock } = vi.hoisted(() => ({ const { dispatchReadFileToolMock, runSandboxToolsMock, runWorkflowMock } = vi.hoisted(() => ({
dispatchReadFileToolMock: vi.fn(), dispatchReadFileToolMock: vi.fn(),
...@@ -93,25 +94,39 @@ const createRunner = ({ ...@@ -93,25 +94,39 @@ const createRunner = ({
fileUrls?: string[]; fileUrls?: string[];
}) => { }) => {
const cacheToolFlowResponse = vi.fn(); const cacheToolFlowResponse = vi.fn();
const appendToolFlowResponse = vi.fn(); const appendInteractiveToolSummary = vi.fn();
const streamToolResponse = vi.fn(); const streamToolResponse = vi.fn();
const nodeResponseWriter = {
record: vi.fn(async (responses = []) => responses),
recordWithParent: vi.fn(async (responses = [], parentId?: string) => {
return responses.map((response) => ({
...response,
parentId: response.parentId || parentId
}));
})
};
const runner = useToolRunner({ const runner = useToolRunner({
workflowProps: createWorkflowProps(), workflowProps: {
...createWorkflowProps(),
nodeResponseParentId: 'toolcall_parent',
nodeResponseWriter
},
runtimeNodes, runtimeNodes,
runtimeEdges, runtimeEdges,
allFiles, allFiles,
fileUrls, fileUrls,
getToolInfo, getToolInfo,
cacheToolFlowResponse, cacheToolFlowResponse,
appendToolFlowResponse, appendInteractiveToolSummary,
streamToolResponse streamToolResponse
}); });
return { return {
...runner, ...runner,
cacheToolFlowResponse, cacheToolFlowResponse,
appendToolFlowResponse, appendInteractiveToolSummary,
streamToolResponse streamToolResponse,
nodeResponseWriter
}; };
}; };
...@@ -179,19 +194,24 @@ describe('useToolRunner', () => { ...@@ -179,19 +194,24 @@ describe('useToolRunner', () => {
expect(cacheToolFlowResponse).toHaveBeenCalledWith({ expect(cacheToolFlowResponse).toHaveBeenCalledWith({
call, call,
flowResponse: expect.objectContaining({ flowResponse: expect.objectContaining({
flowResponses: [ builtinNodeResponses: [
expect.objectContaining({ expect.objectContaining({
moduleName: 'Run shell',
moduleType: FlowNodeTypeEnum.tool, moduleType: FlowNodeTypeEnum.tool,
moduleName: 'Run shell',
moduleLogo: 'sandbox-avatar', moduleLogo: 'sandbox-avatar',
toolId: 'sandbox_shell', toolId: 'sandbox_shell',
toolInput: { toolInput: {
cmd: 'ls' cmd: 'ls'
}, },
toolRes: 'sandbox ok', toolRes: 'sandbox ok',
runningTime: 0.5 runningTime: 0.5,
totalPoints: 0
}) })
] ],
runtimeNodeResponseSummary: expect.objectContaining({
childResponseCount: 1,
finishedNodeIds: expect.any(Array)
})
}) })
}); });
}); });
...@@ -201,13 +221,15 @@ describe('useToolRunner', () => { ...@@ -201,13 +221,15 @@ describe('useToolRunner', () => {
moduleName: 'read_file', moduleName: 'read_file',
totalPoints: 0.2 totalPoints: 0.2
}; };
const fileNodeResponse = {
id: 'call_read',
nodeId: 'call_read',
moduleType: FlowNodeTypeEnum.readFiles,
moduleName: 'Read file'
};
const flowResponse = { const flowResponse = {
flowResponses: [ runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, [fileNodeResponse]),
{ builtinNodeResponses: [fileNodeResponse],
id: 'call_read',
moduleName: 'Read file'
}
],
flowUsages: [usage], flowUsages: [usage],
runTimes: 0 runTimes: 0
}; };
...@@ -288,11 +310,11 @@ describe('useToolRunner', () => { ...@@ -288,11 +310,11 @@ describe('useToolRunner', () => {
} }
]; ];
runWorkflowMock.mockResolvedValue({ runWorkflowMock.mockResolvedValue({
toolResponses: 'dataset ok', toolResponse: 'dataset ok',
assistantResponses: [], assistantResponses: [],
flowUsages: [], flowUsages: [],
workflowInteractiveResponse: undefined, workflowInteractiveResponse: undefined,
flowResponses: [] runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, [])
}); });
const { runTool } = createRunner({ const { runTool } = createRunner({
...@@ -370,38 +392,53 @@ describe('useToolRunner', () => { ...@@ -370,38 +392,53 @@ describe('useToolRunner', () => {
} }
]; ];
runWorkflowMock runWorkflowMock
.mockResolvedValueOnce({ .mockImplementationOnce(async (props) => {
toolResponses: { const nodeResponses = [
answer: 'workflow ok'
},
assistantResponses: [{ text: { content: 'assistant text' } }],
flowUsages: [usage],
workflowInteractiveResponse: {
type: 'userSelect'
},
flowResponses: [
{ {
id: 'workflow_tool_response',
nodeId: 'workflow_tool_response',
parentId: 'toolcall_parent',
moduleType: 'tool' as any,
moduleName: 'Workflow Tool',
toolStop: true toolStop: true
} }
] ];
await props.nodeResponseWriter?.record(nodeResponses);
return {
toolResponse: {
answer: 'workflow ok'
},
assistantResponses: [{ text: { content: 'assistant text' } }],
flowUsages: [usage],
workflowInteractiveResponse: {
type: 'userSelect'
},
runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, nodeResponses)
};
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
toolResponses: 'interactive ok', toolResponse: 'interactive ok',
assistantResponses: [], assistantResponses: [],
flowUsages: [], flowUsages: [],
workflowInteractiveResponse: undefined, workflowInteractiveResponse: undefined,
flowResponses: [ runtimeNodeResponseSummary: summarizeRuntimeNodeResponses(undefined, [
{ {
id: 'interactive_tool_response',
nodeId: 'interactive_tool_response',
moduleType: 'tool' as any,
moduleName: 'Interactive Tool',
toolStop: false toolStop: false
} }
] ])
}); });
const { const {
runTool, runTool,
runInteractiveTool, runInteractiveTool,
cacheToolFlowResponse, cacheToolFlowResponse,
appendToolFlowResponse, appendInteractiveToolSummary,
streamToolResponse streamToolResponse,
nodeResponseWriter
} = createRunner({ } = createRunner({
runtimeNodes, runtimeNodes,
runtimeEdges, runtimeEdges,
...@@ -421,6 +458,7 @@ describe('useToolRunner', () => { ...@@ -421,6 +458,7 @@ describe('useToolRunner', () => {
}); });
const result = await runTool({ call }); const result = await runTool({ call });
const workflowRunProps = runWorkflowMock.mock.calls[0][0];
expect(runtimeNodes[0]).toEqual({ expect(runtimeNodes[0]).toEqual({
nodeId: 'search', nodeId: 'search',
...@@ -443,12 +481,20 @@ describe('useToolRunner', () => { ...@@ -443,12 +481,20 @@ describe('useToolRunner', () => {
}); });
expect(result.stop).toBe(true); expect(result.stop).toBe(true);
expect(result.assistantMessages.length).toBeGreaterThan(0); expect(result.assistantMessages.length).toBeGreaterThan(0);
expect(workflowRunProps.nodeResponseWriter).toBe(nodeResponseWriter);
expect(nodeResponseWriter.record).toHaveBeenCalledWith([
expect.objectContaining({
id: 'workflow_tool_response'
})
]);
expect(cacheToolFlowResponse).toHaveBeenCalledWith({ expect(cacheToolFlowResponse).toHaveBeenCalledWith({
call, call,
flowResponse: expect.objectContaining({ flowResponse: expect.objectContaining({
toolResponses: { flowUsages: [usage],
answer: 'workflow ok' runtimeNodeResponseSummary: expect.objectContaining({
} responseIds: ['workflow_tool_response'],
hasToolStop: true
})
}) })
}); });
...@@ -465,9 +511,11 @@ describe('useToolRunner', () => { ...@@ -465,9 +511,11 @@ describe('useToolRunner', () => {
toolCallId: 'call_interactive', toolCallId: 'call_interactive',
response: 'interactive ok' response: 'interactive ok'
}); });
expect(appendToolFlowResponse).toHaveBeenCalledWith( expect(appendInteractiveToolSummary).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
toolResponses: 'interactive ok' runtimeNodeResponseSummary: expect.objectContaining({
responseIds: ['interactive_tool_response']
})
}) })
); );
expect(interactiveResult).toEqual({ expect(interactiveResult).toEqual({
......
This diff is collapsed. Click to expand it.
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