diff --git a/.claude/design/bug/chat-file-remap-功能开发文档.md b/.claude/design/bug/chat-file-remap-功能开发文档.md new file mode 100644 index 0000000000..990f811191 --- /dev/null +++ b/.claude/design/bug/chat-file-remap-功能开发文档.md @@ -0,0 +1,287 @@ +# 功能开发文档 + +## 文档标识 + +- 任务前缀:`chat-file-remap` +- 文档文件名:`chat-file-remap-功能开发文档.md` +- 更新时间:2026-04-24 +- 文档定位:实现对齐与验收口径(运行时逐条 user file 注入) + +## 0. 开发目标与约束 + +- 功能目标:修复 file-only user message 发给模型时退化为 `null` 的问题,并确保历史记录中每条带 file URL 的 human message 在运行时把文件内容注入回自己的 user message。 +- 代码范围:`v1/v2/chatTest` 保存层回退、`chat/tool` 运行时消息构造、文件解析 helper、相关测试与文档同步。 +- 非目标:历史数据回填、`file_url` 直传模型、API/DB schema 调整、前端交互调整。 +- 实现原则:保存层不污染原始输入;运行时只改 LLM messages 副本;文件内容进 user message,不进 system prompt。 +- 文件上限:每条 user query 的文件解析数量沿用 `chatConfig.fileSelectConfig.maxFiles`,不做跨 message URL 去重。 +- 必须遵循规范:`references/style-standards-entry.md`。 +- 适用维度:API[ ] DB[ ] Front[ ] Logger[ ] Package[x] BugFix[x] DocUpdate[x] DocI18n[ ]。 + +## 1. 实施任务拆解(可直接执行) + +| 任务ID | 任务名称 | 责任层 | 输入 | 输出 | 完成定义(DoD) | +|---|---|---|---|---|---| +| T1 | 保存层回退为原始输入 | API/Service | `userQuestion` | 原始保存参数 | `v1/v2/chatTest` 不再保存 `enrichedUserQuestion` | +| T2 | 运行时单条 user query 文件重写 helper | Service | 单条 human `value`、`maxFiles`、文件读取 helper | 增强后的 user query 副本 | 每条 human file 的 `` 回填到所属 user message | +| T3 | Chat node 接入运行时注入 | Service | Chat node runtime props | LLM messages | 文件内容进入 user message,system 不含文件内容 | +| T4 | Tool node 接入运行时注入 | Service | Tool node runtime props | Tool-call LLM messages | 无 `readFiles` tool 时注入;有 `readFiles` tool 时跳过 | +| T5 | 测试与清理 | Test/Service | T1-T4 改动 | 可执行测试 + 干净 import | 覆盖当前轮、历史逐条、`maxFiles`、system 纯净、保存层不污染 | + +## 2. 文件级改动清单 + +| 文件路径 | 改动类型 | 变更摘要 | 关键代码(可伪代码) | 关联任务ID | +|---|---|---|---|---| +| `projects/app/src/pages/api/v1/chat/completions.ts` | 修改 | 移除保存前增强使用,保存原始 `userQuestion` | `userContent: userQuestion` | T1 | +| `projects/app/src/pages/api/v2/chat/completions.ts` | 修改 | 同 v1,`prepare/finalize/updateInteractive` 使用原始输入 | `userContent: userQuestion` | T1 | +| `projects/app/src/pages/api/core/chat/chatTest.ts` | 修改 | 修复 review 评论点,不再改写输入问题 | `userContent: userQuestion` | T1 | +| `packages/service/core/chat/utils.ts` | 修改 | 删除或回退保存前 `enrichUserContentWithParsedFiles` 新增能力 | 移除未使用导入/函数 | T1 | +| `packages/service/core/workflow/dispatch/ai/chat.ts` | 修改 | 并行处理 human messages,逐条重写 user query,文件内容不进 system | `Promise.all(...rewriteUserQueryWithFileContent(...))` | T2/T3 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | 修改 | Tool LLM messages 同步并行重写;保留 `hasReadFilesTool` skip | `skip: hasReadFilesTool` | T2/T4 | +| `packages/service/core/workflow/utils/context.ts` | 修改/复用 | 承载单条 user query 文件内容重写 helper | `rewriteUserQueryWithFileContent(...)` | T2 | +| `packages/service/core/workflow/dispatch/tools/readFiles.ts` | 修改/复用 | 保留可读文件 URL 标准化、读文件与解析文件能力,供 readFiles tool 和重写 helper 复用 | `normalizeReadableFileUrl(...)` / `getFileContentFromLinks(...)` | T2 | +| `packages/service/core/ai/llm/utils.ts` | 修改/测试驱动 | 保持 `file_url` 过滤,确保同条 text 保留 | 不改协议行为 | T5 | +| `test/cases/...` | 修改/新增 | 替换保存前增强测试,新增运行时逐条注入测试 | 当前轮/历史/Tool/maxFiles | T5 | + +## 2.1 关键代码片段(用于规划核对) + +```ts +// 保存层:禁止保存增强后的 userContent +await finalizeChatRound({ + ...params, + userContent: userQuestion +}); +``` + +```ts +// 运行时:只增强发给 LLM 的 messages 副本 +const userMessages = await Promise.all( + rawUserMessages.map(async (message) => { + if (message.obj !== ChatRoleEnum.Human) return message; + + return { + ...message, + value: await rewriteUserQueryWithFileContent({ + userQuery: message.value, + requestOrigin, + maxFiles, + customPdfParse, + getFileContentFromLinks, + teamId, + tmbId + }) + }; + }) +); +``` + +```ts +// 注入规则:文件内容回填到所属 user message,不集中塞到最后一条 +const finalText = [originText, filePrompt].filter(Boolean).join('\n\n===---===---===\n\n'); +``` + +## 3. 后端实施说明 + +### 3.1 API 改动 + +N/A(无对外接口结构变化)。 + +内部保存链路要求: + +1. `prepareChatRound`、`finalizeChatRound`、`pushChatRecords`、`updateInteractiveChat` 均使用原始 `userQuestion`。 +2. 不在 API handler 中解析文件并改写 `userQuestion`。 +3. 不新增请求/响应字段。 + +### 3.2 Service/Core 改动 + +| 模块 | 函数/类型 | 具体改动 | 依赖关系 | +|---|---|---|---| +| `packages/service/core/workflow/dispatch/ai/chat.ts` | `getChatMessages` 附近 | 构造 LLM messages 前,对历史 human 与当前轮 user 做文件内容注入 | 依赖 `getFileContentFromLinks` | +| `packages/service/core/workflow/dispatch/ai/chat.ts` | `getMultiInput` | 不再把文件正文作为 system quote;当前轮文件参与逐条注入 | 与 token 裁剪链路协同 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | `dispatchRunTools` | 与 Chat 路径一致;无 `readFiles` tool 时注入,有则跳过 | 避免与 readFiles tool 重复预解析 | +| `packages/service/core/workflow/utils/context.ts` | `rewriteUserQueryWithFileContent` | 单条 user query 重写 ``,外层负责并行处理 history/current messages | 通过入参复用 `getFileContentFromLinks` | +| `packages/service/core/workflow/dispatch/tools/readFiles.ts` | `normalizeReadableFileUrl` / `getFileContentFromLinks` | 统一负责 URL 标准化、过滤、文件读取与解析;按单条 query URL 顺序与 `maxFiles` 控制解析量 | 保持现有错误兜底 | +| `packages/service/core/ai/llm/utils.ts` | `loadRequestMessages` | 保持 `file_url` 过滤;回归验证 text part 不丢 | 最终模型请求安全过滤 | + +### 3.3 运行时注入算法 + +1. 输入为 `chatHistories` 与当前轮 user prompt,先 clone 或新建消息数组,不能 mutate 原对象。 +2. Chat/Tool 外层通过 `Promise.all` 并行处理运行时 messages。 +3. 非 human message 原样返回;human message 调用 `rewriteUserQueryWithFileContent`。 +4. 单条 user query 内只收集本条 `file.url`,不做跨 message URL 去重或共享缓存。 +5. 调用 `getFileContentFromLinks` 统一完成 URL 标准化、过滤、`maxFiles` 截断与文件解析。 +6. 将解析结果回填到当前 user query: + - message 原本有 text:追加分隔符和 ``。 + - message 原本无 text:新增 text part。 +7. 构造最终 `chats2GPTMessages` / tool-call messages。 +8. 进入 `loadRequestMessages` 后,`file_url` 可以继续被过滤,但 text 中的文件正文必须保留。 + +### 3.4 数据层改动 + +N/A(不改 schema、索引、迁移逻辑)。 + +### 3.5 Bug 修复实施 + +| 项目 | 内容 | +|---|---| +| 问题点文件 | `packages/service/core/ai/llm/utils.ts`、`projects/app/src/pages/api/v1/chat/completions.ts`、`projects/app/src/pages/api/v2/chat/completions.ts`、`projects/app/src/pages/api/core/chat/chatTest.ts`、`packages/service/core/workflow/dispatch/ai/chat.ts`、`packages/service/core/workflow/dispatch/ai/tool/index.ts` | +| 问题点函数/代码段 | `loadRequestMessages`、三个路由的保存参数组装、`getMultiInput/getChatMessages/dispatchRunTools` | +| 触发条件 | 当前轮或历史 human message 中存在 file-only / file+text | +| 根因(直接原因) | `file_url` 被过滤后没有可供模型消费的文件正文 | +| 根因(深层原因) | 文件正文注入位置放错到保存层或 system prompt,没有在消费输入的 node 内逐条处理 user message | +| 修复动作 | 保存层回退原始输入;运行时逐条 user message 注入文件正文 | +| 影响范围 | Chat/Tool LLM 请求构造与历史 file 后续对话 | + +修复关键伪代码: + +```ts +const userMessages = await Promise.all( + rawUserMessages.map(async (message) => + message.obj === ChatRoleEnum.Human + ? { + ...message, + value: await rewriteUserQueryWithFileContent({ + userQuery: message.value, + maxFiles, + requestOrigin, + customPdfParse, + getFileContentFromLinks, + teamId, + tmbId + }) + } + : message + ) +); + +const messages = [ + ...getSystemPrompt_ChatItemType(concatenateSystemPrompt), + ...userMessages +]; +``` + +回归验证: + +1. 当前轮 file-only 不再变 `null`。 +2. 历史每条 human file 都注入回自己的 user message。 +3. 保存层不新增 ``。 +4. 无 file 场景无回归。 + +## 4. 前端实施说明 + +N/A(本期无前端改动)。 + +## 5. 日志与可观测性 + +| 触发点 | 日志级别 | category | 字段 | 备注 | +|---|---|---|---|---| +| 本次修复 | N/A | N/A | N/A | 不新增日志点,不打印文件正文 | + +注意事项: + +- 不新增观测方案。 +- 不在日志中输出用户文件正文、解析文本或完整 prompt。 + +## 6. 文档更新提醒 + +| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 | +|---|---|---|---|---|---|---| +| `chat-file-remap-需求设计文档.md` | 研发设计文档 | PR review 后方案口径调整 | 改为运行时逐条 user file 注入 | Codex | 2026-04-24 | 本次完成 | +| `chat-file-remap-功能开发文档.md` | 研发开发文档 | 实施任务与测试口径需同步 | 更新任务拆解、改动清单、测试计划 | Codex | 2026-04-24 | 本次完成 | + +## 7. 文档 i18n 实施说明 + +N/A,原因:本次只改 `.claude/design` 研发文档,不改 `document/content/docs` 目录。 + +## 8. 测试与验证 + +### 8.1 测试文件映射 + +| 源文件路径 | 文件类型 | 目标测试文件路径 | 是否跳过 | 跳过理由 | +|---|---|---|---|---| +| `packages/service/core/workflow/dispatch/ai/chat.ts` | packages | 新增/复用 dispatch chat 相关测试 | 否 | 运行时注入核心逻辑 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | packages | 新增/复用 dispatch tool 相关测试 | 否 | Tool 分支需覆盖 | +| `packages/service/core/ai/llm/utils.ts` | packages | `test/cases/service/core/ai/llm/utils.test.ts` | 否 | 回归 `file_url` 过滤后 text 保留 | +| `projects/app/src/pages/api/v1/chat/completions.ts` | projects | `-` | 是 | 当前仓库无路由级测试,本期以代码走查和核心单测覆盖 | +| `projects/app/src/pages/api/v2/chat/completions.ts` | projects | `-` | 是 | 当前仓库无路由级测试,本期以代码走查和核心单测覆盖 | +| `projects/app/src/pages/api/core/chat/chatTest.ts` | projects | `-` | 是 | 当前仓库无路由级测试,本期以代码走查和核心单测覆盖 | + +### 8.2 自动化测试设计 + +| 类型 | 用例 | 预期结果 | +|---|---|---| +| 单元测试 | 当前轮 file-only | LLM user message 包含 ``,`loadRequestMessages` 后不为 `null` | +| 单元测试 | 当前轮 file+text | 原问题与文件正文都在当前轮 user message | +| 单元测试 | 多条历史 human 均有 file | 每条历史 user message 各自注入自己的文件正文 | +| 单元测试 | 单条 user query 超过 `maxFiles` | 只解析并注入该 query 内前 `maxFiles` 个文件 | +| 单元测试 | Tool node 无 `readFiles` tool | 并行执行逐条 user query 重写 | +| 单元测试 | Tool node 有 `readFiles` tool | 跳过预解析注入 | +| 回归测试 | system prompt 检查 | system message 不包含 `` | +| 回归测试 | 保存层检查 | 保存参数仍为原始 `userQuestion` | + +### 8.3 场景覆盖核对 + +| 场景 | 是否覆盖 | 对应用例/describe | +|---|---|---| +| 基础场景 | 是 | 当前轮 file-only、file+text | +| 历史场景 | 是 | 多条历史 human file 逐条注入 | +| 边界值 | 是 | 单条 query `maxFiles`、重复 URL 分别读取、空解析 | +| Tool 场景 | 是 | 有/无 `readFiles` tool | +| 安全边界 | 是 | 不打印正文、不改 API/DB schema | +| 异常场景 | 是 | 文件解析失败时不污染原 userContent | + +### 8.4 执行命令 + +```shell +pnpm -s vitest run test/cases/service/core/ai/llm/utils.test.ts +``` + +实现新增运行时注入测试后,同步补充对应 test file 命令。 + +### 8.5 手工验证(可选) + +| 场景 | 操作步骤 | 预期结果 | +|---|---|---| +| 正常流程 | 首轮上传文件并提问,次轮继续文本提问 | 次轮 LLM 请求中首轮 user message 仍带文件正文 | +| 多历史文件 | 连续多轮分别上传文件,再继续追问 | 每条历史 user message 各自带对应文件正文 | +| 调试流程 | 使用 chatTest 发送 file-only | 保存层不改原始输入,LLM 请求 user message 不为 `null` | + +## 9. 质量自检清单 + +- [ ] 保存链路三入口(v1/v2/chatTest)均回退为原始 `userQuestion` +- [ ] 未改动 API/DB schema +- [ ] 未引入 `file_url` 直传模型逻辑 +- [ ] 文件内容不进入 system prompt +- [ ] 历史 human file 逐条注入到所属 user message +- [ ] 当前轮 user file 同样注入到当前轮 user message +- [ ] `maxFiles` 作为单条 user query 的解析上限 +- [ ] Tool node 有 `readFiles` tool 时跳过预解析 +- [ ] 测试覆盖当前轮、历史轮、Tool、`maxFiles`、保存层不污染 +- [ ] 文档更新提醒已填写 + +## 10. 发布与回滚 + +### 10.1 发布步骤 + +1. 完成 T1-T4 代码实现与测试。 +2. 执行自动化测试并记录结果。 +3. 合并发布。 + +### 10.2 回滚触发条件 + +- LLM 请求构造异常。 +- 历史文件解析导致明显性能问题。 +- Tool node 文件读取行为与 `readFiles` tool 冲突。 + +### 10.3 回滚步骤 + +1. 回退运行时逐条文件注入 helper 与调用点。 +2. 保持保存层原始 `userQuestion` 逻辑不变。 +3. 重新验证 file_url 过滤回归测试。 + +## 11. AI 实施提示(给执行模型) + +- 先做保存层回退,再做运行时注入,不要反过来糊一锅。 +- 注入对象是 LLM messages 副本,禁止 mutate `userQuestion`、`histories` 原对象。 +- 历史文件内容必须回填到所属 user message,不能统一拼到最后一条。 +- system prompt 禁止出现 ``。 +- 不扩展到历史回填、协议调整或前端 UI。 diff --git a/.claude/design/bug/chat-file-remap-需求设计文档.md b/.claude/design/bug/chat-file-remap-需求设计文档.md new file mode 100644 index 0000000000..f0b4bf711b --- /dev/null +++ b/.claude/design/bug/chat-file-remap-需求设计文档.md @@ -0,0 +1,229 @@ +# 需求设计文档 + +## 0. 文档标识 + +- 任务前缀:`chat-file-remap` +- 文档文件名:`chat-file-remap-需求设计文档.md` +- 更新时间:2026-04-24 +- 文档定位:对齐 PR review 后的最终口径(运行时注入,不污染保存层) + +## 1. 需求背景与目标 + +### 1.1 背景 + +当前问题来自消息整理链路的既有行为: + +1. 用户文件会在适配阶段转成 `file_url`(`packages/global/core/chat/adapt.ts` 的 `chats2GPTMessages`)。 +2. `packages/service/core/ai/llm/utils.ts` 的 `loadRequestMessages` 会过滤 `file_url`:`if (item.type === 'file_url') return;`。 +3. 当某条 user message 只有文件、没有文本时,过滤后模型侧内容可能退化为 `content: 'null'`。 +4. PR review 明确指出:不能在 API 保存层直接改写输入问题,只能在真正使用该输入的 node 内做运行时处理。 + +用户确认的最终目标边界: + +1. 文件解析内容必须出现在发给模型的 user message 中,而不是 system prompt 中。 +2. 历史记录中每一条带 file URL 的 human message,都要在运行时把自己的文件内容注入回自己的 user message。 +3. 保存层保持原始 `userQuestion`,不把 `` 固化入库。 +4. 不做历史数据回填,不走 `file_url` 直传模型方案。 +5. 每条 user query 的文件解析数量沿用 `chatConfig.fileSelectConfig.maxFiles`,不做跨 message URL 去重或共享缓存。 + +### 1.2 目标 + +- 业务目标:模型请求中,每条包含文件的 user message 都能携带对应文件正文;前面轮次的 user file 在后续聊天中继续可用。 +- 技术目标:在 AI Chat node / Tool node 构造 LLM messages 前,对运行时消息副本进行逐条 user 文件内容注入,不修改 API 请求体和 MongoDB 保存内容。 +- 成功指标: + - file-only 的当前轮 user message 发给模型前包含 ``,不再退化为 `null`。 + - 历史中每条带 file 的 human message,在后续请求里各自注入对应 ``,不集中塞到最后一条 user message。 + - system prompt 不包含文件解析内容。 + - MongoDB 中 human 原始消息不新增 ``。 + +## 2. 当前项目事实基线(基于代码) + +| 能力项 | 现有实现位置(文件路径) | 现状说明 | 结论(复用/修改/新增) | +|---|---|---|---| +| 用户消息整理 | `packages/service/core/ai/llm/utils.ts` (`loadRequestMessages`) | 过滤 `file_url`,但保留同条消息中的 text | 复用,补回归测试 | +| v1 保存链路 | `projects/app/src/pages/api/v1/chat/completions.ts` | 当前 PR 中存在保存前增强 `enrichedUserQuestion` 的倾向 | 需回退,保存原始 `userQuestion` | +| v2 保存链路 | `projects/app/src/pages/api/v2/chat/completions.ts` | 当前 PR 中存在保存前增强 `enrichedUserQuestion` 的倾向 | 需回退,保存原始 `userQuestion` | +| chatTest 保存链路 | `projects/app/src/pages/api/core/chat/chatTest.ts` | review 评论点:不应保存增强后的输入 | 需回退,保存原始 `userQuestion` | +| 保存实现 | `packages/service/core/chat/saveChat.ts` | 每轮只持久化当前轮 Human/AI;并会清理 file.url | 复用,不改 schema | +| 历史文件收集 | `packages/service/core/workflow/dispatch/tools/readFiles.ts` (`getHistoryFileLinks`) | 已能从历史 human message 中提取 file URL | 复用,但需要支持逐条消息归属 | +| Chat 运行时拼接 | `packages/service/core/workflow/dispatch/ai/chat.ts` | 当前 PR 已把当前轮文件内容改到 user,但历史文件逐条注入不足 | 修改为逐条运行时注入 | +| Tool 运行时拼接 | `packages/service/core/workflow/dispatch/ai/tool/index.ts` | 与 Chat 类似;有 `readFiles` tool 时应跳过预解析 | 修改为逐条运行时注入,保留 skip 分支 | + +## 3. 需求澄清记录 + +| 维度 | 已确认内容 | 待确认内容 | 备注 | +|---|---|---|---| +| 业务目标 | 文件内容进入 LLM user message,不进 system prompt | 无 | 已确认 | +| 历史行为 | 历史记录里每条 file URL 都需要注入回对应 user message | 无 | 已确认 | +| 文件上限 | 每条 user query 文件解析数量沿用 `maxFiles` | 无 | 已确认 | +| 保存层 | 不改写 `userQuestion`,不把 `` 入库 | 无 | 对齐 PR review | +| 数据模型 | 不改 DB schema,不新增字段 | 无 | 已确认 | +| API 行为 | 对外请求/响应协议不变 | 无 | 已确认 | +| 前端交互 | 无页面改动要求 | 无 | 已确认 | +| 文档更新 | 更新本任务两份研发文档 | 无 | 已确认 | +| 文档 i18n | 不命中 `document/content/docs` | 无 | 本文档更新不涉及 docs 站点 | + +## 3.1 影响域判定 + +| 维度 | 是否命中 | 证据(需求/代码锚点) | 结论 | +|---|---|---|---| +| API | No | 不新增/修改对外路由协议;仅回退保存层增强接入 | 协议不变 | +| DB | No | 不改 `MongoChatItem` schema 与索引 | 无结构改动 | +| Front | No | 未涉及前端组件与页面行为改造 | N/A | +| Logger | No | 不新增观测方案 | N/A | +| Package | Yes | 涉及 `packages/service` 与 `projects/app` 既有调用链对齐 | 最小改动 | +| BugFix | Yes | `file_url` 过滤导致 file-only 退化 `null` | 命中 | +| DocUpdate | Yes | 用户明确要求更新设计/开发文档 | 命中 | +| DocI18n | No | 本文档不改 docs 站点目录 | N/A | + +## 4. 范围定义 + +### 4.1 In Scope(本期必须) + +1. 回退 `v1/v2/chatTest` 保存前增强:保存链路统一使用原始 `userQuestion`。 +2. Chat node 构造 LLM messages 前,对历史 human messages 与当前轮 user message 做运行时文件内容注入。 +3. Tool node 在无 `readFiles` tool 时执行同样的逐条 user message 注入;有 `readFiles` tool 时跳过预解析。 +4. 文件内容只注入 user message,不进入 system prompt。 +5. 按 message 并行重写 user query,单条 user query 内文件解析数量受 `maxFiles` 控制。 +6. 补齐对应回归测试与文档说明。 + +### 4.2 Out of Scope(本期不做) + +1. 历史数据回填(批处理/迁移脚本)。 +2. `file_url` 透传到模型。 +3. API/DB schema 变更。 +4. 不为超过 `maxFiles` 的文件新增特殊提示或额外 UI。 +5. 不重构完整 chat message adapter。 + +## 5. 方案对比 + +| 方案 | 核心思路 | 优点 | 风险 | 实施成本 | 结论 | +|---|---|---|---|---|---| +| 方案A:保存前固化 | 在保存前把 file 解析文本拼到 `userContent` 并入库 | 后续回放天然复用 | 污染原始输入,已被 review 指出不合适 | 中 | 放弃 | +| 方案B:运行时逐条 user 注入(推荐) | 发给模型前增强 messages 副本,每条 human file 回填到自己的 user message,messages 并行处理 | 不污染保存层,满足 user 而非 system,历史文件后续可用 | 每次请求可能重新解析,受单条 query `maxFiles` 限制 | 中 | 推荐 | +| 方案C:直传 `file_url` 给模型 | 去掉过滤,依赖模型直接处理文件链接 | 表面改动少 | 多模型/OpenAI 兼容实现不稳定,容易报参错 | 中 | 放弃 | +| 方案D:历史回填 + 新流量修复 | 批量补齐旧库,再修新流量 | 历史一致性最好 | 工程面大、风险高、超出本期目标 | 高 | 本期不做 | + +推荐方案:方案B(运行时逐条 user 注入)。 + +## 6. 推荐方案详细设计 + +### 6.1 API 设计 + +- 对外 API:无变化。 +- 内部链路调整:`v1/v2/chatTest` 的保存入参保持原始 `userQuestion`,不再使用 `enrichedUserQuestion`。 + +### 6.2 数据设计 + +- DB 字段:无新增。 +- DB 索引:无变化。 +- 兼容策略:历史旧数据不迁移;运行时只要历史 human message 仍能提供 file key/url,就按当前策略解析注入。 + +### 6.3 核心代码设计 + +| 模块 | 关键函数/类型 | 变更说明 | 上下游影响 | +|---|---|---|---| +| `projects/app/src/pages/api/v1/chat/completions.ts` | `handler` | 移除保存前 `enrichUserContentWithParsedFiles` 使用,保存原始 `userQuestion` | 对外响应不变,避免污染历史 | +| `projects/app/src/pages/api/v2/chat/completions.ts` | `handler` | 同 v1,`prepare/finalize/updateInteractive` 使用原始 `userQuestion` | 对齐 review | +| `projects/app/src/pages/api/core/chat/chatTest.ts` | `handler` | 同 v1/v2,调试链路也不保存增强内容 | 修复 review 评论点 | +| `packages/service/core/workflow/dispatch/ai/chat.ts` | `getMultiInput/getChatMessages` | 构造 LLM messages 前增强运行时副本:历史和当前轮每条 user message 注入自己的文件内容;文件内容不进 system | Chat node 满足历史逐条注入 | +| `packages/service/core/workflow/dispatch/ai/tool/index.ts` | `getMultiInput/dispatchRunTools` | 无 `readFiles` tool 时同 Chat;有 `readFiles` tool 时跳过预解析 | 避免与 readFiles tool 职责冲突 | +| `packages/service/core/workflow/utils/context.ts` | `rewriteUserQueryWithFileContent` | 承载单条 user query 的文件内容重写逻辑,外层并行处理 history/current messages | 不污染 readFiles tool 职责 | +| `packages/service/core/workflow/dispatch/tools/readFiles.ts` | `normalizeReadableFileUrl` / `getFileContentFromLinks` | `getFileContentFromLinks` 统一负责 URL 标准化、过滤、文件读取与解析;`normalizeReadableFileUrl` 仅作为底层清洗工具 | 不改对外 API | +| `packages/service/core/ai/llm/utils.ts` | `loadRequestMessages` | 保持 `file_url` 过滤逻辑;确保同条消息 text 不被过滤 | 回归保障 | + +### 6.4 运行时注入规则 + +1. 使用消息副本,不修改 `histories`、`query`、`userQuestion` 原对象。 +2. Chat/Tool 外层用 `Promise.all` 并行处理运行时 messages。 +3. 单条 user query 只收集本条 `file.url`;不做跨 message URL 去重,不共享解析缓存。 +4. `getFileContentFromLinks` 负责 URL 标准化、过滤、`maxFiles` 截断和文件解析。 +5. 文件解析结果回填到原本所属的 user message: + - 原 message 已有 text:追加 `\n\n===---===---===\n\n...`。 + - 原 message 只有 file:新增一个 text part 存放 ``。 +6. 不把历史文件内容集中拼到最后一条 user message。 +7. system prompt 只保留模型默认 system、用户配置 system、dataset system quote。 + +### 6.5 日志与观测设计 + +- 不新增日志点。 +- 不打印用户文件正文或解析结果。 + +### 6.6 文档 i18n 设计 + +N/A(未命中 docs 站点目录)。 + +## 7. Bug 修复分析 + +| 项目 | 内容 | +|---|---| +| Bug 现象 | file-only user message 发给模型时可能退化为 `content: 'null'`,历史 file 在后续聊天中无法稳定保留语义 | +| 复现步骤 | 首轮只传 file -> 后续轮次继续聊天 -> 模型请求中过滤 `file_url` 后缺少文件正文 | +| 期望行为 | 每条带 file 的 user message 在 LLM 请求中都有自己的 `` 文本 | +| 实际行为 | `file_url` 被过滤,文件正文未逐条注入 user message | +| 定位证据 | `loadRequestMessages` 过滤 `file_url`;当前保存前增强方案被 review 指出不应改原始输入 | +| 问题点文件与函数 | `loadRequestMessages`、`v1/v2/chatTest` 保存链路、`chat.ts/tool/index.ts` 运行时消息构造 | +| 根因分析(直接原因) | file URL 不是模型可直接消费的文本,过滤后缺少正文 | +| 根因分析(深层原因) | 保存层与运行时层职责混淆;文件正文应该在消费输入的 node 内注入,而不是改写保存内容 | +| 影响范围 | Chat/Tool node 的 LLM 请求构造、file-only 与历史 file 后续对话 | + +回归验证要点: + +1. file-only 当前轮发给模型不再退化 `null`。 +2. 历史每条带 file 的 user message 各自获得文件正文。 +3. 保存后的 human 原始内容不包含新增 ``。 +4. 无 file 轮次无行为变化。 + +## 8. 风险、迁移与回滚 + +### 8.1 风险清单 + +1. 每次请求可能重新解析历史文件,存在额外耗时;通过单条 user query `maxFiles` 控制风险,并通过 messages 并行处理降低串行等待。 +2. 文件正文进入 user message 后 token 增加,可能触发上下文裁剪;沿用现有 `filterGPTMessageByMaxContext`。 +3. 历史文件若只剩 key 而无可解析 URL,需要实现时确认是否可通过现有 key 生成可读地址。 + +### 8.2 迁移策略 + +- 本期不迁移历史数据。 +- 修复通过运行时消息增强生效,不改历史存量内容。 + +### 8.3 回滚策略 + +1. 回滚运行时逐条注入 helper 与调用点。 +2. 保持保存层原始输入逻辑不变。 +3. `loadRequestMessages` 原过滤逻辑保持不变。 + +## 9. 验收标准 + +| 验收项 | 验收方式 | 通过标准 | +|---|---|---| +| 当前轮 file-only 可用 | 单测/联调 | LLM 请求最后一条 user message 含 ``,不为 `null` | +| 历史逐条注入 | 单测/联调 | 多条历史 human file 分别注入到各自 user message | +| 不污染保存层 | 单测/代码走查 | `prepare/finalize/push/updateInteractive` 保存原始 `userQuestion` | +| system prompt 纯净 | 单测/代码走查 | system message 不包含 `` | +| `maxFiles` 生效 | 单测 | 单条 user query 文件解析数不超过 `maxFiles` | +| Tool readFiles 分支 | 单测/代码走查 | 有 `readFiles` tool 时不提前注入 | +| 普通轮次无回归 | 回归测试 | 无 file 请求与修复前行为一致 | + +## 10. MECE 核查结论 + +### 10.1 相互独立检查结果 + +发现问题:保存前固化与运行时注入职责混淆。 +影响范围:容易污染原始用户输入,并触发 review 反对。 +修订动作:保存层只保存原始输入,运行时只增强 LLM messages 副本。 +修订后结果:职责边界清晰。 + +### 10.2 完全穷尽检查结果 + +发现问题:只处理当前轮文件无法满足“历史记录每条 file URL 都注入回来”。 +影响范围:后续聊天中前面 user file 仍可能丢语义。 +修订动作:历史 human messages 与当前轮 user message 统一按条注入。 +修订后结果:当前轮、历史轮、tool 场景均覆盖。 + +### 10.3 修订动作与最终边界 + +发现问题:历史文件过多时可能带来解析成本和 token 风险。 +影响范围:性能、成本、上下文窗口。 +修订动作:确认采用 `maxFiles` 作为单条 user query 的解析上限,并通过 messages 并行处理降低串行等待。 +修订后结果:需求完整且有明确成本边界。 diff --git a/.claude/issue/openai-agent-sdk-integration/report.md b/.claude/issue/openai-agent-sdk-integration/report.md new file mode 100644 index 0000000000..fd5e7caa7c --- /dev/null +++ b/.claude/issue/openai-agent-sdk-integration/report.md @@ -0,0 +1,712 @@ +# OpenAI Agents SDK 集成调研报告 + +> 目标:评估将 [@openai/agents](https://github.com/openai/openai-agents-js)(TypeScript 版 OpenAI Agents SDK,下称 **OAI-Agents**)作为 FastGPT `dispatchRunAgent` 的第三种调度引擎引入的可行性,重点回答:**计费 token 能否拿到、tool 能否传入、skill 能否使用**。 +> +> 研究对象:`/Volumes/code/fastgpt-pro/FastGPT/packages/service/core/workflow/dispatch/ai/agent/index.ts` +> +> 调研日期:2026-04-27 +> SDK 版本:`@openai/agents` 0.8.5(npm latest) + +--- + +## 0. 执行摘要(TL;DR) + +| 关注点 | 结论 | 关键依据 | +|---|---|---| +| ① 拿到 token 用于计费 | ✅ **可行,且粒度比 pi 引擎更细** | `result.state.usage.requestUsageEntries[]` 暴露每次 LLM 调用的 input/output/cached/reasoning tokens;`result.rawResponses[].usage` 还能拿到 `responseId / providerData`。完全满足 FastGPT 现有 `usagePush(ChatNodeUsageType[])` 的梯度计费需求。 | +| ② 传入 tool | ✅ **可行,可直接复用现有 `getExecuteTool` 分发链** | `tool({ parameters, execute })` 接受 **JSON Schema** 或 **zod v4**,FastGPT 已锁定 zod v4,现有 `ChatCompletionTool[]` 的 `function.parameters`(JSON Schema)可直接喂入;execute 内部回调到 `getExecuteTool` 即可保持工具分发逻辑不变。 | +| ③ 使用 skill | ✅ **可行,沙箱 skill 机制对 SDK 透明** | FastGPT 的 skill 实质 = 「systemPrompt 中的 skill 元数据 + 6 个 sandbox tool + sandbox 容器中的 SKILL.md」,LLM 通过 `sandbox_read_file` 自主加载 SKILL.md。这套机制不依赖具体的 Agent loop 实现,只要把 `capabilitySystemPrompt` 注入 `Agent.instructions`、`capabilityTools` 注入 `Agent.tools` 即可。 | + +**总评**:可以用 **新增第三种引擎**(`AGENT_ENGINE='openai'`)的方式接入,**不替换** 现有 `default`/`pi` 两条路径,与 piAgent 走同一类桥接套路(modelBridge + toolAdapter + 主调度),改动量约 4 个新文件 ≈ 600 行代码 + 1 行 env 枚举扩展。 + +**主要风险点**(需用户拍板,详见 §6): +1. **Plan + Step 拆解能力**:OAI-Agents 自身没有 FastGPT 的「显式 plan + interactive ask」机制,需要决定是「完全交给 SDK 自主多轮 reasoning」还是「把 PlanAgentTool 作为一个 SDK tool 喂进去」。 +2. **Tracing 默认外发**:SDK 默认会把 trace 上传到 OpenAI 平台,必须 `setTracingDisabled(true)` 关闭。 +3. **第三方 OpenAI 兼容 endpoint**:必须 `setOpenAIAPI('chat_completions')` 切到 Chat Completions 路径;多租户并发场景需按 `Runner` 实例隔离,不要用进程级全局 setter。 + +--- + +## 1. 现有 agent 调度架构 + +### 1.1 入口分支 +`dispatchRunAgent` 顶部按 `env.AGENT_ENGINE` 分流([index.ts:81-83](../../../packages/service/core/workflow/dispatch/ai/agent/index.ts)): + +```ts +if (env.AGENT_ENGINE === 'pi') { + return dispatchPiAgent(props); +} +// default 引擎:Plan + Step 编排 +``` + +env 枚举([env.ts:127](../../../packages/service/env.ts)): +```ts +AGENT_ENGINE: z.enum(['default', 'pi']).default('default') +``` + +### 1.2 default 引擎(Plan + Master) +- **核心循环**:`dispatchPlanAgent`(计划)→ `masterCall`(执行)→ `runAgentLoop`(FastGPT 自家 LLM 多轮工具循环) +- **能力**:显式 plan 拆解 → 串行执行每个 step → 支持 plan 中途 ask 用户、续跑、最大 10 轮规划 +- **关键产物**:每次 LLM 调用、每次 tool 调用都通过 `usagePush([ChatNodeUsageType])` 推送账单([agentLoop/index.ts:336-344](../../../packages/service/core/ai/llm/agentLoop/index.ts)) + +### 1.3 pi 引擎(pi-agent-core 桥接) +- **核心循环**:`agent.prompt(input)` 由 `@mariozechner/pi-agent-core` 自管多轮 reasoning +- **桥接套路**([piAgent/](../../../packages/service/core/workflow/dispatch/ai/agent/piAgent/),**这是 OAI-Agents 集成的最佳参考**): + - `modelBridge.ts` — 把 FastGPT `LLMModelItemType` 转成 pi-ai 的 `Model` 配置(baseUrl/apiKey/headers) + - `toolAdapter.ts` — 把 `ChatCompletionTool[]` 包装成 pi-agent-core `AgentTool[]`,内部仍调 `getExecuteTool(ctx)` 复用 FastGPT 工具分发 + - `index.ts` — 主调度,订阅 `agent.subscribe(event)` 拿流式 token,`agent.state.messages` 存到 memories 跨轮恢复 +- **不支持**:plan 拆解(pi-agent-core 自己管 reasoning),interactive ask + +### 1.4 工具分发(两个引擎共用) +统一在 [utils.ts:`getExecuteTool`](../../../packages/service/core/workflow/dispatch/ai/agent/utils.ts): +- 三类来源汇总到 `completionTools: ChatCompletionTool[]`: + - **System tools**:`PlanAgentTool` / `readFileTool` / `datasetSearchTool` / `SANDBOX_TOOLS` + - **Capability tools**:当前主要是 `sandboxSkills` 提供的 6 个(read/write/edit/execute/search/fetchUserFile) + - **User tools**:`getAgentRuntimeTools` 从 `selectedTools` 转成 `tool / workflow / toolWorkflow` 三类 +- 输入 `{ callId, toolId, args }`,输出 `{ response, usages, nodeResponse, planResult, capabilityAssistantResponses, stop }` + +### 1.5 Skill 机制(**关键**) +Skill 不是 SDK 概念,是 FastGPT 自创的 progressive disclosure 模式([capability/sandboxSkills.ts](../../../packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts) + [sub/sandbox/prompt.ts:30](../../../packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/prompt.ts)): + +``` +skill = ( + systemPrompt 中注入 skill 元数据 // + + 6 个 sandbox tool 暴露给 LLM // sandbox_read_file 等 + + sandbox 容器中放置 SKILL.md // 容器内 /workspace//SKILL.md +) +``` + +LLM 看到 skill 元数据后,**自主**用 `sandbox_read_file` 加载完整 SKILL.md,再用 `sandbox_execute` 跑里面的脚本。 + +> **结论**:skill 机制对底层 Agent SDK 完全透明,只要 SDK 能(a)拼接 systemPrompt(b)暴露 tool,就能用 skill。 + +### 1.6 计费数据流 +``` +Tool/LLM 调用产生 ChatNodeUsageType{ inputTokens, outputTokens, totalPoints, moduleName, model } + ↓ +usagePush(usages: ChatNodeUsageType[]) // dispatchProps 透传下来的回调 + ↓ +工作流上层结算 +``` + +每次 LLM 调用都要 push 一条,不是只 push 总和(**梯度计费**要求按调用计价后累加,见 [agentLoop/index.ts:328-344](../../../packages/service/core/ai/llm/agentLoop/index.ts))。 + +--- + +## 2. OpenAI Agents SDK 关键能力(已验证) + +> 详细调研结果见同目录 `research-notes.md`(如需),此处只列与三大问题相关的结论。 + +### 2.1 Token / Usage 数据结构 + +**Run 级别**(来自 `packages/agents-core/src/usage.ts:31-200`、`result.ts:69-200`): +```ts +result.state.usage = { + requests: number, + inputTokens, outputTokens, totalTokens, + inputTokensDetails: { cached_tokens?: number, ... }, + outputTokensDetails: { reasoning_tokens?: number, ... }, + requestUsageEntries: RequestUsage[] // ← 每次 LLM 调用一条 +} + +type RequestUsage = { + inputTokens, outputTokens, totalTokens, + inputTokensDetails, outputTokensDetails, + endpoint: 'responses.create' | 'responses.compact' | 'chat.completions' | ... +} +``` + +更细到 raw response: +```ts +result.rawResponses: ModelResponse[] +// 每个 ModelResponse 自带 usage、responseId、requestId、providerData +``` + +Stream 模式:`runContext.usage` 实时更新,`await stream.completed` 后从 `stream.state.usage` 一次性拿到全量;中途也可订阅 `raw_model_stream_event` 的 `response.completed` 子事件读取每次响应的 usage。 + +**对比 pi-agent-core**:pi 只在 `turn_end` 给汇总 usage,要细分得自己累加;OAI-Agents 原生就提供 per-request 明细。 + +### 2.2 自定义 Model Provider + +**核心发现**:可以走 `OpenAIProvider({ openAIClient: customOpenAI })` 注入自建 `OpenAI` 客户端实例,每个 dispatch 一个 `Runner`,无需用进程级全局 setter,天然支持多租户多 baseUrl 并发。 + +```ts +import { Agent, Runner, OpenAIProvider, setOpenAIAPI } from '@openai/agents'; +import OpenAI from 'openai'; + +setOpenAIAPI('chat_completions'); // 第三方兼容 endpoint 必须切这条路径 + +function makeRunner(cfg: { baseURL: string; apiKey: string; headers?: Record }) { + const client = new OpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL, defaultHeaders: cfg.headers }); + return new Runner({ modelProvider: new OpenAIProvider({ openAIClient: client }) }); +} +``` + +> 不要用 `setDefaultOpenAIClient`(进程级全局),多并发会互相污染。 + +### 2.3 Tool 定义 + +**`tool()` 接受 zod object 或 JSON Schema**(`packages/agents-core/src/tool.ts:1215-1260`): + +```ts +import { tool } from '@openai/agents'; +const myTool = tool({ + name: 'foo', + description: 'do foo', + parameters: { type: 'object', properties: {...}, additionalProperties: false }, // JSON Schema + strict: false, // 必须,因为现有 schema 不一定满足 OpenAI strict 规范 + async execute(input, runContext, details) { + details?.signal?.throwIfAborted(); + return await fastgptDispatchTool({ callId: details!.toolCall.callId, toolId: 'foo', args: JSON.stringify(input) }); + }, + errorFunction: (ctx, err) => `tool error: ${err.message}`, + timeoutMs: 60_000 +}); +``` + +**Tool 流事件**:`run_item_stream_event` → `name: 'tool_called' | 'tool_output' | 'tool_approval_requested' | 'message_output_created' | ...` + +### 2.4 Skill 概念 + +❌ **SDK 没有 Skill 一等概念**。但 FastGPT 的 skill 是「prompt + tools」组合,对 SDK 透明: +- 把 `capabilitySystemPrompt`(含 `` 块)拼到 `Agent.instructions` +- 把 `capabilityTools`(6 个 sandbox tool)放进 `Agent.tools` +- LLM 自主调用 `sandbox_read_file` 时,SDK 转发到 FastGPT `executeTool` → `dispatchSandboxReadFile` → 沙箱容器 + +**未来扩展**:如果想做"按场景动态切换 skill 集",可以用 `Agent.asTool(...)` 把每个 skill 包成子 Agent,由 router agent 通过 `handoffs` 切换。 + +### 2.5 中断 & 序列化 + +```ts +const ctrl = new AbortController(); +checkIsStopping 轮询 → ctrl.abort() +const result = await run(agent, input, { signal: ctrl.signal, maxTurns: 100 }); + +// 跨轮恢复 +const snapshot = result.state.toString(); // 整个状态序列化为 JSON 字符串,存到 memories +const state = await RunState.fromString(agent, snapshot); +const resumed = await run(agent, state); +``` + +### 2.6 兼容性 + +| 项 | 要求 | FastGPT 现状 | +|---|---|---| +| Node | ≥20 | ✅ 20 | +| zod | **v4** | ✅ catalog 锁 `^4` | +| openai | `^6.26.0`(peer) | 待确认(需 `cd packages/service && pnpm why openai` 实测) | +| ESM | 纯 ESM + CJS dual | ✅ `@fastgpt/service` 已是 ESM | + +--- + +## 3. 三大问题对照方案 + +### 3.1 ✅ 计费 token + +**对照映射**: +``` +SDK: result.state.usage.requestUsageEntries[] + ↓ 每条 RequestUsage → ChatNodeUsageType +FastGPT: usagePush([{ inputTokens, outputTokens, totalPoints, moduleName, model }]) +``` + +**实现方式(伪代码,见 §5.3)**: +```ts +// run 结束后 +const entries = result.state.usage.requestUsageEntries ?? []; +const usages: ChatNodeUsageType[] = entries.map(e => { + const totalPoints = userKey ? 0 : formatModelChars2Points({ + model: modelData, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens + }).totalPoints; + return { + moduleName: i18nT('account_usage:agent_call'), + model: modelData.name, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + totalPoints + }; +}); +usagePush(usages); +``` + +**风险**:第三方 provider(DeepSeek、阿里、火山)的 `cached_tokens` / `reasoning_tokens` 字段名可能不一致,**首期可以先不读这两个细分字段**,只取 `inputTokens` / `outputTokens` 走基础计费;后续要做缓存折扣计费时再按 provider 适配。 + +### 3.2 ✅ 传入 tool + +**关键洞察**:**完全复用** 现有的 `getExecuteTool` —— 桥接层只负责把 `ChatCompletionTool[]` 转成 SDK tool[],execute 直接回调 FastGPT 的工具分发。 + +```ts +import { tool as oaiTool } from '@openai/agents'; + +function buildOpenAITools(ctx: ToolDispatchContext) { + const executeTool = getExecuteTool(ctx); + return ctx.completionTools + .filter(t => t.function.name !== SubAppIds.plan) // 看决策点 §6.1 + .map(t => oaiTool({ + name: t.function.name, + description: t.function.description ?? '', + parameters: (t.function.parameters as any) ?? { type: 'object', properties: {} }, + strict: false, + execute: async (input, _runCtx, details) => { + const callId = details?.toolCall.callId ?? getNanoid(8); + const { response, usages, nodeResponse, capabilityAssistantResponses } = await executeTool({ + callId, + toolId: t.function.name, + args: JSON.stringify(input) + }); + // 工具内部产生的 usage 立刻 push(沙箱、子工作流、子工具会带) + if (usages?.length) ctx.usagePush(usages); + if (nodeResponse) ctx.nodeResponses.push(nodeResponse); + if (capabilityAssistantResponses?.length) ctx.capAssistantResponses.push(...capabilityAssistantResponses); + return response; + } + })); +} +``` + +**所有现存工具都能直接接入**: +- ✅ User tools(dispatchTool / dispatchApp / dispatchPlugin) +- ✅ System tools(fileRead / datasetSearch / SANDBOX_TOOLS) +- ✅ Capability tools(sandboxSkills 的 6 个工具) +- ⚠️ PlanAgentTool 看 §6.1 决策 + +### 3.3 ✅ 使用 skill + +**直接复用** [createSandboxSkillsCapability](../../../packages/service/core/workflow/dispatch/ai/agent/capability/sandboxSkills.ts:192) 即可,跟 `dispatchPiAgent` 用法一模一样: + +```ts +// 在 dispatchOpenAIAgent 里,照抄 piAgent/index.ts 的 capabilities 初始化逻辑 +if (env.SHOW_SKILL) { + const sandboxCap = await createSandboxSkillsCapability({ + skillIds: normalizedSkillIds, + teamId, tmbId, sessionId, mode: sandboxMode, + workflowStreamResponse, + showSkillReferences, + allFilesMap + }); + capabilities.push(sandboxCap); +} + +const capabilitySystemPrompt = capabilities.map(c => c.systemPrompt).filter(Boolean).join('\n\n'); +const capabilityTools = capabilities.flatMap(c => c.completionTools ?? []); +const capabilityToolCallHandler = createCapabilityToolCallHandler(capabilities); + +// 然后构造 Agent +const agent = new Agent({ + name: 'fastgpt-agent', + instructions: parseUserSystemPrompt({ + userSystemPrompt: `${systemPrompt}\n\n${capabilitySystemPrompt}`.trim(), + selectedDataset: datasetParams?.datasets + }), + tools: buildOpenAITools(toolCtx), // ← 已含 capabilityTools(沙箱 skill 工具) + model: cfg.model +}); +``` + +skill 元数据进 prompt、sandbox tool 进 tools,LLM 自主调用 → 走到 `executeTool` → `capabilityToolCallHandler` → `buildSessionHandler` → 沙箱容器。**与现有 default/pi 引擎逻辑完全一致**。 + +--- + +## 4. 集成方案设计 + +### 4.1 总体策略 +**新增第三种引擎**,不替换 default / pi: + +```ts +// env.ts +AGENT_ENGINE: z.enum(['default', 'pi', 'openai']).default('default') + +// dispatch/ai/agent/index.ts +if (env.AGENT_ENGINE === 'pi') return dispatchPiAgent(props); +if (env.AGENT_ENGINE === 'openai') return dispatchOpenAIAgent(props); +// 否则走 default Plan+Master +``` + +理由: +- `default` 引擎是 FastGPT 自家 Plan+Step 能力,OAI-Agents 替代不了 plan +- 三种引擎并存便于 A/B 比较与回滚 +- env 切换零业务侵入 + +### 4.2 文件结构(新增) + +``` +packages/service/core/workflow/dispatch/ai/agent/ +├─ openaiAgent/ (新增目录,参照 piAgent/) +│ ├─ index.ts (主调度入口) +│ ├─ modelBridge.ts (OpenAI 客户端构建 + Provider 注入) +│ ├─ toolAdapter.ts (ChatCompletionTool[] → tool[]) +│ ├─ usageBridge.ts (RequestUsageEntry[] → ChatNodeUsageType[]) +│ └─ streamBridge.ts (run_item_stream_event → SSE) +└─ index.ts (顶部多加一个 if 分支) +``` + +依赖:`packages/service/package.json` 新增 `"@openai/agents": "^0.8.5"`、`"openai": "^6.26.0"`(确认与现有版本兼容)。 + +### 4.3 核心代码骨架 + +#### 4.3.1 modelBridge.ts +```ts +import OpenAI from 'openai'; +import { OpenAIProvider, setOpenAIAPI, setTracingDisabled } from '@openai/agents'; +import { getLLMModel } from '../../../../../ai/model'; + +setOpenAIAPI('chat_completions'); // 全局:兼容第三方 endpoint +setTracingDisabled(true); // 全局:禁止 trace 外发到 OpenAI + +const aiProxyBaseUrl = process.env.AIPROXY_API_ENDPOINT ? `${process.env.AIPROXY_API_ENDPOINT}/v1` : undefined; +const defaultBaseUrl = aiProxyBaseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'; +const defaultApiKey = process.env.AIPROXY_API_TOKEN || process.env.CHAT_API_KEY || ''; + +export function buildOpenAIRunner(modelNameOrId?: string) { + const cfg = getLLMModel(modelNameOrId); + const rawUrl = cfg?.requestUrl ?? ''; + const baseURL = rawUrl ? rawUrl.replace(/\/chat\/completions$/, '') : defaultBaseUrl; + const apiKey = cfg?.requestAuth || defaultApiKey; + + const client = new OpenAI({ apiKey, baseURL }); + const provider = new OpenAIProvider({ openAIClient: client }); + + return { + provider, + modelId: cfg?.model ?? 'gpt-4o', + modelData: cfg + }; +} +``` + +#### 4.3.2 toolAdapter.ts +```ts +import { tool as oaiTool } from '@openai/agents'; +import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants'; +import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { getExecuteTool, type ToolDispatchContext } from '../utils'; + +export function buildOpenAITools({ + ctx, + nodeResponses, + capabilityAssistantResponses, + usagePush +}: { ctx: ToolDispatchContext; nodeResponses: ChatHistoryItemResType[]; capabilityAssistantResponses: AIChatItemValueItemType[]; usagePush: (u: ChatNodeUsageType[]) => void }) { + const executeTool = getExecuteTool(ctx); + + return ctx.completionTools + .filter(t => t.function.name !== SubAppIds.plan) // OAI-Agents 自管 reasoning,先不喂 plan + .map(t => { + const toolId = t.function.name; + return oaiTool({ + name: toolId, + description: t.function.description ?? '', + parameters: (t.function.parameters as any) ?? { type: 'object', properties: {}, additionalProperties: false }, + strict: false, + async execute(input, _ctx, details) { + const callId = details?.toolCall.callId ?? ''; + const subInfo = ctx.getSubAppInfo(toolId); + + ctx.streamResponseFn?.({ + id: callId, event: SseResponseEventEnum.toolCall, + data: { tool: { id: callId, toolName: subInfo?.name || toolId, toolAvatar: subInfo?.avatar || '', functionName: toolId, params: JSON.stringify(input) } } + }); + + const { response, usages = [], nodeResponse, capabilityAssistantResponses: capResps = [] } = await executeTool({ + callId, toolId, args: JSON.stringify(input) + }); + + if (nodeResponse) nodeResponses.push(nodeResponse); + if (usages.length) usagePush(usages); + if (capResps.length) capabilityAssistantResponses.push(...capResps); + + ctx.streamResponseFn?.({ + id: callId, event: SseResponseEventEnum.toolResponse, + data: { tool: { response } } + }); + + return response; + } + }); + }); +} +``` + +#### 4.3.3 usageBridge.ts +```ts +import type { ChatNodeUsageType } from '@fastgpt/global/support/wallet/bill/type'; +import type { Usage as OAIUsage } from '@openai/agents'; +import { formatModelChars2Points } from '../../../../../support/wallet/usage/utils'; +import { i18nT } from '../../../../../../web/i18n/utils'; + +export function convertOAIUsageToChatNodeUsages({ + usage, modelData, userKey +}: { usage: OAIUsage; modelData: LLMModelItemType; userKey?: any }): ChatNodeUsageType[] { + const entries = usage.requestUsageEntries ?? []; + if (entries.length === 0) { + // fallback: 总和当一条 + const totalPoints = userKey ? 0 : formatModelChars2Points({ + model: modelData, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens + }).totalPoints; + return [{ + moduleName: i18nT('account_usage:agent_call'), + model: modelData.name, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalPoints + }]; + } + + return entries.map(e => { + const totalPoints = userKey ? 0 : formatModelChars2Points({ + model: modelData, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens + }).totalPoints; + return { + moduleName: i18nT('account_usage:agent_call'), + model: modelData.name, + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + totalPoints + }; + }); +} +``` + +#### 4.3.4 index.ts(主调度,关键流程) +```ts +export const dispatchOpenAIAgent = async (props: DispatchAgentModuleProps): Promise => { + // ... 文件、capabilities、systemPrompt、subapps 初始化(直接照抄 piAgent/index.ts:70-160)... + + const { provider, modelId, modelData } = buildOpenAIRunner(model); + const runner = new Runner({ modelProvider: provider }); + + const oaiMessagesKey = `oaiMessages-${nodeId}`; + const lastHistory = chatHistories[chatHistories.length - 1]; + const restoredStateJSON = lastHistory?.obj === ChatRoleEnum.AI + ? (lastHistory.memories?.[oaiMessagesKey] as string | undefined) + : undefined; + + const tools = buildOpenAITools({ ctx: toolCtx, nodeResponses, capabilityAssistantResponses, usagePush }); + + const agent = new Agent({ + name: 'fastgpt-agent', + instructions: formatedSystemPrompt, + model: modelId, + tools + }); + + const ctrl = new AbortController(); + const stopPoller = setInterval(() => { + if (checkIsStopping()) { ctrl.abort(); clearInterval(stopPoller); } + }, 200); + + let answerText = ''; + let result; + try { + const input = restoredStateJSON + ? await RunState.fromString(agent, restoredStateJSON) // 续跑 + : formatUserChatInput; + + // 追加新输入到 state(如果是续跑场景) + const stream = await runner.run(agent, input, { + signal: ctrl.signal, + maxTurns: 100, + stream: true + }); + + for await (const event of stream) { + if (event.type === 'raw_model_stream_event') { + // 文本增量 + const delta = (event.data as any).delta; + if (typeof delta === 'string') { + answerText += delta; + workflowStreamResponse?.({ + event: SseResponseEventEnum.answer, + data: textAdaptGptResponse({ text: delta }) + }); + } + } + // tool_called / tool_output 事件已在 buildOpenAITools 内手动 emit,不重复 + } + + await stream.completed; + result = stream; + } finally { + clearInterval(stopPoller); + } + + // ===== 计费 ===== + usagePush(convertOAIUsageToChatNodeUsages({ usage: result.state.usage, modelData, userKey: externalProvider.openaiAccount })); + + // ===== 返回 ===== + if (answerText) assistantResponses.push({ text: { content: answerText } }); + + return { + data: { [NodeOutputKeyEnum.answerText]: answerText }, + [DispatchNodeResponseKeyEnum.memories]: { + [oaiMessagesKey]: result.state.toString() // 序列化全部状态用于跨轮恢复 + }, + [DispatchNodeResponseKeyEnum.assistantResponses]: assistantResponses, + [DispatchNodeResponseKeyEnum.nodeResponses]: nodeResponses + }; +}; +``` + +### 4.4 数据流总览 + +``` +用户输入 + ↓ +dispatchRunAgent (env.AGENT_ENGINE='openai') + ↓ +dispatchOpenAIAgent + ├─ formatFileInput / capabilities / getSubapps (复用) + ├─ buildOpenAIRunner(model) (新) + │ └─ new OpenAI({ baseURL, apiKey }) + │ └─ new OpenAIProvider({ openAIClient }) + ├─ buildOpenAITools(ctx) (新) + │ └─ 每个 tool.execute → getExecuteTool(ctx) → 现有分发链 + ├─ runner.run(agent, input, { signal, stream }) + │ ↓ + │ SDK 内部多轮 LLM + tool_call + │ ↓ + │ stream: raw_model_stream_event / run_item_stream_event + │ ↓ (toolAdapter 内 emit SSE) + │ workflowStreamResponse → 客户端 + ├─ convertOAIUsageToChatNodeUsages(result.state.usage) + │ └─ usagePush(usages) (新桥接,复用 formatModelChars2Points) + └─ result.state.toString() → memories (跨轮恢复) +``` + +--- + +## 5. 三大问题对照实现速查 + +| 问题 | 实现位置 | 关键 API | 改动量 | +|---|---|---|---| +| 1. 拿到 token 计费 | `usageBridge.ts` | `result.state.usage.requestUsageEntries[]` → `ChatNodeUsageType[]` → `usagePush(...)` | ~30 行 | +| 2. 传入 tool | `toolAdapter.ts` | `tool({ parameters: t.function.parameters, execute: ... })` | ~50 行 | +| 3. 使用 skill | 复用 `createSandboxSkillsCapability`,把 systemPrompt 注入 `Agent.instructions`、tools 注入 `Agent.tools` | 0 行新代码(与 piAgent 一致) | + +--- + +## 6. 决策点与风险 + +### 6.1 ⚠️ Plan + Step 拆解能力如何处理 [需用户拍板] + +**背景**:default 引擎的 `PlanAgentTool` 提供两个核心价值: +- 显式拆解任务为多个 step +- 支持 plan 中途用户 ask(人在回路) + +**OAI-Agents 没有等价机制**。三种选择: + +| 方案 | 描述 | 优劣 | +|---|---|---| +| **A. 不要 plan** | 完全交给 SDK 自主多轮 reasoning(max_turns=100) | 最简单;但任务复杂度高时模型可能跑偏 | +| **B. Plan as tool** | 把现有 `PlanAgentTool` 作为一个 SDK tool 喂进去(保留 toolAdapter 中对 plan 的过滤逻辑反过来) | 兼容现有 plan 能力;interactive ask 需要走 SDK 的 `needsApproval` + `RunState` 序列化机制重写 | +| **C. 双 Agent + handoff** | plannerAgent + workerAgent,handoff 切换 | 最贴近原 default 引擎模型;改造量最大 | + +**推荐**:**A**(首期)。理由:OAI-Agents 引擎本身就是为「自主多步推理 + 工具调用」设计的,强行套 plan 反而压制了它的优势;如果要 plan,留着 default 引擎用就行。 + +### 6.2 ⚠️ Tracing 默认外发 [必须处理] + +OAI-Agents 默认会上传 trace 到 `https://api.openai.com/v1/traces`,**包含完整的 prompt / tool args / response**。 + +**解决**:`modelBridge.ts` 顶部 `setTracingDisabled(true)`(已写入 §4.3.1)。 + +### 6.3 ⚠️ 多租户并发下的全局 setter [必须处理] + +下列 setter 是**进程级单例**: +- `setDefaultOpenAIClient` +- `setDefaultOpenAIKey` +- `setDefaultModelProvider` +- `setOpenAIAPI`(部分例外,下面说明) + +**对策**: +- ✅ 用 `new Runner({ modelProvider })` 每次 dispatch 创建独立 Runner(已在 §4.3.4 体现) +- ✅ `setOpenAIAPI('chat_completions')` 和 `setTracingDisabled(true)` 是「全进程一次性配置」性质,进程启动时设一次即可,不会有多租户冲突 +- ❌ 不要在 dispatch 路径中调 `setDefaultOpenAIClient` + +### 6.4 ⚠️ Cached / Reasoning Tokens [可延后] + +第三方 provider(DeepSeek、阿里、火山等)的 `inputTokensDetails.cached_tokens` / `outputTokensDetails.reasoning_tokens` 字段名可能不一致。 + +**首期**:只读 `inputTokens` / `outputTokens` 走基础计费,已能 100% 满足现有计费精度。 +**后期**:要做 cached token 折扣计费时再按 provider 适配。 + +### 6.5 ⚠️ Interactive 工具响应 [影响范围有限] + +OAI-Agents 通过 `tool({ needsApproval: true })` + `RunState.fromString` 实现 HITL,与 FastGPT 的 `WorkflowInteractiveResponseType` 机制不兼容。 + +**首期对策**:在 `toolAdapter` 里**不开启** interactive;如果走到产生 interactive 的工具,直接当 stop 处理(response = 错误消息)。default 引擎仍然支持 interactive,是 default 的差异化能力。 + +### 6.6 ⚠️ 包版本冲突 [需验证] + +OAI-Agents peer dep `openai@^6.26.0`,需确认 `pnpm why openai` 现有版本是否兼容。FastGPT 可能在 `packages/service` 下接入了别的 openai 调用,可能要统一版本。 + +**验证命令**: +```bash +cd /Volumes/code/fastgpt-pro/FastGPT/packages/service && pnpm why openai +``` + +### 6.7 ⚠️ State 序列化体积 [可观测] + +`result.state.toString()` 会把 history、turn、pending tool calls 全部序列化。多轮长会话场景下 memories 字段会很大。 + +**对策**: +- 监控 `oaiMessagesKey` 字段大小 +- 如超过阈值(如 200KB),降级为只保存 `result.history`,下次启动新 Agent 重新构建(损失 plan/turn 元信息但消息历史保留) + +--- + +## 7. 落地里程碑(建议) + +| 里程碑 | 工作内容 | 预估工时 | +|---|---|---| +| **M1:依赖与基础设施** | `pnpm add @openai/agents`;env 增加 `'openai'` 枚举;新建 `openaiAgent/` 目录骨架 | 0.5d | +| **M2:modelBridge + toolAdapter** | 实现 `buildOpenAIRunner` / `buildOpenAITools` / `usageBridge`;写最小 e2e(hello world tool) | 1.5d | +| **M3:主调度 + skill** | 实现 `dispatchOpenAIAgent`;接入 `createSandboxSkillsCapability`;接入 SSE 流;接入 `RunState` 续跑 | 2d | +| **M4:计费验证** | 跑通 OpenAI / DeepSeek / 阿里 三类 endpoint;对 `usagePush` 输出做单测,对比 default 引擎一致性 | 1d | +| **M5:边界 & 灰度** | abort、超时、错误重试、context 压缩、长会话 | 1d | +| **M6:文档 + 灰度** | 写 docs;先内部 `AGENT_ENGINE=openai` 灰度 | 0.5d | + +总计 ~ **6.5 人日**。 + +--- + +## 8. 待用户确认的问题 + +1. **是否同意"新增第三种引擎"而非替换 pi**?(推荐新增) +2. **Plan 拆解能力是否要保留**?(推荐首期不要,详见 §6.1) +3. **Interactive ask 是否要支持**?(推荐首期不要,详见 §6.5) +4. **首期支持的 LLM provider 范围**:仅 OpenAI 官方 / OpenAI + 第三方兼容 endpoint / 含 Claude+Gemini(需走 ai-sdk 桥,beta)? +5. **是否接受 `setTracingDisabled(true)` 直接禁掉所有 trace 上传**?(推荐是;如果想留 trace,需自建 trace 上报 endpoint) + +--- + +## 附录 A:参考链接 + +- 主文档:https://openai.github.io/openai-agents-js/ +- Models 指南:https://openai.github.io/openai-agents-js/guides/models +- AI SDK 适配(Claude/Gemini 走这条):https://openai.github.io/openai-agents-js/extensions/ai-sdk +- 仓库:https://github.com/openai/openai-agents-js +- 关键源码(建议直接看): + - `packages/agents-core/src/usage.ts`(Usage / RequestUsage) + - `packages/agents-core/src/result.ts`(RunResult / StreamedRunResult) + - `packages/agents-core/src/run.ts`(Runner / RunConfig) + - `packages/agents-openai/src/openaiProvider.ts` + - `packages/agents-core/src/runState.ts:914-931`(fromString / 续跑) + - `examples/model-providers/custom-example-global.ts`(最贴近 FastGPT 需求的示例) + +## 附录 B:文件清单 + +| 路径 | 状态 | 行数估算 | +|---|---|---| +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/index.ts` | 新增 | ~280 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/modelBridge.ts` | 新增 | ~50 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/toolAdapter.ts` | 新增 | ~80 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/usageBridge.ts` | 新增 | ~40 | +| `packages/service/core/workflow/dispatch/ai/agent/openaiAgent/streamBridge.ts` | 新增(如必要) | ~60 | +| `packages/service/core/workflow/dispatch/ai/agent/index.ts` | 修改(+1 if) | +3 | +| `packages/service/env.ts` | 修改(枚举扩展) | +0(改字面量) | +| `packages/service/package.json` | 修改 | +1 deps | diff --git a/.claude/skills/system-test/SKILL.md b/.claude/skills/system-test/SKILL.md index beb0ec3777..fc925c1417 100644 --- a/.claude/skills/system-test/SKILL.md +++ b/.claude/skills/system-test/SKILL.md @@ -11,9 +11,9 @@ description: 当用户需要编写一个单元测试时,触发该 skill,编 ### packages 测试 -packages 里的测试,写在 FastGPT/test/cases 目录下,子路径对应 packages 的目录结构。例如: +packages 里的测试,写在 FastGPT/packages/xxx/test 目录下,子路径对应 packages 的目录结构。例如: -`packages/global/common/error/s3.ts`文件,对应的测例文件路径为 `test/cases/global/common/error/s3.test.ts`。 +`packages/global/common/error/s3.ts`文件,对应的测例文件路径为 `packages/test/global/common/error/s3.test.ts`。 并且,可以通过 @fastgpt 来导入 packages 里的文件。 例如: @@ -39,7 +39,7 @@ projects 里的测试,写在 FastGPT/projects/app/test 目录下,子路径 ```ts // FastGPT/packages/service/common/geo/index.ts import type { NextApiRequest } from 'next'; -// 同时导出一个依赖给 FastGPT/test/cases/service/common/geo/index.test.ts 使用 +// 同时导出一个依赖给 FastGPT/packages/service/test/common/geo/index.test.ts 使用 export type { NextApiRequest } from 'next'; ``` diff --git a/document/content/self-host/upgrading/4-15/4150.mdx b/document/content/self-host/upgrading/4-15/4150.mdx index f1063a78e2..c3840065ce 100644 --- a/document/content/self-host/upgrading/4-15/4150.mdx +++ b/document/content/self-host/upgrading/4-15/4150.mdx @@ -11,10 +11,13 @@ description: 'FastGPT V4.15.0 更新说明' ## ⚙️ 优化 1. 增加父子节点选中互斥功能,解决:同时选中父子节点时,移动节点会出现抖动。 +2. 调整文件注入 messages 位置,从 system 调整至 user,便于命中缓存。 ## 🐛 修复 ## 代码优化 -1. 优化 Agent tool 声明和运行,统一所有 tool 的声明和运行方式。 \ No newline at end of file +1. 重新调整代码结构,升级 nextjs 最新版,切换至 turbopack 构建,提高构建速度;升级容器默认 node 至 24。 +2. 优化 Agent tool 声明和运行,统一所有 tool 的声明和运行方式。 +3. 文件上传内容从 system prompt 中放到 user message 中,提高 cache 命中率 \ No newline at end of file diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index a0dd6b5ab7..91863428ac 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -230,7 +230,7 @@ "content/self-host/upgrading/4-14/41415.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/41415.mdx": "2026-04-26T21:28:27+08:00", "content/self-host/upgrading/4-14/41416.en.mdx": "2026-04-26T21:28:27+08:00", - "content/self-host/upgrading/4-14/41416.mdx": "2026-04-26T21:28:27+08:00", + "content/self-host/upgrading/4-14/41416.mdx": "2026-04-26T22:41:57+08:00", "content/self-host/upgrading/4-14/4142.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4142.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4143.en.mdx": "2026-04-26T21:08:47+08:00", @@ -251,7 +251,7 @@ "content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/4-15/4150.mdx": "2026-04-26T21:08:47+08:00", + "content/self-host/upgrading/4-15/4150.mdx": "2026-04-24T13:02:20+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/41.en.mdx": "2026-04-26T21:08:47+08:00", @@ -426,4 +426,4 @@ "content/use-cases/external-integration/wecom.mdx": "2026-04-26T21:08:47+08:00", "content/use-cases/index.en.mdx": "2026-04-26T21:08:47+08:00", "content/use-cases/index.mdx": "2026-04-26T21:08:47+08:00" -} +} \ No newline at end of file diff --git a/document/package-lock.json b/document/package-lock.json index 5162e21cde..4c4563cb5a 100644 --- a/document/package-lock.json +++ b/document/package-lock.json @@ -1,11 +1,11 @@ { - "name": "fast", + "name": "@fastgpt/document", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "fast", + "name": "@fastgpt/document", "version": "0.0.0", "hasInstallScript": true, "dependencies": { @@ -20,7 +20,7 @@ "fumadocs-ui": "15.6.3", "gray-matter": "^4.0.3", "lucide-react": "^0.525.0", - "next": "15.5.9", + "next": "^15.5.15", "react": "^19.1.0", "react-dom": "^19.1.0", "react-responsive": "^10.0.1", @@ -32,7 +32,7 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { - "@content-collections/core": "^0.10.0", + "@content-collections/core": "^0.15.0", "@content-collections/next": "^0.2.6", "@tailwindcss/postcss": "^4.1.11", "@types/mdx": "^2.0.13", @@ -73,37 +73,26 @@ } }, "node_modules/@content-collections/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@content-collections/core/-/core-0.10.0.tgz", - "integrity": "sha512-GDBYbvhoj9lHNlarY5wr+3PoO3m9GBMjftio9NXatLuZaenY+EHHNCcbbA3J+c06Q7WBYwNoLAaMX2I5N0duAg==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@content-collections/core/-/core-0.15.0.tgz", + "integrity": "sha512-GCbG7p+7cGnPUAyoOPoT8ABsd9D6VWF17YJUl+IHJWr+VYTYNKyDaQtJ+6Pfg55GvtuW3gCfqJkOx6Pu2SB6YA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "camelcase": "^8.0.0", "chokidar": "^4.0.3", - "esbuild": "^0.25.0", + "esbuild": "^0.25.11", "gray-matter": "^4.0.3", "p-limit": "^6.1.0", "picomatch": "^4.0.2", "pluralize": "^8.0.0", - "serialize-javascript": "^6.0.2", + "serialize-javascript": "^7.0.3", "tinyglobby": "^0.2.5", - "yaml": "^2.4.5", - "zod": "^3.24.4" + "yaml": "^2.4.5" }, "peerDependencies": { - "typescript": "^5.0.2" - } - }, - "node_modules/@content-collections/core/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "typescript": "^5.0.2 || ^6.0.0" } }, "node_modules/@content-collections/integrations": { @@ -139,9 +128,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -155,9 +144,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -171,9 +160,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -187,9 +176,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -203,9 +192,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -219,9 +208,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -235,9 +224,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -251,9 +240,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -267,9 +256,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -283,9 +272,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -299,9 +288,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -315,9 +304,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -331,9 +320,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -347,9 +336,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -363,9 +352,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -379,9 +368,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -395,9 +384,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -411,9 +400,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -427,9 +416,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -443,9 +432,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -459,9 +448,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -475,9 +464,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -491,9 +480,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -507,9 +496,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -523,9 +512,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -539,9 +528,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -1108,15 +1097,15 @@ } }, "node_modules/@next/env": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", - "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz", + "integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", - "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz", + "integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==", "cpu": [ "arm64" ], @@ -1130,9 +1119,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", - "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz", + "integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==", "cpu": [ "x64" ], @@ -1146,9 +1135,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", - "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz", + "integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==", "cpu": [ "arm64" ], @@ -1162,9 +1151,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", - "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz", + "integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==", "cpu": [ "arm64" ], @@ -1178,9 +1167,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", - "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz", + "integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==", "cpu": [ "x64" ], @@ -1194,9 +1183,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", - "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz", + "integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==", "cpu": [ "x64" ], @@ -1210,9 +1199,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", - "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz", + "integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==", "cpu": [ "arm64" ], @@ -1226,9 +1215,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", - "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz", + "integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==", "cpu": [ "x64" ], @@ -3025,9 +3014,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -3037,32 +3026,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escape-string-regexp": { @@ -5125,9 +5114,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5193,12 +5182,12 @@ } }, "node_modules/next": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", - "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", + "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", "license": "MIT", "dependencies": { - "@next/env": "15.5.9", + "@next/env": "15.5.15", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -5211,14 +5200,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.7", - "@next/swc-darwin-x64": "15.5.7", - "@next/swc-linux-arm64-gnu": "15.5.7", - "@next/swc-linux-arm64-musl": "15.5.7", - "@next/swc-linux-x64-gnu": "15.5.7", - "@next/swc-linux-x64-musl": "15.5.7", - "@next/swc-win32-arm64-msvc": "15.5.7", - "@next/swc-win32-x64-msvc": "15.5.7", + "@next/swc-darwin-arm64": "15.5.15", + "@next/swc-darwin-x64": "15.5.15", + "@next/swc-linux-arm64-gnu": "15.5.15", + "@next/swc-linux-arm64-musl": "15.5.15", + "@next/swc-linux-x64-gnu": "15.5.15", + "@next/swc-linux-x64-musl": "15.5.15", + "@next/swc-win32-arm64-msvc": "15.5.15", + "@next/swc-win32-x64-msvc": "15.5.15", "sharp": "^0.34.3" }, "peerDependencies": { @@ -5368,9 +5357,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -5472,16 +5461,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -5857,27 +5836,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -5926,13 +5884,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/shallow-equal": { @@ -6430,9 +6388,9 @@ } }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -6440,6 +6398,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { diff --git a/document/package.json b/document/package.json index 8e70e24a6f..3a233b91c6 100644 --- a/document/package.json +++ b/document/package.json @@ -25,7 +25,7 @@ "fumadocs-ui": "15.6.3", "gray-matter": "^4.0.3", "lucide-react": "^0.525.0", - "next": "15.5.9", + "next": "^15.5.15", "react": "^19.1.0", "react-dom": "^19.1.0", "react-responsive": "^10.0.1", @@ -37,7 +37,7 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { - "@content-collections/core": "^0.10.0", + "@content-collections/core": "^0.15.0", "@content-collections/next": "^0.2.6", "@tailwindcss/postcss": "^4.1.11", "@types/mdx": "^2.0.13", diff --git a/packages/global/core/ai/prompt/AIChat.ts b/packages/global/core/ai/prompt/AIChat.ts index d701ce9e6c..b74c0c52f3 100644 --- a/packages/global/core/ai/prompt/AIChat.ts +++ b/packages/global/core/ai/prompt/AIChat.ts @@ -311,16 +311,3 @@ export const getQuotePrompt = (version?: string, role: 'user' | 'system' = 'user return getPromptByVersion(version, defaultTemplate); }; - -// Document quote prompt -export const getDocumentQuotePrompt = (version?: string) => { - const promptMap = { - ['4.9.7']: `将 中的内容作为本次对话的参考: - -{{quote}} - -` - }; - - return getPromptByVersion(version, promptMap); -}; diff --git a/packages/global/test/core/ai/prompt/AIChat.test.ts b/packages/global/test/core/ai/prompt/AIChat.test.ts index b65eeb833b..0278cd1f34 100644 --- a/packages/global/test/core/ai/prompt/AIChat.test.ts +++ b/packages/global/test/core/ai/prompt/AIChat.test.ts @@ -4,8 +4,7 @@ import { Prompt_systemQuotePromptList, Prompt_QuoteTemplateList, getQuoteTemplate, - getQuotePrompt, - getDocumentQuotePrompt + getQuotePrompt } from '@fastgpt/global/core/ai/prompt/AIChat'; /** @@ -244,41 +243,3 @@ describe('getQuotePrompt', () => { }); }); }); - -// =========================================================================== -// 6. getDocumentQuotePrompt -// =========================================================================== -describe('getDocumentQuotePrompt', () => { - it('should return a prompt containing tags', () => { - const result = getDocumentQuotePrompt('4.9.7'); - expect(result).toContain(''); - expect(result).toContain(''); - }); - - it('should return a prompt containing {{quote}} placeholder', () => { - const result = getDocumentQuotePrompt('4.9.7'); - expect(result).toContain('{{quote}}'); - }); - - it('should return the 4.9.7 version when that version is requested', () => { - const result = getDocumentQuotePrompt('4.9.7'); - expect(typeof result).toBe('string'); - expect(result!.length).toBeGreaterThan(0); - }); - - it('should return highest version when no version is provided', () => { - const result = getDocumentQuotePrompt(); - expect(typeof result).toBe('string'); - expect(result).toContain('{{quote}}'); - expect(result).toContain(''); - }); - - it('should fall back to highest version for non-existing version', () => { - const result = getDocumentQuotePrompt('99.99.99'); - expect(result).toBe(getDocumentQuotePrompt()); - }); - - it('should return the same result for undefined and non-existing version', () => { - expect(getDocumentQuotePrompt(undefined)).toBe(getDocumentQuotePrompt('0.0.1')); - }); -}); diff --git a/packages/service/core/ai/llm/agentLoop/prompt.ts b/packages/service/core/ai/llm/agentLoop/prompt.ts index b731483e9d..7adf5ec83a 100644 --- a/packages/service/core/ai/llm/agentLoop/prompt.ts +++ b/packages/service/core/ai/llm/agentLoop/prompt.ts @@ -45,19 +45,28 @@ ${list} }; /* ===== Inject user query ===== */ -export const injectUserFilesPrompt = (files: { index: number; name: string }[] = []) => { +export const getUserFilesPrompt = ( + files: { id: string; name: string; content?: string }[] = [] +) => { if (files.length === 0) return ''; return `# Input Files 本次用户上传的文件: -${files.map((file) => `- 文件${file.index}: ${file.name}`).join('\n')}`; +${files + .map((file) => + ` +${file.name} +${file.content ? `${file.content}` : ''} +`.trim() + ) + .join('\n')}`; }; export const injectUserQueryTimePrompt = (time: string) => { return `# Current time ${time}`; }; export const injectUserQueryPrompt = ({ - query, + query = '', filePrompt, timePrompt }: { diff --git a/packages/service/core/chat/utils.ts b/packages/service/core/chat/utils.ts index d3badd0b2d..0996f84748 100644 --- a/packages/service/core/chat/utils.ts +++ b/packages/service/core/chat/utils.ts @@ -5,7 +5,7 @@ import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/i import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import type { VariableItemType } from '@fastgpt/global/core/app/type'; import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants'; -import { clone, cloneDeep } from 'lodash'; +import { cloneDeep } from 'lodash'; export const addPreviewUrlToChatItems = async ( histories: ChatItemMiniType[], diff --git a/packages/service/core/workflow/dispatch/ai/chat.ts b/packages/service/core/workflow/dispatch/ai/chat.ts index 163533616b..29ef5285ca 100644 --- a/packages/service/core/workflow/dispatch/ai/chat.ts +++ b/packages/service/core/workflow/dispatch/ai/chat.ts @@ -15,11 +15,7 @@ import { GPTMessages2Chats, runtimePrompt2ChatsValue } from '@fastgpt/global/core/chat/adapt'; -import { - getQuoteTemplate, - getQuotePrompt, - getDocumentQuotePrompt -} from '@fastgpt/global/core/ai/prompt/AIChat'; +import { getQuoteTemplate, getQuotePrompt } from '@fastgpt/global/core/ai/prompt/AIChat'; import type { AIChatNodeProps } from '@fastgpt/global/core/workflow/runtime/type'; import { replaceVariable } from '@fastgpt/global/common/string/tools'; import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; @@ -34,8 +30,9 @@ import { getHistoryPreview } from '@fastgpt/global/core/chat/utils'; import { computedMaxToken } from '../../../ai/utils'; import { formatTime2YMDHM } from '@fastgpt/global/common/string/time'; import type { AiChatQuoteRoleType } from '@fastgpt/global/core/workflow/template/system/aiChat/type'; -import { getFileContentFromLinks, getHistoryFileLinks } from '../tools/readFiles'; +import { getFileContentFromLinks } from '../../utils/file'; import { parseUrlToFileType } from '../../utils/context'; +import { rewriteUserQueryWithFiles } from '../../utils/file'; import { i18nT } from '../../../../../web/i18n/utils'; import { postTextCensor } from '../../../chat/postTextCensor'; import { createLLMResponse } from '../../../ai/llm/request'; @@ -94,13 +91,10 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise { @@ -174,6 +167,9 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise { function getValue({ item, index }: { item: SearchDataResponseItemType; index: number }) { return replaceVariable(quoteTemplate, { id: item.id, @@ -323,112 +330,6 @@ async function filterDatasetQuote({ ? `${filterQuoteQA.map((item, index) => getValue({ item, index }).trim()).join('\n------\n')}` : ''; - return { - datasetQuoteText - }; -} - -async function getMultiInput({ - histories, - inputFiles, - fileLinks, - stringQuoteText, - requestOrigin, - maxFiles, - customPdfParse, - usageId, - runningUserInfo -}: { - histories: ChatItemMiniType[]; - inputFiles: UserChatItemFileItemType[]; - fileLinks?: string[]; - stringQuoteText?: string; // file quote - requestOrigin?: string; - maxFiles: number; - customPdfParse?: boolean; - usageId?: string; - runningUserInfo: ChatDispatchProps['runningUserInfo']; -}) { - // 旧版本适配====> - if (stringQuoteText) { - return { - documentQuoteText: stringQuoteText, - userFiles: inputFiles - }; - } - - // 没有引用文件参考,但是可能用了图片识别 - if (!fileLinks) { - return { - documentQuoteText: '', - userFiles: inputFiles - }; - } - // 旧版本适配<==== - - // If fileLinks params is not empty, it means it is a new version, not get the global file. - - // Get files from histories - const filesFromHistories = getHistoryFileLinks(histories); - const urls = [...fileLinks, ...filesFromHistories]; - - if (urls.length === 0) { - return { - documentQuoteText: '', - userFiles: [] - }; - } - - const { text } = await getFileContentFromLinks({ - // Concat fileUrlList and filesFromHistories; remove not supported files - urls, - requestOrigin, - maxFiles, - teamId: runningUserInfo.teamId, - tmbId: runningUserInfo.tmbId, - customPdfParse, - usageId - }); - - return { - documentQuoteText: text, - userFiles: fileLinks - .map((url) => parseUrlToFileType(url)) - .filter(Boolean) as UserChatItemFileItemType[] - }; -} - -async function getChatMessages({ - model, - maxTokens = 0, - aiChatQuoteRole, - datasetQuotePrompt = '', - datasetQuoteText, - useDatasetQuote, - version, - histories = [], - systemPrompt, - userChatInput, - userFiles, - documentQuoteText -}: { - model: LLMModelItemType; - maxTokens?: number; - // dataset quote - aiChatQuoteRole: AiChatQuoteRoleType; // user: replace user prompt; system: replace system prompt - datasetQuotePrompt?: string; - datasetQuoteText: string; - version?: string; - - useDatasetQuote: boolean; - histories: ChatItemMiniType[]; - systemPrompt: string; - userChatInput: string; - - userFiles: UserChatItemFileItemType[]; - documentQuoteText?: string; // document quote -}) { - // Dataset prompt ====> // User role or prompt include question const quoteRole = aiChatQuoteRole === 'user' || datasetQuotePrompt.includes('{{question}}') ? 'user' : 'system'; @@ -445,49 +346,105 @@ async function getChatMessages({ question: userChatInput }) : userChatInput; - // Dataset prompt <==== - // Concat system prompt - const concatenateSystemPrompt = [ - model.defaultSystemChatPrompt, - systemPrompt, + const systemPrompt = useDatasetQuote && quoteRole === 'system' ? replaceVariable(datasetQuotePromptTemplate, { quote: datasetQuoteText }) - : '', - documentQuoteText - ? replaceVariable(getDocumentQuotePrompt(version), { - quote: documentQuoteText - }) - : '' + : ''; + + return { + userInput: replaceInputValue, + systemPrompt + }; +}; + +const getInputFiles = ({ fileLinks = [] }: { fileLinks?: string[] }) => { + return fileLinks + .map((url) => parseUrlToFileType(url)) + .filter(Boolean) as UserChatItemFileItemType[]; +}; + +const getChatMessages = async ({ + model, + maxTokens = 0, + histories = [], + datasetCiteSystemPrompt, + systemPrompt, + userChatInput, + userFiles, + requestOrigin, + maxFiles, + customPdfParse, + usageId, + runningUserInfo +}: { + model: LLMModelItemType; + maxTokens?: number; + histories: ChatItemMiniType[]; + + datasetCiteSystemPrompt?: string; + systemPrompt: string; + + userChatInput: string; + userFiles: UserChatItemFileItemType[]; + + requestOrigin?: string; + maxFiles: number; + customPdfParse?: boolean; + usageId?: string; + runningUserInfo: ChatDispatchProps['runningUserInfo']; +}) => { + const concatenateSystemPrompt = [ + model.defaultSystemChatPrompt, + systemPrompt, + datasetCiteSystemPrompt ] .filter(Boolean) .join('\n\n===---===---===\n\n'); - const messages: ChatItemMiniType[] = [ + const rawUserMessages: ChatItemMiniType[] = [ ...getSystemPrompt_ChatItemType(concatenateSystemPrompt), ...histories, { obj: ChatRoleEnum.Human, value: runtimePrompt2ChatsValue({ files: userFiles, - text: replaceInputValue + text: userChatInput }) } ]; + const messages = await Promise.all( + rawUserMessages.map(async (message, index): Promise => { + if (message.obj !== ChatRoleEnum.Human) { + return message; + } + + return { + ...message, + value: await rewriteUserQueryWithFiles({ + queryId: message.dataId || `${index}`, + userQuery: message.value, + requestOrigin, + maxFiles, + customPdfParse, + usageId, + teamId: runningUserInfo.teamId, + tmbId: runningUserInfo.tmbId + }) + }; + }) + ); + const adaptMessages = chats2GPTMessages({ messages, reserveId: false }); - const filterMessages = await filterGPTMessageByMaxContext({ + return await filterGPTMessageByMaxContext({ messages: adaptMessages, maxContext: model.maxContext - maxTokens // filter token. not response maxToken }); - - return { - filterMessages - }; -} +}; diff --git a/packages/service/core/workflow/dispatch/ai/tool/constants.ts b/packages/service/core/workflow/dispatch/ai/toolcall/constants.ts similarity index 79% rename from packages/service/core/workflow/dispatch/ai/tool/constants.ts rename to packages/service/core/workflow/dispatch/ai/toolcall/constants.ts index 03b583f8e9..804f23b1d3 100644 --- a/packages/service/core/workflow/dispatch/ai/tool/constants.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/constants.ts @@ -4,19 +4,6 @@ import { getNanoid } from '@fastgpt/global/common/string/tools'; import type { ChildResponseItemType } from './type'; import { SANDBOX_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/constants'; -export const getMultiplePrompt = (obj: { - fileCount: number; - imgCount: number; - question: string; -}) => { - const prompt = `Number of session file inputs: -Document:{{fileCount}} -Image:{{imgCount}} ------- -{{question}}`; - return replaceVariable(prompt, obj); -}; - export const getSandboxToolWorkflowResponse = ({ name, logo, diff --git a/packages/service/core/workflow/dispatch/ai/tool/index.ts b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts similarity index 63% rename from packages/service/core/workflow/dispatch/ai/tool/index.ts rename to packages/service/core/workflow/dispatch/ai/toolcall/index.ts index 54a45cfc79..6b8095ac69 100644 --- a/packages/service/core/workflow/dispatch/ai/tool/index.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts @@ -1,39 +1,25 @@ import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import type { - ChatDispatchProps, - DispatchNodeResultType, - RuntimeNodeItemType -} from '@fastgpt/global/core/workflow/runtime/type'; +import type { DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; import { getLLMModel } from '../../../../ai/model'; import { filterToolNodeIdByEdges, getNodeErrResponse, getHistories } from '../../utils'; import { runToolCall } from './toolCall'; import { type DispatchToolModuleProps, type ToolNodeItemType } from './type'; -import type { - UserChatItemFileItemType, - ChatItemMiniType, - UserChatItemValueItemType -} from '@fastgpt/global/core/chat/type'; +import type { UserChatItemFileItemType, ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { GPTMessages2Chats, - chatValue2RuntimePrompt, chats2GPTMessages, getSystemPrompt_ChatItemType, runtimePrompt2ChatsValue } from '@fastgpt/global/core/chat/adapt'; import { getHistoryPreview } from '@fastgpt/global/core/chat/utils'; -import { replaceVariable } from '@fastgpt/global/common/string/tools'; -import { getMultiplePrompt } from './constants'; import { filterToolResponseToPreview } from './utils'; -import { getFileContentFromLinks, getHistoryFileLinks } from '../../tools/readFiles'; import { parseUrlToFileType } from '../../../utils/context'; -import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; -import { getDocumentQuotePrompt } from '@fastgpt/global/core/ai/prompt/AIChat'; +import { rewriteUserQueryWithFiles } from '../../../utils/file'; import { postTextCensor } from '../../../../chat/postTextCensor'; import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type'; -import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema'; import { getToolConfigStatus } from '@fastgpt/global/core/app/formEdit/utils'; type Response = DispatchNodeResultType<{ @@ -42,11 +28,10 @@ type Response = DispatchNodeResultType<{ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise => { let { - node: { nodeId, name, isEntry, version, inputs }, + node: { nodeId, isEntry, inputs }, runtimeNodes, runtimeEdges, histories, - query, requestOrigin, chatConfig, lastInteractive, @@ -126,69 +111,52 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< // Check interactive entry props.node.isEntry = false; - const hasReadFilesTool = toolNodes.some( - (item) => item.flowNodeType === FlowNodeTypeEnum.readFiles - ); - const globalFiles = chatValue2RuntimePrompt(query).files; - const { documentQuoteText, userFiles } = await getMultiInput({ - runningUserInfo, - histories: chatHistories, - requestOrigin, - maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20, - customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse, - fileLinks, - inputFiles: globalFiles, - hasReadFilesTool, - usageId, - appId: props.runningAppInfo.id, - chatId: props.chatId, - uId: props.uid + const { userFiles } = await getMultiInput({ + fileLinks }); - const concatenateSystemPrompt = [ - toolModel.defaultSystemChatPrompt, - systemPrompt, - documentQuoteText - ? replaceVariable(getDocumentQuotePrompt(version), { - quote: documentQuoteText - }) - : '' - ] + const concatenateSystemPrompt = [toolModel.defaultSystemChatPrompt, systemPrompt] .filter(Boolean) - .join('\n\n===---===---===\n\n'); + .join('\n\n-----\n\n'); - const messages: ChatItemMiniType[] = (() => { + const messages = await (async () => { const value: ChatItemMiniType[] = [ ...getSystemPrompt_ChatItemType(concatenateSystemPrompt), - // Add file input prompt to histories - ...chatHistories.map((item) => { - if (item.obj === ChatRoleEnum.Human) { - return { - ...item, - value: toolCallMessagesAdapt({ - userInput: item.value, - skip: !hasReadFilesTool - }) - }; - } - return item; - }), + ...chatHistories, { obj: ChatRoleEnum.Human, - value: toolCallMessagesAdapt({ - skip: !hasReadFilesTool, - userInput: runtimePrompt2ChatsValue({ - text: userChatInput, - files: userFiles - }) + value: runtimePrompt2ChatsValue({ + text: userChatInput, + files: userFiles }) } ]; - if (lastInteractive && isEntry) { - return value.slice(0, -2); - } - return value; + + const runtimeMessages = lastInteractive && isEntry ? value.slice(0, -2) : value; + + const maxFiles = chatConfig?.fileSelectConfig?.maxFiles || 20; + return Promise.all( + runtimeMessages.map(async (message, index): Promise => { + if (message.obj !== ChatRoleEnum.Human) { + return message; + } + + return { + ...message, + value: await rewriteUserQueryWithFiles({ + queryId: message.dataId || `${index}`, + userQuery: message.value, + requestOrigin, + maxFiles, + customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse, + usageId, + teamId: runningUserInfo.teamId, + tmbId: runningUserInfo.tmbId + }) + }; + }) + ); })(); // censor model and system key @@ -310,115 +278,10 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< } }; -const getMultiInput = async ({ - runningUserInfo, - histories, - fileLinks, - requestOrigin, - maxFiles, - customPdfParse, - inputFiles, - hasReadFilesTool, - usageId, - appId, - chatId, - uId -}: { - runningUserInfo: ChatDispatchProps['runningUserInfo']; - histories: ChatItemMiniType[]; - fileLinks?: string[]; - requestOrigin?: string; - maxFiles: number; - customPdfParse?: boolean; - inputFiles: UserChatItemFileItemType[]; - hasReadFilesTool: boolean; - usageId?: string; - appId: string; - chatId?: string; - uId: string; -}) => { - // Not file quote - if (!fileLinks || hasReadFilesTool) { - return { - documentQuoteText: '', - userFiles: inputFiles - }; - } - - const filesFromHistories = getHistoryFileLinks(histories); - const urls = [...fileLinks, ...filesFromHistories]; - - if (urls.length === 0) { - return { - documentQuoteText: '', - userFiles: [] - }; - } - - // Get files from histories - const { text } = await getFileContentFromLinks({ - // Concat fileUrlList and filesFromHistories; remove not supported files - urls, - requestOrigin, - maxFiles, - customPdfParse, - usageId, - teamId: runningUserInfo.teamId, - tmbId: runningUserInfo.tmbId - }); - +const getMultiInput = async ({ fileLinks = [] }: { fileLinks?: string[] }) => { return { - documentQuoteText: text, userFiles: fileLinks .map((url) => parseUrlToFileType(url)) .filter(Boolean) as UserChatItemFileItemType[] }; }; - -/* -Tool call, auth add file prompt to question。 -Guide the LLM to call tool. -*/ -const toolCallMessagesAdapt = ({ - userInput, - skip -}: { - userInput: UserChatItemValueItemType[]; - skip?: boolean; -}): UserChatItemValueItemType[] => { - if (skip) return userInput; - - const files = userInput.filter((item) => 'file' in item); - - if (files.length > 0) { - const filesCount = files.filter((file) => file.file?.type === 'file').length; - const imgCount = files.filter((file) => file.file?.type === 'image').length; - - if (userInput.some((item) => 'text' in item)) { - return userInput.map((item) => { - if ('text' in item) { - const text = item.text?.content || ''; - - return { - ...item, - text: { - content: getMultiplePrompt({ fileCount: filesCount, imgCount, question: text }) - } - }; - } - return item; - }); - } - - // Every input is a file - return [ - { - text: { - content: getMultiplePrompt({ fileCount: filesCount, imgCount, question: '' }) - } - } - ]; - } - - return userInput; -}; diff --git a/packages/service/core/workflow/dispatch/ai/tool/stopTool.ts b/packages/service/core/workflow/dispatch/ai/toolcall/stopTool.ts similarity index 100% rename from packages/service/core/workflow/dispatch/ai/tool/stopTool.ts rename to packages/service/core/workflow/dispatch/ai/toolcall/stopTool.ts diff --git a/packages/service/core/workflow/dispatch/ai/tool/toolCall.ts b/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts similarity index 100% rename from packages/service/core/workflow/dispatch/ai/tool/toolCall.ts rename to packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts diff --git a/packages/service/core/workflow/dispatch/ai/tool/toolParams.ts b/packages/service/core/workflow/dispatch/ai/toolcall/toolParams.ts similarity index 100% rename from packages/service/core/workflow/dispatch/ai/tool/toolParams.ts rename to packages/service/core/workflow/dispatch/ai/toolcall/toolParams.ts diff --git a/packages/service/core/workflow/dispatch/ai/tool/type.ts b/packages/service/core/workflow/dispatch/ai/toolcall/type.ts similarity index 100% rename from packages/service/core/workflow/dispatch/ai/tool/type.ts rename to packages/service/core/workflow/dispatch/ai/toolcall/type.ts diff --git a/packages/service/core/workflow/dispatch/ai/tool/utils.ts b/packages/service/core/workflow/dispatch/ai/toolcall/utils.ts similarity index 100% rename from packages/service/core/workflow/dispatch/ai/tool/utils.ts rename to packages/service/core/workflow/dispatch/ai/toolcall/utils.ts diff --git a/packages/service/core/workflow/dispatch/ai/utils.ts b/packages/service/core/workflow/dispatch/ai/utils.ts index ae05e17002..e562fd99dc 100644 --- a/packages/service/core/workflow/dispatch/ai/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/utils.ts @@ -12,7 +12,7 @@ import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/ import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type'; import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema'; -import type { ToolNodeItemType } from './tool/type'; +import type { ToolNodeItemType } from './toolcall/type'; import json5 from 'json5'; import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; diff --git a/packages/service/core/workflow/dispatch/constants.ts b/packages/service/core/workflow/dispatch/constants.ts index f054734fec..e052c25ffa 100644 --- a/packages/service/core/workflow/dispatch/constants.ts +++ b/packages/service/core/workflow/dispatch/constants.ts @@ -3,9 +3,9 @@ import { dispatchAppRequest } from './abandoned/runApp'; import { dispatchLoop } from './abandoned/runLoop'; import { dispatchClassifyQuestion } from './ai/classifyQuestion'; import { dispatchContentExtract } from './ai/extract'; -import { dispatchRunTools } from './ai/tool/index'; -import { dispatchStopToolCall } from './ai/tool/stopTool'; -import { dispatchToolParams } from './ai/tool/toolParams'; +import { dispatchRunTools } from './ai/toolcall/index'; +import { dispatchStopToolCall } from './ai/toolcall/stopTool'; +import { dispatchToolParams } from './ai/toolcall/toolParams'; import { dispatchChatCompletion } from './ai/chat'; import { dispatchCodeSandbox } from './tools/codeSandbox'; import { dispatchDatasetConcat } from './dataset/concat'; diff --git a/packages/service/core/workflow/dispatch/tools/readFiles.ts b/packages/service/core/workflow/dispatch/tools/readFiles.ts index 2f2721dfe2..33a4b6bdc5 100644 --- a/packages/service/core/workflow/dispatch/tools/readFiles.ts +++ b/packages/service/core/workflow/dispatch/tools/readFiles.ts @@ -3,60 +3,21 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/ import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; -import { axios } from '../../../../common/api/axios'; -import { serverRequestBaseUrl } from '../../../../common/api/serverRequest'; -import { getErrText } from '@fastgpt/global/common/error/utils'; -import { - detectFileEncoding, - parseContentDispositionFilename -} from '@fastgpt/global/common/file/tools'; -import { parseUrlToFileType } from '../../utils/context'; -import { readFileContentByBuffer } from '../../../../common/file/read/utils'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { type ChatItemMiniType } from '@fastgpt/global/core/chat/type'; -import { addDays } from 'date-fns'; import { getNodeErrResponse } from '../utils'; -import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils'; -import { replaceS3KeyToPreviewUrl } from '../../../dataset/utils'; -import { getFileS3Key } from '../../../../common/s3/utils'; -import { S3ChatSource } from '../../../../common/s3/sources/chat'; -import path from 'node:path'; -import { S3Buckets } from '../../../../common/s3/config/constants'; -import { S3Sources } from '../../../../common/s3/contracts/type'; -import { getS3RawTextSource } from '../../../../common/s3/sources/rawText'; -import { getLogger, LogCategories } from '../../../../common/logger'; - -const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS); +import { getFileContentFromLinks } from '../../utils/file'; +import { getUserFilesPrompt } from '../../../ai/llm/agentLoop/prompt'; +import { sliceStrStartEnd } from '@fastgpt/global/common/string/tools'; type Props = ModuleDispatchProps<{ [NodeInputKeyEnum.fileUrlList]: string[]; }>; type Response = DispatchNodeResultType<{ [NodeOutputKeyEnum.text]: string; - [NodeOutputKeyEnum.rawResponse]: ReturnType[]; + [NodeOutputKeyEnum.rawResponse]: { filename: string; url: string; text: string }[]; }>; -const formatResponseObject = ({ - filename, - url, - content -}: { - filename: string; - url: string; - content: string; -}) => ({ - filename, - url, - text: `File: ${filename} - -${content} -`, - nodeResponsePreviewText: `File: ${filename} - -${content.slice(0, 100)}${content.length > 100 ? '......' : ''} -` -}); - export const dispatchReadFiles = async (props: Props): Promise => { const { requestOrigin, @@ -74,7 +35,7 @@ export const dispatchReadFiles = async (props: Props): Promise => { const filesFromHistories = version !== '489' ? [] : getHistoryFileLinks(histories); try { - const { text, readFilesResult } = await getFileContentFromLinks({ + const readFilesResult = await getFileContentFromLinks({ // Concat fileUrlList and filesFromHistories; remove not supported files urls: [...fileUrlList, ...filesFromHistories], requestOrigin, @@ -84,20 +45,33 @@ export const dispatchReadFiles = async (props: Props): Promise => { customPdfParse, usageId }); + const files = readFilesResult.map((item, index) => ({ + id: `${index}`, + name: item.filename, + content: item.content + })); + + const text = getUserFilesPrompt(files); + + const getPreviewResponse = files + .map((item) => `## ${item.name}\n${sliceStrStartEnd(item.content, 1000, 1000)}`) + .join('\n\n'); return { data: { [NodeOutputKeyEnum.text]: text, - [NodeOutputKeyEnum.rawResponse]: readFilesResult + [NodeOutputKeyEnum.rawResponse]: readFilesResult.map((item) => ({ + filename: item.filename, + url: item.url, + text: item.content + })) }, [DispatchNodeResponseKeyEnum.nodeResponse]: { readFiles: readFilesResult.map((item) => ({ - name: item?.filename || '', - url: item?.url || '' + name: item.filename, + url: item.url })), - readFilesResult: readFilesResult - .map((item) => item?.nodeResponsePreviewText ?? '') - .join('\n******\n') + readFilesResult: getPreviewResponse }, [DispatchNodeResponseKeyEnum.toolResponses]: { fileContent: text @@ -123,165 +97,3 @@ export const getHistoryFileLinks = (histories: ChatItemMiniType[]) => { return []; }); }; - -export const getFileContentFromLinks = async ({ - urls, - requestOrigin, - maxFiles, - teamId, - tmbId, - customPdfParse, - usageId -}: { - urls: string[]; - requestOrigin?: string; - maxFiles: number; - teamId: string; - tmbId: string; - customPdfParse?: boolean; - usageId?: string; -}) => { - const parseUrlList = urls - // Remove invalid urls - .filter((url) => { - if (typeof url !== 'string') return false; - - // 检查相对路径 - const validPrefixList = ['/', 'http', 'ws']; - if (validPrefixList.some((prefix) => url.startsWith(prefix))) { - return true; - } - - return false; - }) - // Just get the document type file - .filter((url) => parseUrlToFileType(url)?.type === 'file') - .map((url) => { - try { - // Check is system upload file - const parsedURL = new URL(url, 'http://localhost:3000'); - if (requestOrigin && parsedURL.origin === requestOrigin) { - url = url.replace(requestOrigin, ''); - } - - return url; - } catch (error) { - logger.warn('Failed to parse file URL', { url, error }); - return ''; - } - }) - .filter(Boolean) - .slice(0, maxFiles); - - const readFilesResult = await Promise.all( - parseUrlList - .map(async (url) => { - // Get from buffer - const rawTextBuffer = await getS3RawTextSource().getRawTextBuffer({ - sourceId: url, - customPdfParse - }); - if (rawTextBuffer) { - return formatResponseObject({ - filename: rawTextBuffer.filename || url, - url, - content: rawTextBuffer.text - }); - } - - try { - if (await isInternalAddress(url)) { - return Promise.reject(PRIVATE_URL_TEXT); - } - - // Get file buffer data - const response = await axios.get(url, { - baseURL: serverRequestBaseUrl, - responseType: 'arraybuffer' - }); - - const buffer = Buffer.from(response.data, 'binary'); - - const urlObj = new URL(url, 'http://localhost:3000'); - const isChatExternalUrl = !urlObj.pathname.startsWith( - `/${S3Buckets.private}/${S3Sources.chat}/` - ); - - // Get file name - const { filename, extension, imageParsePrefix } = (() => { - if (isChatExternalUrl) { - const contentDisposition = response.headers['content-disposition'] || ''; - const matchFilename = parseContentDispositionFilename(contentDisposition); - const filename = matchFilename || urlObj.pathname.split('/').pop() || 'file'; - const extension = path.extname(filename).replace('.', ''); - - return { - filename, - extension, - imageParsePrefix: getFileS3Key.temp({ teamId, filename }).fileParsedPrefix - }; - } - - return S3ChatSource.parseChatUrl(url); - })(); - - // Get encoding - const encoding = (() => { - const contentType = response.headers['content-type']; - if (contentType) { - const charsetRegex = /charset=([^;]*)/; - const matches = charsetRegex.exec(contentType); - if (matches != null && matches[1]) { - return matches[1]; - } - } - - return detectFileEncoding(buffer); - })(); - - const { rawText } = await readFileContentByBuffer({ - extension, - teamId, - tmbId, - buffer, - encoding, - customPdfParse, - getFormatText: true, - imageKeyOptions: imageParsePrefix - ? { - prefix: imageParsePrefix, - // 聊天对话里面上传的外部链接,解析出来的图片过期时间设置为1天,而且是存储在临时文件夹的 - expiredTime: isChatExternalUrl ? addDays(new Date(), 1) : undefined - } - : undefined, - usageId - }); - - const replacedText = replaceS3KeyToPreviewUrl(rawText, addDays(new Date(), 90)); - - // Add to buffer - getS3RawTextSource().addRawTextBuffer({ - sourceId: url, - sourceName: filename, - text: replacedText, - customPdfParse - }); - - return formatResponseObject({ filename, url, content: replacedText }); - } catch (error) { - return formatResponseObject({ - filename: '', - url, - content: getErrText(error, 'Load file error') - }); - } - }) - .filter(Boolean) - ); - const text = readFilesResult.map((item) => item?.text ?? '').join('\n******\n'); - - return { - text, - readFilesResult - }; -}; diff --git a/packages/service/core/workflow/utils/context.ts b/packages/service/core/workflow/utils/context.ts index 36e72233bd..2874e54aad 100644 --- a/packages/service/core/workflow/utils/context.ts +++ b/packages/service/core/workflow/utils/context.ts @@ -1,6 +1,7 @@ import { imageFileType } from '@fastgpt/global/common/file/constants'; import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; import type { UserChatItemFileItemType } from '@fastgpt/global/core/chat/type'; + import { AsyncLocalStorage } from 'async_hooks'; import path from 'path'; import type { MCPClient } from '../../app/mcp'; diff --git a/packages/service/core/workflow/utils/file.ts b/packages/service/core/workflow/utils/file.ts new file mode 100644 index 0000000000..88fc17708a --- /dev/null +++ b/packages/service/core/workflow/utils/file.ts @@ -0,0 +1,252 @@ +import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; +import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; +import { parseUrlToFileType } from './context'; +import { getS3RawTextSource } from '../../../common/s3/sources/rawText'; +import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../common/system/utils'; +import { axios } from '../../../common/api/axios'; +import { serverRequestBaseUrl } from '../../../common/api/serverRequest'; +import { S3Buckets } from '../../../common/s3/config/constants'; +import { S3Sources } from '../../../common/s3/contracts/type'; +import { + detectFileEncoding, + parseContentDispositionFilename +} from '@fastgpt/global/common/file/tools'; +import path from 'path'; +import { getFileS3Key } from '../../../common/s3/utils'; +import { S3ChatSource } from '../../../common/s3/sources/chat'; +import { readFileContentByBuffer } from '../../../common/file/read/utils'; +import { addDays } from 'date-fns'; +import { replaceS3KeyToPreviewUrl } from '../../dataset/utils'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { getUserFilesPrompt, injectUserQueryPrompt } from '../../ai/llm/agentLoop/prompt'; + +type GetFileProps = { + requestOrigin?: string; + maxFiles: number; + customPdfParse?: boolean; + teamId: string; + tmbId: string; + usageId?: string; +}; + +export const rewriteUserQueryWithFiles = async ({ + queryId, + userQuery, + requestOrigin, + maxFiles, + customPdfParse, + teamId, + tmbId, + usageId +}: GetFileProps & { + queryId: string; + userQuery: UserChatItemValueItemType[]; +}) => { + const urls = userQuery + .map((item) => (item.file?.type === ChatFileTypeEnum.file ? item.file.url : '')) + .filter(Boolean); + + if (urls.length === 0) { + return userQuery; + } + + const readFilesResult = await getFileContentFromLinks({ + urls, + requestOrigin, + maxFiles, + teamId, + tmbId, + customPdfParse, + usageId + }); + + if (readFilesResult.length === 0) { + return userQuery; + } + + const files = readFilesResult.map((item, index) => ({ + id: `${queryId}-${index}`, + name: item.filename, + content: item.content + })); + + // 把 file 和 text 合并成一个 text(实际上应该只会有一个 text+多个 files) + const text = userQuery.find((item) => item.text?.content)?.text?.content; + const fileQuery = getUserFilesPrompt(files); + + const finalQuery = injectUserQueryPrompt({ + query: text, + filePrompt: fileQuery + }); + + return [ + { + text: { + content: finalQuery + } + } + ]; +}; + +/** + * 格式化文件 URL,移除请求头部分,只保留文件 URL + */ +export const normalizeReadableFileUrl = ({ + url, + requestOrigin +}: { + url?: string; + requestOrigin?: string; +}) => { + if (typeof url !== 'string') return ''; + + let normalizedUrl = url.trim(); + if (!normalizedUrl) return ''; + + const validPrefixList = ['/', 'http', 'ws']; + if (!validPrefixList.some((prefix) => normalizedUrl.startsWith(prefix))) { + return ''; + } + + if (parseUrlToFileType(normalizedUrl)?.type !== ChatFileTypeEnum.file) { + return ''; + } + + try { + const parsedURL = new URL(normalizedUrl, 'http://localhost:3000'); + if (requestOrigin && parsedURL.origin === requestOrigin) { + normalizedUrl = normalizedUrl.replace(requestOrigin, ''); + } + + return normalizedUrl; + } catch { + return ''; + } +}; + +export const getFileContentFromLinks = async ({ + urls, + requestOrigin, + maxFiles, + teamId, + tmbId, + customPdfParse, + usageId +}: GetFileProps & { + urls: string[]; +}) => { + const parseUrlList = urls + .map((url) => normalizeReadableFileUrl({ url, requestOrigin })) + .filter(Boolean) + .slice(0, maxFiles); + + const readFilesResult = await Promise.all( + parseUrlList + .map(async (url) => { + // Get from buffer + const rawTextBuffer = await getS3RawTextSource().getRawTextBuffer({ + sourceId: url, + customPdfParse + }); + if (rawTextBuffer) { + return { + success: true, + filename: rawTextBuffer.filename, + url, + content: rawTextBuffer.text + }; + } + + try { + if (await isInternalAddress(url)) { + return Promise.reject(PRIVATE_URL_TEXT); + } + + // Get file buffer data + const response = await axios.get(url, { + baseURL: serverRequestBaseUrl, + responseType: 'arraybuffer' + }); + + const buffer = Buffer.from(response.data, 'binary'); + + const urlObj = new URL(url, 'http://localhost:3000'); + const isChatExternalUrl = !urlObj.pathname.startsWith( + `/${S3Buckets.private}/${S3Sources.chat}/` + ); + + // Get file name + const { filename, extension, imageParsePrefix } = (() => { + if (isChatExternalUrl) { + const contentDisposition = response.headers['content-disposition'] || ''; + const matchFilename = parseContentDispositionFilename(contentDisposition); + const filename = matchFilename || urlObj.pathname.split('/').pop() || 'file'; + const extension = path.extname(filename).replace('.', ''); + + return { + filename, + extension, + imageParsePrefix: getFileS3Key.temp({ teamId, filename }).fileParsedPrefix + }; + } + + return S3ChatSource.parseChatUrl(url); + })(); + + // Get encoding + const encoding = (() => { + const contentType = response.headers['content-type']; + if (contentType) { + const charsetRegex = /charset=([^;]*)/; + const matches = charsetRegex.exec(contentType); + if (matches != null && matches[1]) { + return matches[1]; + } + } + + return detectFileEncoding(buffer); + })(); + + const { rawText } = await readFileContentByBuffer({ + extension, + teamId, + tmbId, + buffer, + encoding, + customPdfParse, + getFormatText: true, + imageKeyOptions: imageParsePrefix + ? { + prefix: imageParsePrefix, + // 聊天对话里面上传的外部链接,解析出来的图片过期时间设置为1天,而且是存储在临时文件夹的 + expiredTime: isChatExternalUrl ? addDays(new Date(), 1) : undefined + } + : undefined, + usageId + }); + + const replacedText = replaceS3KeyToPreviewUrl(rawText, addDays(new Date(), 90)); + + // Add to buffer + getS3RawTextSource().addRawTextBuffer({ + sourceId: url, + sourceName: filename, + text: replacedText, + customPdfParse + }); + + return { success: true, filename, url, content: replacedText }; + } catch (error) { + return { + success: false, + filename: '', + url, + content: getErrText(error, 'Load file error') + }; + } + }) + .filter(Boolean) + ); + + return readFilesResult; +}; diff --git a/test/cases/service/core/agentSkills/controller.test.ts b/packages/service/test/core/agentSkills/controller.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/controller.test.ts rename to packages/service/test/core/agentSkills/controller.test.ts diff --git a/test/cases/service/core/agentSkills/import.test.ts b/packages/service/test/core/agentSkills/import.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/import.test.ts rename to packages/service/test/core/agentSkills/import.test.ts diff --git a/test/cases/service/core/agentSkills/sandboxConfig.test.ts b/packages/service/test/core/agentSkills/sandboxConfig.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/sandboxConfig.test.ts rename to packages/service/test/core/agentSkills/sandboxConfig.test.ts diff --git a/test/cases/service/core/agentSkills/sandboxSchema.test.ts b/packages/service/test/core/agentSkills/sandboxSchema.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/sandboxSchema.test.ts rename to packages/service/test/core/agentSkills/sandboxSchema.test.ts diff --git a/test/cases/service/core/agentSkills/skillMdBuilder.test.ts b/packages/service/test/core/agentSkills/skillMdBuilder.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/skillMdBuilder.test.ts rename to packages/service/test/core/agentSkills/skillMdBuilder.test.ts diff --git a/test/cases/service/core/agentSkills/storage.test.ts b/packages/service/test/core/agentSkills/storage.test.ts similarity index 95% rename from test/cases/service/core/agentSkills/storage.test.ts rename to packages/service/test/core/agentSkills/storage.test.ts index e70e83268d..6f5f3a86f3 100644 --- a/test/cases/service/core/agentSkills/storage.test.ts +++ b/packages/service/test/core/agentSkills/storage.test.ts @@ -10,9 +10,9 @@ import { S3PrivateBucket } from '@fastgpt/service/common/s3/buckets/private'; // Mock the S3 bucket vi.mock('@fastgpt/service/common/s3/buckets/private', () => ({ - S3PrivateBucket: vi.fn().mockImplementation(() => ({ - bucketName: 'fastgpt-private', - client: { + S3PrivateBucket: vi.fn(function (this: any) { + this.bucketName = 'fastgpt-private'; + this.client = { uploadObject: vi.fn().mockResolvedValue(undefined), downloadObject: vi.fn().mockResolvedValue({ // body must be async-iterable; an array satisfies for-await-of @@ -20,8 +20,8 @@ vi.mock('@fastgpt/service/common/s3/buckets/private', () => ({ }), deleteObject: vi.fn().mockResolvedValue(undefined), checkObjectExists: vi.fn().mockResolvedValue({ exists: true }) - } - })) + }; + }) })); describe('storage', () => { @@ -131,12 +131,12 @@ describe('storage', () => { const { S3PrivateBucket: MockBucket } = await import( '@fastgpt/service/common/s3/buckets/private' ); - (MockBucket as any).mockImplementationOnce(() => ({ - bucketName: 'fastgpt-private', - client: { + (MockBucket as any).mockImplementationOnce(function (this: any) { + this.bucketName = 'fastgpt-private'; + this.client = { downloadObject: vi.fn().mockResolvedValue({ body: null }) - } - })); + }; + }); const storageInfo = { bucket: 'fastgpt-private', diff --git a/test/cases/service/core/agentSkills/utils.test.ts b/packages/service/test/core/agentSkills/utils.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/utils.test.ts rename to packages/service/test/core/agentSkills/utils.test.ts diff --git a/test/cases/service/core/agentSkills/version.test.ts b/packages/service/test/core/agentSkills/version.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/version.test.ts rename to packages/service/test/core/agentSkills/version.test.ts diff --git a/test/cases/service/core/agentSkills/zipBuilder.test.ts b/packages/service/test/core/agentSkills/zipBuilder.test.ts similarity index 100% rename from test/cases/service/core/agentSkills/zipBuilder.test.ts rename to packages/service/test/core/agentSkills/zipBuilder.test.ts diff --git a/test/cases/service/core/ai/embedding/index.test.ts b/packages/service/test/core/ai/embedding/index.test.ts similarity index 100% rename from test/cases/service/core/ai/embedding/index.test.ts rename to packages/service/test/core/ai/embedding/index.test.ts diff --git a/packages/service/test/core/ai/llm/utils.test.ts b/packages/service/test/core/ai/llm/utils.test.ts index b44d035eb4..48d36d30e4 100644 --- a/packages/service/test/core/ai/llm/utils.test.ts +++ b/packages/service/test/core/ai/llm/utils.test.ts @@ -500,6 +500,30 @@ describe('loadRequestMessages function tests', () => { const content = result[0].content as any[]; expect(content).toHaveLength(2); }); + + it('should keep text content when file_url is filtered out', async () => { + const messages: ChatCompletionMessageParam[] = [ + { + role: ChatCompletionRequestMessageRoleEnum.User, + content: [ + { + type: 'file_url', + name: 'a.pdf', + url: '/private/chat/a.pdf' + }, + { + type: 'text', + text: 'File body' + } + ] + } + ]; + + const result = await loadRequestMessages({ messages, useVision: true }); + + expect(result).toHaveLength(1); + expect(result[0].content).toBe('File body'); + }); }); describe('Image processing', () => { diff --git a/test/cases/service/core/app/tool/httpTool/entity.test.ts b/packages/service/test/core/app/tool/httpTool/entity.test.ts similarity index 100% rename from test/cases/service/core/app/tool/httpTool/entity.test.ts rename to packages/service/test/core/app/tool/httpTool/entity.test.ts diff --git a/test/cases/service/core/app/tool/mcpTool/entity.test.ts b/packages/service/test/core/app/tool/mcpTool/entity.test.ts similarity index 100% rename from test/cases/service/core/app/tool/mcpTool/entity.test.ts rename to packages/service/test/core/app/tool/mcpTool/entity.test.ts diff --git a/test/cases/global/core/workflow/dispatch/benchmark.test.ts b/packages/service/test/core/workflow/dispatch/benchmark.test.ts similarity index 100% rename from test/cases/global/core/workflow/dispatch/benchmark.test.ts rename to packages/service/test/core/workflow/dispatch/benchmark.test.ts diff --git a/test/cases/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts b/packages/service/test/core/workflow/dispatch/loopRun/runLoopRun.test.ts similarity index 100% rename from test/cases/service/core/workflow/dispatch/loopRun/runLoopRun.test.ts rename to packages/service/test/core/workflow/dispatch/loopRun/runLoopRun.test.ts diff --git a/test/cases/service/core/workflow/dispatch/loopRun/service.test.ts b/packages/service/test/core/workflow/dispatch/loopRun/service.test.ts similarity index 100% rename from test/cases/service/core/workflow/dispatch/loopRun/service.test.ts rename to packages/service/test/core/workflow/dispatch/loopRun/service.test.ts diff --git a/packages/service/test/core/workflow/dispatch/tools/readFiles.test.ts b/packages/service/test/core/workflow/dispatch/tools/readFiles.test.ts new file mode 100644 index 0000000000..c281ab2fd0 --- /dev/null +++ b/packages/service/test/core/workflow/dispatch/tools/readFiles.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ChatRoleEnum, ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; +import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; +import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; + +const mockGetFileContentFromLinks = vi.hoisted(() => vi.fn()); + +vi.mock('@fastgpt/service/core/workflow/utils/file', () => ({ + getFileContentFromLinks: mockGetFileContentFromLinks +})); + +import { + dispatchReadFiles, + getHistoryFileLinks +} from '@fastgpt/service/core/workflow/dispatch/tools/readFiles'; + +const baseProps = { + requestOrigin: 'http://localhost:3000', + runningUserInfo: { teamId: 'team-1', tmbId: 'tmb-1' }, + histories: [] as ChatItemMiniType[], + chatConfig: {} as any, + node: { version: '490' } as any, + params: { fileUrlList: [] as string[] }, + usageId: 'usage-1' +} as any; + +describe('dispatchReadFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetFileContentFromLinks.mockResolvedValue([]); + }); + + it('成功读取并返回文本/原始响应/节点响应/工具响应结构', async () => { + mockGetFileContentFromLinks.mockResolvedValue([ + { success: true, filename: 'a.pdf', url: '/a.pdf', content: 'Alpha' }, + { success: true, filename: 'b.pdf', url: '/b.pdf', content: 'Beta' } + ]); + + const result = await dispatchReadFiles({ + ...baseProps, + params: { fileUrlList: ['/a.pdf', '/b.pdf'] } + }); + + expect(mockGetFileContentFromLinks).toHaveBeenCalledWith({ + urls: ['/a.pdf', '/b.pdf'], + requestOrigin: 'http://localhost:3000', + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1', + customPdfParse: false, + usageId: 'usage-1' + }); + + const text = result.data?.[NodeOutputKeyEnum.text]; + expect(text).toContain('Alpha'); + expect(text).toContain('Beta'); + expect(text).toContain('a.pdf'); + expect(text).toContain('b.pdf'); + + expect(result.data?.[NodeOutputKeyEnum.rawResponse]).toEqual([ + { filename: 'a.pdf', url: '/a.pdf', text: 'Alpha' }, + { filename: 'b.pdf', url: '/b.pdf', text: 'Beta' } + ]); + + const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse] as any; + expect(nodeResponse.readFiles).toEqual([ + { name: 'a.pdf', url: '/a.pdf' }, + { name: 'b.pdf', url: '/b.pdf' } + ]); + expect(nodeResponse.readFilesResult).toContain('## a.pdf'); + expect(nodeResponse.readFilesResult).toContain('Alpha'); + expect(nodeResponse.readFilesResult).toContain('## b.pdf'); + expect(nodeResponse.readFilesResult).toContain('Beta'); + + expect(result[DispatchNodeResponseKeyEnum.toolResponses]).toEqual({ + fileContent: text + }); + }); + + it('chatConfig 提供 maxFiles 和 customPdfParse 时按其值传入', async () => { + await dispatchReadFiles({ + ...baseProps, + chatConfig: { + fileSelectConfig: { + maxFiles: 5, + customPdfParse: true + } + }, + params: { fileUrlList: ['/a.pdf'] } + }); + + expect(mockGetFileContentFromLinks).toHaveBeenCalledWith( + expect.objectContaining({ + maxFiles: 5, + customPdfParse: true + }) + ); + }); + + it('chatConfig 缺失时 maxFiles 兜底为 20,customPdfParse 兜底为 false', async () => { + await dispatchReadFiles({ + ...baseProps, + chatConfig: undefined, + params: { fileUrlList: ['/a.pdf'] } + }); + + expect(mockGetFileContentFromLinks).toHaveBeenCalledWith( + expect.objectContaining({ maxFiles: 20, customPdfParse: false }) + ); + }); + + it('fileSelectConfig.maxFiles 为 0/undefined 时仍兜底为 20', async () => { + await dispatchReadFiles({ + ...baseProps, + chatConfig: { fileSelectConfig: { maxFiles: 0 } }, + params: { fileUrlList: ['/a.pdf'] } + }); + + expect(mockGetFileContentFromLinks).toHaveBeenCalledWith( + expect.objectContaining({ maxFiles: 20 }) + ); + }); + + it('node.version === "489" 时拼接 histories 中的文件链接', async () => { + const histories: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.Human, + value: [ + { + file: { + type: ChatFileTypeEnum.file, + name: 'history.pdf', + url: '/history.pdf' + } + } + ] + } + ]; + + await dispatchReadFiles({ + ...baseProps, + node: { version: '489' }, + histories, + params: { fileUrlList: ['/current.pdf'] } + }); + + expect(mockGetFileContentFromLinks).toHaveBeenCalledWith( + expect.objectContaining({ + urls: ['/current.pdf', '/history.pdf'] + }) + ); + }); + + it('node.version !== "489" 时忽略 histories', async () => { + const histories: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.Human, + value: [ + { + file: { + type: ChatFileTypeEnum.file, + name: 'history.pdf', + url: '/history.pdf' + } + } + ] + } + ]; + + await dispatchReadFiles({ + ...baseProps, + node: { version: '490' }, + histories, + params: { fileUrlList: ['/current.pdf'] } + }); + + expect(mockGetFileContentFromLinks).toHaveBeenCalledWith( + expect.objectContaining({ + urls: ['/current.pdf'] + }) + ); + }); + + it('params.fileUrlList 缺省时按空数组处理', async () => { + await dispatchReadFiles({ + ...baseProps, + params: {} + }); + + expect(mockGetFileContentFromLinks).toHaveBeenCalledWith(expect.objectContaining({ urls: [] })); + }); + + it('空文件结果返回空文本和空数组结构', async () => { + mockGetFileContentFromLinks.mockResolvedValue([]); + + const result = await dispatchReadFiles({ + ...baseProps, + params: { fileUrlList: [] } + }); + + expect(result.data?.[NodeOutputKeyEnum.text]).toBe(''); + expect(result.data?.[NodeOutputKeyEnum.rawResponse]).toEqual([]); + const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse] as any; + expect(nodeResponse.readFiles).toEqual([]); + expect(nodeResponse.readFilesResult).toBe(''); + expect(result[DispatchNodeResponseKeyEnum.toolResponses]).toEqual({ fileContent: '' }); + }); + + it('超大内容下预览仍按 sliceStrStartEnd 截断 (start/end 各 1000)', async () => { + const huge = 'x'.repeat(5000); + mockGetFileContentFromLinks.mockResolvedValue([ + { success: true, filename: 'big.txt', url: '/big.txt', content: huge } + ]); + + const result = await dispatchReadFiles({ + ...baseProps, + params: { fileUrlList: ['/big.txt'] } + }); + + const preview = (result[DispatchNodeResponseKeyEnum.nodeResponse] as any) + .readFilesResult as string; + + // sliceStrStartEnd 在超长文本上会截断中间,preview 长度远小于原文 + expect(preview.length).toBeLessThan(huge.length); + expect(preview).toContain('## big.txt'); + }); + + it('getFileContentFromLinks 抛错时通过 getNodeErrResponse 返回错误结构', async () => { + mockGetFileContentFromLinks.mockRejectedValue(new Error('boom')); + + const result = await dispatchReadFiles({ + ...baseProps, + params: { fileUrlList: ['/a.pdf'] } + }); + + expect((result as any).error?.[NodeOutputKeyEnum.errorText]).toBe('boom'); + expect((result[DispatchNodeResponseKeyEnum.nodeResponse] as any).errorText).toBe('boom'); + expect((result[DispatchNodeResponseKeyEnum.toolResponses] as any).error).toBe('boom'); + }); +}); + +describe('getHistoryFileLinks', () => { + it('空历史返回空数组', () => { + expect(getHistoryFileLinks([])).toEqual([]); + }); + + it('仅保留 Human 消息中的文件 URL', () => { + const histories: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.Human, + value: [ + { + file: { + type: ChatFileTypeEnum.file, + name: 'a.pdf', + url: '/a.pdf' + } + } + ] + }, + { + obj: ChatRoleEnum.AI, + value: [{ text: { content: 'AI 不会贡献文件' } }] + } as any + ]; + + expect(getHistoryFileLinks(histories)).toEqual(['/a.pdf']); + }); + + it('单条消息内多个文件按顺序展开', () => { + const histories: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.Human, + value: [ + { + file: { type: ChatFileTypeEnum.file, name: 'a.pdf', url: '/a.pdf' } + }, + { + file: { type: ChatFileTypeEnum.file, name: 'b.pdf', url: '/b.pdf' } + }, + { text: { content: '附带说明' } } + ] + } + ]; + + expect(getHistoryFileLinks(histories)).toEqual(['/a.pdf', '/b.pdf']); + }); + + it('Human 消息中无文件时被过滤', () => { + const histories: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.Human, + value: [{ text: { content: '只是文本' } }] + } + ]; + + expect(getHistoryFileLinks(histories)).toEqual([]); + }); + + it('混合多条消息时按出现顺序汇总 Human 文件', () => { + const histories: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.Human, + value: [{ file: { type: ChatFileTypeEnum.file, name: 'a.pdf', url: '/a.pdf' } }] + }, + { + obj: ChatRoleEnum.AI, + value: [{ text: { content: '回答' } }] + } as any, + { + obj: ChatRoleEnum.Human, + value: [ + { text: { content: '继续' } }, + { file: { type: ChatFileTypeEnum.file, name: 'b.pdf', url: '/b.pdf' } } + ] + } + ]; + + expect(getHistoryFileLinks(histories)).toEqual(['/a.pdf', '/b.pdf']); + }); +}); diff --git a/packages/service/test/core/workflow/utils/file.test.ts b/packages/service/test/core/workflow/utils/file.test.ts new file mode 100644 index 0000000000..a93e6dc5fb --- /dev/null +++ b/packages/service/test/core/workflow/utils/file.test.ts @@ -0,0 +1,644 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import type { ChatItemMiniType, UserChatItemValueItemType } from '@fastgpt/global/core/chat/type'; +import { PRIVATE_URL_TEXT } from '@fastgpt/service/common/system/utils'; + +const mockGetRawTextBuffer = vi.hoisted(() => vi.fn()); +const mockAddRawTextBuffer = vi.hoisted(() => vi.fn()); +const mockIsInternalAddress = vi.hoisted(() => vi.fn()); +const mockAxiosGet = vi.hoisted(() => vi.fn()); +const mockReadFileContentByBuffer = vi.hoisted(() => vi.fn()); + +vi.mock('@fastgpt/service/common/s3/sources/rawText', () => ({ + getS3RawTextSource: () => ({ + getRawTextBuffer: mockGetRawTextBuffer, + addRawTextBuffer: mockAddRawTextBuffer + }) +})); + +vi.mock('@fastgpt/service/common/system/utils', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + isInternalAddress: mockIsInternalAddress + }; +}); + +vi.mock('@fastgpt/service/common/api/axios', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + axios: { + get: mockAxiosGet + } + }; +}); + +vi.mock('@fastgpt/service/common/file/read/utils', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + readFileContentByBuffer: mockReadFileContentByBuffer + }; +}); + +// 全局 s3 mock 把 S3ChatSource 替换成了 vi.fn(),丢失了静态方法。 +// 这里用真实模块覆盖回来,让 parseChatUrl 静态方法可用。 +vi.mock('@fastgpt/service/common/s3/sources/chat/index', async (importOriginal) => { + const mod = + await importOriginal(); + return { + ...mod, + getS3ChatSource: () => ({ + createUploadChatFileURL: vi.fn(), + deleteChatFilesByPrefix: vi.fn(), + deleteChatFile: vi.fn() + }) + }; +}); + +import { + getFileContentFromLinks, + normalizeReadableFileUrl, + rewriteUserQueryWithFiles +} from '@fastgpt/service/core/workflow/utils/file'; + +const createHumanMessage = (value: UserChatItemValueItemType[]): ChatItemMiniType => ({ + obj: ChatRoleEnum.Human, + value +}); + +const rewriteMessagesWithFileContent = async ({ + messages, + maxFiles = 20 +}: { + messages: ChatItemMiniType[]; + maxFiles?: number; +}) => + Promise.all( + messages.map(async (message, index): Promise => { + if (message.obj !== ChatRoleEnum.Human) { + return message; + } + + return { + ...message, + value: await rewriteUserQueryWithFiles({ + queryId: message.dataId || `${index}`, + userQuery: message.value, + maxFiles, + teamId: 'team-1', + tmbId: 'tmb-1' + }) + }; + }) + ); + +describe('normalizeReadableFileUrl', () => { + it('标准化可读取的文档 URL,并过滤非文档 URL', () => { + expect( + normalizeReadableFileUrl({ + url: ' http://localhost:3000/a.pdf ', + requestOrigin: 'http://localhost:3000' + }) + ).toBe('/a.pdf'); + expect(normalizeReadableFileUrl({ url: '/a.pdf' })).toBe('/a.pdf'); + expect(normalizeReadableFileUrl({ url: '/image.png' })).toBe(''); + expect(normalizeReadableFileUrl({ url: 'chat/a.pdf' })).toBe(''); + expect(normalizeReadableFileUrl({ url: '' })).toBe(''); + }); + + it('非字符串 url 返回空串', () => { + expect(normalizeReadableFileUrl({ url: undefined })).toBe(''); + expect(normalizeReadableFileUrl({ url: null as unknown as string })).toBe(''); + expect(normalizeReadableFileUrl({ url: 123 as unknown as string })).toBe(''); + }); + + it('requestOrigin 不匹配时保留原 URL', () => { + expect( + normalizeReadableFileUrl({ + url: 'http://other.example.com/a.pdf', + requestOrigin: 'http://localhost:3000' + }) + ).toBe('http://other.example.com/a.pdf'); + }); + + it('requestOrigin 未提供时保留绝对 URL', () => { + expect(normalizeReadableFileUrl({ url: 'http://example.com/a.pdf' })).toBe( + 'http://example.com/a.pdf' + ); + }); + + it('URL 解析失败时返回空串', () => { + // parseUrlToFileType catch fallback 会把 url 当作 file 类型,但第二个 new URL 仍会抛错 + expect(normalizeReadableFileUrl({ url: 'http://[bad-host.pdf' })).toBe(''); + }); +}); + +describe('getFileContentFromLinks (buffer hit)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetRawTextBuffer.mockImplementation(({ sourceId }: { sourceId: string }) => { + const textMap: Record = { + '/a.pdf': 'Alpha', + '/b.pdf': 'Beta' + }; + + return textMap[sourceId] + ? { + filename: sourceId.split('/').pop(), + text: textMap[sourceId] + } + : undefined; + }); + }); + + it('在读取前统一标准化 URL', async () => { + const result = await getFileContentFromLinks({ + urls: ['http://localhost:3000/a.pdf', '/b.pdf'], + requestOrigin: 'http://localhost:3000', + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockGetRawTextBuffer).toHaveBeenNthCalledWith(1, { + sourceId: '/a.pdf', + customPdfParse: undefined + }); + expect(mockGetRawTextBuffer).toHaveBeenNthCalledWith(2, { + sourceId: '/b.pdf', + customPdfParse: undefined + }); + expect(result.map((item) => item.url)).toEqual(['/a.pdf', '/b.pdf']); + expect(result.map((item) => item.content)).toEqual(['Alpha', 'Beta']); + expect(result.every((item) => item.success)).toBe(true); + }); +}); + +describe('getFileContentFromLinks (external fetch)', () => { + beforeEach(() => { + vi.clearAllMocks(); + // 默认 buffer 缓存未命中,强制走外部读取路径 + mockGetRawTextBuffer.mockResolvedValue(undefined); + mockIsInternalAddress.mockResolvedValue(false); + mockReadFileContentByBuffer.mockResolvedValue({ rawText: 'parsed text' }); + }); + + it('内部地址命中时整体 reject 抛出 PRIVATE_URL_TEXT', async () => { + mockIsInternalAddress.mockResolvedValue(true); + + // 源码中使用 `return Promise.reject(...)`,async 函数的 try/catch 不会捕获, + // 因此整个 getFileContentFromLinks 会以 PRIVATE_URL_TEXT 作为 reason 拒绝 + await expect( + getFileContentFromLinks({ + urls: ['http://internal.svc/a.pdf'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }) + ).rejects.toBe(PRIVATE_URL_TEXT); + + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + it('外部地址下载并使用 content-disposition 的文件名,按 charset 解码', async () => { + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('hello'), + headers: { + 'content-disposition': 'attachment; filename="report.pdf"', + 'content-type': 'application/pdf; charset=utf-8' + } + }); + + const result = await getFileContentFromLinks({ + urls: ['http://example.com/raw'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + extension: 'pdf', + teamId: 'team-1', + tmbId: 'tmb-1', + encoding: 'utf-8', + getFormatText: true, + imageKeyOptions: expect.objectContaining({ prefix: expect.any(String) }) + }) + ); + expect(mockAddRawTextBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + sourceId: 'http://example.com/raw', + sourceName: 'report.pdf', + text: 'parsed text' + }) + ); + expect(result[0]).toMatchObject({ + success: true, + filename: 'report.pdf', + url: 'http://example.com/raw', + content: 'parsed text' + }); + }); + + it('外部地址无 content-disposition 时回退到 pathname 文件名,并自动检测编码', async () => { + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('plain text content'), + headers: { + 'content-type': 'text/plain' + } + }); + + const result = await getFileContentFromLinks({ + urls: ['http://example.com/files/notes.txt'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + extension: 'txt', + encoding: expect.any(String) + }) + ); + expect(result[0]).toMatchObject({ + success: true, + filename: 'notes.txt', + url: 'http://example.com/files/notes.txt' + }); + }); + + it('chat S3 key URL 走 S3ChatSource.parseChatUrl 解析路径', async () => { + const chatUrl = 'http://example.com/fastgpt-private/chat/app1/u1/c1/abc123-doc.pdf'; + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('chat-file'), + headers: {} + }); + + const result = await getFileContentFromLinks({ + urls: [chatUrl], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockReadFileContentByBuffer).toHaveBeenCalledWith( + expect.objectContaining({ extension: 'pdf' }) + ); + expect(result[0]).toMatchObject({ + success: true, + filename: 'abc123-doc.pdf', + url: chatUrl + }); + }); + + it('pathname 没有最后一段时,文件名回退为 "file"', async () => { + mockAxiosGet.mockResolvedValue({ + data: Buffer.from('payload'), + headers: {} + }); + + const result = await getFileContentFromLinks({ + urls: ['http://example.com/?filename=fake.pdf'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + // pathname 是 '/',split('/').pop() 返回 '',最终落到 'file' 兜底 + expect(result[0]).toMatchObject({ + success: true, + filename: 'file', + url: 'http://example.com/?filename=fake.pdf' + }); + }); + + it('axios 抛错时返回失败结果,错误信息作为 content', async () => { + mockAxiosGet.mockRejectedValue(new Error('network down')); + + const result = await getFileContentFromLinks({ + urls: ['http://example.com/x.pdf'], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockAddRawTextBuffer).not.toHaveBeenCalled(); + expect(result[0]).toMatchObject({ + success: false, + filename: '', + url: 'http://example.com/x.pdf', + content: 'network down' + }); + }); +}); + +describe('rewriteUserQueryWithFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetRawTextBuffer.mockImplementation(({ sourceId }: { sourceId: string }) => { + const textMap: Record = { + '/a.pdf': 'Alpha', + '/b.pdf': 'Beta', + '/c.pdf': 'Gamma' + }; + + return textMap[sourceId] + ? { + filename: sourceId.split('/').pop(), + text: textMap[sourceId] + } + : undefined; + }); + }); + + it('userQuery 不含文件时直接返回原 query', async () => { + const userQuery: UserChatItemValueItemType[] = [{ text: { content: '只有文本' } }]; + const result = await rewriteUserQueryWithFiles({ + queryId: 'q1', + userQuery, + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockGetRawTextBuffer).not.toHaveBeenCalled(); + expect(result).toBe(userQuery); + }); + + it('文件 URL 全部被标准化过滤后返回原 query', async () => { + const userQuery: UserChatItemValueItemType[] = [ + { text: { content: '不应被改写' } }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'bad.pdf', + // 不以 / http ws 开头,会被 normalizeReadableFileUrl 过滤掉 + url: 'chat/bad.pdf' + } + } + ]; + const result = await rewriteUserQueryWithFiles({ + queryId: 'q1', + userQuery, + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockGetRawTextBuffer).not.toHaveBeenCalled(); + expect(result).toBe(userQuery); + }); + + it('image 类型不会被作为文件去解析', async () => { + const userQuery: UserChatItemValueItemType[] = [ + { text: { content: '看看这张图' } }, + { + file: { + type: ChatFileTypeEnum.image, + name: 'pic.png', + url: '/pic.png' + } + } + ]; + const result = await rewriteUserQueryWithFiles({ + queryId: 'q1', + userQuery, + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockGetRawTextBuffer).not.toHaveBeenCalled(); + expect(result).toBe(userQuery); + }); + + it('把历史和当前轮文件内容分别注入到所属 user message', async () => { + const messages: ChatItemMiniType[] = [ + createHumanMessage([ + { + file: { + type: ChatFileTypeEnum.file, + name: 'a.pdf', + url: '/a.pdf' + } + } + ]), + { + obj: ChatRoleEnum.AI, + value: [ + { + text: { + content: '上一轮回答' + } + } + ] + }, + createHumanMessage([ + { + text: { + content: '继续回答' + } + }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'b.pdf', + url: '/b.pdf' + } + } + ]) + ]; + + const result = await rewriteMessagesWithFileContent({ messages }); + + expect(messages[0].value.some((item) => item.text)).toBe(false); + expect(mockGetRawTextBuffer).toHaveBeenCalledTimes(2); + + const firstHumanText = result[0].value.find((item) => item.text)?.text?.content; + expect(firstHumanText).toContain('Alpha'); + + const secondHumanText = result[2].value.find((item) => item.text)?.text?.content; + expect(secondHumanText).toContain('继续回答'); + expect(secondHumanText).toContain('Beta'); + expect(secondHumanText).not.toContain('Alpha'); + }); + + it('单条 user query 的文件解析数量受 maxFiles 控制', async () => { + const messages: ChatItemMiniType[] = [ + createHumanMessage([ + { + file: { + type: ChatFileTypeEnum.file, + name: 'a.pdf', + url: '/a.pdf' + } + }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'b.pdf', + url: '/b.pdf' + } + } + ]) + ]; + + const result = await rewriteMessagesWithFileContent({ messages, maxFiles: 1 }); + + expect(mockGetRawTextBuffer).toHaveBeenCalledTimes(1); + expect(mockGetRawTextBuffer).toHaveBeenCalledWith({ + sourceId: '/a.pdf', + customPdfParse: undefined + }); + + const content = result[0].value.find((item) => item.text)?.text?.content; + expect(content).toContain('Alpha'); + expect(content).not.toContain('Beta'); + }); + + it('同一条 user query 内重复 URL 不去重', async () => { + const result = await rewriteUserQueryWithFiles({ + queryId: 'q1', + userQuery: [ + { + text: { + content: '总结这个文件' + } + }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'a.pdf', + url: '/a.pdf' + } + }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'a-copy.pdf', + url: '/a.pdf' + } + } + ], + maxFiles: 20, + teamId: 'team-1', + tmbId: 'tmb-1' + }); + + expect(mockGetRawTextBuffer).toHaveBeenCalledTimes(2); + expect(mockGetRawTextBuffer).toHaveBeenNthCalledWith(1, { + sourceId: '/a.pdf', + customPdfParse: undefined + }); + expect(mockGetRawTextBuffer).toHaveBeenNthCalledWith(2, { + sourceId: '/a.pdf', + customPdfParse: undefined + }); + + const content = result.find((item) => item.text)?.text?.content; + expect(content).toContain('总结这个文件'); + // 两次重复 URL 都被注入到最终 prompt 里 + expect(content?.match(/Alpha<\/content>/g)?.length).toBe(2); + }); + + it('相同 URL 出现在不同 message 时会分别读取并注入', async () => { + const messages: ChatItemMiniType[] = [ + createHumanMessage([ + { + file: { + type: ChatFileTypeEnum.file, + name: 'a.pdf', + url: '/a.pdf' + } + } + ]), + createHumanMessage([ + { + text: { + content: '再次引用' + } + }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'a-copy.pdf', + url: '/a.pdf' + } + } + ]) + ]; + + const result = await rewriteMessagesWithFileContent({ messages }); + + expect(mockGetRawTextBuffer).toHaveBeenCalledTimes(2); + expect(result[0].value.find((item) => item.text)?.text?.content).toContain('Alpha'); + expect(result[1].value.find((item) => item.text)?.text?.content).toContain('Alpha'); + }); + + it('多条 user message 会并行重写', async () => { + const messages: ChatItemMiniType[] = [ + createHumanMessage([ + { + file: { + type: ChatFileTypeEnum.file, + name: 'a.pdf', + url: '/a.pdf' + } + } + ]), + createHumanMessage([ + { + text: { + content: '继续回答' + } + }, + { + file: { + type: ChatFileTypeEnum.file, + name: 'b.pdf', + url: '/b.pdf' + } + } + ]) + ]; + + const resolveList: (() => void)[] = []; + mockGetRawTextBuffer.mockImplementation( + ({ sourceId }: { sourceId: string }) => + new Promise((resolve) => { + resolveList.push(() => + resolve({ + filename: sourceId.split('/').pop(), + text: sourceId === '/a.pdf' ? 'Alpha' : 'Beta' + }) + ); + }) + ); + + const pendingResult = rewriteMessagesWithFileContent({ messages }); + + await Promise.resolve(); + + // 在底层 buffer 调用 resolve 之前,两条 user message 已并行触发各自的读取 + expect(mockGetRawTextBuffer).toHaveBeenCalledTimes(2); + expect(mockGetRawTextBuffer).toHaveBeenNthCalledWith(1, { + sourceId: '/a.pdf', + customPdfParse: undefined + }); + expect(mockGetRawTextBuffer).toHaveBeenNthCalledWith(2, { + sourceId: '/b.pdf', + customPdfParse: undefined + }); + + resolveList.forEach((resolve) => resolve()); + const result = await pendingResult; + + expect(result[0].value.find((item) => item.text)?.text?.content).toContain('Alpha'); + expect(result[1].value.find((item) => item.text)?.text?.content).toContain('Beta'); + }); +}); diff --git a/test/cases/service/support/outLink/wechat/messageParser.test.ts b/packages/service/test/support/outLink/wechat/messageParser.test.ts similarity index 100% rename from test/cases/service/support/outLink/wechat/messageParser.test.ts rename to packages/service/test/support/outLink/wechat/messageParser.test.ts diff --git a/test/cases/service/support/wallet/usage/utils.test.ts b/packages/service/test/support/wallet/usage/utils.test.ts similarity index 100% rename from test/cases/service/support/wallet/usage/utils.test.ts rename to packages/service/test/support/wallet/usage/utils.test.ts diff --git a/test/cases/service/worker/readFile/extension/rawText.test.ts b/packages/service/test/worker/readFile/extension/rawText.test.ts similarity index 100% rename from test/cases/service/worker/readFile/extension/rawText.test.ts rename to packages/service/test/worker/readFile/extension/rawText.test.ts diff --git a/pro b/pro index 20bf396872..e56f30f8ab 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 20bf396872d2b072018c7d2251cb4631d75500dd +Subproject commit e56f30f8abe05dffb68b50778c06b85c51381335