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 校验单元测试
# 流恢复表单输入值修复
## 背景
流恢复过程中,工作流的「表单输入」节点可以恢复出交互节点本身,但已提交的表单值没有完整恢复,其中 `fileSelect` 文件列表最容易暴露。
典型表现:
1. 刷新页面后,历史记录接口 `getRecords_v2` 返回的交互节点中有 `interactive.params.inputForm[].value`,已提交表单值可以从历史数据恢复。
2. 一旦自动流恢复开始,页面上仍然能看到表单输入交互节点,但文件上传区域只剩禁用上传框,已提交字段值消失,文件字段表现为文件列表消失。
3. 恢复流中有 `flowNodeResponse` 事件,数据里包含:
```json
{
"moduleType": "formInput",
"formInputResult": {
"File": [
"http://localhost:3000/api/system/file/download/xxx?filename=H6%E4%BA%A7%E5%93%81%E6%A6%82%E8%BF%B0V1.5_tBF8kj.docx"
]
},
"nodeId": "j1Ifb41hX176ezmo"
}
```
用户期望:表单输入节点的已提交字段值恢复到「用户交互表单节点」内部;其中 fileSelect 应恢复为文件列表,而不是展示在 AI 普通回复气泡或文本区域里。
## 相关数据结构
### 历史记录里的表单交互节点
`getRecords_v2` 返回的交互节点中,字段值已经存在;其中文件字段是 `FileSelector` 可渲染的结构:
```json
{
"interactive": {
"type": "userInput",
"params": {
"submitted": true,
"inputForm": [
{
"type": "fileSelect",
"key": "File",
"value": [
{
"key": "chat/xxx.docx",
"name": "H6产品概述V1.5.docx",
"type": "file",
"url": "http://localhost:3000/api/system/file/download/xxx"
}
]
}
]
},
"entryNodeIds": ["j1Ifb41hX176ezmo"]
}
}
```
### 恢复流里的节点响应
`flowNodeResponse.formInputResult` 只包含字段结果,不是完整的表单渲染结构:
```json
{
"formInputResult": {
"File": ["http://localhost:3000/api/system/file/download/xxx?filename=file.docx"]
}
}
```
因此前端不能直接把 `formInputResult` 当成 AI 文本渲染,也不能只展示在响应详情里;需要把它转换并回填到交互表单的 `inputForm[].value`。
## 根因分析
### 1. 表单控件本身不是根因
以 fileSelect 为例,`FileSelector` 接收以下两类值都能渲染文件列表:
```ts
[{ name: 'file.docx', url: 'https://example.com/file.docx' }]
```
或:
```ts
[{ name: 'file.docx', key: 'chat/xxx/file.docx' }]
```
页面能看到禁用上传区域,说明:
1. 交互节点已经渲染出来。
2. `submitted=true` 已经生效。
3. 真正缺失的是交互节点 `inputForm[].value`;fileSelect 只是表现为传给 `FileSelector` 的 value 为空。
### 2. 恢复事件没有天然回填表单值
恢复流的 `flowNodeResponse` 会进入 `generatingMessage`,并追加到当前 AI 记录的 `responseData`。但原始实现只保存节点响应详情,没有把 `formInputResult.File` 回填到已提交的 `interactive.params.inputForm[].value`。
结果:交互节点还在,但字段值仍是空数组。
### 3. completed records 会覆盖当前恢复态
流恢复结束时,后端会返回 `completedChat.records`,前端用它覆盖当前 `chatRecords`。
这个覆盖有两个风险:
1. 恢复过程中刚回填到交互节点的字段值,被 completed records 里空的 `inputForm.value` 覆盖。
2. 当前已回填的交互节点和 completed records 里的交互节点 `dataId` 不一定完全一致。只按 `dataId` 合并可能漏掉。
因此需要在 completed 覆盖阶段保留 submitted interactive 中已经恢复出来的字段值。
### 4. 仍需要渲染层兜底
即使状态层做了回填和 merge,仍可能出现中间态或边界情况:
1. `responseData` 已经有 `formInputResult`。
2. `interactive.params.inputForm[].value` 被后续 records 替换成空。
3. 渲染表单时只看 `inputForm.value`,导致已提交表单值仍然不显示。
所以渲染表单时也应当能从同一条 AI 消息的 `responseData.formInputResult` 还原字段默认值。
## 修复方案
本次修复不是四个备选方案,而是一个组合修复。最终采用:
```text
恢复事件回填 + completed 覆盖保护 + 过期交互去重 + 渲染层兜底
```
四个修复点分别覆盖不同阶段的问题:
1. 恢复流事件到达时,把节点结果写回表单交互节点。
2. 恢复完成 records 覆盖时,保留已经写回的表单值。
3. 恢复流重复推送交互节点时,避免过期未提交交互覆盖已提交交互。
4. 渲染表单时,如果状态值仍为空,从同条消息的 `responseData.formInputResult` 做最后兜底。
### 修复点一:恢复事件回填 submitted 表单交互节点
位置:
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx`
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts`
逻辑:
1. `generatingMessage` 收到 `flowNodeResponse`。
2. 如果 `nodeResponse.formInputResult` 存在,调用 `refreshSubmittedFormInteractiveValues`。
3. 在当前 `chatRecords` 里寻找已提交的 `userInput` 交互节点。
4. 匹配方式:
- 优先用 `interactive.entryNodeIds.includes(nodeResponse.nodeId)`。
- 如果只有一个 submitted 表单交互节点,并且字段 key 能匹配,也允许兜底匹配。
5. 对 `fileSelect` 字段,把 `formInputResult.File: string[]` 转成:
```ts
[
{
name: 'file.docx',
url: 'http://localhost:3000/api/system/file/download/xxx?filename=file.docx'
}
]
```
转换复用 `normalizeFormInputResultFile`,保证文件名从 `filename` query 或 URL path 中取。
### 修复点二:completed records 覆盖时保留已回填交互值
位置:
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts`
逻辑:
1. `mergeResumeCompletedChatRecords` 建立当前 AI record map。
2. 对 completed records 的 AI 消息做合并:
- 保留恢复过程中 replay 出来的 `responseData`。
- 保留当前 submitted `userInput` 交互节点里的 `inputForm.value`。
3. 如果 completed record 能按 `dataId` 找到当前 record,则使用对应 current values。
4. 如果按 `dataId` 找不到,则从当前所有 AI records 中寻找 submitted interactive,并按交互身份匹配。
交互身份匹配规则:
```ts
type 相同 &&
(usageId 相同 || entryNodeIds 数组相同)
```
这个逻辑覆盖 completed records 中交互节点 `dataId` 变化的情况。
### 修复点三:跳过过期的未提交恢复交互
位置:
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts`
逻辑:
如果当前已经有同一个 submitted `userInput` 交互节点,恢复流里又来了同一个未提交 interactive,不再 append。
作用:
1. 避免恢复过程中重新插入一个空表单。
2. 避免 submitted 表单被未提交表单视觉上覆盖。
### 修复点四:渲染层从 `responseData.formInputResult` 兜底恢复表单值
位置:
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx`
- `projects/app/src/components/core/chat/components/AIResponseBox/index.tsx`
- `projects/app/src/components/core/chat/components/AIResponseBox/RenderUserFormInteractive.tsx`
调用链:
```text
ChatItem
-> AIContentCard
-> AIResponseBox
-> RenderUserFormInteractive
-> FormInputComponent
-> InputRender
-> FileSelector
```
新增传参:
1. `ChatItem` 把当前 AI 消息的 `chat.responseData` 传给 `AIContentCard`。
2. `AIContentCard` 传给 `AIResponseBox`。
3. `AIResponseBox` 在渲染 `userInput` 时传给 `RenderUserFormInteractive`。
`RenderUserFormInteractive` 生成 `defaultValues` 时:
1. 从 `responseData` 里倒序查找带 `formInputResult` 的节点响应。
2. 优先匹配 `nodeId` 与 `interactive.entryNodeIds`。
3. 取同名字段,例如 `File`。
4. 如果 `formInputResult` 中存在该字段,则优先使用该字段值作为 submitted 表单的渲染默认值。
5. 对普通输入、选择、数字等字段,直接使用 `formInputResult[key]`。
6. 对 `fileSelect` 字段,额外把 URL 数组归一化为 `FileSelector` 可渲染的 `{ name, url }[]`。
作用:
即使状态层 `inputForm.value` 被 completed records 覆盖为空,只要同一条 AI 消息还带有 `responseData.formInputResult`,表单节点仍然能渲染出已提交表单值。
## 非目标
本 PR 不做以下事情:
1. 不把 `formInputResult` 渲染到 AI 普通文字回复气泡里。
2. 不改变 `FileSelector` 的基础交互行为。
3. 不改变后端 `formInputResult` 的输出结构。
4. 不迁移历史数据。
5. 不处理非 `userInput` 类型的假想表单交互类型;当前类型定义中不存在 `agentPlanAskUserForm`。
## 影响文件
### 全部改动文件清单
代码改动:
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx`
- 把当前 AI 消息的 `chat.responseData` 传入 AI 响应渲染链路。
- `projects/app/src/components/core/chat/components/AIResponseBox/index.tsx`
- 接收并继续下传 `responseData` 给表单交互组件。
- `projects/app/src/components/core/chat/components/AIResponseBox/RenderUserFormInteractive.tsx`
- 表单渲染时,从 `responseData.formInputResult` 兜底恢复 submitted 表单字段值;`fileSelect` 字段额外归一化为文件列表渲染结构。
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts`
- 处理恢复流回填、completed records 覆盖合并、submitted interactive 保留、过期 interactive 去重。
- `projects/app/src/components/core/chat/components/FormInputResult.tsx`
- 提供 `normalizeFormInputResultFile`,并支持在响应详情里展示表单输入文件结果。
- `projects/app/src/components/core/chat/components/WholeResponseModal.tsx`
- 在响应详情弹窗里挂载 `FormInputResult` 展示 `formInputResult`。
测试改动:
- `projects/app/test/components/core/chat/ChatContainer/ChatBox/utils.test.ts`
- 增加恢复回填、completed 覆盖保留、`dataId` 变化保留等测试。
- `projects/app/test/components/core/chat/components/FormInputResult.test.ts`
- 测试签名 URL 文件名解析和显式文件名保留。
- `projects/app/test/components/core/app/FileSelector/utils.test.ts`
- 覆盖文件选择器值清洗、URL 文件保留等能力。
设计文档:
- `.codex/design/bug/stream-resume-form-input-file-list.md`
- 新增本次问题的设计说明、根因、方案、测试和 TODO。
### 数据合并与恢复工具
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts`
职责:
1. `refreshSubmittedFormInteractiveValues`:把 `formInputResult` 回填到 submitted 表单节点。
2. `mergeResumeCompletedChatRecords`:completed records 覆盖时保留已恢复的 submitted interactive values。
3. `shouldAppendResumeInteractive`:避免追加过期未提交交互。
### 聊天项渲染链路
- `projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx`
- `projects/app/src/components/core/chat/components/AIResponseBox/index.tsx`
- `projects/app/src/components/core/chat/components/AIResponseBox/RenderUserFormInteractive.tsx`
职责:
1. 把 `responseData` 从 chat item 下传到表单交互渲染组件。
2. 在渲染表单默认值时,从 `responseData.formInputResult` 兜底恢复表单字段值。
### 文件结果展示辅助
- `projects/app/src/components/core/chat/components/FormInputResult.tsx`
职责:
1. 提供 `normalizeFormInputResultFile`。
2. 详情弹窗中展示 `formInputResult` 文件。
注意:详情弹窗展示不是本 bug 的核心修复,核心修复是交互节点内已提交表单值恢复,文件列表只是 fileSelect 字段的展示结果。
### 测试
- `projects/app/test/components/core/chat/ChatContainer/ChatBox/utils.test.ts`
- `projects/app/test/components/core/chat/components/FormInputResult.test.ts`
- `projects/app/test/components/core/app/FileSelector/utils.test.ts`
## 测试覆盖
已覆盖场景:
1. `flowNodeResponse.formInputResult.File` 能回填到 submitted `userInput` 的 `inputForm[].value`。
2. `nodeId` 不匹配但只有一个 submitted 表单交互节点时,可以按字段 key 兜底回填。
3. completed records 覆盖时保留已恢复的 submitted interactive 字段值。
4. completed records 中交互节点 `dataId` 变化时,仍能按交互身份保留字段值。
5. `FormInputResult` 能从签名 URL 的 `filename` query 中解析文件名。
6. `FileSelector` 的值清洗函数能保留可渲染 URL 字段值。
局部测试命令:
```bash
source ~/.zshrc >/dev/null 2>&1; pnpm --filter @fastgpt/app test test/components/core/chat/ChatContainer/ChatBox/utils.test.ts test/components/core/chat/components/FormInputResult.test.ts test/components/core/app/FileSelector/utils.test.ts
```
当前结果:
```text
Test Files 3 passed
Tests 32 passed
```
## 已知验证情况
1. 用户本地验证:恢复流开始后,表单输入节点内文件列表已能正常展示。
2. touched files eslint 无 error,仅剩当前文件已有 warning:
```text
projects/app/src/components/core/chat/ChatContainer/ChatBox/utils.ts
'error' is defined but never used
```
3. `@fastgpt/app typecheck` 在当前分支仍有无关类型错误,集中在:
- `ChatItem.tsx` 的 `stepId/stepTitle` 类型声明缺失。
- `ResponseTags.tsx` / `RenderResponseDetail.tsx` 缺 `chatTime` 参数。
- `WholeResponseModal.tsx` 中 `queryExtensionResult` 类型名与当前 schema 不一致。
这些不是本修复新增逻辑引入的问题。
## TODO
- [x] 确认恢复流 `flowNodeResponse` 会进入前端 `generatingMessage`。
- [x] 回填 `formInputResult` 到 submitted 表单交互节点。
- [x] completed records 覆盖时保留恢复出的表单字段值。
- [x] 处理 completed 交互节点 `dataId` 变化场景。
- [x] 渲染层从 `responseData.formInputResult` 兜底恢复表单值。
- [x] 保证已提交表单值展示在用户交互表单节点内,而不是 AI 文本回复气泡内。
- [x] 增加局部测试。
- [x] 用户完成手动验证。
- [ ] 用户确认后提交本地未提交改动并推送到 draft PR。
# 流恢复历史记录名称修复设计
## 背景
新对话发起后,侧栏会先展示一个临时历史项。旧逻辑在服务端历史记录尚未落库或尚未拉取回来时,容易显示固定的“新对话”。流恢复场景下,这个临时标题更容易停留在默认文案,导致用户在历史列表中无法通过刚输入的问题识别会话。
本 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}
......
......@@ -41,7 +41,12 @@ import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constant
import {
getInteractiveByHistories,
formatChatValue2InputType,
mergeResumeCompletedChatRecords,
refreshSubmittedFormInteractiveValues,
rewriteHistoriesByInteractiveResponse,
shouldAppendResumeInteractive,
shouldReplaceResumeAiValue,
shouldResetResumeAiPlaceholder,
stripChatValueFileUrls
} from './utils';
import { ChatTypeEnum, textareaMinH } from './constants';
......@@ -59,7 +64,10 @@ import { useContextSelector } from 'use-context-selector';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useCreation, useDebounceEffect, useMemoizedFn, useThrottleFn } from 'ahooks';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { mergeChatResponseData } from '@fastgpt/global/core/chat/utils';
import {
getChatTitleFromChatMessage,
mergeChatResponseData
} from '@fastgpt/global/core/chat/utils';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { ChatContext } from '@/web/core/chat/context/chatContext';
......@@ -78,6 +86,7 @@ import {
shouldFollowGeneratingScroll,
shouldForceScrollAfterRecordsLoaded
} from './scrollUtils';
import { isChatRoundPending } from './chatStatus';
const FeedbackModal = dynamic(() => import('./components/FeedbackModal'));
const SelectMarkCollection = dynamic(() => import('./components/SelectMarkCollection'));
......@@ -204,6 +213,14 @@ const ChatBox = ({
const setAudioPlayingChatId = useContextSelector(ChatBoxContext, (v) => v.setAudioPlayingChatId);
const splitText2Audio = useContextSelector(ChatBoxContext, (v) => v.splitText2Audio);
const isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting);
const isRoundPending = isChatRoundPending({
isChatting,
chatGenerateStatus:
chatBoxData.appId === appId && chatBoxData.chatId === chatId
? chatBoxData.chatGenerateStatus
: undefined,
lastChat: chatRecords[chatRecords.length - 1]
});
const setHistories = useContextSelector(ChatContext, (v) => v.setHistories);
const loadHistories = useContextSelector(ChatContext, (v) => v.loadHistories);
......@@ -211,7 +228,12 @@ const ChatBox = ({
const syncSidebarChatGenerateStatus = useMemoizedFn(
(
status: ChatGenerateStatusEnum,
options?: { hasBeenRead?: boolean; targetAppId?: string; targetChatId?: string }
options?: {
hasBeenRead?: boolean;
targetAppId?: string;
targetChatId?: string;
title?: string;
}
) => {
const targetAppId = options?.targetAppId ?? appId;
if (targetAppId !== appId) return;
......@@ -226,7 +248,7 @@ const ChatBox = ({
{
chatId: targetChatId,
appId: targetAppId,
title: chatBoxData.title || t('common:core.chat.New Chat'),
title: options?.title || chatBoxData.title || t('common:core.chat.New Chat'),
customTitle: '',
top: false,
updateTime: new Date(),
......@@ -370,9 +392,16 @@ const ChatBox = ({
durationSeconds,
autoTTSResponse
}: generatingMessageProps & { autoTTSResponse?: boolean }) => {
setChatRecords((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
setChatRecords((state) => {
const histories = nodeResponse?.formInputResult
? refreshSubmittedFormInteractiveValues({
histories: state,
nodeResponse
})
: state;
return histories.map((item, index) => {
if (index !== histories.length - 1) return item;
if (item.obj !== ChatRoleEnum.AI) return item;
if (autoTTSResponse) {
......@@ -625,6 +654,15 @@ const ChatBox = ({
resetVariables({ variables });
}
if (event === SseResponseEventEnum.interactive && interactive) {
if (
!shouldAppendResumeInteractive({
existingValues: item.value,
incomingInteractive: interactive
})
) {
return item;
}
const val: AIChatItemValueItemType = {
interactive
};
......@@ -645,8 +683,8 @@ const ChatBox = ({
}
return item;
})
);
});
});
const forceScroll = event === SseResponseEventEnum.interactive;
generatingScroll(forceScroll);
......@@ -737,7 +775,13 @@ const ChatBox = ({
setChatRecords((state) => {
const lastItem = state[state.length - 1];
if (lastItem?.dataId === responseChatId && lastItem.obj === ChatRoleEnum.AI) {
if (!text && !options?.resetExistingValue) {
const shouldReplaceValue = shouldReplaceResumeAiValue({
hasExistingAiOutput: hasMeaningfulAiOutput(lastItem),
text,
resetExistingValue: options?.resetExistingValue
});
if (!shouldReplaceValue && lastItem.status === status) {
return state;
}
......@@ -746,14 +790,18 @@ const ChatBox = ({
? item
: {
...item,
value: [
{
text: {
content: text
...(shouldReplaceValue
? {
value: [
{
text: {
content: text
}
}
],
responseData: options?.resetExistingValue ? [] : item.responseData
}
}
],
responseData: options?.resetExistingValue ? [] : item.responseData,
: {}),
status,
...(status === ChatStatusEnum.finish ? { time: new Date() } : {})
}
......@@ -796,7 +844,7 @@ const ChatBox = ({
variablesForm.handleSubmit(
async ({ variables = {} }) => {
if (!onStartChat) return;
if (isChatting) {
if (isRoundPending) {
!hideInUI &&
toast({
title: t('chat:is_chatting'),
......@@ -890,6 +938,7 @@ const ChatBox = ({
status: ChatStatusEnum.loading
}
];
const temporaryHistoryTitle = getChatTitleFromChatMessage(currentHumanChat);
resumedChatTargetRef.current = `${appId}:${chatId}`;
......@@ -897,12 +946,16 @@ const ChatBox = ({
state.chatId === chatId
? {
...state,
title: temporaryHistoryTitle,
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
}
: state
);
syncSidebarChatGenerateStatus(ChatGenerateStatusEnum.generating, { hasBeenRead: false });
syncSidebarChatGenerateStatus(ChatGenerateStatusEnum.generating, {
hasBeenRead: false,
title: temporaryHistoryTitle
});
// Update histories(Interactive input does not require new session rounds)
setChatRecords(
......@@ -1090,6 +1143,20 @@ const ChatBox = ({
}
);
const handleStopSettled = useMemoizedFn((status: ChatGenerateStatusEnum, completed: boolean) => {
const nextStatus = completed ? status : ChatGenerateStatusEnum.generating;
setChatBoxData((state) =>
state.chatId === chatId && state.appId === appId
? {
...state,
chatGenerateStatus: nextStatus,
hasBeenRead: false
}
: state
);
syncSidebarChatGenerateStatus(nextStatus, { hasBeenRead: false });
});
// retry input
const onDelMessage = useCallback(
(contentId: string, delFile = true) => {
......@@ -1361,6 +1428,7 @@ const ChatBox = ({
scrollToBottom('auto', 100);
let resumeFinalStatus = ChatGenerateStatusEnum.done;
let hasPreparedResumeAiRecord = false;
let hasReceivedResumeOutput = false;
(async () => {
try {
......@@ -1382,11 +1450,15 @@ const ChatBox = ({
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
if (shouldCreateResumeAiPlaceholder(message.event)) {
upsertResumeAiPlaceholder(responseChatId, '', ChatStatusEnum.loading, {
resetExistingValue: !hasPreparedResumeAiRecord
resetExistingValue: shouldResetResumeAiPlaceholder({
hasPreparedResumeAiRecord,
hasReceivedResumeOutput
})
});
hasPreparedResumeAiRecord = true;
}
generatingMessage(message);
hasReceivedResumeOutput = true;
}
});
......@@ -1394,11 +1466,15 @@ const ChatBox = ({
if (completedChat) {
resumeFinalStatus = completedChat.chatGenerateStatus;
setChatRecords(
completedChat.records.list.map((item) => ({
...item,
status: ChatStatusEnum.finish
}))
setChatRecords((state) =>
mergeResumeCompletedChatRecords({
currentRecords: state,
completedRecords: completedChat.records.list.map((item) => ({
...item,
status: ChatStatusEnum.finish
})),
responseChatId
})
);
scrollToBottom('auto');
scrollToBottom('auto', 100);
......@@ -1567,7 +1643,8 @@ const ChatBox = ({
toast
]);
const canSendPrompt = onStartChat && chatStarted && active && canSendQuery;
const canRenderChatInput = onStartChat && chatStarted && active && canSendQuery;
const canSendPrompt = canRenderChatInput && !isRoundPending;
// Add listener
useEffect(() => {
......@@ -1974,6 +2051,8 @@ const ChatBox = ({
<ChatInput
onSendMessage={sendPrompt}
onStop={() => abortRequest('stop')}
onStopSettled={handleStopSettled}
disableSend={isRoundPending}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
......@@ -1984,7 +2063,7 @@ const ChatBox = ({
) : (
<>
{AppChatRenderBox}
{canSendPrompt && (
{canRenderChatInput && (
<Box
px={[3, 5]}
m={['0 auto 10px', '10px auto']}
......@@ -1997,6 +2076,8 @@ const ChatBox = ({
onSendMessage={sendPrompt}
lastInteractive={lastInteractive}
onStop={() => abortRequest('stop')}
onStopSettled={handleStopSettled}
disableSend={isRoundPending}
TextareaDom={TextareaDom}
resetInputVal={resetInputVal}
chatForm={chatForm}
......
import type {
AIChatItemValueItemType,
ChatHistoryItemResType,
ChatItemValueItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import type { ChatSiteItemType } from './type';
import { type ChatBoxInputType, type UserInputFileItemType } from './type';
import { getFileIcon } from '@fastgpt/global/common/file/icon';
import { ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import {
extractDeepestInteractive,
getLastInteractiveValue
} from '@fastgpt/global/core/workflow/runtime/utils';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import { checkInteractiveResponseStatus } from '@fastgpt/global/core/chat/utils';
import {
checkInteractiveResponseStatus,
mergeChatResponseData
} from '@fastgpt/global/core/chat/utils';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { normalizeFormInputResultFile } from '../../components/FormInputResult';
export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): ChatBoxInputType => {
if (!value) {
......@@ -64,6 +71,311 @@ export const stripChatValueFileUrls = (value: UserChatItemValueItemType[] = [])
return item;
});
/**
* 判断是否应清空恢复流开始前的 AI 占位内容。
* 仅在「尚未准备 resume AI record」且「尚未收到任何 resume 输出」时重置,
* 避免恢复过程中误清已 replay 的流式文本或交互状态。
*/
export const shouldResetResumeAiPlaceholder = ({
hasPreparedResumeAiRecord,
hasReceivedResumeOutput
}: {
hasPreparedResumeAiRecord: boolean;
hasReceivedResumeOutput: boolean;
}) => !hasPreparedResumeAiRecord && !hasReceivedResumeOutput;
/**
* 判断是否用 resume 占位内容覆盖当前 AI record 的 value。
* 已有真实 AI 输出时不覆盖;空 record 时可写入「停止中」等提示或占位标记。
*/
export const shouldReplaceResumeAiValue = ({
hasExistingAiOutput,
text,
resetExistingValue
}: {
hasExistingAiOutput: boolean;
text: string;
resetExistingValue?: boolean;
}) => !hasExistingAiOutput && (!!text || !!resetExistingValue);
/**
* 恢复流结束时,用 completed records 覆盖当前聊天记录,同时保留恢复过程中的中间状态。
*
* 覆盖 completed records 会丢失两类恢复期间才存在的数据:
* 1. 当前 streaming AI record 上 replay 出来的 `responseData`(含 `formInputResult`);
* 2. 已提交 `userInput` 交互节点里经 {@link refreshSubmittedFormInteractiveValues} 回填的 `inputForm.value`。
*
* 合并策略:
* - `responseData`:仅对 `dataId === responseChatId` 的 AI 消息,把 current 中多出的节点响应追加进去(去重);
* - 交互值:优先按 `dataId` 匹配 current AI record;匹配不到时,从所有 current AI record 中
* 按 {@link areSameInteractive} 身份规则寻找已提交交互并覆盖 completed 里的空值。
*
* 若 current 侧既无 replay `responseData` 也无交互态,直接返回 completed records,避免无意义遍历。
*/
export const mergeResumeCompletedChatRecords = ({
currentRecords,
completedRecords,
responseChatId
}: {
currentRecords: ChatSiteItemType[];
completedRecords: ChatSiteItemType[];
responseChatId: string;
}) => {
const currentAiRecordMap = new Map(
currentRecords
.filter(
(item): item is Extract<ChatSiteItemType, { obj: ChatRoleEnum.AI }> =>
item.obj === ChatRoleEnum.AI && !!item.dataId
)
.map((item) => [item.dataId as string, item])
);
const currentAiRecord = currentAiRecordMap.get(responseChatId);
const resumedResponseData = currentAiRecord?.responseData;
const hasCurrentInteractive = Array.from(currentAiRecordMap.values()).some((record) =>
record.value.some((value) => value.interactive)
);
if (!resumedResponseData?.length && !hasCurrentInteractive) {
return completedRecords;
}
return completedRecords.map((item) => {
if (item.obj !== ChatRoleEnum.AI) return item;
const matchedCurrentAiRecord = item.dataId ? currentAiRecordMap.get(item.dataId) : undefined;
const shouldMergeResponseData = item.dataId === responseChatId;
const currentValuesForInteractiveMerge = matchedCurrentAiRecord
? matchedCurrentAiRecord.value
: Array.from(currentAiRecordMap.values()).flatMap((record) => record.value);
if (!currentValuesForInteractiveMerge.length && !shouldMergeResponseData) return item;
const mergedResponseData =
shouldMergeResponseData && resumedResponseData?.length
? mergeChatResponseData([
...(item.responseData || []),
...(resumedResponseData.filter(
(resumedItem) =>
!item.responseData?.some((completedItem) =>
areSameChatResponseDataItem(completedItem, resumedItem)
)
) || [])
])
: item.responseData;
return {
...item,
value: mergeSubmittedInteractiveValues({
completedValues: item.value,
currentValues: currentValuesForInteractiveMerge
}),
responseData: mergedResponseData
};
});
};
const areSameChatResponseDataItem = (a: ChatHistoryItemResType, b: ChatHistoryItemResType) =>
a.id === b.id && a.nodeId === b.nodeId;
/** 判断两个交互是否为同一轮工作流交互(比较最内层 interactive,而非 child 包装层)。 */
const areSameInteractive = (
a: WorkflowInteractiveResponseType,
b: WorkflowInteractiveResponseType
) => {
const finalA = extractDeepestInteractive(a);
const finalB = extractDeepestInteractive(b);
return (
finalA.type === finalB.type &&
// 同一轮交互:usageId 相同,或 entryNodeIds 数组完全一致(dataId 变化时仍视为同一表单)
(finalA.usageId === finalB.usageId || isSameArray(finalA.entryNodeIds, finalB.entryNodeIds))
);
};
/**
* 将 current 侧已提交的 `userInput` 交互写回 completed 侧同身份交互节点。
* completed record 持久化后 `inputForm.value` 可能为空(尤其 fileSelect URL 数组),
* 而恢复流 replay 期间已在 current record 中 hydrate 过,此处防止覆盖时丢失。
*/
const mergeSubmittedInteractiveValues = ({
completedValues,
currentValues
}: {
completedValues: AIChatItemValueItemType[];
currentValues: AIChatItemValueItemType[];
}) => {
const currentSubmittedInteractives = currentValues
.map((value) => value.interactive)
.filter((interactive): interactive is WorkflowInteractiveResponseType => {
if (!interactive) return false;
const finalInteractive = extractDeepestInteractive(interactive);
return finalInteractive.type === 'userInput' && !!finalInteractive.params.submitted;
});
if (!currentSubmittedInteractives.length) return completedValues;
let hasUpdated = false;
const nextValues = completedValues.map((value) => {
if (!value.interactive) return value;
const finalInteractive = extractDeepestInteractive(value.interactive);
if (finalInteractive.type !== 'userInput') {
return value;
}
const currentInteractive = currentSubmittedInteractives.find((interactive) =>
areSameInteractive(interactive, value.interactive!)
);
if (!currentInteractive) return value;
hasUpdated = true;
return {
...value,
interactive: currentInteractive
};
});
return hasUpdated ? nextValues : completedValues;
};
/**
* 恢复流 replay 交互节点时,判断是否应 append 到 AI record.value。
*
* 核心约束:若 existing 中已有同一身份且已 submitted 的 `userInput`,
* 则跳过 incoming 的未提交副本,避免空表单覆盖已回填的文件/字段值。
* 不同身份交互,或同身份但 existing 尚未 submitted(例如中间插入了确认文本),仍允许 append。
*/
export const shouldAppendResumeInteractive = ({
existingValues,
incomingInteractive
}: {
existingValues: AIChatItemValueItemType[];
incomingInteractive: WorkflowInteractiveResponseType;
}) => {
const incomingFinalInteractive = extractDeepestInteractive(incomingInteractive);
const lastExistingInteractive = existingValues[existingValues.length - 1]?.interactive;
if (!lastExistingInteractive) return true;
const existingFinalInteractive = extractDeepestInteractive(lastExistingInteractive);
const isSameInteractive =
existingFinalInteractive.type === incomingFinalInteractive.type &&
(existingFinalInteractive.usageId === incomingFinalInteractive.usageId ||
isSameArray(existingFinalInteractive.entryNodeIds, incomingFinalInteractive.entryNodeIds));
if (!isSameInteractive) return true;
return !(
existingFinalInteractive.type === 'userInput' && existingFinalInteractive.params.submitted
);
};
/**
* 恢复流收到 `flowNodeResponse` 且带 `formInputResult` 时,把节点结果写回已提交的表单交互节点。
*
* 匹配目标交互节点(二者满足其一即可):
* 1. `entryNodeIds` 包含 `nodeResponse.nodeId`;
* 2. 全历史仅有一个 submitted 表单交互,且其字段 key 与 `formInputResult` 有交集(dataId 变化时的兜底)。
*
* `fileSelect` 字段会把 URL 字符串数组归一化为 `{ name, url }[]`(复用 `normalizeFormInputResultFile`)。
* 无任何字段更新时返回原 `histories` 引用,避免触发多余渲染。
*/
export const refreshSubmittedFormInteractiveValues = ({
histories,
nodeResponse
}: {
histories: ChatSiteItemType[];
nodeResponse: ChatHistoryItemResType;
}): ChatSiteItemType[] => {
const formInputResult = nodeResponse.formInputResult;
if (!formInputResult || typeof formInputResult !== 'object' || Array.isArray(formInputResult)) {
return histories;
}
const formInputValueMap = formInputResult as Record<string, unknown>;
const formInputKeys = Object.keys(formInputValueMap);
const submittedFormInteractiveCount = histories.reduce((count, history) => {
if (history.obj !== ChatRoleEnum.AI) return count;
return (
count +
history.value.filter((value) => {
if (!value.interactive) return false;
const finalInteractive = extractDeepestInteractive(value.interactive);
return finalInteractive.type === 'userInput' && !!finalInteractive.params.submitted;
}).length
);
}, 0);
let hasUpdated = false;
const nextHistories = histories.map((history) => {
if (history.obj !== ChatRoleEnum.AI) return history;
const nextValues = history.value.map((value) => {
if (!value.interactive) return value;
const finalInteractive = extractDeepestInteractive(value.interactive);
if (finalInteractive.type !== 'userInput') {
return value;
}
if (!finalInteractive.params.submitted) return value;
// 优先 nodeId 精确匹配;仅一个 submitted 表单时允许 key 交集兜底(覆盖 dataId 漂移)
const matchedByNodeId = finalInteractive.entryNodeIds?.includes(nodeResponse.nodeId);
const matchedByOnlySubmittedForm =
submittedFormInteractiveCount === 1 &&
finalInteractive.params.inputForm.some((input) => formInputKeys.includes(input.key));
if (!matchedByNodeId && !matchedByOnlySubmittedForm) return value;
const nextInputForm = finalInteractive.params.inputForm.map((input) => {
if (!(input.key in formInputValueMap)) return input;
const nextValue = (() => {
const responseValue = formInputValueMap[input.key];
if (input.type !== FlowNodeInputTypeEnum.fileSelect || !Array.isArray(responseValue)) {
return responseValue;
}
return responseValue
.map(normalizeFormInputResultFile)
.filter((file): file is NonNullable<ReturnType<typeof normalizeFormInputResultFile>> =>
Boolean(file)
);
})();
hasUpdated = true;
return {
...input,
value: nextValue
};
});
return {
...value,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
inputForm: nextInputForm,
submitted: true
}
}
};
});
return {
...history,
value: nextValues
};
});
return hasUpdated ? nextHistories : histories;
};
const isSameArray = (a?: string[], b?: string[]) => {
if (!a?.length || !b?.length || a.length !== b.length) return false;
return a.every((item, index) => item === b[index]);
};
// 用于判断当前对话框状态。所以,如果是 child 的 interactive,需要递归去找到最后一个。
export const getInteractiveByHistories = (
chatHistories: ChatSiteItemType[]
......
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 { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { stripChatValueFileUrls } from '@/components/core/chat/ChatContainer/ChatBox/utils';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import {
mergeResumeCompletedChatRecords,
refreshSubmittedFormInteractiveValues,
shouldAppendResumeInteractive,
shouldReplaceResumeAiValue,
shouldResetResumeAiPlaceholder,
stripChatValueFileUrls
} from '@/components/core/chat/ChatContainer/ChatBox/utils';
describe('stripChatValueFileUrls', () => {
it('removes signed urls from keyed files before sending messages', () => {
......@@ -53,3 +62,708 @@ describe('stripChatValueFileUrls', () => {
expect(value[0].file.url).toBe('https://preview.example.com/image.png');
});
});
describe('shouldResetResumeAiPlaceholder', () => {
it('should reset only before any resume output has been applied', () => {
expect(
shouldResetResumeAiPlaceholder({
hasPreparedResumeAiRecord: false,
hasReceivedResumeOutput: false
})
).toBe(true);
expect(
shouldResetResumeAiPlaceholder({
hasPreparedResumeAiRecord: false,
hasReceivedResumeOutput: true
})
).toBe(false);
expect(
shouldResetResumeAiPlaceholder({
hasPreparedResumeAiRecord: true,
hasReceivedResumeOutput: false
})
).toBe(false);
});
});
describe('shouldReplaceResumeAiValue', () => {
it('does not replace loaded AI output with a resume placeholder', () => {
expect(
shouldReplaceResumeAiValue({
hasExistingAiOutput: true,
text: '',
resetExistingValue: true
})
).toBe(false);
});
it('can initialize an empty AI record as a resume placeholder', () => {
expect(
shouldReplaceResumeAiValue({
hasExistingAiOutput: false,
text: '',
resetExistingValue: true
})
).toBe(true);
});
it('can show resume unavailable text on an empty AI record', () => {
expect(
shouldReplaceResumeAiValue({
hasExistingAiOutput: false,
text: '停止中',
resetExistingValue: false
})
).toBe(true);
});
});
describe('shouldAppendResumeInteractive', () => {
it('does not append an unsubmitted resume interactive over a submitted one', () => {
const baseInteractive = {
type: 'userInput',
entryNodeIds: ['form-node-id'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
params: {
description: '',
inputForm: [
{
type: 'fileSelect',
key: 'File',
label: 'File',
valueType: 'arrayString',
description: '',
required: false,
defaultValue: '',
canLocalUpload: true,
canSelectFile: true,
maxFiles: 5,
value: []
}
]
}
} as const;
expect(
shouldAppendResumeInteractive({
existingValues: [
{
interactive: {
...baseInteractive,
params: {
...baseInteractive.params,
submitted: true,
inputForm: [
{
...baseInteractive.params.inputForm[0],
value: [
{
key: 'chat/file.docx',
name: 'file.docx',
type: 'file',
url: 'http://localhost:3000/api/system/file/download/file.docx'
}
]
}
]
}
}
}
],
incomingInteractive: baseInteractive
})
).toBe(false);
});
it('appends a new interactive when there is no submitted matching interactive', () => {
expect(
shouldAppendResumeInteractive({
existingValues: [],
incomingInteractive: {
type: 'userInput',
entryNodeIds: ['form-node-id'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
params: {
description: '',
inputForm: [],
submitted: false
}
}
})
).toBe(true);
});
it('appends a repeated form node after a previous submitted form is no longer last', () => {
const submittedInteractive = {
type: 'userInput',
entryNodeIds: ['form-node-id'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'first-usage-id',
params: {
description: '',
inputForm: [],
submitted: true
}
} as const;
expect(
shouldAppendResumeInteractive({
existingValues: [
{
interactive: submittedInteractive
},
{
text: {
content: 'confirmed, continuing workflow'
}
}
],
incomingInteractive: {
...submittedInteractive,
usageId: 'second-usage-id',
params: {
...submittedInteractive.params,
submitted: false
}
}
})
).toBe(true);
});
});
describe('mergeResumeCompletedChatRecords', () => {
it('preserves responseData replayed during resume when completed records overwrite the chat', () => {
const responseChatId = 'ai-data-id';
const currentRecords = [
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'loading',
value: [{ text: { content: 'streaming' } }],
responseData: [
{
id: 'node-response-id',
nodeId: 'form-node-id',
moduleName: '表单输入',
moduleType: FlowNodeTypeEnum.formInput,
formInputResult: {
File: ['http://localhost:3000/api/system/file/download/file.docx']
}
}
]
}
] as ChatSiteItemType[];
const completedRecords = [
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'finish',
value: [{ text: { content: 'done' } }],
responseData: []
}
] as ChatSiteItemType[];
const result = mergeResumeCompletedChatRecords({
currentRecords,
completedRecords,
responseChatId
});
expect(result[0].value).toEqual([{ text: { content: 'done' } }]);
expect(result[0].responseData).toEqual(currentRecords[0].responseData);
});
it('does not duplicate responseData already present in completed records', () => {
const responseChatId = 'ai-data-id';
const responseData = [
{
id: 'node-response-id',
nodeId: 'form-node-id',
moduleName: '表单输入',
moduleType: FlowNodeTypeEnum.formInput,
formInputResult: {
File: ['http://localhost:3000/api/system/file/download/file.docx']
}
}
];
const currentRecords = [
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'loading',
value: [{ text: { content: 'streaming' } }],
responseData
}
] as ChatSiteItemType[];
const completedRecords = [
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'finish',
value: [{ text: { content: 'done' } }],
responseData
}
] as ChatSiteItemType[];
const result = mergeResumeCompletedChatRecords({
currentRecords,
completedRecords,
responseChatId
});
expect(result[0].responseData).toHaveLength(1);
});
it('preserves hydrated submitted interactive values when completed records overwrite the chat', () => {
const responseChatId = 'ai-data-id';
const hydratedInteractive = {
type: 'userInput',
entryNodeIds: ['form-node-id'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
params: {
description: '',
submitted: true,
inputForm: [
{
type: 'fileSelect',
key: 'File',
label: 'File',
valueType: 'arrayString',
description: '',
required: false,
defaultValue: '',
canLocalUpload: true,
canSelectFile: true,
maxFiles: 5,
value: [
{
name: 'file.docx',
url: 'https://example.com/file.docx'
}
]
}
]
}
};
const currentRecords = [
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'loading',
value: [{ interactive: hydratedInteractive }]
}
] as ChatSiteItemType[];
const completedRecords = [
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'finish',
value: [
{
interactive: {
...hydratedInteractive,
params: {
...hydratedInteractive.params,
inputForm: [
{
...hydratedInteractive.params.inputForm[0],
value: []
}
]
}
}
}
]
}
] as ChatSiteItemType[];
const result = mergeResumeCompletedChatRecords({
currentRecords,
completedRecords,
responseChatId
});
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toEqual([
{
name: 'file.docx',
url: 'https://example.com/file.docx'
}
]);
});
it('preserves hydrated submitted interactive values outside of the streaming response record', () => {
const responseChatId = 'streaming-ai-data-id';
const interactiveChatId = 'interactive-ai-data-id';
const hydratedInteractive = {
type: 'userInput',
entryNodeIds: ['form-node-id'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
params: {
description: '',
submitted: true,
inputForm: [
{
type: 'fileSelect',
key: 'File',
label: 'File',
valueType: 'arrayString',
description: '',
required: false,
defaultValue: '',
canLocalUpload: true,
canSelectFile: true,
maxFiles: 5,
value: [
{
name: 'file.docx',
url: 'https://example.com/file.docx'
}
]
}
]
}
};
const currentRecords = [
{
id: interactiveChatId,
dataId: interactiveChatId,
obj: ChatRoleEnum.AI,
status: 'finish',
value: [{ interactive: hydratedInteractive }]
},
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'loading',
value: [{ text: { content: 'streaming' } }]
}
] as ChatSiteItemType[];
const completedRecords = [
{
id: interactiveChatId,
dataId: interactiveChatId,
obj: ChatRoleEnum.AI,
status: 'finish',
value: [
{
interactive: {
...hydratedInteractive,
params: {
...hydratedInteractive.params,
inputForm: [
{
...hydratedInteractive.params.inputForm[0],
value: []
}
]
}
}
}
]
},
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'finish',
value: [{ text: { content: 'done' } }]
}
] as ChatSiteItemType[];
const result = mergeResumeCompletedChatRecords({
currentRecords,
completedRecords,
responseChatId
});
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toEqual([
{
name: 'file.docx',
url: 'https://example.com/file.docx'
}
]);
expect(result[1].value).toEqual([{ text: { content: 'done' } }]);
});
it('preserves hydrated submitted interactive values when completed interactive dataId changes', () => {
const responseChatId = 'streaming-ai-data-id';
const hydratedInteractive = {
type: 'userInput',
entryNodeIds: ['form-node-id'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
params: {
description: '',
submitted: true,
inputForm: [
{
type: 'fileSelect',
key: 'File',
label: 'File',
valueType: 'arrayString',
description: '',
required: false,
defaultValue: '',
canLocalUpload: true,
canSelectFile: true,
maxFiles: 5,
value: [
{
name: 'file.docx',
url: 'https://example.com/file.docx'
}
]
}
]
}
};
const currentRecords = [
{
id: 'temporary-interactive-ai-data-id',
dataId: 'temporary-interactive-ai-data-id',
obj: ChatRoleEnum.AI,
status: 'finish',
value: [{ interactive: hydratedInteractive }]
},
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'loading',
value: [{ text: { content: 'streaming' } }]
}
] as ChatSiteItemType[];
const completedRecords = [
{
id: 'persisted-interactive-ai-data-id',
dataId: 'persisted-interactive-ai-data-id',
obj: ChatRoleEnum.AI,
status: 'finish',
value: [
{
interactive: {
...hydratedInteractive,
params: {
...hydratedInteractive.params,
inputForm: [
{
...hydratedInteractive.params.inputForm[0],
value: []
}
]
}
}
}
]
},
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
status: 'finish',
value: [{ text: { content: 'done' } }]
}
] as ChatSiteItemType[];
const result = mergeResumeCompletedChatRecords({
currentRecords,
completedRecords,
responseChatId
});
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toEqual([
{
name: 'file.docx',
url: 'https://example.com/file.docx'
}
]);
});
});
describe('refreshSubmittedFormInteractiveValues', () => {
it('writes resumed form input files back into the submitted interactive node', () => {
const signedUrl =
'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';
const histories = [
{
id: 'ai-data-id',
dataId: 'ai-data-id',
obj: ChatRoleEnum.AI,
status: 'finish',
value: [
{
interactive: {
type: 'userInput',
entryNodeIds: ['form-node-id'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-id',
params: {
description: '',
submitted: true,
inputForm: [
{
type: 'fileSelect',
key: 'File',
label: 'File',
valueType: 'arrayString',
description: '',
required: false,
defaultValue: '',
canLocalUpload: true,
canSelectFile: true,
maxFiles: 5,
value: []
}
]
}
}
}
]
}
] as ChatSiteItemType[];
const result = refreshSubmittedFormInteractiveValues({
histories,
nodeResponse: {
id: 'node-response-id',
nodeId: 'form-node-id',
moduleName: '表单输入',
moduleType: FlowNodeTypeEnum.formInput,
formInputResult: {
File: [signedUrl]
}
}
});
expect(result).not.toBe(histories);
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toEqual([
{
name: 'H6产品概述V1.5_tBF8kj.docx',
url: signedUrl
}
]);
});
it('does not update unrelated interactive nodes', () => {
const histories = [
{
id: 'ai-data-id',
dataId: 'ai-data-id',
obj: ChatRoleEnum.AI,
status: 'finish',
value: [
{
interactive: {
type: 'userInput',
entryNodeIds: ['other-form-node-id'],
memoryEdges: [],
nodeOutputs: [],
params: {
description: '',
submitted: true,
inputForm: []
}
}
}
]
}
] as ChatSiteItemType[];
const result = refreshSubmittedFormInteractiveValues({
histories,
nodeResponse: {
id: 'node-response-id',
nodeId: 'form-node-id',
moduleName: '表单输入',
moduleType: FlowNodeTypeEnum.formInput,
formInputResult: {
File: ['https://example.com/file.docx']
}
}
});
expect(result).toBe(histories);
});
it('falls back to the only submitted form interactive when node ids do not match', () => {
const signedUrl =
'http://localhost:3000/api/system/file/download/token?filename=%E6%96%87%E4%BB%B6.docx';
const histories = [
{
id: 'ai-data-id',
dataId: 'ai-data-id',
obj: ChatRoleEnum.AI,
status: 'finish',
value: [
{
interactive: {
type: 'userInput',
entryNodeIds: ['different-node-id'],
memoryEdges: [],
nodeOutputs: [],
params: {
description: '',
submitted: true,
inputForm: [
{
type: 'fileSelect',
key: 'File',
label: 'File',
valueType: 'arrayString',
description: '',
required: false,
defaultValue: '',
canLocalUpload: true,
canSelectFile: true,
maxFiles: 5,
value: []
}
]
}
}
}
]
}
] as ChatSiteItemType[];
const result = refreshSubmittedFormInteractiveValues({
histories,
nodeResponse: {
id: 'node-response-id',
nodeId: 'form-node-id',
moduleName: '表单输入',
moduleType: FlowNodeTypeEnum.formInput,
formInputResult: {
File: [signedUrl]
}
}
});
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toEqual([
{
name: '文件.docx',
url: signedUrl
}
]);
});
});
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