Commit ff6bb82c by YeYuheng Committed by GitHub

fix: file-bug (#6799)

* file-bug

* file-bug

* file-bug

* fix

* fix

* perf: test

* perf: file inject prompt

---------

Co-authored-by: archer <545436317@qq.com>
parent 7e6dff29
# 功能开发文档
# 功能开发文档
## 文档标识
- 任务前缀:`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 的 `<FilesContent>` 回填到所属 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 重写 `<FilesContent>`,外层负责并行处理 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:追加分隔符和 `<FilesContent>`
- 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. 保存层不新增 `<FilesContent>`
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 包含 `<FilesContent>``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 不包含 `<FilesContent>` |
| 回归测试 | 保存层检查 | 保存参数仍为原始 `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 禁止出现 `<FilesContent>`
- 不扩展到历史回填、协议调整或前端 UI。
# 需求设计文档
# 需求设计文档
## 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`,不把 `<FilesContent>` 固化入库。
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 发给模型前包含 `<FilesContent>`,不再退化为 `null`
- 历史中每条带 file 的 human message,在后续请求里各自注入对应 `<FilesContent>`,不集中塞到最后一条 user message。
- system prompt 不包含文件解析内容。
- MongoDB 中 human 原始消息不新增 `<FilesContent>`
## 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`,不把 `<FilesContent>` 入库 | 无 | 对齐 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<FilesContent>...`
- 原 message 只有 file:新增一个 text part 存放 `<FilesContent>`
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 请求中都有自己的 `<FilesContent>` 文本 |
| 实际行为 | `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 原始内容不包含新增 `<FilesContent>`
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 含 `<FilesContent>`,不为 `null` |
| 历史逐条注入 | 单测/联调 | 多条历史 human file 分别注入到各自 user message |
| 不污染保存层 | 单测/代码走查 | `prepare/finalize/push/updateInteractive` 保存原始 `userQuestion` |
| system prompt 纯净 | 单测/代码走查 | system message 不包含 `<FilesContent>` |
| `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 并行处理降低串行等待。
修订后结果:需求完整且有明确成本边界。
# 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 元数据 // <agent_skills><skill><name/></skill></agent_skills>
+ 6 个 sandbox tool 暴露给 LLM // sandbox_read_file 等
+ sandbox 容器中放置 SKILL.md // 容器内 /workspace/<skill>/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<string,string> }) {
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_skills>` 块)拼到 `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<Response> => {
// ... 文件、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 |
......@@ -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';
```
......
......@@ -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
......@@ -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
{
"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": {
......
......@@ -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",
......
......@@ -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']: `将 <FilesContent></FilesContent> 中的内容作为本次对话的参考:
<FilesContent>
{{quote}}
</FilesContent>
`
};
return getPromptByVersion(version, promptMap);
};
......@@ -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 <FilesContent> tags', () => {
const result = getDocumentQuotePrompt('4.9.7');
expect(result).toContain('<FilesContent>');
expect(result).toContain('</FilesContent>');
});
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('<FilesContent>');
});
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'));
});
});
......@@ -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.name}</name>
${file.content ? `<content>${file.content}</content>` : ''}
</file>`.trim()
)
.join('\n')}`;
};
export const injectUserQueryTimePrompt = (time: string) => {
return `# Current time
${time}`;
};
export const injectUserQueryPrompt = ({
query,
query = '',
filePrompt,
timePrompt
}: {
......
......@@ -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[],
......
......@@ -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<ChatResp
aiChatResponseFormat,
aiChatJsonSchema,
fileUrlList: fileLinks, // node quote file links
stringQuoteText //abandon
fileUrlList: fileLinks // node quote file links
}
} = props;
const { files: inputFiles } = chatValue2RuntimePrompt(query); // Chat box input files
const modelConstantsData = getLLMModel(model);
if (!modelConstantsData) {
return getNodeErrResponse({
......@@ -119,26 +113,25 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
const chatHistories = getHistories(history, histories);
quoteQA = checkQuoteQAValue(quoteQA);
const [{ datasetQuoteText }, { documentQuoteText, userFiles }] = await Promise.all([
filterDatasetQuote({
const [datasetCiteData, userFiles] = await Promise.all([
getDatasetCiteData({
quoteQA,
model: modelConstantsData,
quoteTemplate: quoteTemplate || getQuoteTemplate(version)
quoteTemplate: quoteTemplate || getQuoteTemplate(version),
aiChatQuoteRole,
datasetQuotePrompt: quotePrompt,
userChatInput,
version,
useDatasetQuote: quoteQA !== undefined
}),
getMultiInput({
histories: chatHistories,
inputFiles,
fileLinks,
stringQuoteText,
requestOrigin,
maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20,
customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse,
usageId,
runningUserInfo
getInputFiles({
fileLinks
})
]);
// 赋值给 userChatInput
userChatInput = datasetCiteData.userInput;
if (!userChatInput && !documentQuoteText && userFiles.length === 0) {
if (!userChatInput && userFiles.length === 0) {
return getNodeErrResponse({ error: i18nT('chat:AI_input_is_empty') });
}
......@@ -147,20 +140,20 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
maxToken
});
const [{ filterMessages }] = await Promise.all([
const [filterMessages] = await Promise.all([
getChatMessages({
model: modelConstantsData,
maxTokens: max_tokens,
histories: chatHistories,
useDatasetQuote: quoteQA !== undefined,
datasetQuoteText,
aiChatQuoteRole,
datasetQuotePrompt: quotePrompt,
version,
userChatInput,
datasetCiteSystemPrompt: datasetCiteData.systemPrompt,
systemPrompt,
userChatInput,
userFiles,
documentQuoteText
requestOrigin,
maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20,
customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse,
usageId,
runningUserInfo
}),
// Censor = true and system key, will check content
(() => {
......@@ -174,6 +167,9 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
})()
]);
console.log(111111);
console.dir(filterMessages, { depth: null });
const {
completeMessages,
reasoningText,
......@@ -294,15 +290,26 @@ export const dispatchChatCompletion = async (props: ChatProps): Promise<ChatResp
}
};
async function filterDatasetQuote({
const getDatasetCiteData = async ({
quoteQA = [],
model,
quoteTemplate
quoteTemplate,
aiChatQuoteRole,
datasetQuotePrompt = '',
userChatInput,
version,
useDatasetQuote
}: {
quoteQA: ChatProps['params']['quoteQA'];
model: LLMModelItemType;
quoteTemplate: string;
}) {
userChatInput: string;
aiChatQuoteRole: AiChatQuoteRoleType;
datasetQuotePrompt?: string;
version?: string;
useDatasetQuote: boolean;
}) => {
function getValue({ item, index }: { item: SearchDataResponseItemType; index: number }) {
return replaceVariable(quoteTemplate, {
id: item.id,
......@@ -323,171 +330,121 @@ 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
};
}
// 旧版本适配<====
// User role or prompt include question
const quoteRole =
aiChatQuoteRole === 'user' || datasetQuotePrompt.includes('{{question}}') ? 'user' : 'system';
// If fileLinks params is not empty, it means it is a new version, not get the global file.
const defaultQuotePrompt = getQuotePrompt(version, quoteRole);
// Get files from histories
const filesFromHistories = getHistoryFileLinks(histories);
const urls = [...fileLinks, ...filesFromHistories];
const datasetQuotePromptTemplate = datasetQuotePrompt || defaultQuotePrompt;
if (urls.length === 0) {
return {
documentQuoteText: '',
userFiles: []
};
}
// Reset user input, add dataset quote to user input
const replaceInputValue =
useDatasetQuote && quoteRole === 'user'
? replaceVariable(datasetQuotePromptTemplate, {
quote: datasetQuoteText,
question: userChatInput
})
: userChatInput;
const { text } = await getFileContentFromLinks({
// Concat fileUrlList and filesFromHistories; remove not supported files
urls,
requestOrigin,
maxFiles,
teamId: runningUserInfo.teamId,
tmbId: runningUserInfo.tmbId,
customPdfParse,
usageId
});
const systemPrompt =
useDatasetQuote && quoteRole === 'system'
? replaceVariable(datasetQuotePromptTemplate, {
quote: datasetQuoteText
})
: '';
return {
documentQuoteText: text,
userFiles: fileLinks
.map((url) => parseUrlToFileType(url))
.filter(Boolean) as UserChatItemFileItemType[]
userInput: replaceInputValue,
systemPrompt
};
}
};
const getInputFiles = ({ fileLinks = [] }: { fileLinks?: string[] }) => {
return fileLinks
.map((url) => parseUrlToFileType(url))
.filter(Boolean) as UserChatItemFileItemType[];
};
async function getChatMessages({
const getChatMessages = async ({
model,
maxTokens = 0,
aiChatQuoteRole,
datasetQuotePrompt = '',
datasetQuoteText,
useDatasetQuote,
version,
histories = [],
datasetCiteSystemPrompt,
systemPrompt,
userChatInput,
userFiles,
documentQuoteText
requestOrigin,
maxFiles,
customPdfParse,
usageId,
runningUserInfo
}: {
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[];
datasetCiteSystemPrompt?: string;
systemPrompt: string;
userChatInput: 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';
const defaultQuotePrompt = getQuotePrompt(version, quoteRole);
const datasetQuotePromptTemplate = datasetQuotePrompt || defaultQuotePrompt;
// Reset user input, add dataset quote to user input
const replaceInputValue =
useDatasetQuote && quoteRole === 'user'
? replaceVariable(datasetQuotePromptTemplate, {
quote: datasetQuoteText,
question: userChatInput
})
: userChatInput;
// Dataset prompt <====
// Concat system prompt
requestOrigin?: string;
maxFiles: number;
customPdfParse?: boolean;
usageId?: string;
runningUserInfo: ChatDispatchProps['runningUserInfo'];
}) => {
const concatenateSystemPrompt = [
model.defaultSystemChatPrompt,
systemPrompt,
useDatasetQuote && quoteRole === 'system'
? replaceVariable(datasetQuotePromptTemplate, {
quote: datasetQuoteText
})
: '',
documentQuoteText
? replaceVariable(getDocumentQuotePrompt(version), {
quote: documentQuoteText
})
: ''
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<ChatItemMiniType> => {
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
};
}
};
......@@ -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,
......
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<Response> => {
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<ChatItemMiniType> => {
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;
};
......@@ -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';
......
......@@ -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';
......
......@@ -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<typeof formatResponseObject>[];
[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>
${content}
</Content>`,
nodeResponsePreviewText: `File: ${filename}
<Content>
${content.slice(0, 100)}${content.length > 100 ? '......' : ''}
</Content>`
});
export const dispatchReadFiles = async (props: Props): Promise<Response> => {
const {
requestOrigin,
......@@ -74,7 +35,7 @@ export const dispatchReadFiles = async (props: Props): Promise<Response> => {
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<Response> => {
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
};
};
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';
......
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;
};
......@@ -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',
......
......@@ -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: '<FilesContent>File body</FilesContent>'
}
]
}
];
const result = await loadRequestMessages({ messages, useVision: true });
expect(result).toHaveLength(1);
expect(result[0].content).toBe('<FilesContent>File body</FilesContent>');
});
});
describe('Image processing', () => {
......
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']);
});
});
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<typeof import('@fastgpt/service/common/system/utils')>();
return {
...mod,
isInternalAddress: mockIsInternalAddress
};
});
vi.mock('@fastgpt/service/common/api/axios', async (importOriginal) => {
const mod = await importOriginal<typeof import('@fastgpt/service/common/api/axios')>();
return {
...mod,
axios: {
get: mockAxiosGet
}
};
});
vi.mock('@fastgpt/service/common/file/read/utils', async (importOriginal) => {
const mod = await importOriginal<typeof import('@fastgpt/service/common/file/read/utils')>();
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<typeof import('@fastgpt/service/common/s3/sources/chat/index')>();
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<ChatItemMiniType> => {
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<string, string> = {
'/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<string, string> = {
'/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(/<content>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');
});
});
Subproject commit 20bf396872d2b072018c7d2251cb4631d75500dd
Subproject commit e56f30f8abe05dffb68b50778c06b85c51381335
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment