Commit 06d590c3 by Xianquan Committed by GitHub

Merge tmp-chat into main (#6958)

* fix: sync chat stop state

* docs: add stream resume stop state design

* fix: reset stale generating chats by stream activity

* docs: add stream resume stale reset design

* fix: sync temporary chat history title

* docs: add stream resume history title design

* fix: validate chat data ids before run

* docs: add stream resume data id validation design

* fix: preserve resumed flow node responses

* fix: merge completed resume records with replayed node responses

* fix: keep loaded AI output when resume starts

* fix: skip stale resume interactive after submitted form

* fix: refresh interactive form values during resume

* fix: render form input result files in response detail

* fix: render resumed form input files

* fix: hydrate resumed form interactives

* fix: preserve resumed form file values

* fix: recover resumed form values

* fix chat resume interactive state

* remove stop chat warning toast

* fix: prevent chat history leaking when switching apps

Sync appId/chatId on app switch and align history list state so sidebar no longer shows the previous app's conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: restore last chat per app when switching apps

Remember chatId per app and fall back to the most recent history instead of always starting a new conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: add detailed comments for chat resume and form input recovery

Document stream resume merge logic, formInputResult normalization, and chat store storage layering to make the recent form file recovery fixes easier to maintain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: resolve WholeResponseModal merge artifact and build type errors

Remove the stale monolithic WholeResponseModal.tsx left from a bad merge, wire FormInputResult into the modular response modal, and drop invalid stepId folding logic in ChatItem.

* docs: 41502

* fix: copy server-proxy.js into app image for SHOW_SKILL entrypoint

Restore the runner-stage COPY removed in #6808 so pods with SHOW_SKILL=true can start server-proxy.js instead of failing with MODULE_NOT_FOUND.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 5a00c065
# Chat dataId 前置去重校验设计
## 背景
流恢复依赖 `appId + chatId + obj + dataId` 定位和合并对话记录。历史上入口没有统一校验 `dataId` 唯一性,调用方如果重复传入 `dataId`,可能导致对话项、响应项或恢复合并时定位错误。
本 PR 目标是在工作流执行前发现重复 `dataId`,直接抛业务错误,避免脏数据继续进入工作流和持久化链路。
## 约束
本 PR 不采用 human 和 AI 共用同一个 `dataId` 的方案。
当前选择是保持 human/AI 各自拥有独立 `dataId`,但要求同一 `appId + chatId` 下新一轮要写入的 `dataId` 不与请求内或库内已有记录重复。
## 问题分析
1. v1、v2、chatTest 三个入口都可以创建新一轮对话,但此前没有统一的 dataId 前置校验。
2. 如果用户请求里 `userContent.dataId``responseChatItemId` 相同,会在同一轮内产生重复。
3. 如果用户传入的 `dataId` 已存在于当前会话历史中,会导致后续查找、恢复、合并逻辑产生歧义。
4. 校验必须发生在工作流执行前,否则即使后续发现重复,也可能已经触发模型调用或工作流副作用。
## 最终方案
### 1. 抽象统一校验模块
新增 `packages/service/core/chat/dataIdValidation.ts`,提供:
- `assertNoDuplicateChatDataIdsInRequest`
- `assertNoExistingChatDataIds`
- `validateChatRoundDataIds`
统一错误信息为 `Chat dataId already exists: {dataId}`,并通过 `UserError` 抛出业务错误。
### 2. 校验请求内重复
每一轮新写入的 dataId 集合包括:
- `userContent.dataId`
- `responseChatItemId`
先过滤空值,再检查集合内是否重复。重复时直接抛错,不进入数据库查询和工作流执行。
### 3. 校验库内重复
对有效 dataId 查询 `MongoChatItem`
- `appId`
- `chatId`
- `dataId: { $in: validDataIds }`
只要命中已有记录,就抛业务错误。
### 4. 三个入口在工作流前调用
接入点:
- `/api/v1/chat/completions`
- `/api/v2/chat/completions`
- `/api/core/chat/chatTest`
校验发生在 `saveChatId``responseChatItemId` 已确定之后,且早于 workflow dispatch。
## 涉及文件
- `packages/service/core/chat/dataIdValidation.ts`
- 新增 dataId 校验工具。
- `projects/app/src/pages/api/v1/chat/completions.ts`
- 在 v1 对话入口工作流执行前校验当前轮 dataId。
- `projects/app/src/pages/api/v2/chat/completions.ts`
- 在 v2 对话入口工作流执行前校验当前轮 dataId。
- `projects/app/src/pages/api/core/chat/chatTest.ts`
- 在 chatTest 入口工作流执行前校验当前轮 dataId。
- `packages/service/test/core/chat/dataIdValidation.test.ts`
- 覆盖请求内重复、库内重复和非重复场景。
## 验证点
1. 请求内 `userContent.dataId === responseChatItemId` 时抛业务错误。
2. 请求 dataId 已存在于 `MongoChatItem` 时抛业务错误。
3. 空 dataId 不参与重复校验。
4. 非重复 dataId 可以继续进入后续流程。
5. v1、v2、chatTest 三个入口都在工作流执行前完成校验。
## 后续迁移
本 PR 只阻止新重复数据继续产生,不处理既有历史重复。
既有数据需要通过独立迁移 PR 处理:
- dry-run 扫描重复 `chat_items.dataId`
- apply 改写重复 dataId
- 同步修复 `chat_item_response.chatItemDataId`
- 重复数归零后再考虑唯一索引
## TODO
- [x] 新增 chat dataId 校验模块
- [x] 校验请求内 human/AI dataId 重复
- [x] 校验当前会话库内已有 dataId
- [x] v1 入口接入前置校验
- [x] v2 入口接入前置校验
- [x] chatTest 入口接入前置校验
- [x] 增加 dataId 校验单元测试
# 流恢复历史记录名称修复设计
## 背景
新对话发起后,侧栏会先展示一个临时历史项。旧逻辑在服务端历史记录尚未落库或尚未拉取回来时,容易显示固定的“新对话”。流恢复场景下,这个临时标题更容易停留在默认文案,导致用户在历史列表中无法通过刚输入的问题识别会话。
本 PR 目标是让临时历史项先使用用户输入生成的标题,服务端标题回来后再覆盖。
## 问题分析
1. 侧栏临时项默认标题固定为“新对话”,没有优先使用当前轮用户输入。
2. `onUpdateHistoryTitle` 发现本地 histories 没有目标 chatId 时,会直接触发拉取服务端历史;如果服务端还没落库,本地仍然没有可展示标题。
3. 当前会话 `chatBoxData.title` 与侧栏临时项标题没有在新对话开始时同步。
4. 生成完成后仍需要尊重服务端最终标题,不能让临时标题永久覆盖服务端标题。
## 最终方案
### 1. 新对话开始时生成临时标题
`ChatBox` 发起新一轮对话时,通过 `getChatTitleFromChatMessage(currentHumanChat)` 从用户输入生成临时标题。
该标题同步写入:
- 当前会话 `chatBoxData.title`
- 侧栏临时历史项
这样服务端历史未返回前,侧栏也能展示用户可识别的标题。
### 2. 侧栏展示标题统一走 display helper
新增 `getDisplayHistoryTitle`
- 有非空标题时展示标题。
- 标题为空时回退到“新对话”。
侧栏临时项不再无条件展示固定“新对话”,而是优先使用 `chatBoxData.title`
### 3. `onUpdateHistoryTitle` 支持本地 upsert
新增 `upsertHistoryTitle`
- 如果 histories 中已有目标会话,直接本地替换标题。
- 如果还没有目标会话,先插入一个临时历史项。
- 插入临时项时标记为 `generating``hasBeenRead=false`
随后仍然调用 `loadHistories({ init: true })` 拉取服务端数据。这样既保证即时展示,又能在服务端落库后回到真实历史记录。
### 4. 服务端标题回来后覆盖临时标题
本 PR 不改变服务端标题生成逻辑。拉取历史成功后,服务端返回的历史项会覆盖本地临时项,从而把临时标题替换成最终落库标题。
## 涉及文件
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx`
- 新对话开始时根据用户输入生成临时标题。
- 同步更新当前会话标题和侧栏临时历史项标题。
- `projects/app/src/pageComponents/chat/slider/ChatSliderList.tsx`
- 临时历史项展示时优先使用 `chatBoxData.title`
- 接入 `getDisplayHistoryTitle` 处理空标题回退。
- `projects/app/src/web/core/chat/context/chatContext.tsx`
- `onUpdateHistoryTitle` 改为先本地 upsert,再拉取服务端历史。
- 使用 ref effect 同步 histories,避免直接渲染期赋值。
- `projects/app/src/web/core/chat/context/historyTitleUtils.ts`
- 新增 `getDisplayHistoryTitle``upsertHistoryTitle`
- `projects/app/test/web/core/chat/context/historyTitleUtils.test.ts`
- 覆盖标题回退、已存在历史替换、不存在历史插入等场景。
## 验证点
1. 新会话刚开始时,侧栏展示用户输入生成的标题,而不是固定“新对话”。
2. `onUpdateHistoryTitle` 在 histories 尚未包含目标 chatId 时,也能先插入临时项。
3. 标题为空时仍回退到“新对话”。
4. 服务端历史拉取回来后,可以用服务端标题覆盖临时标题。
## TODO
- [x] 新对话开始时生成并同步临时标题
- [x] 侧栏临时项优先展示当前会话标题
- [x] `onUpdateHistoryTitle` 支持本地 upsert
- [x] 保留服务端历史刷新覆盖最终标题
- [x] 增加 history title 工具函数测试
# 流恢复服务重启生成态快速重置设计
## 背景
服务崩溃或重启时,正在生成的对话可能来不及把 `chatGenerateStatus``generating` 写回 `done`。如果只依赖历史的 30 分钟清理,侧栏和恢复逻辑会在较长时间内继续认为该会话还在生成,用户需要等待很久才能恢复正常状态。
流恢复的 stream 模式会持续向 Redis stream 写入数据,并通过心跳维持连接。因此可以把 Redis stream 的最近活动时间作为更精确的“服务是否还在持续生成”的依据。
## 问题分析
1. 旧清理逻辑只看 `MongoChat.updateTime` 是否超过 30 分钟,修正速度慢。
2. 服务重启后,Mongo 里的 `generating` 状态可能残留,但 Redis stream 不再有新数据或心跳。
3. 如果直接把 cron 改成频繁扫描 Mongo updateTime,仍然无法区分“正常长耗时生成”和“异常中断”。
4. Redis 可能短暂异常,不能因为 Redis 读失败就误把正在生成的会话改成 done。
## 最终方案
### 1. Redis 记录 stream activity
流恢复写入 Redis stream 时,同步刷新 `stream:resume:active:{teamId}:{appId}:{chatId}`
activity key 记录:
- `updatedAt`
写入策略跟 stream TTL touch 绑定,不对每个 chunk 都强制写 Redis,而是按既有 touch 间隔刷新,避免额外压力。
### 2. 2 分钟无活动视为异常中断
新增 `STREAM_RESUME_INACTIVE_MS = 2 * 60 * 1000`
`cleanStaleGeneratingChats` 先筛选生成态且 `updateTime` 早于 2 分钟前的会话,再读取 Redis activity:
- activity 不存在:视为 stale。
- activity 存在但 `now - updatedAt > 2min`:视为 stale。
- activity 仍新鲜:跳过,认为生成仍活跃。
这里的 2 分钟基于 stream 模式每分钟会推送心跳的前提,给一次心跳延迟留出缓冲。
### 3. 保留 30 分钟 Mongo 兜底
当会话 `updateTime` 已超过 30 分钟时,直接按旧逻辑修正为 done。
兜底作用:
- Redis activity key 被提前清理或不存在时,长期异常仍能被修正。
- Redis 读异常时,不会立刻误判短时生成会话。
### 4. Redis 异常时跳过快速修正
如果读取 Redis activity 抛错,本轮清理记录 warn,并停止依赖 Redis 的快速判定;只保留 30 分钟兜底修正。
这样 Redis 短暂不可用时,不会把真实仍在生成的会话误改成 done。
### 5. cron 调整为每分钟
清理任务从每 5 分钟调整为每 1 分钟,锁时间同步缩短为 1 分钟。
由于候选查询先限制 `generating``updateTime < now - 2min`,再按候选逐个检查 Redis activity,频率提升主要用于缩短异常恢复时间,不是全量高频扫描。
## 涉及文件
- `packages/service/core/chat/resume.ts`
- 增加 `keyOfActive`
- 增加 `STREAM_RESUME_INACTIVE_MS`
- stream 写入时刷新 active state。
- stream 完成后同步缩短 stream key 和 active key TTL。
- 清理 mirror key 时删除 active key。
- 暴露 `getStreamResumeActiveState``isStreamResumeActiveStale`
- `packages/service/core/chat/cleanStaleGeneratingChats.ts`
- 从 30 分钟 updateTime 单条件清理,改为 Redis activity 快速判定 + 30 分钟兜底。
- 返回 `modifiedCount``inactiveCount``fallbackCount`,便于观察修正来源。
- `projects/app/src/service/common/system/cron.ts`
- 清理任务执行频率从 5 分钟改为 1 分钟。
- 定时锁从 4 分钟改为 1 分钟。
- `projects/app/test/api/core/chat/resume.test.ts`
- 覆盖 stream mirror active key 刷新。
- `projects/app/test/service/core/chat/cleanStaleGeneratingChats.test.ts`
- 覆盖 active stale、active fresh、Redis 异常、30 分钟兜底等分支。
## 验证点
1. stream 写入会刷新 active key。
2. active 超过 2 分钟未更新时,`generating` 会话被修正为 `done`
3. active 仍新鲜时,不修正生成态。
4. Redis 读取异常时,不执行 2 分钟快速修正。
5. 超过 30 分钟的旧会话仍能通过兜底逻辑修正。
## TODO
- [x] stream resume 写入时刷新 Redis activity
- [x] stale cleaner 使用 activity 判断 2 分钟无更新
- [x] Redis 异常时保留 30 分钟兜底
- [x] cron 调整为每分钟执行
- [x] 增加 resume active key 测试
- [x] 增加 stale cleaner 分支测试
# 流恢复暂停状态与禁发保护设计
## 背景
流式对话暂停时,前端原本只把 stop 请求当作一次本地 abort 处理。若后端工作流尚未真正完成,用户立刻发送下一轮消息,会出现上一轮仍在写入、下一轮已经开始的并发状态,进而导致上下文混乱、侧栏生成态和输入框状态不一致。
本 PR 目标是让“暂停”从单纯的前端中断动作,升级为前后端共同确认的生成态切换:后端返回真实完成情况,前端在未完成时继续保持禁发。
## 问题分析
1. `/api/v2/chat/stop` 只返回 `success`,前端不知道工作流是否已经结束。
2. 前端 stop 后会立即执行本地 abort,输入区可能恢复可发送状态。
3. `sendPrompt` 只依赖局部 `isChatting` 判断,不能覆盖服务端仍为 `generating` 或最后一条 AI 记录未 finish 的情况。
4. 侧栏生成态、输入按钮状态和当前会话 `chatGenerateStatus` 没有在 stop 结果返回后统一同步。
## 最终方案
### 1. stop 接口返回生成态
`/api/v2/chat/stop` 在触发 `finishWorkflow` 并最多等待工作流完成后,重新读取 `MongoChat.chatGenerateStatus`,返回:
- `success`
- `completed`
- `chatGenerateStatus`
其中 `completed = chatGenerateStatus !== generating`。这样前端能区分“停止完成”和“停止请求已发出但后台还没完全收尾”。
### 2. 前端 stop 等待结果并同步状态
`ChatInput` 调用 `postStopV2Chat` 后,把 `chatGenerateStatus``completed` 回传给 `ChatBox`
`ChatBox` 统一处理 stop settle:
- `completed=true` 时使用后端返回的状态。
- `completed=false` 时继续保持 `generating`
- 同步 `chatBoxData` 和侧栏历史项生成态。
- 未完成时提示“停止中”,避免用户误以为可以马上开始下一轮。
### 3. 抽象下一轮禁发判断
新增 `isChatRoundPending`,统一判断本轮是否仍处于未完成状态:
- 本地 `isChatting=true`
- 当前会话 `chatGenerateStatus=generating`
- 最后一条 AI 记录存在但 `status !== finish`
只要任一条件成立,`sendPrompt` 和输入按钮都进入禁发状态。
### 4. 补齐提示文案和接口 schema
OpenAPI schema 增加 stop response 字段定义,前端 API 类型跟随 schema 更新。
新增 `chat:stopping_chat` 国际化文案,用于 stop 未真正完成时的提示。
## 涉及文件
- `projects/app/src/pages/api/v2/chat/stop.ts`
- stop 后读取当前 `chatGenerateStatus`,返回 `completed` 与生成态。
- `packages/global/openapi/core/chat/controler/api.ts`
- 更新 stop response schema。
- `projects/app/src/web/core/chat/api.ts`
- 更新前端 stop API 返回类型。
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/chatStatus.ts`
- 新增 `isChatRoundPending`,集中维护禁发判断。
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx`
- 使用 `isChatRoundPending` 禁止下一轮发送,并处理 stop settle 后的状态同步。
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx`
- stop 请求等待后端结果;输入发送受 `disableSend` 控制。
- `packages/web/i18n/en/chat.json`
- `packages/web/i18n/zh-CN/chat.json`
- `packages/web/i18n/zh-Hant/chat.json`
- 新增“停止中”提示文案。
- `packages/global/test/core/chat/controler.test.ts`
- 覆盖 stop response schema。
- `projects/app/test/components/core/chat/ChatContainer/ChatBox/chatStatus.test.ts`
- 覆盖禁发判断。
## 验证点
1. stop response schema 支持 `completed``chatGenerateStatus`
2. stop 未完成时,前端仍视为生成中并禁止继续发送。
3. 本地 `isChatting=false` 但服务端生成态仍为 `generating` 时,不能发起下一轮。
4. 最后一条 AI 记录未 finish 时,不能发起下一轮。
## TODO
- [x] stop 接口返回 `completed``chatGenerateStatus`
- [x] 前端 stop settle 后同步当前会话和侧栏生成态
- [x] 抽象并接入统一禁发判断
- [x] 增加 stop response schema 测试
- [x] 增加禁发判断单元测试
......@@ -16,14 +16,26 @@ description: 'FastGPT V4.15.0-beta2 更新说明'
4. 知识库搜索测试交互。
5. 知识库数据编辑弹窗。
6. reason hide 开关完善,确保只是 UI 不显示,但是 request llm 时候依然可以保留。
7. **流恢复暂停体验**:暂停后会等待后端返回真实生成态;若工作流尚未收尾,输入区保持禁发并提示「停止中」,避免上一轮未结束就发送下一轮。
8. **异常中断会话更快恢复**:服务崩溃或重启后,结合 Redis stream 活动检测(约 2 分钟无心跳)更快将卡住的「生成中」会话纠正为已完成;仍保留 30 分钟 Mongo 兜底,Redis 短暂异常时不会误改正在生成的会话。
9. **切换应用记住最近会话**:同一浏览器内切换应用时,会按应用恢复上次打开的 chatId,不再共用单一全局会话 id。
10. **响应详情展示优化**:完整响应弹窗中,表单输入节点的文件字段以文件列表形式展示,而不仅是 JSON 文本。
## 🐛 修复
1. 工作流,单节点调试,存在异常默认值。
2. 模型配置,defaultConfig 覆盖异常。
3. 切换团队时,清除本地 chat 缓存。
4. **流恢复表单输入**:刷新或断线续传后,已提交的表单输入值(含 `fileSelect` 文件列表)能正确回填到交互节点内,不再出现空表单或文件消失。
5. **流恢复内容保留**:自动续传开始时保留已加载的 AI 输出与节点响应;completed 记录覆盖时不再丢失已恢复的交互表单值与 flow 节点响应。
6. **流恢复交互状态**:已提交表单后不再重复追加过期未提交交互;恢复过程中表单默认值能随 `formInputResult` 同步更新。
7. **流恢复历史标题**:新对话发起后,侧栏临时历史项优先展示用户输入生成的标题,服务端标题落库后再覆盖,避免长时间显示「新对话」。
8. **切换应用历史串线**:修复切换不同应用时,侧栏或会话内容短暂展示其他应用聊天记录的问题。
9. **停止对话提示**:移除停止时的 warning toast,改为与后端生成态同步的状态提示。
10. **Chat API dataId 校验**:`/v1/chat/completions`、`/v2/chat/completions` 与 `chatTest` 在工作流执行前校验本轮 `dataId` 是否与请求内或当前会话已有记录重复;重复时直接返回业务错误,避免脏数据进入工作流与流恢复合并逻辑。
## 代码优化
1. 拆分 AI request、工作流运行详情代码。
2. 用户自定义密钥计费逻辑。
3. 流恢复相关模块补充设计文档与单元测试(stop 状态、stale 清理、历史标题、dataId 校验、表单回填等)。
......@@ -256,7 +256,7 @@
"content/self-host/upgrading/4-14/41420.en.mdx": "2026-05-21T15:39:08+08:00",
"content/self-host/upgrading/4-14/41420.mdx": "2026-05-19T14:44:21+08:00",
"content/self-host/upgrading/4-14/41421.en.mdx": "2026-05-21T15:39:08+08:00",
"content/self-host/upgrading/4-14/41421.mdx": "2026-05-21T15:39:08+08:00",
"content/self-host/upgrading/4-14/41421.mdx": "2026-05-21T23:24:07+08:00",
"content/self-host/upgrading/4-14/4143.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4143.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4144.en.mdx": "2026-04-26T21:08:47+08:00",
......
......@@ -76,11 +76,15 @@ export type StopV2ChatParams = z.infer<typeof StopV2ChatSchema>;
export const StopV2ChatResponseSchema = z
.object({
success: z.boolean().describe('是否成功停止')
success: z.boolean().describe('是否成功发送停止信号'),
completed: z.boolean().describe('工作流是否已在本次请求等待窗口内完成停止'),
chatGenerateStatus: z.enum(ChatGenerateStatusEnum).optional().describe('当前对话生成状态')
})
.meta({
example: {
success: true
success: true,
completed: true,
chatGenerateStatus: ChatGenerateStatusEnum.done
}
});
export type StopV2ChatResponse = z.infer<typeof StopV2ChatResponseSchema>;
import { describe, expect, it } from 'vitest';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { StopV2ChatResponseSchema } from '@fastgpt/global/openapi/core/chat/controler/api';
describe('StopV2ChatResponseSchema', () => {
it('parses completed stop response with chat generate status', () => {
const result = StopV2ChatResponseSchema.parse({
success: true,
completed: true,
chatGenerateStatus: ChatGenerateStatusEnum.done
});
expect(result).toEqual({
success: true,
completed: true,
chatGenerateStatus: ChatGenerateStatusEnum.done
});
});
it('requires completed flag', () => {
expect(() =>
StopV2ChatResponseSchema.parse({
success: true
})
).toThrow();
});
});
import { subMinutes } from 'date-fns';
import { subMilliseconds, subMinutes } from 'date-fns';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { getLogger, LogCategories } from '../../common/logger';
import { MongoChat } from './chatSchema';
import {
getStreamResumeActiveState,
isStreamResumeActiveStale,
STREAM_RESUME_INACTIVE_MS
} from './resume';
const logger = getLogger(LogCategories.MODULE.CHAT.HISTORY);
/** 超过该时间仍停留在 generating 的会话视为异常中断,需纠正状态(分钟) */
export const STALE_GENERATING_CHAT_MINUTES = 30;
/**
* 定时将长时间卡在 generating 的对话标记为 done,避免侧栏/恢复逻辑永久认为「生成中」。
* 依赖 MongoChat.updateTime:进入 generating 时会更新;若进程崩溃未写 done/error,则时间停留在发起时刻。
*/
export const cleanStaleGeneratingChats = async (): Promise<{ modifiedCount: number }> => {
const threshold = subMinutes(new Date(), STALE_GENERATING_CHAT_MINUTES);
const now = new Date();
type GeneratingChat = {
_id: unknown;
teamId: { toString: () => string } | string;
appId: { toString: () => string } | string;
chatId: string;
updateTime?: Date;
};
const result = await MongoChat.updateMany(
type CleanStaleGeneratingChatsResult = {
modifiedCount: number;
inactiveCount: number;
fallbackCount: number;
};
const markChatAsDone = async (chat: GeneratingChat, now: Date) => {
const result = await MongoChat.updateOne(
{
chatGenerateStatus: ChatGenerateStatusEnum.generating,
updateTime: { $lt: threshold }
_id: chat._id,
chatGenerateStatus: ChatGenerateStatusEnum.generating
},
{
$set: {
......@@ -30,13 +42,87 @@ export const cleanStaleGeneratingChats = async (): Promise<{ modifiedCount: numb
}
);
if (result.modifiedCount > 0) {
return result.modifiedCount ?? 0;
};
/**
* 定时将卡在 generating 的对话标记为 done,避免侧栏/恢复逻辑永久认为「生成中」。
* 优先依赖 Redis stream activity:stream 模式会持续推送心跳,activity 超过 2 分钟未刷新视为异常中断。
* Redis 异常时保留 30 分钟 updateTime 兜底,避免短暂 Redis 故障误改正在生成的会话。
*/
export const cleanStaleGeneratingChats = async (): Promise<CleanStaleGeneratingChatsResult> => {
const now = new Date();
const fallbackThreshold = subMinutes(now, STALE_GENERATING_CHAT_MINUTES);
const inactiveThreshold = subMilliseconds(now, STREAM_RESUME_INACTIVE_MS);
let modifiedCount = 0;
let inactiveCount = 0;
let fallbackCount = 0;
let redisFailed = false;
const generatingChats = (await MongoChat.find(
{
chatGenerateStatus: ChatGenerateStatusEnum.generating,
updateTime: { $lt: inactiveThreshold }
},
{
_id: 1,
teamId: 1,
appId: 1,
chatId: 1,
updateTime: 1
}
)
.lean()
.exec()) as GeneratingChat[];
for (const chat of generatingChats) {
const shouldUseFallback = !!chat.updateTime && chat.updateTime < fallbackThreshold;
if (shouldUseFallback) {
const currentModifiedCount = await markChatAsDone(chat, now);
modifiedCount += currentModifiedCount;
fallbackCount += currentModifiedCount;
continue;
}
if (redisFailed) {
continue;
}
try {
const activeState = await getStreamResumeActiveState({
teamId: chat.teamId.toString(),
appId: chat.appId.toString(),
chatId: chat.chatId
});
if (isStreamResumeActiveStale(activeState, now.getTime())) {
const currentModifiedCount = await markChatAsDone(chat, now);
modifiedCount += currentModifiedCount;
inactiveCount += currentModifiedCount;
}
} catch (error) {
redisFailed = true;
logger.warn('cleanStaleGeneratingChats: failed to inspect stream resume activity', {
error
});
}
}
if (modifiedCount > 0) {
logger.info('cleanStaleGeneratingChats: corrected stuck generating chats', {
modifiedCount: result.modifiedCount,
threshold,
modifiedCount,
inactiveCount,
fallbackCount,
inactiveMs: STREAM_RESUME_INACTIVE_MS,
fallbackThreshold,
staleMinutes: STALE_GENERATING_CHAT_MINUTES
});
}
return { modifiedCount: result.modifiedCount };
return {
modifiedCount,
inactiveCount,
fallbackCount
};
};
import type { ChatItemMiniType, UserChatItemType } from '@fastgpt/global/core/chat/type';
import { UserError } from '@fastgpt/global/common/error/utils';
import { MongoChatItem } from './chatItemSchema';
export const CHAT_DATA_ID_DUPLICATE_ERROR_MESSAGE = 'Chat dataId already exists';
type ValidateChatRoundDataIdsParams = {
appId: string;
chatId: string;
userContent: UserChatItemType & { dataId?: string };
responseChatItemId: string;
};
const getValidDataIds = (dataIds: Array<string | undefined>) =>
dataIds.filter((dataId): dataId is string => typeof dataId === 'string' && dataId.length > 0);
const findDuplicateDataId = (dataIds: string[]) => {
const seen = new Set<string>();
for (const dataId of dataIds) {
if (seen.has(dataId)) return dataId;
seen.add(dataId);
}
};
export const getChatMessagesDataIds = (chatMessages: ChatItemMiniType[]) =>
getValidDataIds(chatMessages.map((item) => item.dataId));
export const assertNoDuplicateChatDataIdsInRequest = (dataIds: Array<string | undefined>) => {
const duplicateDataId = findDuplicateDataId(getValidDataIds(dataIds));
if (duplicateDataId) {
throw new UserError(`${CHAT_DATA_ID_DUPLICATE_ERROR_MESSAGE}: ${duplicateDataId}`);
}
};
export const assertNoExistingChatDataIds = async ({
appId,
chatId,
dataIds
}: {
appId: string;
chatId: string;
dataIds: Array<string | undefined>;
}) => {
const validDataIds = getValidDataIds(dataIds);
if (validDataIds.length === 0) return;
const existingChatItem = await MongoChatItem.findOne(
{
appId,
chatId,
dataId: { $in: validDataIds }
},
'dataId'
)
.lean()
.exec();
if (existingChatItem?.dataId) {
throw new UserError(`${CHAT_DATA_ID_DUPLICATE_ERROR_MESSAGE}: ${existingChatItem.dataId}`);
}
};
export const validateChatRoundDataIds = async ({
appId,
chatId,
userContent,
responseChatItemId
}: ValidateChatRoundDataIdsParams) => {
const currentRoundDataIds = [userContent.dataId, responseChatItemId];
assertNoDuplicateChatDataIdsInRequest(currentRoundDataIds);
await assertNoExistingChatDataIds({
appId,
chatId,
dataIds: currentRoundDataIds
});
};
......@@ -22,6 +22,7 @@ export const STREAM_RESUME_REDIS_MEMORY_CHECK_INTERVAL_MS =
*/
export const STREAM_RESUME_BLOCK_MS = 30000;
export const STREAM_RESUME_TTL_TOUCH_INTERVAL_MS = 1000;
export const STREAM_RESUME_INACTIVE_MS = 2 * 60 * 1000;
type ResumeRequestHeaderValue = string | string[] | undefined;
type RedisMemoryPressureCache = {
......@@ -47,7 +48,8 @@ export const getStreamResumeRedisKeys = ({
chatId
}: StreamResumeRedisKeysParams) => ({
keyOfStream: `stream:resume:data:${teamId}:${appId}:${chatId}`,
keyOfUnavailable: `stream:resume:unavailable:${teamId}:${appId}:${chatId}`
keyOfUnavailable: `stream:resume:unavailable:${teamId}:${appId}:${chatId}`,
keyOfActive: `stream:resume:active:${teamId}:${appId}:${chatId}`
});
type StreamResumeKeys = ReturnType<typeof getStreamResumeRedisKeys>;
......@@ -62,6 +64,10 @@ export type StreamResumeUnavailableState = {
reason: `${StreamResumeUnavailableReasonEnum}`;
};
export type StreamResumeActiveState = {
updatedAt: number;
};
const resumeRequestEnabledValues = new Set(['1', 'true', 'yes', 'on']);
const getNormalizedHeaderValue = (value: string | string[] | undefined) => {
......@@ -183,9 +189,26 @@ const touchStreamResumeTTL = async ({ keyOfStream }: StreamResumeKeys) => {
await redis.expire(keyOfStream, STREAM_RESUME_TTL_SECONDS);
};
const shrinkStreamResumeTTL = async ({ keyOfStream }: StreamResumeKeys) => {
const touchStreamResumeActiveState = async (keys: StreamResumeKeys) => {
const redis = getGlobalRedisConnection();
await redis.expire(keyOfStream, STREAM_RESUME_POST_COMPLETE_TTL_SECONDS);
await redis.set(
keys.keyOfActive,
JSON.stringify({ updatedAt: Date.now() } satisfies StreamResumeActiveState),
'EX',
STREAM_RESUME_TTL_SECONDS
);
};
const touchStreamResumeState = async (keys: StreamResumeKeys) => {
await Promise.all([touchStreamResumeTTL(keys), touchStreamResumeActiveState(keys)]);
};
const shrinkStreamResumeTTL = async ({ keyOfStream, keyOfActive }: StreamResumeKeys) => {
const redis = getGlobalRedisConnection();
await Promise.all([
redis.expire(keyOfStream, STREAM_RESUME_POST_COMPLETE_TTL_SECONDS),
redis.expire(keyOfActive, STREAM_RESUME_POST_COMPLETE_TTL_SECONDS)
]);
};
const setStreamResumeUnavailableState = async (
......@@ -217,6 +240,27 @@ export const getStreamResumeUnavailableState = async (params: StreamResumeRedisK
}
};
export const getStreamResumeActiveState = async (params: StreamResumeRedisKeysParams) => {
const redis = getGlobalRedisConnection();
const keys = getStreamResumeRedisKeys(params);
const state = await redis.get(keys.keyOfActive);
if (!state) return;
try {
const parsed = JSON.parse(state) as StreamResumeActiveState;
if (!Number.isFinite(parsed?.updatedAt)) return;
return parsed;
} catch {
return;
}
};
export const isStreamResumeActiveStale = (
state: StreamResumeActiveState | undefined,
now = Date.now()
) => !state || now - state.updatedAt > STREAM_RESUME_INACTIVE_MS;
const chunkToString = (chunk: string | Buffer | Uint8Array, encoding?: BufferEncoding) => {
if (typeof chunk === 'string') return chunk;
if (Buffer.isBuffer(chunk)) return chunk.toString(encoding || 'utf8');
......@@ -227,7 +271,8 @@ const chunkToString = (chunk: string | Buffer | Uint8Array, encoding?: BufferEnc
const clearStreamResumeMirrorKeys = async (keys: StreamResumeKeys) => {
await Promise.all([
clearStreamResumeUnavailableState(keys),
getGlobalRedisConnection().del(keys.keyOfStream)
getGlobalRedisConnection().del(keys.keyOfStream),
getGlobalRedisConnection().del(keys.keyOfActive)
]);
};
......@@ -247,7 +292,7 @@ export const mirrorChatStream = (params: StreamResumeRedisKeysParams) => {
await redis.call('XADD', rawKeys.rawKeyOfStream, '*', 'raw', chunk);
const now = Date.now();
if (lastTouchedAt === 0 || now - lastTouchedAt >= STREAM_RESUME_TTL_TOUCH_INTERVAL_MS) {
await touchStreamResumeTTL(keys);
await touchStreamResumeState(keys);
lastTouchedAt = now;
}
})
......
import { beforeEach, describe, expect, it } from 'vitest';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { MongoChatItem } from '@fastgpt/service/core/chat/chatItemSchema';
import {
CHAT_DATA_ID_DUPLICATE_ERROR_MESSAGE,
assertNoDuplicateChatDataIdsInRequest,
getChatMessagesDataIds,
validateChatRoundDataIds
} from '@fastgpt/service/core/chat/dataIdValidation';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { getUser } from '@test/datas/users';
describe('chat dataId validation', () => {
let testUser: Awaited<ReturnType<typeof getUser>>;
let appId: string;
let chatId: string;
beforeEach(async () => {
testUser = await getUser('test-user');
chatId = getNanoid(24);
const app = await MongoApp.create({
name: 'Test App',
type: AppTypeEnum.simple,
teamId: testUser.teamId,
tmbId: testUser.tmbId,
modules: []
});
appId = String(app._id);
});
it('should collect only valid dataIds from chat messages', () => {
expect(
getChatMessagesDataIds([
{
obj: ChatRoleEnum.Human,
dataId: 'human-1',
value: [{ text: { content: 'hello' } }]
},
{
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'hi' } }]
},
{
obj: ChatRoleEnum.Human,
dataId: '',
value: [{ text: { content: 'next' } }]
}
])
).toEqual(['human-1']);
});
it('should reject duplicate dataIds in the current request', () => {
expect(() =>
assertNoDuplicateChatDataIdsInRequest(['history-1', undefined, 'history-1'])
).toThrow(`${CHAT_DATA_ID_DUPLICATE_ERROR_MESSAGE}: history-1`);
});
it('should reject human and ai sharing one dataId', async () => {
await expect(
validateChatRoundDataIds({
appId,
chatId,
userContent: {
obj: ChatRoleEnum.Human,
dataId: 'same-data-id',
value: [{ text: { content: 'hello' } }]
},
responseChatItemId: 'same-data-id'
})
).rejects.toThrow(`${CHAT_DATA_ID_DUPLICATE_ERROR_MESSAGE}: same-data-id`);
});
it('should reject dataIds that already exist in chat items', async () => {
await MongoChatItem.create({
teamId: testUser.teamId,
tmbId: testUser.tmbId,
appId,
chatId,
dataId: 'existing-ai',
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'old answer' } }]
});
await expect(
validateChatRoundDataIds({
appId,
chatId,
userContent: {
obj: ChatRoleEnum.Human,
dataId: 'new-human',
value: [{ text: { content: 'hello' } }]
},
responseChatItemId: 'existing-ai'
})
).rejects.toThrow(`${CHAT_DATA_ID_DUPLICATE_ERROR_MESSAGE}: existing-ai`);
});
it('should allow unique current round dataIds', async () => {
await expect(
validateChatRoundDataIds({
appId,
chatId,
userContent: {
obj: ChatRoleEnum.Human,
dataId: 'new-human',
value: [{ text: { content: 'hello' } }]
},
responseChatItemId: 'new-ai'
})
).resolves.toBeUndefined();
});
});
......@@ -98,6 +98,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/projects/app/.next/server/chunks
# copy worker
COPY --from=builder --chown=nextjs:nodejs /app/projects/app/worker /app/projects/app/worker
COPY --from=builder --chown=nextjs:nodejs /app/projects/app/worker /app/worker
# Skill 模式入口:esbuild 打包 server.ts,供 SHOW_SKILL=true 时使用
COPY --from=builder --chown=nextjs:nodejs /app/projects/app/server-proxy.js /app/projects/app/server-proxy.js
# copy standload packages
COPY --from=maindeps /app/node_modules/tiktoken ./node_modules/tiktoken
......
......@@ -22,6 +22,7 @@ import VoiceInput, { type VoiceInputComponentRef } from './VoiceInput';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { postStopV2Chat } from '@/web/core/chat/api';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
const InputGuideBox = dynamic(() => import('./InputGuideBox'));
......@@ -36,6 +37,8 @@ const ChatInput = ({
lastInteractive,
onSendMessage,
onStop,
onStopSettled,
disableSend,
TextareaDom,
resetInputVal,
chatForm
......@@ -43,6 +46,8 @@ const ChatInput = ({
lastInteractive?: WorkflowInteractiveResponseType;
onSendMessage: SendPromptFnType;
onStop: () => void;
onStopSettled?: (status: ChatGenerateStatusEnum, completed: boolean) => void;
disableSend?: boolean;
TextareaDom: React.MutableRefObject<HTMLTextAreaElement | null>;
resetInputVal: (val: ChatBoxInputType) => void;
chatForm: UseFormReturn<ChatBoxInputFormType>;
......@@ -100,7 +105,7 @@ const ChatInput = ({
chatId
});
const havInput = !!inputValue || fileList.length > 0;
const canSendMessage = havInput && !hasFileUploading;
const canSendMessage = havInput && !hasFileUploading && !disableSend;
const canUploadFile =
showSelectFile ||
showSelectImg ||
......@@ -117,28 +122,30 @@ const ChatInput = ({
/* on send */
const handleSend = useCallback(
async (val?: string) => {
async (val: string = inputValue) => {
if (!canSendMessage) return;
const textareaValue = val || TextareaDom.current?.value || '';
onSendMessage({
text: textareaValue.trim(),
text: val.trim(),
files: fileList,
interactive: lastInteractive
});
replaceFiles([]);
},
[TextareaDom, lastInteractive, canSendMessage, fileList, onSendMessage, replaceFiles]
[inputValue, lastInteractive, canSendMessage, fileList, onSendMessage, replaceFiles]
);
const { runAsync: handleStop, loading: isStopping } = useRequest(async () => {
try {
if (isChatting) {
await postStopV2Chat({
const result = await postStopV2Chat({
appId,
chatId,
outLinkAuthData
}).catch();
});
onStopSettled?.(result.chatGenerateStatus ?? ChatGenerateStatusEnum.done, result.completed);
}
} catch {
onStopSettled?.(ChatGenerateStatusEnum.generating, false);
} finally {
onStop();
}
......@@ -205,26 +212,28 @@ const ChatInput = ({
onKeyDown={(e) => {
// enter send.(pc or iframe && enter and unPress shift)
const isEnter = e.key === 'Enter';
if (isEnter && TextareaDom.current && (e.ctrlKey || e.altKey)) {
const textarea = e.currentTarget;
if (isEnter && (e.ctrlKey || e.altKey)) {
// Add a new line
const index = TextareaDom.current.selectionStart;
const val = TextareaDom.current.value;
TextareaDom.current.value = `${val.slice(0, index)}\n${val.slice(index)}`;
TextareaDom.current.selectionStart = index + 1;
TextareaDom.current.selectionEnd = index + 1;
const index = textarea.selectionStart;
const val = textarea.value;
textarea.value = `${val.slice(0, index)}\n${val.slice(index)}`;
textarea.selectionStart = index + 1;
textarea.selectionEnd = index + 1;
TextareaDom.current.style.height = textareaMinH;
TextareaDom.current.style.height = `${TextareaDom.current.scrollHeight}px`;
textarea.style.height = textareaMinH;
textarea.style.height = `${textarea.scrollHeight}px`;
return;
}
// Select all content
// @ts-ignore
e.key === 'a' && e.ctrlKey && e.target?.select();
if (e.key === 'a' && e.ctrlKey) {
textarea.select();
}
if ((isPc || window !== parent) && e.keyCode === 13 && !e.shiftKey) {
handleSend();
handleSend(textarea.value);
e.preventDefault();
}
}}
......@@ -366,7 +375,7 @@ const ChatInput = ({
if (isChatting) {
return handleStop();
}
return handleSend();
return void handleSend(inputValue);
}}
>
{isChatting ? (
......@@ -394,10 +403,12 @@ const ChatInput = ({
isStopping,
isChatting,
canSendMessage,
disableSend,
onOpenSelectFile,
onSelectFile,
handleSend,
handleStop
handleStop,
onStopSettled
]);
const activeStyles: FlexProps = {
......
import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatSiteItemType } from './type';
type ChatRoundStatusItem = Pick<ChatSiteItemType, 'obj' | 'status'>;
export const isChatRoundPending = ({
isChatting,
chatGenerateStatus,
lastChat
}: {
isChatting: boolean;
chatGenerateStatus?: ChatGenerateStatusEnum;
lastChat?: ChatRoundStatusItem;
}) => {
if (isChatting) return true;
if (chatGenerateStatus === ChatGenerateStatusEnum.generating) return true;
return !!lastChat && lastChat.obj === ChatRoleEnum.AI && lastChat.status !== 'finish';
};
......@@ -18,7 +18,10 @@ import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import {
type AIChatItemValueItemType,
type ChatHistoryItemResType
} from '@fastgpt/global/core/chat/type';
import { CodeClassNameEnum } from '@/components/Markdown/utils';
import { isEqual } from 'lodash';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
......@@ -112,6 +115,7 @@ const HumanContentCard = React.memo(
);
const AIContentCard = React.memo(function AIContentCard({
chatValue,
responseData,
dataId,
isLastChild,
isChatting,
......@@ -120,6 +124,7 @@ const AIContentCard = React.memo(function AIContentCard({
}: {
dataId: string;
chatValue: AIChatItemValueItemType[];
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
isChatting: boolean;
questionGuides: string[];
......@@ -144,6 +149,7 @@ const AIContentCard = React.memo(function AIContentCard({
<AIResponseBox
chatItemDataId={dataId}
value={value}
responseData={responseData}
isLastResponseValue={isLastResponse}
isLastChild={isLastChild}
isChatting={isChatting}
......@@ -458,6 +464,7 @@ const ChatItem = (props: Props) => {
<>
<AIContentCard
chatValue={value as AIChatItemValueItemType[]}
responseData={chat.responseData}
dataId={chat.dataId}
isLastChild={isLastChild && i === splitAiResponseResults.length - 1}
isChatting={isChatting}
......
import { Button, Flex } from '@chakra-ui/react';
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { useTranslation } from 'next-i18next';
import React, { useCallback, useMemo } from 'react';
import { normalizeFormInputResultFile } from '../FormInputResult';
import { FormInputComponent } from '../Interactive/InteractiveComponents';
import { onSendPrompt } from './utils';
/** 恢复/渲染表单时,把 fileSelect 字段值归一化为 `{ name, url }[]`,与流恢复逻辑保持一致。 */
const normalizeRecoveredFormValue = ({
inputType,
value
}: {
inputType: FlowNodeInputTypeEnum;
value: unknown;
}) => {
if (inputType !== FlowNodeInputTypeEnum.fileSelect || !Array.isArray(value)) {
return value;
}
return value
.map(normalizeFormInputResultFile)
.filter((file): file is NonNullable<ReturnType<typeof normalizeFormInputResultFile>> =>
Boolean(file)
);
};
/**
* 从同条 AI 消息的 `responseData` 中查找某字段的 `formInputResult` 值(渲染层兜底)。
*
* 恢复完成后 interactive 上的 `inputForm.value` 可能仍为空(持久化时未 hydrate),
* 但 `responseData` 里已保存节点输出的 `formInputResult`。此处从后往前找最近一条
* 匹配节点(`nodeId` 在 `entryNodeIds` 内,或未指定 nodeId 时取最近一条),
* 作为 `FormInputComponent` 的 defaultValues 来源。
*/
const getInputFormValueFromResponseData = ({
responseData,
interactive,
inputKey
}: {
responseData?: ChatHistoryItemResType[];
interactive: UserInputInteractive;
inputKey: string;
}) => {
const entryNodeIds = (interactive as UserInputInteractive & { entryNodeIds?: string[] })
.entryNodeIds;
const formInputResult = responseData
?.slice()
.reverse()
.find(
(item) =>
item.formInputResult &&
(!item.nodeId || !entryNodeIds?.length || entryNodeIds.includes(item.nodeId))
)?.formInputResult;
if (!formInputResult || typeof formInputResult !== 'object' || Array.isArray(formInputResult)) {
return;
}
return (formInputResult as Record<string, unknown>)[inputKey];
};
/**
* 渲染已提交/待提交的 `userInput` 工作流交互表单。
*
* defaultValues 优先级:`responseData.formInputResult`(恢复兜底)> `inputForm.value` > `defaultValue`。
* 非最后一条子消息时强制 `submitted: true`,禁止重复提交历史表单。
*/
const RenderUserFormInteractive = React.memo(function RenderUserFormInteractive({
interactive,
responseData,
isLastChild
}: {
interactive: UserInputInteractive;
responseData?: ChatHistoryItemResType[];
isLastChild: boolean;
}) {
const { t } = useTranslation();
const defaultValues = useMemo(() => {
return interactive.params.inputForm?.reduce((acc: Record<string, any>, item) => {
const responseValue = getInputFormValueFromResponseData({
responseData,
interactive,
inputKey: item.key
});
if (responseValue !== undefined) {
acc[item.key] = normalizeRecoveredFormValue({
inputType: item.type,
value: responseValue
});
return acc;
}
// 使用 ?? 运算符,只有 undefined 或 null 时才使用 defaultValue
acc[item.key] = item.value ?? item.defaultValue;
return acc;
}, {});
}, [interactive]);
}, [interactive, responseData]);
const handleFormSubmit = useCallback(
(data: Record<string, any>) => {
......
import { Box, Flex } from '@chakra-ui/react';
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import type {
AIChatItemValueItemType,
ChatHistoryItemResType
} from '@fastgpt/global/core/chat/type';
import { extractDeepestInteractive } from '@fastgpt/global/core/workflow/runtime/utils';
import React from 'react';
import { useContextSelector } from 'use-context-selector';
......@@ -21,6 +24,7 @@ import RenderUserSelectInteractive from './RenderUserSelectInteractive';
const AIResponseBox = ({
chatItemDataId,
value,
responseData,
isLastResponseValue,
isLastChild,
isChatting,
......@@ -28,6 +32,7 @@ const AIResponseBox = ({
}: {
chatItemDataId: string;
value: AIChatItemValueItemType;
responseData?: ChatHistoryItemResType[];
isLastResponseValue: boolean;
isLastChild: boolean;
isChatting: boolean;
......@@ -106,6 +111,7 @@ const AIResponseBox = ({
<RenderUserFormInteractive
key="interactive"
interactive={interactive}
responseData={responseData}
isLastChild={isLastChild}
/>
);
......
import React from 'react';
import { Box, Flex, HStack } from '@chakra-ui/react';
import Markdown from '@/components/Markdown';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { getFileIcon } from '@fastgpt/global/common/file/icon';
/**
* 表单输入结果中的单个文件项。
* 工作流 `formInputResult` 里 fileSelect 字段可能存 URL 字符串或 `{ name, url }` 对象,
* 归一化后统一为该结构,便于 UI 展示与跨模块复用(流恢复回填、响应详情等)。
*/
export type FormInputResultFileItem = {
name: string;
url: string;
};
/**
* 从文件下载 URL 中解析展示用文件名。
*
* FastGPT 签名下载链接通常把真实文件名放在 `filename` query 中(见
* `/api/system/file/download/token?filename=...`),path 段往往只是 token,不可读。
* 解析优先级:query `filename` > URL path 最后一段 > 原 URL 字符串。
*
* @param url - 文件下载地址;非法 URL 时直接返回入参,避免展示层抛错。
*/
export const getFilenameFromFormInputFileUrl = (url: string) => {
try {
const parsedUrl = new URL(url);
const filename = parsedUrl.searchParams.get('filename');
if (filename) return filename;
const pathname = parsedUrl.pathname.split('/').pop();
return pathname ? decodeURIComponent(pathname) : url;
} catch {
return url;
}
};
/**
* 将 `formInputResult` 中单条文件值归一化为 `{ name, url }`。
*
* 兼容两种历史/并存形态:
* - `string`:仅存下载 URL,文件名由 {@link getFilenameFromFormInputFileUrl} 推导;
* - `{ name?, url }`:显式 name 优先,缺失时同样从 URL 推导。
*
* 无效输入(空字符串、非对象、缺少 url)返回 `undefined`,便于调用方 `.filter(Boolean)` 过滤。
* 该函数被流恢复(`ChatBox/utils`)、表单交互回填(`RenderUserFormInteractive`)等多处复用。
*/
export const normalizeFormInputResultFile = (
value: unknown
): FormInputResultFileItem | undefined => {
if (typeof value === 'string') {
if (!value) return;
return {
name: getFilenameFromFormInputFileUrl(value),
url: value
};
}
if (!value || typeof value !== 'object') return;
const file = value as Record<string, unknown>;
const url = typeof file.url === 'string' ? file.url : undefined;
if (!url) return;
return {
name:
typeof file.name === 'string' && file.name ? file.name : getFilenameFromFormInputFileUrl(url),
url
};
};
/**
* 只读展示用户提交的表单输入结果(`formInputResult`)。
*
* `value` 为字段 key -> 字段值的映射。每个字段按值类型分支渲染:
* - 值为文件 URL 数组:渲染可点击的文件 chip(新窗口打开下载链接);
* - 其他类型:以 JSON 代码块展示,便于查看文本、数字、嵌套结构等非文件字段。
*
* 文件数组元素经 {@link normalizeFormInputResultFile} 归一化,跳过无法识别的项。
*/
const FormInputResult = React.memo(function FormInputResult({
value
}: {
value: Record<string, unknown>;
}) {
return (
<Flex flexDirection={'column'} gap={3}>
{Object.entries(value).map(([key, inputValue]) => {
// 仅当字段值为数组时尝试按文件列表解析;非数组走 JSON 展示分支
const files = Array.isArray(inputValue)
? inputValue
.map(normalizeFormInputResultFile)
.filter((file): file is FormInputResultFileItem => Boolean(file))
: [];
return (
<Box key={key}>
<Box fontSize={'12px'} color={'myGray.900'} fontWeight={500} mb={1}>
{key}
</Box>
{files.length > 0 ? (
<Flex flexWrap={'wrap'} gap={2}>
{files.map((file, index) => (
<HStack
key={`${file.url}-${index}`}
bg={'white'}
border={'1px solid'}
borderColor={'myGray.200'}
borderRadius={'sm'}
py={1}
px={2}
maxW={'100%'}
cursor={'pointer'}
onClick={() => window.open(file.url, '_blank')}
>
<MyIcon name={getFileIcon(file.name) as any} w={'1rem'} flexShrink={0} />
<Box className={'textEllipsis'}>{file.name}</Box>
</HStack>
))}
</Flex>
) : (
// 非文件字段或空数组:Markdown JSON 块,保持与聊天消息区一致的代码高亮样式
<Markdown source={`~~~json\n${JSON.stringify(inputValue, null, 2)}`} />
)}
</Box>
);
})}
</Flex>
);
});
export default FormInputResult;
import React from 'react';
import { Box, Button, Flex, FormControl, FormErrorMessage } from '@chakra-ui/react';
import { Box, Flex, FormControl, FormErrorMessage } from '@chakra-ui/react';
import { Controller, useForm, type UseFormHandleSubmit } from 'react-hook-form';
import Markdown from '@/components/Markdown';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
......@@ -92,13 +92,17 @@ export const FormInputComponent = React.memo(function FormInputComponent({
}) {
const { t } = useTranslation();
const { handleSubmit, control, watch } = useForm({
const { handleSubmit, control, watch, reset } = useForm({
defaultValues
});
const runtimeFileUploading = useContextSelector(WorkflowRuntimeContext, (v) => v.fileUploading);
const formValues = watch();
React.useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset]);
const isFileUploading = React.useMemo(() => {
if (runtimeFileUploading) return true;
......
......@@ -8,6 +8,7 @@ import { completionFinishReasonMap } from '@fastgpt/global/core/ai/constants';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
import QuoteList from '../../ChatContainer/ChatBox/components/QuoteList';
import FormInputResult from '../FormInputResult';
import { agentPlanStatusMap } from './constants';
import { Row } from './Row';
......@@ -424,7 +425,12 @@ export const WorkflowResultRows = ({ activeModule }: { activeModule: ChatHistory
label={t('common:core.chat.response.loop_output_element')}
value={activeModule.loopOutputValue}
/>
<Row label={t('workflow:form_input_result')} value={activeModule.formInputResult} />
{activeModule.formInputResult && (
<Row
label={t('workflow:form_input_result')}
rawDom={<FormInputResult value={activeModule.formInputResult} />}
/>
)}
<Row
label={t('workflow:tool_params.tool_params_result')}
value={activeModule.toolParamsResult}
......
......@@ -115,7 +115,6 @@ const MobileDrawer = ({ onCloseDrawer, appId }: { onCloseDrawer: () => void; app
}
const { t } = useTranslation();
const { setChatId } = useChatStore();
const myApps = useContextSelector(ChatPageContext, (v) => v.myApps);
const [currentTab, setCurrentTab] = useState<TabEnum>(TabEnum.recently);
......@@ -136,7 +135,6 @@ const MobileDrawer = ({ onCloseDrawer, appId }: { onCloseDrawer: () => void; app
const onclickApp = (id: string) => {
handlePaneChange(ChatSidebarPaneEnum.RECENTLY_USED_APPS, id);
onCloseDrawer();
setChatId();
};
return (
......
......@@ -12,12 +12,13 @@ import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { getDisplayHistoryTitle } from '@/web/core/chat/context/historyTitleUtils';
const ChatSliderList = () => {
const { isPc } = useSystem();
const { t } = useTranslation();
const { chatId: activeChatId } = useChatStore();
const { chatId: activeChatId, appId } = useChatStore();
const histories = useContextSelector(ChatContext, (v) => v.histories);
const ScrollData = useContextSelector(ChatContext, (v) => v.ScrollData);
......@@ -29,6 +30,8 @@ const ChatSliderList = () => {
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const concatHistory = useMemo(() => {
const scopedHistories = histories.filter((item) => item.appId === appId);
const formatHistories: {
id: string;
title: string;
......@@ -37,7 +40,7 @@ const ChatSliderList = () => {
updateTime: Date;
chatGenerateStatus?: ChatGenerateStatusEnum;
hasBeenRead?: boolean;
}[] = histories.map((item) => {
}[] = scopedHistories.map((item) => {
const isActiveChat = item.chatId === activeChatId && chatBoxData.chatId === item.chatId;
return {
......@@ -47,9 +50,9 @@ const ChatSliderList = () => {
top: item.top,
updateTime: item.updateTime,
chatGenerateStatus: isActiveChat
? chatBoxData.chatGenerateStatus ?? item.chatGenerateStatus
? (chatBoxData.chatGenerateStatus ?? item.chatGenerateStatus)
: item.chatGenerateStatus,
hasBeenRead: isActiveChat ? chatBoxData.hasBeenRead ?? item.hasBeenRead : item.hasBeenRead
hasBeenRead: isActiveChat ? (chatBoxData.hasBeenRead ?? item.hasBeenRead) : item.hasBeenRead
};
});
......@@ -63,20 +66,31 @@ const ChatSliderList = () => {
hasBeenRead?: boolean;
} = {
id: activeChatId,
title: t('common:core.chat.New Chat'),
title: getDisplayHistoryTitle({
title: chatBoxData.chatId === activeChatId ? chatBoxData.title : undefined,
fallbackTitle: t('common:core.chat.New Chat')
}),
updateTime: new Date(),
chatGenerateStatus:
chatBoxData.chatId === activeChatId ? chatBoxData.chatGenerateStatus : undefined,
hasBeenRead: chatBoxData.chatId === activeChatId ? chatBoxData.hasBeenRead : undefined
};
const activeChat = histories.find((item) => item.chatId === activeChatId);
const activeChat = scopedHistories.find((item) => item.chatId === activeChatId);
const shouldPrependActiveChat =
chatBoxData.appId === appId &&
chatBoxData.chatId === activeChatId &&
!activeChat &&
!!activeChatId;
return !activeChat ? [newChat].concat(formatHistories) : formatHistories;
return shouldPrependActiveChat ? [newChat].concat(formatHistories) : formatHistories;
}, [
activeChatId,
appId,
histories,
t,
chatBoxData.appId,
chatBoxData.chatId,
chatBoxData.title,
chatBoxData.chatGenerateStatus,
chatBoxData.hasBeenRead
]);
......@@ -89,6 +103,7 @@ const ChatSliderList = () => {
return (
<>
{/* eslint-disable-next-line react-hooks/static-components -- ScrollData is supplied by useScrollPagination. */}
<ScrollData flex={'1 0 0'} h={0} px={[2, 5]} overflow={'overlay'}>
{concatHistory.map((item, i) => (
<Flex
......
......@@ -62,22 +62,24 @@ import {
STREAM_RESUME_REQUEST_HEADER
} from '@fastgpt/global/core/chat/constants';
import { getStreamResumeMirror } from '@fastgpt/service/core/chat/resume';
import { validateChatRoundDataIds } from '@fastgpt/service/core/chat/dataIdValidation';
async function handler(req: NextApiRequest, res: NextApiResponse) {
let streamResumeMirror: Awaited<ReturnType<typeof getStreamResumeMirror>>;
let workflowResponseWrite: ReturnType<typeof getWorkflowResponseWrite> | undefined;
let usePreparedRound = false;
let {
const chatTestProps = ChatTestPropsSchema.parse(req.body);
const {
nodes = [],
edges = [],
messages = [],
responseChatItemId: responseChatItemIdFromBody,
variables = {},
appName,
appId,
chatConfig,
chatId
} = ChatTestPropsSchema.parse(req.body);
} = chatTestProps;
let { variables = {} } = chatTestProps;
const responseChatItemId = responseChatItemIdFromBody ?? getNanoid(24);
const source = ChatSourceEnum.test;
try {
......@@ -158,6 +160,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const newHistories = concatHistories(histories, chatMessages);
const interactive = getLastInteractiveValue(newHistories);
usePreparedRound = !interactive;
await validateChatRoundDataIds({
appId: String(app._id),
chatId,
userContent: userQuestion,
responseChatItemId
});
// Get runtimeNodes
let runtimeNodes = storeNodes2RuntimeNodes(nodes, getWorkflowEntryNodeIds(nodes, interactive));
if (isPlugin) {
......
......@@ -65,6 +65,7 @@ import { LimitTypeEnum, teamFrequencyLimit } from '@fastgpt/service/common/api/f
import { getIpFromRequest } from '@fastgpt/service/common/geo';
import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { validateChatRoundDataIds } from '@fastgpt/service/core/chat/dataIdValidation';
const logger = getLogger(LogCategories.MODULE.CHAT.ITEM);
......@@ -242,6 +243,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
runtimeNodes = rewriteNodeOutputByHistories(runtimeNodes, interactive);
const saveChatId = chatId || getNanoid(24);
await validateChatRoundDataIds({
appId: String(app._id),
chatId: saveChatId,
userContent: userQuestion,
responseChatItemId
});
const source = (() => {
if (shareId) {
return ChatSourceEnum.share;
......
......@@ -75,6 +75,7 @@ import {
STREAM_RESUME_REQUEST_HEADER
} from '@fastgpt/global/core/chat/constants';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { validateChatRoundDataIds } from '@fastgpt/service/core/chat/dataIdValidation';
const logger = getLogger(LogCategories.MODULE.CHAT.ITEM);
async function handler(req: NextApiRequest, res: NextApiResponse) {
......@@ -258,6 +259,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
runtimeNodes = rewriteNodeOutputByHistories(runtimeNodes, interactive);
const saveChatId = chatId || getNanoid(24);
await validateChatRoundDataIds({
appId: String(app._id),
chatId: saveChatId,
userContent: userQuestion,
responseChatItemId: finalResponseChatItemId
});
const source = (() => {
if (shareId) {
return ChatSourceEnum.share;
......
......@@ -7,9 +7,12 @@ import {
} from '@fastgpt/service/core/workflow/dispatch/workflowStatus';
import {
StopV2ChatSchema,
StopV2ChatResponseSchema,
type StopV2ChatResponse
} from '@fastgpt/global/openapi/core/chat/controler/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
async function handler(req: NextApiRequest, res: NextApiResponse): Promise<StopV2ChatResponse> {
const {
......@@ -34,9 +37,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse): Promise<StopV
// 等待工作流完成 (最多等待 5 秒)
await waitForWorkflowComplete({ appId, chatId, timeout: 5000 });
return {
success: true
};
const chat = await MongoChat.findOne({ appId, chatId }, 'chatGenerateStatus').lean();
const chatGenerateStatus = chat?.chatGenerateStatus ?? ChatGenerateStatusEnum.done;
return StopV2ChatResponseSchema.parse({
success: true,
completed: chatGenerateStatus !== ChatGenerateStatusEnum.generating,
chatGenerateStatus
});
}
export default NextAPI(handler);
......@@ -99,26 +99,29 @@ type ChatPageProps = {
};
const ChatContent = (props: ChatPageProps) => {
const { appId, isStandalone } = props;
const { chatId } = useChatStore();
const { appId: pageAppId, isStandalone } = props;
const { appId: storeAppId, chatId } = useChatStore();
const { setUserInfo } = useUserStore();
const { feConfigs } = useSystemStore();
const isInitedUser = useContextSelector(ChatPageContext, (v) => v.isInitedUser);
const userInfo = useContextSelector(ChatPageContext, (v) => v.userInfo);
// 优先使用 store 中的 appId:handlePaneChange 会同步写入,比 page props 更早与 chatId 对齐
const currentAppId = storeAppId || pageAppId;
const chatHistoryProviderParams = useMemo(
() => ({ appId, source: ChatSourceEnum.online }),
[appId]
() => ({ appId: currentAppId, source: ChatSourceEnum.online }),
[currentAppId]
);
const chatRecordProviderParams = useMemo(() => {
return {
appId,
appId: currentAppId,
type: GetChatTypeEnum.normal,
chatId
};
}, [appId, chatId]);
}, [currentAppId, chatId]);
const loginSuccess = useCallback(
async (res: LoginSuccessResponseType) => {
......
......@@ -65,13 +65,13 @@ const scheduleTriggerAppCron = () => {
getScheduleTriggerApp();
};
/** 超过 30 分钟仍为 generating 的会话纠正为 done(与 cleanStaleGeneratingChats 阈值一致) */
/** 基于 Redis stream activity 快速纠正异常中断的 generating 会话,保留 30 分钟兜底 */
const cleanStaleGeneratingChatCron = () => {
setCron('*/5 * * * *', async () => {
setCron('*/1 * * * *', async () => {
if (
await checkTimerLock({
timerId: TimerIdEnum.cleanStaleGeneratingChat,
lockMinuted: 4
lockMinuted: 1
})
) {
await cleanStaleGeneratingChats();
......
......@@ -10,7 +10,8 @@ import type {
InitChatQueryType,
InitChatResponseType,
InitTeamChatQueryType,
StopV2ChatParams
StopV2ChatParams,
StopV2ChatResponse
} from '@fastgpt/global/openapi/core/chat/controler/api';
import type { GetRecentlyUsedAppsResponseType } from '@fastgpt/global/openapi/core/chat/api';
......@@ -49,4 +50,5 @@ export const deleteFavouriteApp = (data: { id: string }) =>
DELETE<null>('/proApi/core/chat/setting/favourite/delete', data);
/* Chat controller */
export const postStopV2Chat = (data: StopV2ChatParams) => POST('/v2/chat/stop', data);
export const postStopV2Chat = (data: StopV2ChatParams) =>
POST<StopV2ChatResponse>('/v2/chat/stop', data);
......@@ -16,6 +16,7 @@ import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
import type { UpdateHistoryBodyType } from '@fastgpt/global/openapi/core/chat/history/api';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { upsertHistoryTitle } from './historyTitleUtils';
type UpdateHistoryParams = Pick<UpdateHistoryBodyType, 'chatId' | 'customTitle' | 'top'>;
......@@ -83,7 +84,8 @@ const ChatContextProvider = ({
const router = useRouter();
const forbidLoadChat = useRef(false);
const { chatId, appId, setChatId, outLinkAuthData } = useChatStore();
const { chatId, setChatId, outLinkAuthData } = useChatStore();
const historyAppId = String(params.appId ?? '');
const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure();
......@@ -139,7 +141,7 @@ const ChatContextProvider = ({
const { runAsync: onUpdateHistory } = useRequest(
(data: UpdateHistoryParams) =>
putChatHistory({
appId,
appId: historyAppId,
...data,
...outLinkAuthData
}),
......@@ -164,7 +166,7 @@ const ChatContextProvider = ({
: updatedHistories;
});
},
refreshDeps: [outLinkAuthData, appId],
refreshDeps: [outLinkAuthData, historyAppId],
errorToast: undefined
}
);
......@@ -172,7 +174,7 @@ const ChatContextProvider = ({
const { runAsync: onDelHistory, loading: isDeletingHistory } = useRequest(
(chatId: string) =>
delChatHistoryById({
appId: appId,
appId: historyAppId,
chatId,
...outLinkAuthData
}),
......@@ -181,18 +183,18 @@ const ChatContextProvider = ({
const chatId = params[0];
setHistories((old) => old.filter((i) => i.chatId !== chatId));
},
refreshDeps: [outLinkAuthData, appId]
refreshDeps: [outLinkAuthData, historyAppId]
}
);
const { runAsync: onClearHistories, loading: isClearingHistory } = useRequest(
() =>
delClearChatHistories({
appId: appId,
appId: historyAppId,
...outLinkAuthData
}),
{
refreshDeps: [outLinkAuthData, appId],
refreshDeps: [outLinkAuthData, historyAppId],
onSuccess() {
setHistories([]);
},
......@@ -204,22 +206,60 @@ const ChatContextProvider = ({
const onUpdateHistoryTitle = useCallback(
({ chatId, newTitle }: { chatId: string; newTitle: string }) => {
// Chat history exists
if (histories.find((item) => item.chatId === chatId)) {
setHistories((state) =>
state.map((item) => (item.chatId === chatId ? { ...item, title: newTitle } : item))
);
} else {
// Chat history not exists
loadHistories({ init: true });
}
const { chatId: currentChatId } = useChatStore.getState();
if (chatId !== currentChatId) return;
setHistories((state) =>
upsertHistoryTitle({
histories: state,
appId: historyAppId,
chatId,
title: newTitle,
fallbackTitle: '新对话'
})
);
loadHistories({ init: true });
},
[histories, loadHistories, setHistories]
[historyAppId, loadHistories, setHistories]
);
const historyChatIdsKey = useMemo(() => histories.map((h) => h.chatId).join(','), [histories]);
const historiesRef = useRef(histories);
historiesRef.current = histories;
const prevHistoryAppIdRef = useRef<string | null>(null);
const pendingAppChatRestoreRef = useRef(false);
useEffect(() => {
historiesRef.current = histories;
}, [histories]);
/** 切换应用后,若当前 chatId 无效则恢复该应用上次会话或最近一条历史 */
useEffect(() => {
if (prevHistoryAppIdRef.current === null) {
prevHistoryAppIdRef.current = historyAppId;
return;
}
if (prevHistoryAppIdRef.current !== historyAppId) {
pendingAppChatRestoreRef.current = true;
prevHistoryAppIdRef.current = historyAppId;
}
}, [historyAppId]);
useEffect(() => {
if (!pendingAppChatRestoreRef.current || isPaginationLoading || !historyAppId) return;
pendingAppChatRestoreRef.current = false;
const { chatId: currentChatId } = useChatStore.getState();
const scopedHistories = histories.filter((item) => item.appId === historyAppId);
if (scopedHistories.some((item) => item.chatId === currentChatId)) {
return;
}
if (scopedHistories.length > 0) {
onChangeChatId(scopedHistories[0].chatId, true);
}
}, [historyAppId, histories, isPaginationLoading, onChangeChatId]);
/** 侧栏是否仍有「思考中」:仅此时需要定时轮询;无则只依赖单次 poll / 可见性拉取,避免一直打接口。 */
const hasGeneratingInSidebar = useMemo(
......@@ -235,7 +275,7 @@ const ChatContextProvider = ({
const poll = () => {
const chatIds = historiesRef.current.map((h) => h.chatId);
getChatHistoryStatus({
...(appId ? { appId } : {}),
...(historyAppId ? { appId: historyAppId } : {}),
chatIds,
...outLinkAuthData
})
......@@ -249,7 +289,7 @@ const ChatContextProvider = ({
const nextRead =
nextGen === ChatGenerateStatusEnum.generating
? false
: s.hasBeenRead ?? item.hasBeenRead;
: (s.hasBeenRead ?? item.hasBeenRead);
return {
...item,
chatGenerateStatus: nextGen,
......@@ -286,7 +326,7 @@ const ChatContextProvider = ({
window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [appId, historyChatIdsKey, hasGeneratingInSidebar, outLinkAuthData, setHistories]);
}, [historyAppId, historyChatIdsKey, hasGeneratingInSidebar, outLinkAuthData, setHistories]);
const isLoading = isDeletingHistory || isClearingHistory || isPaginationLoading;
......
......@@ -151,6 +151,11 @@ export const ChatPageContextProvider = ({
return '';
})();
// 切换应用时同步更新 store,避免 URL / page props 已变但 chatId、chatBoxData 仍停留在上一应用
if (_id) {
setAppId(_id);
}
await router.replace({
query: {
...router.query,
......@@ -163,7 +168,7 @@ export const ChatPageContextProvider = ({
setLastPane(newPane);
setLastChatAppId(_id);
},
[lastestPane, router, setLastPane, setLastChatAppId, chatSettings?.appId]
[lastestPane, router, setAppId, setLastPane, setLastChatAppId, chatSettings?.appId]
);
useEffect(() => {
......
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatHistoryItemType } from '@fastgpt/global/core/chat/type';
export const getDisplayHistoryTitle = ({
title,
fallbackTitle
}: {
title?: string;
fallbackTitle: string;
}) => {
const normalizedTitle = title?.trim();
return normalizedTitle || fallbackTitle;
};
export const upsertHistoryTitle = ({
histories,
appId,
chatId,
title,
fallbackTitle,
now = new Date()
}: {
histories: ChatHistoryItemType[];
appId: string;
chatId: string;
title?: string;
fallbackTitle: string;
now?: Date;
}) => {
const nextTitle = getDisplayHistoryTitle({ title, fallbackTitle });
const existingIndex = histories.findIndex(
(item) => item.chatId === chatId && item.appId === appId
);
if (existingIndex >= 0) {
return histories.map((item, index) =>
index === existingIndex
? {
...item,
title: nextTitle,
updateTime: now
}
: item
);
}
return [
{
chatId,
appId,
title: nextTitle,
customTitle: '',
top: false,
updateTime: now,
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
},
...histories
];
};
......@@ -16,6 +16,8 @@ type State = {
lastChatId: string;
chatId: string;
setChatId: (e?: string) => any;
/** 每个应用最近一次打开的 chatId,用于切换应用时恢复会话 */
appChatIdMap: Record<string, string>;
lastPane: ChatSidebarPaneEnum;
setLastPane: (e: ChatSidebarPaneEnum) => any;
......@@ -27,6 +29,7 @@ type State = {
};
const createCustomStorage = () => {
// source/chatId/appId 跟当前 tab 绑定,放 sessionStorage;其余跨 tab 共享字段放 localStorage
const sessionKeys = ['source', 'chatId', 'appId'];
return {
......@@ -95,17 +98,33 @@ export const useChatStore = create<State>()(
if (!e) return;
set((state) => {
if (state.appId !== e) {
// 离开当前应用前,记住该应用最近一次会话
if (state.appId && state.chatId) {
state.appChatIdMap[state.appId] = state.chatId;
}
// 切换到目标应用:优先恢复该应用上次的 chatId,否则临时生成(待历史列表加载后再对齐)
const restoredChatId = state.appChatIdMap[e];
state.chatId = restoredChatId || getNanoid(24);
if (state.source) {
state.lastChatId = `${state.source}-${state.chatId}`;
}
}
state.appId = e;
state.lastChatAppId = e;
});
},
lastChatId: '',
chatId: '',
appChatIdMap: {},
setChatId(e) {
const id = e || getNanoid(24);
set((state) => {
state.chatId = id;
state.lastChatId = `${state.source}-${id}`;
if (state.appId) {
state.appChatIdMap[state.appId] = id;
}
});
},
lastChatAppId: '',
......@@ -133,6 +152,7 @@ export const useChatStore = create<State>()(
state.lastChatAppId = '';
state.chatId = '';
state.lastChatId = '';
state.appChatIdMap = {};
state.lastPane = ChatSidebarPaneEnum.HOME;
state.outLinkAuthData = {};
});
......@@ -147,14 +167,18 @@ export const useChatStore = create<State>()(
appId: state.appId,
lastChatId: state.lastChatId,
lastChatAppId: state.lastChatAppId,
lastPane: state.lastPane
lastPane: state.lastPane,
appChatIdMap: state.appChatIdMap
})
}
)
)
);
// Storage 事件监听器,用于跨 tab 同步
/**
* 跨 tab 同步 localStorage 中的持久字段(lastChatId、appChatIdMap 等)。
* sessionStorage 字段(source/chatId/appId)各 tab 独立,不参与 storage 事件合并。
*/
const createStorageListener = (store: any) => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'chatStore' && e.newValue && e.storageArea === localStorage) {
......
......@@ -20,6 +20,9 @@ import {
mirrorChatStream,
resetStreamResumeMirrorGuardForTest,
getStreamResumeRedisKeys,
getStreamResumeActiveState,
isStreamResumeActiveStale,
STREAM_RESUME_INACTIVE_MS,
STREAM_RESUME_POST_COMPLETE_TTL_SECONDS,
STREAM_RESUME_REDIS_MAXMEMORY_RATIO,
STREAM_RESUME_TTL_SECONDS,
......@@ -763,6 +766,7 @@ describe('stream resume helpers', () => {
expect(redis.del).toHaveBeenCalledWith(keys.keyOfUnavailable);
expect(redis.del).toHaveBeenCalledWith(keys.keyOfStream);
expect(redis.del).toHaveBeenCalledWith(keys.keyOfActive);
expect(redis.call).toHaveBeenNthCalledWith(
1,
'XADD',
......@@ -782,12 +786,22 @@ describe('stream resume helpers', () => {
expect(redis.expire).toHaveBeenCalledWith(keys.keyOfStream, STREAM_RESUME_TTL_SECONDS);
expect(redis.expire).toHaveBeenCalledTimes(1);
expect(redis.set).toHaveBeenCalledWith(
keys.keyOfActive,
expect.stringMatching(/^\{"updatedAt":\d+\}$/),
'EX',
STREAM_RESUME_TTL_SECONDS
);
expect(await getStreamResumeActiveState({ teamId, appId, chatId })).toEqual({
updatedAt: expect.any(Number)
});
vi.advanceTimersByTime(STREAM_RESUME_TTL_TOUCH_INTERVAL_MS);
await mirror.enqueueRaw('event: done\ndata: [DONE]\n\n');
await mirror.flush();
expect(redis.expire).toHaveBeenCalledTimes(2);
expect(redis.set).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
......@@ -805,7 +819,11 @@ describe('stream resume helpers', () => {
keys.keyOfStream,
STREAM_RESUME_POST_COMPLETE_TTL_SECONDS
);
expect(redis.expire).toHaveBeenCalledTimes(1);
expect(redis.expire).toHaveBeenCalledWith(
keys.keyOfActive,
STREAM_RESUME_POST_COMPLETE_TTL_SECONDS
);
expect(redis.expire).toHaveBeenCalledTimes(2);
});
it('should clear old redis mirror when mirror starts (before first chunk)', async () => {
......@@ -828,6 +846,13 @@ describe('stream resume helpers', () => {
await mirror.flush();
expect(redis.del).toHaveBeenCalledWith(keyOfStream);
expect(redis.del).toHaveBeenCalledWith(
getStreamResumeRedisKeys({
teamId,
appId,
chatId
}).keyOfActive
);
expect(await redis.get(keyOfStream)).toBeFalsy();
});
......@@ -851,6 +876,7 @@ describe('stream resume helpers', () => {
expect(redis.del).toHaveBeenCalledWith(keys.keyOfUnavailable);
expect(redis.del).toHaveBeenCalledWith(keys.keyOfStream);
expect(redis.del).toHaveBeenCalledWith(keys.keyOfActive);
expect(redis.call).toHaveBeenNthCalledWith(1, 'XADD', rawStream, '*', 'raw', 'event: answer\n');
expect(redis.call).toHaveBeenNthCalledWith(2, 'XADD', rawStream, '*', 'raw', 'data: hello\n\n');
});
......@@ -906,4 +932,26 @@ describe('stream resume helpers', () => {
expect(isStreamResumeMirrorRequested('YES')).toBe(true);
expect(isStreamResumeMirrorRequested('0')).toBe(false);
});
it('should detect stale stream resume activity states', () => {
const now = Date.now();
expect(isStreamResumeActiveStale(undefined, now)).toBe(true);
expect(
isStreamResumeActiveStale(
{
updatedAt: now - STREAM_RESUME_INACTIVE_MS + 1
},
now
)
).toBe(false);
expect(
isStreamResumeActiveStale(
{
updatedAt: now - STREAM_RESUME_INACTIVE_MS - 1
},
now
)
).toBe(true);
});
});
import { describe, expect, it } from 'vitest';
import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { isChatRoundPending } from '@/components/core/chat/ChatContainer/ChatBox/chatStatus';
describe('isChatRoundPending', () => {
it('returns true while the local chat is streaming', () => {
expect(
isChatRoundPending({
isChatting: true,
chatGenerateStatus: ChatGenerateStatusEnum.done
})
).toBe(true);
});
it('returns true while the server status is generating', () => {
expect(
isChatRoundPending({
isChatting: false,
chatGenerateStatus: ChatGenerateStatusEnum.generating
})
).toBe(true);
});
it('returns true when the latest AI item has not finished', () => {
expect(
isChatRoundPending({
isChatting: false,
chatGenerateStatus: ChatGenerateStatusEnum.done,
lastChat: {
obj: ChatRoleEnum.AI,
status: 'loading'
}
})
).toBe(true);
});
it('returns false when there is no running signal', () => {
expect(
isChatRoundPending({
isChatting: false,
chatGenerateStatus: ChatGenerateStatusEnum.done,
lastChat: {
obj: ChatRoleEnum.AI,
status: 'finish'
}
})
).toBe(false);
});
});
import { describe, expect, it } from 'vitest';
import {
getFilenameFromFormInputFileUrl,
normalizeFormInputResultFile
} from '@/components/core/chat/components/FormInputResult';
describe('FormInputResult', () => {
it('extracts the download filename from signed file urls', () => {
const url =
'http://localhost:3000/api/system/file/download/token?filename=H6%E4%BA%A7%E5%93%81%E6%A6%82%E8%BF%B0V1.5_tBF8kj.docx';
expect(getFilenameFromFormInputFileUrl(url)).toBe('H6产品概述V1.5_tBF8kj.docx');
expect(normalizeFormInputResultFile(url)).toEqual({
name: 'H6产品概述V1.5_tBF8kj.docx',
url
});
});
it('keeps explicit file object names', () => {
expect(
normalizeFormInputResultFile({
name: 'H6产品概述V1.5.docx',
url: 'https://example.com/download.docx'
})
).toEqual({
name: 'H6产品概述V1.5.docx',
url: 'https://example.com/download.docx'
});
});
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { MongoChat } from '@fastgpt/service/core/chat/chatSchema';
import {
cleanStaleGeneratingChats,
STALE_GENERATING_CHAT_MINUTES
} from '@fastgpt/service/core/chat/cleanStaleGeneratingChats';
import {
getStreamResumeRedisKeys,
STREAM_RESUME_INACTIVE_MS
} from '@fastgpt/service/core/chat/resume';
import { getGlobalRedisConnection } from '@fastgpt/service/common/redis';
vi.mock('@fastgpt/service/core/chat/chatSchema', () => ({
MongoChat: {
find: vi.fn(),
updateOne: vi.fn()
}
}));
const teamId = '507f1f77bcf86cd799439011';
const appId = '507f1f77bcf86cd799439012';
const baseNow = new Date('2026-05-13T08:00:00.000Z');
const mockGeneratingChats = (chats: any[]) => {
vi.mocked(MongoChat.find).mockReturnValue({
lean: () => ({
exec: () => Promise.resolve(chats)
})
} as any);
};
describe('cleanStaleGeneratingChats', () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(baseNow);
vi.clearAllMocks();
const redis = getGlobalRedisConnection() as any;
await redis.flushdb();
redis.get.mockClear?.();
vi.mocked(MongoChat.updateOne).mockResolvedValue({ modifiedCount: 1 } as any);
});
it('should correct generating chats when stream resume activity is stale or missing', async () => {
const activeChat = {
_id: 'active-chat',
teamId,
appId,
chatId: 'active',
updateTime: new Date(baseNow.getTime() - STREAM_RESUME_INACTIVE_MS - 1000)
};
const staleChat = {
_id: 'stale-chat',
teamId,
appId,
chatId: 'stale',
updateTime: new Date(baseNow.getTime() - STREAM_RESUME_INACTIVE_MS - 1000)
};
const missingChat = {
_id: 'missing-chat',
teamId,
appId,
chatId: 'missing',
updateTime: new Date(baseNow.getTime() - STREAM_RESUME_INACTIVE_MS - 1000)
};
mockGeneratingChats([activeChat, staleChat, missingChat]);
const redis = getGlobalRedisConnection() as any;
await redis.set(
getStreamResumeRedisKeys({ teamId, appId, chatId: activeChat.chatId }).keyOfActive,
JSON.stringify({ updatedAt: baseNow.getTime() - STREAM_RESUME_INACTIVE_MS + 1000 }),
'EX',
1800
);
await redis.set(
getStreamResumeRedisKeys({ teamId, appId, chatId: staleChat.chatId }).keyOfActive,
JSON.stringify({ updatedAt: baseNow.getTime() - STREAM_RESUME_INACTIVE_MS - 1000 }),
'EX',
1800
);
const result = await cleanStaleGeneratingChats();
expect(MongoChat.find).toHaveBeenCalledWith(
{
chatGenerateStatus: ChatGenerateStatusEnum.generating,
updateTime: { $lt: new Date(baseNow.getTime() - STREAM_RESUME_INACTIVE_MS) }
},
{
_id: 1,
teamId: 1,
appId: 1,
chatId: 1,
updateTime: 1
}
);
expect(MongoChat.updateOne).toHaveBeenCalledTimes(2);
expect(MongoChat.updateOne).toHaveBeenCalledWith(
{
_id: staleChat._id,
chatGenerateStatus: ChatGenerateStatusEnum.generating
},
{
$set: {
chatGenerateStatus: ChatGenerateStatusEnum.done,
updateTime: baseNow,
hasBeenRead: false
}
}
);
expect(MongoChat.updateOne).toHaveBeenCalledWith(
{
_id: missingChat._id,
chatGenerateStatus: ChatGenerateStatusEnum.generating
},
{
$set: {
chatGenerateStatus: ChatGenerateStatusEnum.done,
updateTime: baseNow,
hasBeenRead: false
}
}
);
expect(result).toEqual({
modifiedCount: 2,
inactiveCount: 2,
fallbackCount: 0
});
});
it('should keep the 30 minute fallback when redis activity lookup fails', async () => {
const inactiveOnlyChat = {
_id: 'inactive-only-chat',
teamId,
appId,
chatId: 'inactive-only',
updateTime: new Date(baseNow.getTime() - STREAM_RESUME_INACTIVE_MS - 1000)
};
const fallbackChat = {
_id: 'fallback-chat',
teamId,
appId,
chatId: 'fallback',
updateTime: new Date(baseNow.getTime() - STALE_GENERATING_CHAT_MINUTES * 60 * 1000 - 1000)
};
mockGeneratingChats([inactiveOnlyChat, fallbackChat]);
const redis = getGlobalRedisConnection() as any;
redis.get.mockRejectedValueOnce(new Error('redis down'));
const result = await cleanStaleGeneratingChats();
expect(MongoChat.updateOne).toHaveBeenCalledTimes(1);
expect(MongoChat.updateOne).toHaveBeenCalledWith(
{
_id: fallbackChat._id,
chatGenerateStatus: ChatGenerateStatusEnum.generating
},
{
$set: {
chatGenerateStatus: ChatGenerateStatusEnum.done,
updateTime: baseNow,
hasBeenRead: false
}
}
);
expect(result).toEqual({
modifiedCount: 1,
inactiveCount: 0,
fallbackCount: 1
});
});
});
import { describe, expect, it } from 'vitest';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatHistoryItemType } from '@fastgpt/global/core/chat/type';
import {
getDisplayHistoryTitle,
upsertHistoryTitle
} from '@/web/core/chat/context/historyTitleUtils';
const appId = 'app-1';
const chatId = 'chat-1';
const now = new Date('2026-05-13T08:00:00.000Z');
const createHistory = (override: Partial<ChatHistoryItemType> = {}): ChatHistoryItemType => ({
appId,
chatId,
title: 'old title',
customTitle: '',
top: false,
updateTime: new Date('2026-05-13T07:00:00.000Z'),
chatGenerateStatus: ChatGenerateStatusEnum.done,
hasBeenRead: true,
...override
});
describe('historyTitleUtils', () => {
it('should prefer non-empty title and fallback when title is blank', () => {
expect(
getDisplayHistoryTitle({
title: ' user question ',
fallbackTitle: '新对话'
})
).toBe('user question');
expect(
getDisplayHistoryTitle({
title: ' ',
fallbackTitle: '新对话'
})
).toBe('新对话');
});
it('should insert a temporary history with user input title', () => {
const result = upsertHistoryTitle({
histories: [],
appId,
chatId,
title: 'What is FastGPT?',
fallbackTitle: '新对话',
now
});
expect(result).toEqual([
{
appId,
chatId,
title: 'What is FastGPT?',
customTitle: '',
top: false,
updateTime: now,
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
}
]);
});
it('should replace existing temporary title with server title without duplicating history', () => {
const otherHistory = createHistory({
chatId: 'chat-2',
title: 'other title'
});
const result = upsertHistoryTitle({
histories: [
createHistory({
title: '新对话',
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
}),
otherHistory
],
appId,
chatId,
title: 'server generated title',
fallbackTitle: '新对话',
now
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
...createHistory({
title: 'server generated title',
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
}),
updateTime: now
});
expect(result[1]).toBe(otherHistory);
});
});
......@@ -33,6 +33,7 @@ describe('useChatStore', () => {
lastChatAppId: '',
lastChatId: '',
chatId: '',
appChatIdMap: {},
lastPane: undefined,
outLinkAuthData: {}
});
......@@ -52,6 +53,49 @@ describe('useChatStore', () => {
expect(newState.lastChatAppId).toBe('test-app-id');
});
it('should use a new chatId when switching to app without saved chat', () => {
const store = useChatStore.getState();
store.setSource(ChatSourceEnum.online);
store.setAppId('app-a');
store.setChatId('chat-from-app-a');
store.setAppId('app-b');
const newState = useChatStore.getState();
expect(newState.appId).toBe('app-b');
expect(newState.chatId).toBe('test-generated-id');
expect(newState.chatId).not.toBe('chat-from-app-a');
expect(newState.lastChatId).toBe(`${ChatSourceEnum.online}-test-generated-id`);
});
it('should save and restore chatId per app when switching appId', () => {
const store = useChatStore.getState();
store.setSource(ChatSourceEnum.online);
store.setAppId('app-a');
store.setChatId('chat-a');
store.setAppId('app-b');
store.setChatId('chat-b');
store.setAppId('app-a');
expect(useChatStore.getState().chatId).toBe('chat-a');
store.setAppId('app-b');
expect(useChatStore.getState().chatId).toBe('chat-b');
});
it('should keep chatId when setting the same appId', () => {
const store = useChatStore.getState();
store.setSource(ChatSourceEnum.online);
store.setAppId('app-a');
store.setChatId('stable-chat-id');
store.setAppId('app-a');
const newState = useChatStore.getState();
expect(newState.chatId).toBe('stable-chat-id');
});
it('should set and get chatId', () => {
const store = useChatStore.getState();
store.setSource(ChatSourceEnum.share);
......@@ -194,6 +238,7 @@ describe('useChatStore', () => {
lastChatAppId: '',
chatId: '',
lastChatId: '',
appChatIdMap: {},
lastPane: ChatSidebarPaneEnum.HOME,
outLinkAuthData: {}
});
......
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