Commit 13ea6f71 by Xianquan Committed by GitHub

refactor(chat): split ChatBox into hooks, utils and UI modules (#6966)

* refactor: extract ChatBox hooks and components

Split ChatBox/index.tsx into hooks, utilities, and UI components while
keeping the public API unchanged. Includes main #6977 stop chat overrides,
mark-read hooks, and quote list type updates.

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

* fix: make requestVariables time test timezone-agnostic

Use formatTime2YMDHMS for expected values so CI in UTC matches local runs.

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

* refactor: organize ChatBox utility modules

* fix: keep active chat refs in sync before resume callbacks

* test: cover ChatBox utility modules

* perf: ui

* test: stabilize workflow benchmark scaling check

* fix: reason render

* fix: ts

* submodule

* doc

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: archer <545436317@qq.com>
parent 1f600c2d
# ChatBox 拆分重构设计文档
## 1. 背景
`projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx` 是当前主聊天界面的核心组件,文件约 2000 行。它不是单纯的 UI 文件,而是同时承担了输入表单、滚动控制、流式生成、恢复生成、消息操作、反馈标注、记录渲染、home/chat/log 多模式分支等职责。
这种结构带来的主要问题:
1. **修改风险高**:一个需求经常需要同时理解 `chatRecords`、`chatBoxData`、`ChatBoxContext`、`WorkflowRuntimeContext` 等多层状态。
2. **副作用集中**:发送消息、恢复生成、停止请求、侧边栏状态同步、已读状态、TTS、问题引导都在同一个组件内交织。
3. **难以局部测试**:大量逻辑以内联函数和 `useMemo` 形式存在,纯逻辑与 React 副作用混在一起,不利于补充单元测试。
4. **后续需求成本高**:暂停、恢复、流式响应、输入状态、反馈、log 展示等需求都容易改动同一个大文件,冲突概率高。
本次重构的目标不是重写聊天协议,也不是一次性替换状态管理,而是按职责逐步拆分 `ChatBox`,让后续维护能在更小的模块里完成。
## 2. 目标
1. 保持 `ChatBox` 对外调用方式不变,包括默认导出、props、`ChatBoxRef.restartChat`、`ChatBoxRef.scrollToBottom`。
2. 将 `index.tsx` 中的核心职责拆成可理解、可测试、可迭代的模块。
3. 优先抽离纯逻辑,再抽离 hook,最后拆分 UI 组件,避免一次性大重构。
4. 保持现有聊天行为不变,包括普通聊天、home chat、share chat、log 展示、skill preview、app chat test 等入口。
5. 为流式生成、恢复生成、输入状态、消息反馈等高频需求建立清晰边界。
6. 每个阶段都提供明确验证方式,保证重构可以分批合入。
## 3. 非目标
1. 不重写聊天接口、SSE 协议、workflow runtime 协议。
2. 不改变 `ChatBox` 外部 props 结构。
3. 不合并或替换 `ChatItemContext`、`ChatRecordContext`、`ChatBoxContext`、`WorkflowRuntimeContext`。
4. 不引入新的全局 store 来承接所有状态。
5. 不同步重构 `HelperBot`,除非抽出的类型或纯工具函数天然复用。
6. 不迁移历史数据结构。
7. 不在第一阶段调整视觉样式或交互文案。
## 4. 当前职责拆解
### 4.1 容器状态
当前 `ChatBox` 内部维护以下 UI 或运行状态:
1. `isLoading`:重试删除等操作的加载态。
2. `feedbackId`:用户点踩后打开反馈弹窗的目标消息。
3. `adminMarkData`:管理员标注弹窗的数据。
4. `questionGuides`:一次回答结束后生成的问题引导。
5. `expandedDeletedGroups`:log 模式中被删除消息组的展开状态。
这些状态分属不同领域,后续可以分别进入 feedback、question guide、record list 等模块。
### 4.2 上下文依赖
`ChatBox` 同时读取多个 context:
1. `ChatItemContext`
- `chatBoxData`
- `setChatBoxData`
- `ChatBoxRef`
- `variablesForm`
- `resetVariables`
- `setIsVariableVisible`
- app/user avatar
2. `ChatRecordContext`
- `chatRecords`
- `setChatRecords`
- `isLoadingRecords`
- `isChatRecordsLoaded`
- `ScrollData`
- `itemRefs`
3. `WorkflowRuntimeContext`
- `appId`
- `chatId`
- `outLinkAuthData`
4. `ChatBoxContext`
- app chat config 派生数据,例如 `welcomeText`、`variableList`、`questionGuide`
- 音频能力,例如 `startSegmentedAudio`、`splitText2Audio`、`finishSegmentedAudio`
- `isChatting`
5. `ChatContext`
- 侧边栏历史列表 `setHistories`
- `loadHistories`
其中 `sendPrompt` 和 `resume` 同时改写多个 context,是当前耦合最重的两块。
### 4.3 输入表单
当前输入表单职责包括:
1. `useForm<ChatBoxInputFormType>` 初始化。
2. 从 `sessionStorage` 读取 `chatInput_${chatId}` 草稿。
3. 监听 `input` 变化并 debounce 写入草稿。
4. 计算 `chatStarted`。
5. `resetInputVal` 同时重置 `files`、`input`、textarea 高度和草稿。
6. home 模式下变量表单与输入框互斥渲染。
这部分适合拆成 `useChatInputForm`,由它返回:
```ts
type UseChatInputFormResult = {
chatForm: UseFormReturn<ChatBoxInputFormType>;
chatStarted: boolean;
chatStartedWatch: boolean;
commonVariableList: VariableItemType[];
showExternalVariable: boolean;
resetInputVal: (value: ChatBoxInputType) => void;
};
```
### 4.4 滚动控制
当前滚动职责包括:
1. `ScrollContainerRef`。
2. `scrollToBottom`,支持延迟和递归等待 DOM。
3. `generatingScroll`,根据当前位置决定流式生成时是否跟随底部。
4. records loaded 后强制滚到底部。
5. `ChatBoxRef.scrollToBottom` 对外暴露。
6. 变量输入区可见性检测。
这部分适合拆成 `useChatScroll`,但变量可见性检测可以单独拆成较小的 `useVariableInputVisibility`,避免滚动 hook 承担太多 UI 观察职责。
### 4.5 流式生成
流式生成是当前最核心、风险最高的逻辑,包含:
1. `generatingMessage`
- 根据 SSE event 更新最后一条 AI 消息。
- 处理 answer、reasoning、tool call、tool params、tool response。
- 处理 skill、agent plan、plan status。
- 处理 sandbox status。
- 处理 workflow duration、interactive、updateVariables。
- 触发 TTS 分段音频和滚动。
2. `sendPrompt`
- 校验输入内容。
- 从变量表单生成 request variables。
- 生成 human/AI placeholder。
- 写入 `chatRecords`。
- 调用 `onStartChat`。
- 收尾更新最后一条 AI 消息状态、responseData、time。
- 处理 workflow error toast。
- 处理 question guide、TTS、输入框聚焦。
- 同步 `chatBoxData.chatGenerateStatus`。
- 同步侧边栏 histories。
- 处理已读状态。
- 异常时恢复输入和记录。
3. `abortRequest`
- 中断 chat、question guide、plugin、resume controller。
这部分不应直接一次性抽到一个大 hook。建议先拆纯函数,再拆 `useChatGenerate`。
### 4.6 恢复生成
当前恢复生成逻辑由 `enableAutoResume` 相关 effect 承担,包含:
1. 判断是否允许恢复。
2. 防止同一 `appId/chatId` 重复恢复。
3. 找到或创建 AI placeholder 的 `dataId`。
4. 调用 `streamResumeFetch`。
5. 按 SSE event 创建 placeholder 并复用 `generatingMessage`。
6. 处理 `completedChat` 覆盖本地记录。
7. 处理 `resumeUnavailable`。
8. 恢复失败时区分 stream error 和普通异常。
9. 收尾更新 `chatBoxData`、侧边栏 histories、已读状态。
这部分适合拆成 `useChatResume`,但前提是 `generatingMessage`、placeholder 纯逻辑、状态同步函数已经有清晰输入输出。
### 4.7 问题引导
当前 `createQuestionGuide` 在回答完成后触发,职责包括:
1. 判断 `questionGuide.open`。
2. 使用独立 `AbortController`。
3. 调用 `postQuestionGuide`。
4. 写入 `questionGuides`。
5. 触发滚动。
这部分边界相对清晰,适合拆成 `useQuestionGuide`。
### 4.8 消息操作
当前消息操作包括:
1. `onDelMessage`:支持外部自定义删除,否则调用默认 API。
2. `retryInput`:删除当前消息及后续消息,然后按旧输入重发。
3. `delOneMessage`:删除一条 human 消息及紧随其后的 AI 回复。
这部分适合拆成 `useChatRecordActions`。它依赖 `chatRecords`、`setChatRecords`、`sendPrompt`、删除 API 和 toast。
### 4.9 反馈与标注
当前反馈与标注职责包括:
1. admin mark:
- `onMark`
- `SelectMarkCollection`
- `updateChatAdminFeedback`
2. 用户反馈:
- `onAddUserLike`
- `onAddUserDislike`
- `FeedbackModal`
- `updateChatUserFeedback`
3. 自定义反馈关闭:
- `onCloseCustomFeedback`
- `closeCustomFeedback`
4. log read status:
- `onToggleFeedbackReadStatus`
- `updateFeedbackReadStatus`
- `onTriggerRefresh`
这部分适合拆成 `useChatFeedbackActions` 和 `ChatBoxModals`。其中 hook 负责动作,modal 组件负责弹窗渲染。
### 4.10 记录渲染
当前记录渲染职责包括:
1. log 模式下将连续 deleted item 分组。
2. 根据 `expandedDeletedGroups` 决定是否渲染删除消息。
3. 渲染顶部和底部折叠按钮。
4. 处理 10 分钟时间间隔显示。
5. 渲染 human `ChatItem`。
6. 渲染 AI `ChatItem`。
7. 注入反馈、标注、语音、问题引导、状态框等 props。
8. 渲染 custom feedback 和 admin mark 详情。
这部分 JSX 大、参数多,但领域相对明确,适合拆成 `ChatRecordsList`。分组逻辑应先抽成纯函数,避免新组件内部继续塞复杂计算。
### 4.11 页面分支
当前 `ChatBox` 根据 `isHomeRender` 分成两套渲染:
1. home 模式
- `WelcomeHomeBox`
- `QuickApps`
- `ChatHomeVariablesForm`
- 或 `ChatInput`
2. 普通聊天模式
- scroll 区域
- welcome text
- `VariableInputForm`
- records list
- workorder
- `ChatInput`
这部分适合在 UI 拆分阶段拆成 `HomeChatMain`、`AppChatMain`、`ChatInputArea`。
## 5. 当前数据流
### 5.1 核心事实源
1. `chatRecords`
- 聊天记录的核心事实源。
- 由 `ChatRecordContext` 提供。
- 流式生成、恢复生成、删除、重试、反馈都会修改它。
2. `chatBoxData`
- 当前会话和 app 元信息。
- 包含 `appId`、`chatId`、`app`、`userAvatar`、`chatGenerateStatus`、`hasBeenRead` 等。
- 由 `ChatItemContext` 提供。
3. `appId/chatId/outLinkAuthData`
- 当前 runtime 目标。
- 由 `WorkflowRuntimeContext` 提供。
4. app chat config
- 由 `ChatProvider` 从 `chatBoxData.app.chatConfig` 派生到 `ChatBoxContext`。
- 包括变量、欢迎语、question guide、TTS、whisper、file select config 等。
### 5.2 发送消息数据流
```txt
ChatInput / eventBus / autoExecute
-> sendPrompt
-> variablesForm.handleSubmit
-> format requestVariables
-> create human item + AI placeholder
-> setChatRecords
-> setChatBoxData(generating)
-> syncSidebarChatGenerateStatus(generating)
-> onStartChat(generatingMessage)
-> generatingMessage updates last AI item
-> finish/error
-> setChatRecords(finish/error)
-> setChatBoxData(done/error)
-> syncSidebarChatGenerateStatus(done/error)
-> postMarkChatRead when active chat
-> createQuestionGuide when no interactive
```
### 5.3 恢复生成数据流
```txt
chatBoxData.chatGenerateStatus === generating
+ enableAutoResume
+ records loaded
+ appId/chatId matched
-> streamResumeFetch
-> upsert AI placeholder when needed
-> generatingMessage updates last AI item
-> completedChat replaces records or local item finish
-> setChatBoxData(done/error/generating)
-> syncSidebarChatGenerateStatus
-> postMarkChatRead when active chat
```
### 5.4 记录渲染数据流
```txt
chatRecords
-> log mode deleted group preprocessing
-> RecordsBox
-> ChatItem
-> feedback/mark/delete/retry callbacks
```
## 6. 拆分原则
1. **先纯逻辑,后副作用,再 UI**:纯函数最容易测试,先降低核心逻辑风险。
2. **保持外部 API 稳定**:重构期间不改变调用方。
3. **每次只拆一个职责域**:避免一个 PR 同时移动生成、恢复、渲染、反馈。
4. **不追求立刻减少到最少行数**:优先减少认知耦合,而不是机械拆文件。
5. **避免跨文件状态穿透**:如果新组件需要几十个 props,先考虑是否应该抽 hook 或上下文选择器。
6. **Provider 后置处理**:`Provider.tsx` 目前负责配置派生和音频能力,暂不优先改动。
7. **测试跟着风险走**:纯函数补单测,副作用 hook 先局部验证,最终再做集成验证。
8. **注释解释业务边界**:对导出函数、核心 hook、复杂纯函数补充函数级注释,说明职责和关键边界。
## 7. 目标模块结构
最终目标结构可以演进为:
```txt
ChatBox/
index.tsx
Provider.tsx
type.ts
constants.ts
utils.ts
scrollUtils.ts
hooks/
useChatBox.tsx
useChatInputForm.ts
useChatScroll.ts
useVariableInputVisibility.ts
useChatGenerate.ts
useChatResume.ts
useQuestionGuide.ts
useChatRecordActions.ts
useChatFeedbackActions.ts
components/
ChatRecordsList.tsx
AppChatMain.tsx
HomeChatMain.tsx
ChatInputArea.tsx
ChatBoxModals.tsx
utils/
generateMessage.ts
recordGroups.ts
requestVariables.ts
resume.ts
```
说明:
1. 这个结构是最终形态,不要求第一阶段一次性创建所有文件。
2. 已存在的 `utils.ts`、`scrollUtils.ts` 可以继续保留;如果文件职责变多,再考虑迁移到 `utils/` 子目录。
3. `hooks/useChatBox.tsx` 当前已经存在,后续需要判断它是否仍然只是导出 context hook,避免命名冲突。
4. 如果某个 hook 参数过多,先回到数据流设计,避免把大组件原样搬进 hook。
## 8. PR 1 纯逻辑提取说明
PR 1 的目标是只移动纯计算逻辑,不改变 React 生命周期、context 数据流、UI 结构和请求副作用。这样第一步可以先降低 `ChatBox/index.tsx` 的认知负担,同时为后续 hook/UI 拆分建立可测试的基础工具函数。
本 PR 抽出了 3 个纯逻辑文件。
### 8.1 `recordGroups.ts`
从 `ChatBox/index.tsx` 中抽离的是 log 模式下的删除消息分组逻辑,原来对应 `processedRecords` 里的大段 `useMemoEnhance` 计算。
它负责:
1. 判断当前是否为 `ChatTypeEnum.log`。
2. 扫描 `chatRecords` 中连续带 `deleteTime` 的消息。
3. 将连续删除消息合并成一个“删除组”。
4. 给删除组首条消息补充 `collapseTop`。
5. 给删除组尾条消息补充 `collapseBottom`。
6. 根据 `expandedDeletedGroups` 判断整组是否展开。
输入:
```ts
{
chatType: ChatTypeEnum;
chatRecords: ChatSiteItemType[];
expandedDeletedGroups: Set<string>;
}
```
输出:
```ts
ChatSiteItemType[]
```
关键边界:
1. 非 log 模式直接返回原 `chatRecords` 引用,不制造额外对象变化。
2. log 模式下只有删除组内消息会被浅拷贝,避免把 `collapseTop/collapseBottom` 写回原始 records。
3. 单条删除消息会同时拥有 `collapseTop` 和 `collapseBottom`。
4. 只有组内所有 `dataId` 都在 `expandedDeletedGroups` 中时,整组才算展开。
作用:
1. 让 `ChatBox/index.tsx` 不再承担 deleted records 分组细节。
2. 后续拆 `ChatRecordsList` 时,可以直接复用这个纯函数。
3. 分组逻辑可以通过 Vitest 独立验证,不需要挂载 React 组件。
### 8.2 `requestVariables.ts`
从 `ChatBox/index.tsx` 中抽离的是 `sendPrompt` 里将变量表单值转换为请求 variables 的逻辑。
它负责:
1. 只保留 `variableList` 中声明过的变量 key。
2. 对空字符串、`null`、`undefined` 使用变量配置里的 `defaultValue`。
3. 对 `timePointSelect` 做时间点格式化。
4. 对 `timeRangeSelect` 做时间范围格式化。
5. 最后通过 `valueTypeFormat` 将值转换成 workflow runtime 期望的类型。
输入:
```ts
{
variableList?: VariableItemType[];
variables?: Record<string, any>;
}
```
输出:
```ts
Record<string, any>
```
关键边界:
1. 表单里多出来的 key 不会进入请求,避免把未声明字段传给 workflow。
2. 时间变量先转为 `YYYY-MM-DD HH:mm:ss`,再走 `valueTypeFormat`。
3. `timeRangeSelect` 里的空字符串会保留为空字符串,用来表达未选择的边界。
4. `variableList` 为空时返回空对象。
作用:
1. 将 `sendPrompt` 中的变量清洗逻辑独立出来,减少发送流程内的分支。
2. 为后续拆 `useChatGenerate` 降低复杂度。
3. 可以单测覆盖默认值、类型转换、时间格式和未声明字段过滤。
### 8.3 `resume.ts`
从 `ChatBox/index.tsx` 中抽离的是恢复生成相关的两个纯判断:
1. `shouldCreateResumeAiPlaceholder`
2. `hasMeaningfulAiOutput`
`shouldCreateResumeAiPlaceholder` 负责判断恢复流中遇到某个 SSE event 时,是否需要提前创建 AI placeholder。
需要创建 placeholder 的事件包括:
1. `flowNodeResponse`
2. `flowNodeStatus`
3. `answer`
4. `fastAnswer`
5. `toolCall`
6. `toolParams`
7. `toolResponse`
8. `interactive`
9. `plan`
10. `planStatus`
11. `workflowDuration`
不创建 placeholder 的典型事件:
1. `error`:只影响最终状态和 toast,不应该制造空 AI 气泡。
2. `updateVariables`:只回写变量,不代表有可展示的 AI 输出。
`hasMeaningfulAiOutput` 负责判断恢复生成结束或失败后,一个 AI placeholder 是否已经有值得保留的输出。
会被认为“有意义”的内容包括:
1. `responseData`
2. `text.content`
3. `reasoning.content`
4. 单 tool 的 `params/response`
5. tools 数组里的 `params/response`
6. `skills`
7. `plan`
8. `interactive`
关键边界:
1. 非 AI 消息永远返回 false。
2. 空文本、空推理、空工具参数不会让 placeholder 被保留。
3. `responseData` 即使没有文本,也要保留,因为它承载节点响应详情。
4. `skills/plan/interactive` 本身就是可见 UI 块,不要求额外文本。
作用:
1. 将恢复生成里的 placeholder 创建/清理规则从 effect 中拆出来。
2. 后续拆 `useChatResume` 时可以复用这些判断,不再把规则藏在 effect 里。
3. 可以通过单测覆盖哪些 SSE event 会创建 placeholder,以及哪些 AI 内容应该保留。
### 8.4 为什么本 PR 不拆 `generatingMessage`
设计文档原计划评估是否将 `generatingMessage` 拆成 reducer。评估后决定不放进 PR 1。
原因:
1. `generatingMessage` 虽然包含大量可计算分支,但它并不是完全纯逻辑。
2. 它会调用 `setChatRecords`,且依赖“只更新最后一条 AI 消息”的状态约束。
3. 它还会触发滚动跟随、TTS 分段、`resetVariables` 等副作用。
4. 它同时被普通发送和恢复生成复用,拆错会影响两个高风险路径。
因此,PR 1 只处理已经明确纯净且可独立验证的逻辑;`generatingMessage` 放到阶段三,和 `useChatGenerate` 一起拆。
## 9. PR 2 基础 hook 提取说明
PR 2 的目标是抽离低风险 React 副作用和基础状态管理,不触碰发送协议、SSE 处理、恢复生成、消息操作和 UI 拆分。这样可以让 `ChatBox/index.tsx` 先少承担输入、滚动、变量可见性、问题引导这几类基础职责,同时为后续 `useChatGenerate`、`useChatResume` 提供更清晰的依赖边界。
本 PR 抽出了 4 个 hook。
### 9.1 `hooks/useChatInputForm.ts`
从 `ChatBox/index.tsx` 中抽离的是输入表单生命周期,原来对应 `useForm` 初始化、草稿缓存、`chatStarted` 计算和 `resetInputVal`。
它负责:
1. 初始化 `ChatBoxInputFormType` 表单。
2. 从 `sessionStorage` 读取 `chatInput_${chatId}` 草稿作为默认输入。
3. debounce 监听 `input`,同步写入或删除草稿。
4. 过滤普通变量,识别外部自定义变量。
5. 根据 app 匹配、已有记录、手动开始和变量状态计算 `chatStarted`。
6. 暴露 `resetInputVal`,用于发送完成、编辑问题、异常恢复时重置输入文本、文件列表、草稿和 textarea 高度。
关键边界:
1. `chatStarted` 仍要求 `chatBoxAppId === appId`,避免 app 切换过程复用旧状态。
2. 有历史记录时直接认为对话已开始,保持原有 chat/log/test 入口行为。
3. 没有普通变量且没有外部变量时,可以自动开始对话。
4. `resetInputVal` 会清理当前 `chatId` 的草稿,并在 textarea DOM 存在时恢复高度。
作用:
1. 把输入表单和草稿细节从主组件中移出。
2. 后续 `sendPrompt` 只需要消费 `chatForm`、`chatStarted` 和 `resetInputVal`。
3. 让输入状态和发送流程保持分层,避免后续生成逻辑拆分时继续扩大主组件。
### 9.2 `hooks/useChatScroll.ts`
从 `ChatBox/index.tsx` 中抽离的是滚动容器和生成中跟随底部逻辑。
它负责:
1. 持有 `ScrollContainerRef`。
2. 暴露 `scrollToBottom`,支持滚动行为和延迟参数。
3. 在 DOM 尚未挂载时延迟重试滚动到底部。
4. 暴露 `generatingScroll`,在流式生成过程中根据当前位置决定是否跟随底部。
关键边界:
1. `scrollToBottom` 保留原有“先延迟、再检查 DOM、未就绪再重试”的行为。
2. `generatingScroll` 仍复用 `shouldFollowGeneratingScroll`,只有用户本来接近底部或调用方强制滚动时才滚动。
3. records loaded 后是否需要强制滚动,仍由 `index.tsx` 中的 effect 和 `shouldForceScrollAfterRecordsLoaded` 判断;PR 2 只把实际滚动能力交给 hook,避免一次移动太多生命周期逻辑。
作用:
1. 把滚动 ref、滚动方法、节流跟随逻辑聚合在同一个 hook。
2. 保持 `ChatBoxRef.scrollToBottom` 和生成中滚动调用方的使用方式稳定。
3. 为后续拆 `useChatResume`、`useChatGenerate` 时复用滚动能力做准备。
### 9.3 `hooks/useVariableInputVisibility.ts`
从 `ChatBox/index.tsx` 中抽离的是变量输入区可见性监听。
它负责:
1. 在滚动容器挂载后查找 `#variable-input`。
2. 通过容器和变量输入区的 `getBoundingClientRect` 判断是否可见。
3. 初次挂载和滚动时同步 `setIsVariableVisible`。
4. 卸载时移除 scroll listener。
关键边界:
1. 继续使用原有 DOM 查询方式,不改变变量表单组件结构。
2. 变量输入区高度为 0 时不更新可见性,避免隐藏状态误判。
3. 该 hook 不放进 `useChatScroll`,避免滚动能力 hook 额外承担 ChatItemContext 状态同步。
作用:
1. 让主组件不再直接管理变量表单可见性监听。
2. 保留 home/chat 不同入口下的变量可见性同步行为。
3. 后续 UI 拆分时可以随变量表单所在区域一起迁移。
### 9.4 `hooks/useQuestionGuide.ts`
从 `ChatBox/index.tsx` 中抽离的是回答完成后的问题引导请求。
它负责:
1. 判断 `questionGuide.open`。
2. 保留聊天请求已中断时不再生成问题引导的保护。
3. 为问题引导请求创建独立 `AbortController`。
4. 调用 `postQuestionGuide`。
5. 成功返回数组时写入 `questionGuides` 并延迟滚动到底部。
关键边界:
1. hook 只封装请求和结果写入,不决定何时触发;调用方仍在回答完成且没有 interactive 时调用。
2. `chatController` 和 `questionGuideController` 都可能阻止请求,分别对应聊天主请求已停止、问题引导请求本身已停止。
3. 请求失败仍保持原逻辑静默处理,不在 PR 2 改变 toast 或错误上报策略。
作用:
1. 把回答后的推荐问题请求从生成流程旁边移走。
2. 保持发送流程只负责在合适时机触发 `createQuestionGuide`。
3. 后续拆 `useChatGenerate` 时可以把它作为外部依赖传入,而不是把请求细节混进生成 hook。
## 10. PR 3 生成与恢复 hook 提取说明
PR 3 原计划覆盖 `syncSidebarChatGenerateStatus`、`useChatGenerate` 和 `useChatResume`。实际拆分时评估后决定先完成“侧边栏状态同步 + 恢复生成”这两个边界,不在同一个 PR 中继续搬迁普通发送链路。
原因:
1. `sendPrompt` 和 `generatingMessage` 同时承担普通发送、恢复生成、TTS、滚动、变量回写、interactive、tool/plan 合并等职责。
2. `useChatResume` 已经依赖 `generatingMessage`,如果同一 PR 同时迁移两者,review 时很难判断行为是否只是移动。
3. 恢复生成本身有异步回写、abort、placeholder 清理、已读状态同步等高风险边界,适合单独审阅。
本 PR 抽出了 2 个 hook。
### 10.1 `hooks/useSidebarChatGenerateStatus.ts`
从 `ChatBox/index.tsx` 中抽离的是侧边栏历史列表生成状态同步逻辑,原来对应 `syncSidebarChatGenerateStatus`。
它负责:
1. 根据当前 runtime `appId/chatId` 或显式传入的 `targetAppId/targetChatId` 定位历史会话。
2. 同步历史项的 `chatGenerateStatus`。
3. 同步历史项的 `hasBeenRead`。
4. 更新历史项的 `updateTime`。
5. 当历史列表中不存在目标会话时,先插入一条本地历史,并通过 `loadHistories` 触发后续服务端校准。
关键边界:
1. 目标 app 和当前 app 不一致时直接跳过,避免跨 app 写错历史列表。
2. `hasBeenRead` 未显式传入时,`generating` 默认未读,其它状态默认已读。
3. 新插入历史项会使用当前 ChatBox title;如果没有 title,则使用默认“New Chat”文案。
4. hook 只改侧边栏 histories,不改当前 `chatBoxData`。
作用:
1. 让发送完成、发送失败、恢复完成都复用同一个侧边栏同步能力。
2. 降低后续拆 `useChatGenerate` 时的依赖数量。
3. 把“当前会话状态”和“侧边栏历史状态”两个事实源的同步关系显式化。
### 10.2 `hooks/useChatResume.ts`
从 `ChatBox/index.tsx` 中抽离的是 auto resume effect 和恢复生成的本地状态维护。
它负责:
1. 判断当前会话是否需要恢复生成。
2. 创建并写回 `resumeControllerRef`,让页面切换或停止时可以中断恢复流。
3. 调用 `streamResumeFetch` 接收恢复流。
4. 在恢复流出现可见 SSE event 时补齐或复用 AI placeholder。
5. 通过调用方传入的 `generatingMessage` 复用普通发送的 SSE 增量合并逻辑。
6. 处理 `completedChat` 覆盖本地 records。
7. 处理 `resumeUnavailable` 占位状态。
8. 恢复完成或失败后清理空 AI placeholder。
9. 同步当前 `chatBoxData`、侧边栏生成状态和已读状态。
关键边界:
1. 只有 `enableAutoResume` 开启、records 已加载、当前 ChatBox app/chat 和 runtime app/chat 对齐,并且状态仍为 `generating` 时才恢复。
2. `resumedChatTargetRef` 继续防止同一个 app/chat 重复恢复。
3. `activeAppIdRef/activeChatIdRef` 继续防止恢复流异步结果写入已切走的会话。
4. 用户离开页面触发的 abort 不会把会话标记为 done/error,也不会 toast。
5. `generatingMessage` 暂时留在 `index.tsx`,恢复生成 hook 只消费它,确保 PR 3 不同时改动普通发送的 SSE 合并逻辑。
作用:
1. 让 `index.tsx` 不再直接承载长恢复生成 effect。
2. 为下一步拆 `useChatGenerate` 留出更清晰的接口:普通发送可以继续复用 `syncSidebarChatGenerateStatus` 和恢复生成留下的 controller 边界。
3. 让恢复生成的高风险条件集中在一个 hook 内,便于单独 review 和后续补测试。
### 10.3 为什么本 PR 暂不拆 `useChatGenerate`
`useChatGenerate` 仍然是阶段三目标,但不放进本 PR。
原因:
1. `sendPrompt` 内部同时处理输入校验、变量格式化、human/AI placeholder、`onStartChat`、错误恢复、TTS、已读状态和侧边栏同步。
2. `generatingMessage` 内部同时处理 answer、reasoning、tool、plan、interactive、sandbox、workflowDuration 和 updateVariables。
3. 当前 PR 已经移动了恢复生成 effect;继续移动普通发送会让 diff 同时覆盖两个运行时主路径。
因此,PR 3 先完成恢复生成和侧边栏同步;`useChatGenerate` 建议作为阶段三的下一次独立 PR。
## 11. PR 4 生成 hook 提取说明
PR 4 继续完成阶段三中剩余的普通生成链路,把 `generatingMessage`、`sendPrompt` 和 `abortRequest` 从 `ChatBox/index.tsx` 移入 `hooks/useChatGenerate.ts`。本 PR 仍然不处理消息删除、重试、反馈、标注和 UI 组件拆分,这些保留给后续阶段。
本 PR 抽出了 1 个 hook。
### 11.1 `hooks/useChatGenerate.ts`
从 `ChatBox/index.tsx` 中抽离的是普通发送和 SSE 增量生成逻辑。
它负责:
1. `generatingMessage`
- 根据 SSE event 更新最后一条 AI 消息。
- 处理 answer、reasoning、tool call、tool params、tool response。
- 处理 skill、sandbox status、agent plan、plan status。
- 处理 interactive、updateVariables、workflowDuration。
- 触发生成中滚动跟随。
2. `sendPrompt`
- 通过变量表单提交拿到 variables。
- 校验输入文本和文件。
- 格式化 request variables。
- 创建 human 消息和 AI placeholder。
- 写入 chatRecords。
- 调用 `onStartChat`。
- 处理完成状态、workflow error toast、TTS、问题引导、输入框 focus。
- 处理异常 toast、输入恢复、records 回滚、当前会话和侧边栏状态同步。
3. `abortRequest`
- 统一中断 chat、question guide、plugin 和 resume 请求。
关键边界:
1. `generatingMessage` 仍只更新最后一条 AI 消息;恢复生成 hook 继续复用它。
2. interactive 输入仍通过 `rewriteHistoriesByInteractiveResponse` 回写上一轮交互,不创建新的普通轮次。
3. 发送失败且没有 `responseText` 时,继续恢复用户输入并移除本轮 human/AI placeholder。
4. `eventBus`、window message、auto execute effect 仍留在 `index.tsx`,只调用 hook 返回的 `sendPrompt`,避免本 PR 同时迁移入口监听生命周期。
5. controller refs 仍由 `index.tsx` 创建并传入,保证 stop/page leave/resume 继续共用同一组 abort controller。
作用:
1. 让 `ChatBox/index.tsx` 不再承载普通生成的长流程和 SSE event 分支。
2. 让阶段三的生成、恢复、侧边栏同步形成清晰边界。
3. 为下一阶段拆消息操作和反馈操作减少主组件噪音。
### 11.2 为什么事件监听仍留在 `index.tsx`
`eventBus`、window message 和 auto execute 都是 ChatBox 的入口编排逻辑。它们依赖 `lastInteractive`、`canSendPrompt`、`active`、`isReady`、`chatStarted`、records loaded 状态等 UI 层条件。
本 PR 只抽生成执行能力,不同时迁移入口监听,原因:
1. 生成 hook 应该表达“如何发送和如何合并流式结果”,不应该再吞掉所有入口条件。
2. 保留监听在 `index.tsx` 可以让 review 更容易确认外部触发行为没有变化。
3. 后续如果需要继续收敛,可以单独抽 `useChatInputEvents` 或类似 hook。
## 12. PR 5 消息记录动作 hook 提取说明
PR 5 开始阶段四,但只处理消息记录动作,不把反馈、标注、log read status 一起移入同一个 PR。这样本 PR 的 review 可以集中确认删除与重试行为是否保持一致,下一 PR 再单独检查反馈动作。
本 PR 抽出了 1 个 hook。
### 12.1 `hooks/useChatRecordActions.ts`
从 `ChatBox/index.tsx` 中抽离的是会直接修改聊天记录的删除与重试逻辑。
它负责:
1. `onDelMessage`
- 作为 hook 内部统一删除通道。
- 如果外部传入 `onDeleteChatItem`,优先使用外部删除能力。
- 如果没有外部删除能力,则调用默认 `delChatRecordById`。
- 默认删除关联文件,重试流程会显式传 `delFile=false`,避免旧输入文件在重试前被删掉。
- 默认删除 API 会自动带上当前 `appId`、`chatId` 和 `outLinkAuthData`。
2. `retryInput`
- 根据目标 `dataId` 找到需要重试的记录位置。
- 删除目标记录以及它后面的所有服务端记录,避免旧后续历史和新回答并存。
- 将本地 `chatRecords` 裁剪到目标记录之前。
- 从被删除的第一条记录里恢复 text/files,转换为 `ChatBoxInputType`。
- 调用 `sendPrompt` 重新发送,并把裁剪后的 records 作为 history 传入。
- 失败时使用 warning toast 提示 `Retry failed`,保持原有反馈方式。
3. `delOneMessage`
- 删除一条 human 消息。
- 如果它的下一条记录是带 `dataId` 的 AI 回复,则同步删除该 AI 回复。
- 不删除更远处的 AI 或其他历史,避免误删跨轮记录。
关键边界:
1. 本 hook 只处理 record action,不处理用户点赞、点踩、自定义反馈关闭、admin mark 和 log read status。
2. 单条删除仍保持原来的 fire-and-forget 语义:本地先过滤记录,不额外等待远端删除完成,也不新增失败 toast。
3. 重试继续复用 `useChatGenerate` 返回的 `sendPrompt`,不在本 hook 内处理 SSE、placeholder、TTS、问题引导和侧边栏状态。
4. `isRecordActionLoading` 只表示 record action 的加载态,目前主要覆盖重试过程,不等同于聊天生成状态。
作用:
1. 让 `ChatBox/index.tsx` 不再直接承载删除和重试的副作用细节。
2. 将“记录变更动作”和“反馈标注动作”拆开,降低阶段四 review 难度。
3. 为后续 `ChatRecordsList` 组件提取准备更稳定的 actions 入参。
### 12.2 为什么反馈动作留到下一 PR
反馈动作虽然也挂在 `ChatItem` 上,但它和删除/重试的业务边界不同:
1. 点赞、点踩、自定义反馈关闭、admin mark、log read status 依赖 feedback API 和弹窗状态。
2. feedback/admin mark 会修改不同字段,例如 `userGoodFeedback`、`userBadFeedback`、`customFeedbacks`、`adminFeedback`、`readFeedbackTmbIdList`。
3. feedback 还牵涉 `FeedbackModal`、`SelectMarkCollection` 和 `onTriggerRefresh`,如果和删除/重试一起移动,单个 diff 会同时覆盖 records、API、modal 三类行为。
因此 PR 5 只完成 `useChatRecordActions`;`useChatFeedbackActions` 会作为阶段四的下一次独立 PR。
## 13. PR 6 反馈与标注动作 hook 提取说明
PR 6 继续阶段四,只处理反馈、标注和 log 模式反馈已读状态。它不拆 `FeedbackModal`、`SelectMarkCollection` 或 records list JSX,避免同时进入 UI 组件提取阶段。
本 PR 抽出了 1 个 hook。
### 13.1 `hooks/useChatFeedbackActions.ts`
从 `ChatBox/index.tsx` 中抽离的是挂在 AI 消息上的反馈与标注动作。
它负责:
1. feedback modal 状态
- 维护 `feedbackId`。
- 点踩无内容时打开 `FeedbackModal`。
- `FeedbackModal` 提交成功后,把返回内容写入目标消息的 `userBadFeedback`,并关闭 modal。
2. admin mark modal 状态
- 维护 `adminMarkData`。
- 点击标注时,如果已有 `adminFeedback`,进入编辑态并带回 dataset、collection、feedback data。
- 如果没有 `adminFeedback`,用上一条 human 文本作为 q,用当前 AI 文本作为 a。
- `SelectMarkCollection` 提交成功后调用 `updateChatAdminFeedback`,并把 `adminFeedback` 写回本地记录。
3. 用户点赞
- 只在 `feedbackType=user`、AI 消息、且当前没有点踩时可用。
- 点击未点赞消息会写入 `userGoodFeedback='yes'`。
- 再次点击已点赞消息会清空 `userGoodFeedback`。
- 本地乐观更新,服务端调用保持原来的静默失败策略。
4. 用户点踩
- 只在 `feedbackType=user`、AI 消息、且当前没有点赞时可用。
- 没有点踩内容时打开 `FeedbackModal`。
- 已有点踩内容时点击会清空本地 `userBadFeedback`,并同步服务端。
5. 自定义反馈关闭
- 保持 checkbox 勾选后关闭指定 custom feedback 的行为。
- 调用 `closeCustomFeedback` 后,本地按 index 过滤 `customFeedbacks`。
6. log 模式反馈已读状态
- 只在 `chatType=log` 且目标消息是 AI 时可用。
- 服务端 `updateFeedbackReadStatus` 成功后更新本地 `isFeedbackRead`。
- 成功后继续触发 `onTriggerRefresh`,让外层日志统计刷新。
关键边界:
1. 本 hook 只抽动作与状态,不移动弹窗 JSX;`ChatBox/index.tsx` 仍负责渲染 modal。
2. 本 hook 不处理删除、重试和 record action loading,这些已经归入 `useChatRecordActions`。
3. 用户反馈相关 API 异常仍保持原来的静默处理,不新增 toast、不新增回滚。
4. admin mark 成功后不会主动关闭弹窗,关闭动作仍由 `SelectMarkCollection` 内部在 `onSuccess` 后调用 `onClose`。
作用:
1. 让 `ChatBox/index.tsx` 不再直接承载 feedback API 调用和乐观更新细节。
2. 完成阶段四 hook 层拆分,为后续 `ChatRecordsList` 和 `ChatBoxModals` UI 提取降低参数复杂度。
3. 将 feedback/admin mark/log read status 的业务边界集中到一个可 review 的文件。
### 13.2 为什么 modal JSX 仍留在 `index.tsx`
`FeedbackModal` 和 `SelectMarkCollection` 是 UI 组件拆分阶段的工作,不在 PR 6 里移动。
原因:
1. 本 PR 已经移动了 feedback API、modal 状态和 records 乐观更新,继续移动 JSX 会让 diff 同时覆盖动作层和渲染层。
2. `SelectMarkCollection` 内部还有 dataset、collection、input data 三段式流程,适合在 `ChatBoxModals` 提取时单独 review。
3. records list JSX 仍依赖 `onMark`、`onAddUserLike`、`onAddUserDislike`、`onCloseCustomFeedback`、`onToggleFeedbackReadStatus`,先稳定 actions 返回值,再拆渲染组件更稳。
## 14. PR 7 弹窗组件提取说明
PR 7 开始阶段五 UI 组件提取,但只移动 ChatBox 底部弹窗层,不拆 records list、普通聊天主区域、home 主区域或输入区。这样本 PR 可以聚焦确认弹窗渲染和反馈 hook 的连接是否保持一致。
本 PR 抽出了 1 个组件。
### 14.1 `components/ChatBoxModals.tsx`
从 `ChatBox/index.tsx` 中抽离的是用户反馈弹窗和管理员标注弹窗的条件渲染。
它负责:
1. `FeedbackModal`
- 当 `feedbackId`、`appId`、`chatId` 都存在时渲染。
- 继续把 `appId`、`chatId`、`feedbackId` 传给 modal。
- 关闭和提交成功处理仍由外部传入。
2. `SelectMarkCollection`
- 当 `adminMarkData` 存在时渲染。
- 继续把完整 `adminMarkData` 传给原组件。
- `setAdminMarkData` 更新时补回当前 `dataId`,保证 dataset/collection/input data 多步流程中不会丢失被标注消息 id。
关键边界:
1. 本组件只负责弹窗渲染,不直接调用 feedback API、不写 `chatRecords`。
2. 弹窗状态和成功回写仍由 `useChatFeedbackActions` 管理。
3. `FeedbackModal` 和 `SelectMarkCollection` 本身不在本 PR 内改动。
4. records list JSX 暂时仍留在 `index.tsx`,避免本 PR 同时迁移大量 `ChatItem` props。
作用:
1. 让 `ChatBox/index.tsx` 的底部 modal JSX 变成单一组件调用。
2. 为后续拆 `ChatRecordsList` 减少主组件里的渲染噪音。
3. 保持 PR 7 的 review 范围足够小,专注弹窗连接关系。
### 14.2 为什么本 PR 不先拆 `ChatRecordsList`
`ChatRecordsList` 会移动当前最大的 JSX 片段,也会一次性承接 avatar、statusBoxData、questionGuides、deleted group、反馈动作、删除重试动作、custom feedback、admin mark 展示等大量 props。
PR 7 先拆 `ChatBoxModals` 的原因:
1. 弹窗层已经在 PR 6 通过 `useChatFeedbackActions` 稳定了状态和回调边界。
2. 弹窗组件参数少,移动后更容易确认没有行为变化。
3. records list 拆分适合作为下一次独立 PR,单独检查 log 折叠、时间间隔、human/AI 渲染和 `ChatItem` action 注入。
## 15. PR 8 聊天记录列表组件提取说明
PR 8 继续阶段五 UI 组件提取,只移动聊天记录列表渲染,不拆普通聊天主区域、home 主区域和输入区。
本 PR 抽出了 1 个组件。
### 15.1 `components/ChatRecordsList.tsx`
从 `ChatBox/index.tsx` 中抽离的是 `RecordsBox` 里的 records map 渲染逻辑。
它负责:
1. log 模式 deleted records 展示
- 根据 `expandedDeletedGroups` 判断 deleted record 是否渲染。
- 渲染顶部 `collapseTop` 和底部 `collapseBottom` 折叠按钮。
- 折叠按钮点击时调用父组件传入的 `onToggleDeletedGroup`。
2. 记录 DOM ref 登记
- 每条可见记录渲染时继续写入 `itemRefs.current.set(item.dataId, element)`。
- `itemRefs` 的事实源仍在 `ChatRecordContext`,本组件不创建新的 ref 容器。
3. 时间分隔
- 保持相邻消息超过 10 分钟时显示 `TimeBox` 的规则。
- 规则被保留在组件内部的 `shouldShowTimeDivider` 小函数里,便于后续测试或继续拆纯函数。
4. human 消息渲染
- 继续渲染 `ChatItem`。
- 注入 `onRetry` 和 `onDelete`。
- 继续跳过 `hideInUI` 的 human 消息。
5. AI 消息渲染
- 继续渲染 `ChatItem`。
- 注入 `showVoiceIcon`、`statusBoxData`、`questionGuides`、admin mark、点赞、点踩、反馈已读动作。
- admin mark 默认 q 仍取上一条 processed record 的文本。
6. AI 子内容
- 继续渲染 custom feedback 关闭 checkbox。
- 继续渲染 admin mark 内容展示。
关键边界:
1. 本组件接收已经由 `getProcessedChatRecords` 处理过的 records,不负责分组计算。
2. 本组件不直接调用删除、重试、feedback、admin mark API,只消费上层 hook 生成的回调。
3. `ChatBox/index.tsx` 仍负责 scroll 容器、welcome、变量表单、输入框和 home/app 分支。
4. 本 PR 不拆 `AppChatMain`、`HomeChatMain` 或 `ChatInputArea`。
作用:
1. 把最大的一段 records JSX 从 `index.tsx` 移出,让主组件更接近编排层。
2. 为下一步拆 `AppChatMain` 降低复杂度:普通聊天区域可以直接组合 welcome、变量表单和 `ChatRecordsList`。
3. 将 log 折叠、时间分隔、human/AI item 渲染集中到 records list 组件里,后续 review 更聚焦。
### 15.2 为什么本 PR 不同时拆 `AppChatMain`
`AppChatMain` 会继续移动 scroll 容器、welcome、变量表单和 records list,涉及 `ScrollData`、`ScrollContainerRef`、`chatStarted`、`chatForm`、`chatType` 等父级编排依赖。
PR 8 先只拆 `ChatRecordsList` 的原因:
1. records list 是当前最大 JSX 块,单独移动后已经能明显降低 `index.tsx` 复杂度。
2. scroll 容器和 records 渲染属于不同边界,分开 review 更容易确认滚动行为未被误改。
3. 后续 `AppChatMain` 可以在 records list 稳定后作为独立 PR 处理。
## 16. PR 9 普通聊天主区域组件提取说明
PR 9 继续阶段五 UI 组件提取,只移动非 home 模式下的主聊天滚动内容区,不拆 home 主区域和底部输入区。
本 PR 抽出了 1 个组件。
### 16.1 `components/AppChatMain.tsx`
从 `ChatBox/index.tsx` 中抽离的是原 `AppChatRenderBox` 对应的 JSX。
它负责:
1. 滚动容器
- 继续使用 `ChatRecordContext` 提供的 `ScrollData`。
- 继续接收并传入 `ScrollContainerRef`。
- 保持原来的 `flex`、`h`、`w`、`overflow`、`px`、`pb` 样式。
2. 普通聊天内容布局
- 保持内层 `Box maxW={['100%', '92%']} h="100%" mx="auto"`。
- 有 `welcomeText` 时继续渲染 `WelcomeBox`。
- 继续渲染 `VariableInputForm`,并传入 `chatStarted`、`chatForm`、`chatType`。
- 继续渲染 `ChatRecordsList`。
3. records list 参数承接
- `AppChatMain` 通过 `recordsListProps` 接收上层组装好的 records list 参数。
- 这样 records action 和 feedback action 仍由 `ChatBox/index.tsx` 的 hooks 管理,组件只做布局组合。
关键边界:
1. 本组件只用于非 home 模式。
2. 本组件不渲染底部输入区,不处理 `ChatInput`、停止按钮和 workorder。
3. 本组件不处理 home 欢迎页、quick apps、home variable form。
4. 本组件不直接读 context,不调用 API,不写 `chatRecords`。
作用:
1. 让 `ChatBox/index.tsx` 的非 home 主内容区从内联 `useMemo` 变成组件调用。
2. 保持 scroll 容器和 records list 的组合关系清晰,为后续拆 `ChatInputArea` 和 `HomeChatMain` 留出边界。
3. 让 `index.tsx` 更接近编排层:准备数据和 actions,然后组合主区域、输入区、modals。
### 16.2 为什么底部输入区仍留在 `index.tsx`
底部输入区虽然也属于非 home 分支,但它和发送/停止运行时关系更紧:
1. 它直接依赖 `sendPrompt`、`abortRequest`、`lastInteractive`、`resetInputVal`、`TextareaDom` 和 `chatForm`。
2. 它还包着 `showWorkorder` 和 `canSendPrompt` 条件。
3. 如果和 `AppChatMain` 同时拆,会让本 PR 同时移动内容区和输入区,review 范围变大。
因此 PR 9 只拆主内容区;`ChatInputArea` 可作为后续独立 PR。
## 17. 分阶段方案
### 17.1 阶段一:纯逻辑提取
目标:不改变 UI 和 React 生命周期,先把可测试的计算逻辑移出 `index.tsx`。
建议提取:
1. `recordGroups.ts`
- log 模式 deleted records 分组。
- 输入:`chatType`、`chatRecords`、`expandedDeletedGroups`。
- 输出:带 `collapseTop/collapseBottom` 的 records。
2. `generateMessage.ts`
- 根据 SSE event 计算下一条 AI item。
- 初期可以只抽最内层 reducer,不直接处理 `setChatRecords`、TTS、scroll。
3. `requestVariables.ts`
- 根据 variable config 和 form values 生成 request variables。
- 处理 time point、time range、defaultValue、`valueTypeFormat`。
4. `resume.ts`
- `shouldCreateResumeAiPlaceholder`
- `hasMeaningfulAiOutput`
- resume unavailable placeholder 相关纯判断。
阶段一完成标准:
1. `index.tsx` 中对应内联逻辑减少。
2. 新增或补充 Vitest 覆盖纯函数。
3. 现有 `utils.test.ts`、`scrollUtils.test.ts` 保持通过。
### 17.2 阶段二:基础 hook 提取
目标:先拆低风险 hook,让主组件的基础状态更清楚。
建议顺序:
1. `useChatInputForm`
- 管理 `useForm`、草稿、`chatStarted`、`resetInputVal`。
- 注意 `chatId` 切换时草稿 key 和 textarea 高度。
2. `useChatScroll`
- 管理 `ScrollContainerRef`、`scrollToBottom`、`generatingScroll`。
- 保持原有 records loaded 后滚动行为。
3. `useVariableInputVisibility`
- 管理变量输入区可见性监听。
- 避免放进 `useChatScroll` 造成职责变宽。
4. `useQuestionGuide`
- 管理问题引导请求、abort controller、写入 `questionGuides`。
阶段二完成标准:
1. `ChatBox` 保持同样 props/ref 行为。
2. 输入草稿、发送后清空、textarea 高度恢复正常。
3. 切换 chat 后不会沿用错误草稿。
4. records loaded 和生成中滚动行为保持一致。
### 17.3 阶段三:生成与恢复 hook 提取
目标:拆出最复杂的运行时逻辑,但要分小步做。
建议顺序:
1. 提取 `syncSidebarChatGenerateStatus` 到 hook 或工具函数。
- 它依赖 `appId`、`chatId`、`chatBoxData.title`、`setHistories`、`loadHistories`、`t`。
2. 提取 `useChatGenerate`
- 输入:`onStartChat`、`chatRecords`、`setChatRecords`、变量配置、输入 reset、滚动、TTS、toast、状态同步等。
- 输出:`sendPrompt`、`abortRequest`、`generatingMessage` 或 `handleGeneratingMessage`。
3. 提取 `useChatResume`
- 输入:`enableAutoResume`、`isReady`、`isChatRecordsLoaded`、`chatBoxData`、`appId/chatId`、`generatingMessage`、状态同步能力。
- 输出:不一定需要返回值,主要注册恢复 effect。
注意事项:
1. `eventBus`、`window.message`、auto execute effect 暂时可以留在 `ChatBox`,等 `sendPrompt` 稳定后再判断是否移动。
2. `AbortController` 的归属要统一,避免停止按钮只能停一部分请求。
3. `activeAppIdRef`、`activeChatIdRef` 用于避免异步回写错误会话,拆 hook 时必须保留。
4. `generatingMessage` 被发送和恢复共用,应优先稳定其类型和副作用边界。
阶段三完成标准:
1. 普通发送消息流程正常。
2. 流式 answer/reasoning/tool/plan/interactive 更新正常。
3. 停止请求仍可中断 chat、question guide、plugin、resume。
4. 恢复生成不会重复触发,也不会写入错误 chat。
5. 侧边栏 generating/done/error 状态和已读状态保持一致。
### 17.4 阶段四:消息操作与反馈 hook 提取
目标:把记录操作和反馈操作从主组件中移出。
建议提取:
1. `useChatRecordActions`
- `onDelMessage`
- `retryInput`
- `delOneMessage`
2. `useChatFeedbackActions`
- `onMark`
- `onAddUserLike`
- `onAddUserDislike`
- `onCloseCustomFeedback`
- `onToggleFeedbackReadStatus`
- feedback/admin mark modal 状态
阶段四完成标准:
1. 删除 human 消息时仍会同步删除紧随 AI 消息。
2. 重试会恢复原输入并删除后续记录。
3. 用户点赞/点踩 optimistic update 正常。
4. admin mark 更新后本地记录同步。
5. log read status 更新后能触发 `onTriggerRefresh`。
### 17.5 阶段五:UI 组件提取
目标:在逻辑边界稳定后,拆 JSX。
建议顺序:
1. `ChatRecordsList`
- 接收 records、avatars、actions、statusBoxData、questionGuides、render flags。
- 内部只负责渲染,不直接调用 API。
2. `AppChatMain`
- 普通聊天滚动区域。
- 渲染 welcome、变量表单、records list。
3. `HomeChatMain`
- home 欢迎、quick apps、home variable form/input。
4. `ChatInputArea`
- workorder + `ChatInput` 包装。
5. `ChatBoxModals`
- 用户反馈弹窗和管理员标注弹窗。
阶段五完成标准:
1. `index.tsx` 成为编排层,主要组合 hooks 和组件。
2. 新组件职责单一,props 数量可接受。
3. home/chat/log 三类 UI 行为保持一致。
### 17.6 阶段六:收敛与清理
目标:减少临时兼容代码,完善测试和注释。
工作项:
1. 检查 hook 和 utils 的命名是否准确。
2. 删除重复类型和过度透传参数。
3. 给导出的复杂 hook 和纯函数补充 `/** ... */` 函数注释。
4. 补齐单元测试。
5. 运行局部测试,最后再运行更完整验证。
## 18. 风险点与边界行为
### 18.1 流式生成最后一条 AI 消息
`generatingMessage` 默认更新 `chatRecords` 最后一条 AI 消息。拆分时必须保持:
1. 非最后一条记录不被误改。
2. 最后一条不是 AI 时不改。
3. `responseValueId` 命中已有 value 时追加,否则新增 value。
4. tool params/response 是增量字符串拼接。
5. reasoning 和 text 分开追加。
6. `flowNodeResponse` 写入 `responseData`,不写入 `value`。
7. `workflowDuration` 累加并保留两位小数。
### 18.2 恢复生成目标会话
恢复逻辑必须防止异步结果写入错误会话:
1. `activeAppIdRef` 和 `activeChatIdRef` 的语义必须保留。
2. `resumedChatTargetRef` 必须继续防止重复恢复。
3. `chatBoxData.appId/chatId` 必须和 runtime `appId/chatId` 对齐才恢复。
4. 用户离开页面或切换 chat 时,abort 后不能继续写状态。
### 18.3 输入状态
输入状态涉及用户体验,边界包括:
1. 空输入且无文件时不能发送。
2. 发送成功后清空输入和文件。
3. 发送失败且无 `responseText` 时恢复原输入。
4. `chatInput_${chatId}` 草稿不能串到其他会话。
5. textarea 高度要在 reset 后恢复。
### 18.4 eventBus 和 window message
当前 `ChatBox` 监听:
1. `window.postMessage({ type: 'sendPrompt' })`
2. `EventNameEnum.sendQuestion`
3. `EventNameEnum.editQuestion`
拆 hook 时要避免 stale closure:
1. `sendPrompt` 必须始终拿到最新 `lastInteractive` 和 `canSendPrompt`。
2. cleanup 时要移除正确监听。
3. 如果 eventBus 支持按 handler off,后续应优先按 handler 精确解绑;若现有 API 是事件级 off,则保持现状语义。
### 18.5 ChatBoxRef
外部依赖 `ChatBoxRef`:
1. `restartChat`
- abort 当前请求。
- 清空 records。
- 重置 `chatStarted`。
2. `scrollToBottom`
- 延迟滚到底部。
拆分后 `useImperativeHandle` 可以留在 `index.tsx`,避免外部 ref 逻辑分散。
### 18.6 多入口复用
`ChatBox` 被多个入口复用,验证不能只覆盖一个页面:
1. `AppChatWindow`
2. `HomeChatWindow`
3. `chat/share`
4. app detail chat test
5. skill preview
6. log detail modal
这些入口的 `chatType`、`feedbackType`、`showMarkIcon`、`showWorkorder`、`enableAutoResume` 不完全一致。
## 19. 验证策略
### 19.1 单元测试
优先补充以下测试:
1. `recordGroups.test.ts`
- 非 log 模式直接返回原 records。
- log 模式连续 deleted items 生成 collapseTop/collapseBottom。
- expanded 状态影响 `isExpanded`。
2. `requestVariables.test.ts`
- 空值使用 defaultValue。
- time point 格式化。
- time range 格式化。
- 只保留声明过的 variables。
3. `generateMessage.test.ts`
- answer/fastAnswer 追加 text。
- reasoning 追加 reasoning。
- tool params/response 增量拼接。
- plan/planStatus 合并。
- workflowDuration 累加。
4. `resume.test.ts`
- placeholder event 判断。
- empty AI output 判断。
保留并持续运行现有测试:
```bash
pnpm test projects/app/test/components/core/chat/ChatContainer/ChatBox/utils.test.ts
pnpm test projects/app/test/components/core/chat/ChatContainer/ChatBox/scrollUtils.test.ts
```
### 19.2 局部手动验证
每个阶段至少验证:
1. 普通发送一条消息。
2. 发送后输入框清空。
3. 生成过程中滚动跟随。
4. 停止生成。
5. 删除和重试。
6. 点赞/点踩。
7. home chat 首屏。
8. log 模式删除记录折叠。
### 19.3 恢复生成验证
涉及 `useChatResume` 时必须额外验证:
1. 刷新页面后 generating 会话能恢复。
2. 恢复完成后状态变为 done。
3. 恢复失败时状态变为 error 或保持 generating,符合原逻辑。
4. 切换到其他 chat 后恢复结果不会写入当前页面。
5. resume unavailable placeholder 能正常展示。
### 19.4 最终验证
全部阶段完成后再考虑:
```bash
pnpm lint
pnpm test
cd projects/app && pnpm build
```
如果中途只是局部拆分,不要求每一步都跑全量测试,但每一步必须至少跑相关局部测试。
## 20. TODO
### 阶段一:纯逻辑提取
- [x] 新增 `recordGroups.ts`,提取 log 模式 deleted records 分组逻辑。
- [x] 为 deleted records 分组补充 Vitest。
- [x] 新增 `requestVariables.ts`,提取 request variables 格式化逻辑。
- [x] 为 request variables 补充 Vitest。
- [x] 新增或扩展 `resume.ts`,提取恢复生成相关纯判断。
- [x] 为恢复生成纯判断补充 Vitest。
- [x] 评估 `generatingMessage` 是否先拆 reducer,再接回 `setChatRecords`。
结论:`generatingMessage` 可以进一步拆成 event reducer,但它同时牵涉 TTS 分段、滚动跟随、`resetVariables` 和 `setChatRecords` 的最后一条 AI 消息约束。为了保持 PR 1 低风险,先不在纯逻辑 PR 中拆它,改放到“阶段三:生成与恢复 hook 提取”里和 `useChatGenerate` 一起处理。
### 阶段二:基础 hook 提取
- [x] 新增 `useChatInputForm`。
- [x] 将草稿保存、`chatStarted`、`resetInputVal` 从 `index.tsx` 移入 `useChatInputForm`。
- [x] 新增 `useChatScroll`。
- [x] 将 `scrollToBottom`、`generatingScroll` 移入 `useChatScroll`。
- [x] records loaded 后滚动继续留在 `index.tsx` 的生命周期 effect 中,但改为调用 `useChatScroll` 暴露的 `scrollToBottom`。
- [x] 新增 `useVariableInputVisibility`。
- [x] 新增 `useQuestionGuide`。
- [x] 跑现有 `utils.test.ts`、`scrollUtils.test.ts` 和 PR 1 新增测试。
### 阶段三:生成与恢复 hook 提取
- [x] 提取侧边栏生成状态同步逻辑。
- [x] 提取 `useChatGenerate`,输出 `sendPrompt`、`abortRequest`、`generatingMessage`。
- [x] 保留 `eventBus`、window message、auto execute 行为在 `index.tsx`,不在 PR 3 同时迁移。
- [x] 提取 `useChatResume`。
- [x] 跑现有 `utils.test.ts`、`scrollUtils.test.ts` 和 PR 1 新增测试。
- [x] 跑 `tsc --noEmit` 验证生成/恢复 hook 类型。
- [ ] 手动验证普通生成、停止、恢复生成、切换 chat 后异步回写。
### 阶段四:消息操作与反馈 hook 提取
- [x] 新增 `useChatRecordActions`。
- [x] 移入 `onDelMessage`、`retryInput`、`delOneMessage`。
- [x] 新增 `useChatFeedbackActions`。
- [x] 移入点赞、点踩、自定义反馈关闭、admin mark、log read status。
- [x] 验证删除、重试、反馈、标注。
PR 5 结论:本次只完成删除与重试相关 record action 提取,并通过现有 ChatBox 局部测试与 `tsc --noEmit` 验证。反馈、标注和 log read status 仍留在 `index.tsx`,等待下一 PR 单独处理。
PR 6 结论:本次完成反馈与标注 action 提取,并通过现有 ChatBox 局部测试与 `tsc --noEmit` 验证。modal JSX 仍留在 `index.tsx`,等待 UI 组件提取阶段处理。
### 阶段五:UI 组件提取
- [x] 新增 `ChatRecordsList`。
- [x] 新增 `AppChatMain`。
- [~] 暂缓新增 `HomeChatMain`:当前 home 分支剩余 JSX 较小,继续拆会增加 props 透传和 review 成本。
- [~] 暂缓新增 `ChatInputArea`:底部输入区直接连接 `sendPrompt`、`abortRequest`、`lastInteractive`、`TextareaDom` 和 workorder,后续有输入区专项需求时再拆。
- [x] 新增 `ChatBoxModals`。
- [x] 完成当前分阶段的类型检查和 ChatBox 局部测试;home/chat/log/share/preview 的完整手动入口验证留到合并集成分支统一做。
PR 7 结论:本次只提取 `ChatBoxModals`,将用户反馈弹窗和管理员标注弹窗从 `index.tsx` 移出。records list、app/home main 和 input area 仍留给后续 PR。
PR 8 结论:本次只提取 `ChatRecordsList`,将 records map、deleted collapse、时间分隔、human/AI `ChatItem` 渲染、custom feedback 和 admin mark 展示从 `index.tsx` 移出。scroll 容器、app/home main 和 input area 仍留给后续 PR。
PR 9 结论:本次只提取 `AppChatMain`,将非 home 模式下的 `ScrollData`、`WelcomeBox`、`VariableInputForm` 和 `ChatRecordsList` 组合从 `index.tsx` 移出。底部输入区、workorder 和 home 主区域仍留给后续 PR。
PR 10 收敛结论:当前阶段暂不继续拆 `HomeChatMain` 和 `ChatInputArea`。`ChatBox/index.tsx` 已从原来的大组件收敛到编排层,剩余逻辑主要是 context 取数、运行时 hook 编排、home/app 分支、输入区和生命周期 effect。后续如果没有输入区或 home 页专项需求,优先做集成验证,而不是继续机械拆分。
### 阶段六:收敛
- [x] 检查并收敛 hook/组件参数边界。
- [x] 补齐导出函数和复杂 hook 的函数级注释。
- [x] 删除不再需要的临时导入和重复工具函数。
- [x] 运行局部测试。
- [x] 最后运行全量测试或 build。
PR 10 验证结论:已运行 `pnpm --dir projects/app exec tsc --noEmit --pretty false`、ChatBox 现有局部测试和 `pnpm --dir projects/app build`。build 退出码为 0,过程中仍有现有的 `styled-jsx/style.js` 解析 warning 和 i18next 初始化提示,不阻塞构建。
...@@ -11,6 +11,10 @@ description: 'FastGPT V4.15.0-beta3 更新说明' ...@@ -11,6 +11,10 @@ description: 'FastGPT V4.15.0-beta3 更新说明'
## 🐛 修复 ## 🐛 修复
1. TTS 语音播放适配最新 OpenAI SDK,避免报错。
## 🛠️ 代码优化 ## 🛠️ 代码优化
1. 调整 token 计算依赖,提高性能。 1. 调整 token 计算依赖,提高性能。
2. 重写了对话框相关代码,进行模块化细分。
3. 优化单测性能,全量从 10 分支将至 5 分钟。
...@@ -125,8 +125,8 @@ ...@@ -125,8 +125,8 @@
"content/guide/getting-started/quick-start.mdx": "2026-05-07T15:06:40+08:00", "content/guide/getting-started/quick-start.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/index.mdx": "2026-05-07T15:06:40+08:00", "content/guide/index.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/faq.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/faq.en.mdx": "2026-05-25T18:16:39+08:00",
"content/guide/version/cloud/faq.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/faq.mdx": "2026-05-25T18:16:39+08:00",
"content/guide/version/cloud/intro.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/intro.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/intro.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/intro.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/version/cloud/privacy.en.mdx": "2026-05-07T15:06:40+08:00", "content/guide/version/cloud/privacy.en.mdx": "2026-05-07T15:06:40+08:00",
...@@ -281,7 +281,7 @@ ...@@ -281,7 +281,7 @@
"content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41502.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-25T11:21:30+08:00", "content/self-host/upgrading/4-15/41503.mdx": "2026-05-26T10:59:48+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
...@@ -6,6 +6,22 @@ import { TypeTable } from 'fumadocs-ui/components/type-table'; ...@@ -6,6 +6,22 @@ import { TypeTable } from 'fumadocs-ui/components/type-table';
import { LocalizedLink } from '@/components/docs/LocalizedLink'; import { LocalizedLink } from '@/components/docs/LocalizedLink';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
/**
* 兼容 Fumadocs/MDX 对本地图片的静态资源对象输出。
* 原生 img 只能接收字符串 src;如果直接透传对象,浏览器会请求 [object Object]。
*/
function getMdxImageSrc(src: unknown): string | undefined {
if (typeof src === 'string') return src;
if (src && typeof src === 'object') {
const image = 'default' in src ? src.default : src;
if (image && typeof image === 'object' && 'src' in image && typeof image.src === 'string') {
return image.src;
}
}
}
function MdxImage(props: ComponentProps<'img'>) { function MdxImage(props: ComponentProps<'img'>) {
const hasWidth = props.width !== undefined && props.width !== null && props.width !== ''; const hasWidth = props.width !== undefined && props.width !== null && props.width !== '';
const hasHeight = props.height !== undefined && props.height !== null && props.height !== ''; const hasHeight = props.height !== undefined && props.height !== null && props.height !== '';
...@@ -14,7 +30,14 @@ function MdxImage(props: ComponentProps<'img'>) { ...@@ -14,7 +30,14 @@ function MdxImage(props: ComponentProps<'img'>) {
return <ImageZoom {...(props as any)} />; return <ImageZoom {...(props as any)} />;
} }
return <img {...props} alt={props.alt ?? ''} loading={props.loading ?? 'lazy'} />; return (
<img
{...props}
src={getMdxImageSrc(props.src)}
alt={props.alt ?? ''}
loading={props.loading ?? 'lazy'}
/>
);
} }
// use this function to get MDX components, you will need it for rendering MDX // use this function to get MDX components, you will need it for rendering MDX
......
...@@ -888,7 +888,6 @@ export class WorkflowQueue { ...@@ -888,7 +888,6 @@ export class WorkflowQueue {
}); });
} }
const startTime = Date.now(); const startTime = Date.now();
// get node running params // get node running params
const params = getNodeRunParams(node); const params = getNodeRunParams(node);
...@@ -1648,16 +1647,40 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR ...@@ -1648,16 +1647,40 @@ export const runWorkflow = async (data: RunWorkflowProps): Promise<DispatchFlowR
); );
}; };
/* Merge consecutive text messages into one */ /**
const mergeAssistantResponseAnswerText = (response: AIChatItemValueItemType[]) => { * 合并连续的纯文本 assistant value,减少普通 answer 片段在页面上分裂展示。
*
* 带 reasoning、工具、交互、计划等结构化信息的 value 必须保留独立边界;否则连续
* AI 节点的第二段 reasoning 会在合并 text 时被吞掉,刷新历史后页面看不到对应思考。
*/
export const mergeAssistantResponseAnswerText = (response: AIChatItemValueItemType[]) => {
const result: AIChatItemValueItemType[] = []; const result: AIChatItemValueItemType[] = [];
const isPlainTextValue = (item: AIChatItemValueItemType) =>
!!item.text &&
!item.id &&
!item.planId &&
!item.reasoning &&
!item.tools &&
!item.skills &&
!item.interactive &&
!item.plan &&
!item.planStatus &&
!item.agentPlanUpdate &&
!item.agentAsk &&
!item.agentStopGate &&
!item.contextCheckpoint &&
!item.tool &&
!item.hideReason &&
!item.hideInUI;
// 合并连续的text // 合并连续的text
for (let i = 0; i < response.length; i++) { for (let i = 0; i < response.length; i++) {
const item = response[i]; const item = response[i];
if (item.text) { if (isPlainTextValue(item)) {
const text = item.text?.content || ''; const text = item.text?.content || '';
const lastItem = result[result.length - 1]; const lastItem = result[result.length - 1];
if (lastItem && lastItem.text?.content) { if (lastItem && isPlainTextValue(lastItem) && lastItem.text?.content) {
lastItem.text.content += text; lastItem.text.content += text;
continue; continue;
} }
......
...@@ -47,7 +47,6 @@ export type DispatchFlowResponse = { ...@@ -47,7 +47,6 @@ export type DispatchFlowResponse = {
const WorkflowResponseItemSchema = z.object({ const WorkflowResponseItemSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
stepId: z.string().optional(),
event: z.custom<SseResponseEventEnum>().optional(), event: z.custom<SseResponseEventEnum>().optional(),
data: z.union([z.string(), z.looseObject({})]) data: z.union([z.string(), z.looseObject({})])
}); });
......
...@@ -16,7 +16,6 @@ import { ...@@ -16,7 +16,6 @@ import {
DispatchNodeResponseKeyEnum, DispatchNodeResponseKeyEnum,
SseResponseEventEnum SseResponseEventEnum
} from '@fastgpt/global/core/workflow/runtime/constants'; } from '@fastgpt/global/core/workflow/runtime/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { type SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type'; import { type SearchDataResponseItemType } from '@fastgpt/global/core/dataset/type';
import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils'; import { getMCPToolRuntimeNode } from '@fastgpt/global/core/app/tool/mcpTool/utils';
import { import {
...@@ -40,7 +39,6 @@ export const getWorkflowResponseWrite = ({ ...@@ -40,7 +39,6 @@ export const getWorkflowResponseWrite = ({
res, res,
detail, detail,
streamResponse, streamResponse,
id = getNanoid(24),
showNodeStatus = true, showNodeStatus = true,
streamResumeMirror streamResumeMirror
}: { }: {
...@@ -69,7 +67,7 @@ export const getWorkflowResponseWrite = ({ ...@@ -69,7 +67,7 @@ export const getWorkflowResponseWrite = ({
}); });
}; };
const fn: WorkflowResponseType = ({ id, stepId, event, data }) => { const fn: WorkflowResponseType = ({ id, event, data }) => {
if (typeof data === 'string') { if (typeof data === 'string') {
writeStreamChunk({ event, data }); writeStreamChunk({ event, data });
return; return;
...@@ -98,7 +96,6 @@ export const getWorkflowResponseWrite = ({ ...@@ -98,7 +96,6 @@ export const getWorkflowResponseWrite = ({
event: detail ? event : undefined, event: detail ? event : undefined,
data: JSON.stringify({ data: JSON.stringify({
...data, ...data,
...(stepId && detail && { stepId }),
...(id && detail && { responseValueId: id }) ...(id && detail && { responseValueId: id })
}) })
}); });
...@@ -107,16 +104,17 @@ export const getWorkflowResponseWrite = ({ ...@@ -107,16 +104,17 @@ export const getWorkflowResponseWrite = ({
}; };
export const getWorkflowChildResponseWrite = ({ export const getWorkflowChildResponseWrite = ({
id, id,
stepId,
fn fn
}: { }: {
id: string; id: string;
stepId: string;
fn?: WorkflowResponseType; fn?: WorkflowResponseType;
}): WorkflowResponseType | undefined => { }): WorkflowResponseType | undefined => {
if (!fn) return; if (!fn) return;
return (e: Parameters<WorkflowResponseType>[0]) => { return (e: Parameters<WorkflowResponseType>[0]) => {
return fn({ ...e, id, stepId }); return fn({
...e,
id: e.id || id
});
}; };
}; };
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "vitest run -c vitest.config.ts", "test": "vitest run -c vitest.config.ts",
"test:benchmark": "vitest run -c vitest.benchmark.config.ts",
"test:watch": "vitest -c vitest.config.ts", "test:watch": "vitest -c vitest.config.ts",
"test:integration": "vitest run -c vitest.integration.config.ts", "test:integration": "vitest run -c vitest.integration.config.ts",
"test:integration:watch": "vitest -c vitest.integration.config.ts" "test:integration:watch": "vitest -c vitest.integration.config.ts"
......
...@@ -152,9 +152,19 @@ function generateComplexWorkflow(nodeCount: number) { ...@@ -152,9 +152,19 @@ function generateComplexWorkflow(nodeCount: number) {
} }
// 性能测试辅助函数 // 性能测试辅助函数
function measurePerformance(name: string, fn: () => void, iterations: number = 1) { function measurePerformance(
name: string,
fn: () => void,
iterations: number = 1,
warmups: number = 0
) {
const times: number[] = []; const times: number[] = [];
// 预热可以避开 JIT/模块首次执行带来的抖动,让 CI 上的性能断言更关注稳定路径。
for (let i = 0; i < warmups; i++) {
fn();
}
for (let i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
const start = performance.now(); const start = performance.now();
fn(); fn();
...@@ -165,8 +175,12 @@ function measurePerformance(name: string, fn: () => void, iterations: number = 1 ...@@ -165,8 +175,12 @@ function measurePerformance(name: string, fn: () => void, iterations: number = 1
const avg = times.reduce((a, b) => a + b, 0) / times.length; const avg = times.reduce((a, b) => a + b, 0) / times.length;
const min = Math.min(...times); const min = Math.min(...times);
const max = Math.max(...times); const max = Math.max(...times);
const sortedTimes = [...times].sort((a, b) => a - b);
const mid = Math.floor(sortedTimes.length / 2);
const median =
sortedTimes.length % 2 === 0 ? (sortedTimes[mid - 1] + sortedTimes[mid]) / 2 : sortedTimes[mid];
return { avg, min, max, times }; return { avg, min, max, median, times };
} }
describe('Workflow Performance Benchmark', () => { describe('Workflow Performance Benchmark', () => {
...@@ -426,11 +440,12 @@ describe('Workflow Performance Benchmark', () => { ...@@ -426,11 +440,12 @@ describe('Workflow Performance Benchmark', () => {
edgeIndex edgeIndex
}); });
}, },
30,
5 5
); );
results.push({ scale, time: result.avg }); results.push({ scale, time: result.median });
console.log(`${scale}\t${nodes.length}\t${edges.length}\t${result.avg.toFixed(3)}`); console.log(`${scale}\t${nodes.length}\t${edges.length}\t${result.median.toFixed(3)}`);
} }
// 验证时间复杂度接近线性 // 验证时间复杂度接近线性
......
...@@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events'; ...@@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowQueue } from '@fastgpt/service/core/workflow/dispatch/index'; import {
WorkflowQueue,
mergeAssistantResponseAnswerText
} from '@fastgpt/service/core/workflow/dispatch/index';
import { createClientAbortTracker } from '@fastgpt/service/core/workflow/dispatch/utils/clientAbort'; import { createClientAbortTracker } from '@fastgpt/service/core/workflow/dispatch/utils/clientAbort';
import { createNode, createEdge } from '../utils'; import { createNode, createEdge } from '../utils';
...@@ -26,6 +29,41 @@ const waitWithTimeout = async <T>(promise: Promise<T>, timeoutMs: number, label: ...@@ -26,6 +29,41 @@ const waitWithTimeout = async <T>(promise: Promise<T>, timeoutMs: number, label:
} }
}; };
describe('mergeAssistantResponseAnswerText', () => {
it('should merge consecutive plain text assistant values', () => {
expect(
mergeAssistantResponseAnswerText([
{ text: { content: 'first' } },
{ text: { content: ' second' } }
])
).toEqual([{ text: { content: 'first second' } }]);
});
it('should preserve reasoning boundaries for consecutive AI chat nodes', () => {
expect(
mergeAssistantResponseAnswerText([
{
reasoning: { content: 'think 1' },
text: { content: 'answer 1' }
},
{
reasoning: { content: 'think 2' },
text: { content: 'answer 2' }
}
])
).toEqual([
{
reasoning: { content: 'think 1' },
text: { content: 'answer 1' }
},
{
reasoning: { content: 'think 2' },
text: { content: 'answer 2' }
}
]);
});
});
describe('createClientAbortTracker', () => { describe('createClientAbortTracker', () => {
const mockRes = (overrides: Record<string, any> = {}) => { const mockRes = (overrides: Record<string, any> = {}) => {
const res = new EventEmitter() as any; const res = new EventEmitter() as any;
......
...@@ -145,7 +145,7 @@ describe('getWorkflowResponseWrite', () => { ...@@ -145,7 +145,7 @@ describe('getWorkflowResponseWrite', () => {
expect(responseWrite).not.toHaveBeenCalled(); expect(responseWrite).not.toHaveBeenCalled();
}); });
it('should include stepId and responseValueId when detail is true', () => { it('should include responseValueId when detail is true', () => {
const res = mockRes(); const res = mockRes();
vi.mocked(responseWrite).mockClear(); vi.mocked(responseWrite).mockClear();
const fn = getWorkflowResponseWrite({ const fn = getWorkflowResponseWrite({
...@@ -154,7 +154,7 @@ describe('getWorkflowResponseWrite', () => { ...@@ -154,7 +154,7 @@ describe('getWorkflowResponseWrite', () => {
streamResponse: true, streamResponse: true,
id: 'test-id' id: 'test-id'
}); });
fn({ id: 'rid', stepId: 'sid', event: SseResponseEventEnum.answer, data: { text: 'hi' } }); fn({ id: 'rid', event: SseResponseEventEnum.answer, data: { text: 'hi' } });
expect(responseWrite).toHaveBeenCalledWith( expect(responseWrite).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
res, res,
...@@ -162,7 +162,6 @@ describe('getWorkflowResponseWrite', () => { ...@@ -162,7 +162,6 @@ describe('getWorkflowResponseWrite', () => {
}) })
); );
const callData = JSON.parse(vi.mocked(responseWrite).mock.calls[0][0].data as string); const callData = JSON.parse(vi.mocked(responseWrite).mock.calls[0][0].data as string);
expect(callData.stepId).toBe('sid');
expect(callData.responseValueId).toBe('rid'); expect(callData.responseValueId).toBe('rid');
}); });
...@@ -201,15 +200,14 @@ describe('getWorkflowResponseWrite', () => { ...@@ -201,15 +200,14 @@ describe('getWorkflowResponseWrite', () => {
describe('getWorkflowChildResponseWrite', () => { describe('getWorkflowChildResponseWrite', () => {
it('should return undefined when fn is undefined', () => { it('should return undefined when fn is undefined', () => {
const result = getWorkflowChildResponseWrite({ id: 'id', stepId: 'step' }); const result = getWorkflowChildResponseWrite({ id: 'id' });
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it('should return a wrapper function that passes id and stepId', () => { it('should return a wrapper function that passes id', () => {
const mockFn = vi.fn(); const mockFn = vi.fn();
const wrapped = getWorkflowChildResponseWrite({ const wrapped = getWorkflowChildResponseWrite({
id: 'child-id', id: 'child-id',
stepId: 'child-step',
fn: mockFn as any fn: mockFn as any
}); });
expect(wrapped).toBeDefined(); expect(wrapped).toBeDefined();
...@@ -220,12 +218,32 @@ describe('getWorkflowChildResponseWrite', () => { ...@@ -220,12 +218,32 @@ describe('getWorkflowChildResponseWrite', () => {
expect(mockFn).toHaveBeenCalledWith( expect(mockFn).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: 'child-id', id: 'child-id',
stepId: 'child-step',
event: SseResponseEventEnum.answer, event: SseResponseEventEnum.answer,
data: { text: 'hi' } data: { text: 'hi' }
}) })
); );
}); });
it('should preserve explicit child event id', () => {
const mockFn = vi.fn();
const wrapped = getWorkflowChildResponseWrite({
id: 'node-response-id',
fn: mockFn as any
});
wrapped!({
id: 'tool-call-id',
event: SseResponseEventEnum.toolCall,
data: { tool: { id: 'tool-call-id' } }
});
expect(mockFn).toHaveBeenCalledWith(
expect.objectContaining({
id: 'tool-call-id',
event: SseResponseEventEnum.toolCall
})
);
});
}); });
describe('filterOrphanEdges', () => { describe('filterOrphanEdges', () => {
......
import { defineConfig } from 'vitest/config';
import baseConfig from './vitest.config';
export default defineConfig({
...baseConfig,
test: {
...baseConfig.test,
coverage: {
...baseConfig.test?.coverage,
enabled: false
},
fileParallelism: false,
include: ['test/**/*.benchmark.ts'],
maxWorkers: 1,
outputFile: 'benchmark-results.json'
}
});
...@@ -43,6 +43,30 @@ const selectedTagStyle: FlexProps = { ...@@ -43,6 +43,30 @@ const selectedTagStyle: FlexProps = {
color: 'myGray.900' color: 'myGray.900'
}; };
const selectedTagSizeStyleMap: Record<
MySelectSize,
Pick<FlexProps, 'fontSize' | 'minH' | 'px' | 'py'>
> = {
sm: {
fontSize: 'xs',
minH: 5,
px: 2,
py: 0.5
},
md: {
fontSize: 'sm',
minH: 6,
px: 2.5,
py: 1
},
lg: {
fontSize: 'sm',
minH: 7,
px: 3,
py: 1
}
};
export type SelectProps<T = any> = { export type SelectProps<T = any> = {
list: { list: {
icon?: string; icon?: string;
...@@ -132,6 +156,7 @@ const MultipleSelect = <T = any,>({ ...@@ -132,6 +156,7 @@ const MultipleSelect = <T = any,>({
return listItem || { value: val, label: String(val) }; return listItem || { value: val, label: String(val) };
}); });
}, [formatValue, list]); }, [formatValue, list]);
const selectedTagSizeStyle = selectedTagSizeStyleMap[size];
const tagWidth = tagStyle?.w; const tagWidth = tagStyle?.w;
const canInferSelectAll = !ScrollData && (isSelectAll !== undefined || !!setIsSelectAll); const canInferSelectAll = !ScrollData && (isSelectAll !== undefined || !!setIsSelectAll);
const isFullSelected = useMemo(() => { const isFullSelected = useMemo(() => {
...@@ -225,9 +250,9 @@ const MultipleSelect = <T = any,>({ ...@@ -225,9 +250,9 @@ const MultipleSelect = <T = any,>({
// 否则根据文本长度估算(更精确) // 否则根据文本长度估算(更精确)
const text = String(item.label || item.value); const text = String(item.label || item.value);
const baseWidth = 16; // 基础padding const baseWidth = size === 'sm' ? 16 : size === 'md' ? 20 : 24; // 基础padding
const charWidth = 8; // 每个字符约8px const charWidth = size === 'sm' ? 7.5 : 8; // 每个字符约8px
const closeIconWidth = closeable ? 20 : 0; // 关闭按钮宽度 const closeIconWidth = closeable ? 22 : 0; // 关闭按钮宽度
return baseWidth + text.length * charWidth + closeIconWidth; return baseWidth + text.length * charWidth + closeIconWidth;
}; };
...@@ -270,7 +295,7 @@ const MultipleSelect = <T = any,>({ ...@@ -270,7 +295,7 @@ const MultipleSelect = <T = any,>({
setVisibleItems(selectedItems.slice(0, visibleCount)); setVisibleItems(selectedItems.slice(0, visibleCount));
setOverflowItems(selectedItems.slice(visibleCount)); setOverflowItems(selectedItems.slice(visibleCount));
}, [closeable, formLabel, selectedItems, tagWidth]); }, [closeable, formLabel, selectedItems, size, tagWidth]);
// 动态监听容器宽度变化并重新计算布局 // 动态监听容器宽度变化并重新计算布局
useEffect(() => { useEffect(() => {
...@@ -401,8 +426,7 @@ const MultipleSelect = <T = any,>({ ...@@ -401,8 +426,7 @@ const MultipleSelect = <T = any,>({
color={'primary.700'} color={'primary.700'}
type={'fill'} type={'fill'}
borderRadius={'sm'} borderRadius={'sm'}
px={2} {...selectedTagSizeStyle}
py={0.5}
flexShrink={0} flexShrink={0}
{...selectedTagStyle} {...selectedTagStyle}
{...tagStyle} {...tagStyle}
...@@ -428,9 +452,9 @@ const MultipleSelect = <T = any,>({ ...@@ -428,9 +452,9 @@ const MultipleSelect = <T = any,>({
))} ))}
{overflowItems.length > 0 && ( {overflowItems.length > 0 && (
<Box <Box
fontSize={formLabelFontSize} {...selectedTagSizeStyle}
px={2} display={'flex'}
py={0.5} alignItems={'center'}
flexShrink={0} flexShrink={0}
borderRadius={'lg'} borderRadius={'lg'}
bg={'myGray.100'} bg={'myGray.100'}
......
Subproject commit 57de3ec7a12091a12dcd0545394dee3ad13e1c55 Subproject commit 64bd986cd29bdf6eeafeb3f65832a877e6a4e7d2
...@@ -175,6 +175,11 @@ const AIChatSettingsModal = ({ ...@@ -175,6 +175,11 @@ const AIChatSettingsModal = ({
[supportParams.audio, supportParams.video, supportParams.vision, t] [supportParams.audio, supportParams.video, supportParams.vision, t]
); );
const singleMultimodalOption = multimodalOptions[0]; const singleMultimodalOption = multimodalOptions[0];
const multimodalSettingLabel =
multimodalOptions.length === 1 &&
singleMultimodalOption?.value === NodeInputKeyEnum.aiChatVision
? t('app:llm_use_vision')
: t('app:llm_use_multimodal');
const selectedMultimodalValues = [ const selectedMultimodalValues = [
useVision && supportParams.vision && NodeInputKeyEnum.aiChatVision, useVision && supportParams.vision && NodeInputKeyEnum.aiChatVision,
useAudio && supportParams.audio && NodeInputKeyEnum.aiChatAudio, useAudio && supportParams.audio && NodeInputKeyEnum.aiChatAudio,
...@@ -377,7 +382,7 @@ const AIChatSettingsModal = ({ ...@@ -377,7 +382,7 @@ const AIChatSettingsModal = ({
)} )}
{showMultimodalSetting && ( {showMultimodalSetting && (
<SettingRow <SettingRow
label={t('app:llm_use_multimodal')} label={multimodalSettingLabel}
switchControl={ switchControl={
!supportParams.multimodal ? ( !supportParams.multimodal ? (
<Box fontSize={'sm'} color={'myGray.500'}> <Box fontSize={'sm'} color={'myGray.500'}>
......
import React from 'react';
import { Box, type BoxProps } from '@chakra-ui/react';
import type { RefObject } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import type { ChatBoxInputFormType } from '../type';
import type { ChatTypeEnum } from '../constants';
import WelcomeBox from './WelcomeBox';
import VariableInputForm from './VariableInputForm';
import ChatRecordsList, { type ChatRecordsListProps } from './ChatRecordsList';
type ScrollDataComponent = ({
children,
...props
}: {
children: React.ReactNode;
ScrollContainerRef?: RefObject<HTMLDivElement>;
} & BoxProps) => React.JSX.Element;
type AppChatMainProps = {
ScrollData: ScrollDataComponent;
ScrollContainerRef: RefObject<HTMLDivElement>;
welcomeText?: string;
chatStarted: boolean;
chatForm: UseFormReturn<ChatBoxInputFormType>;
chatType: ChatTypeEnum;
recordsListProps: ChatRecordsListProps;
};
/**
* 渲染非 home 模式下的 ChatBox 主内容区。
*
* 这个组件直接承接原 `ChatBox/index.tsx` 中的 `AppChatRenderBox`:
* - 外层仍使用 `ChatRecordContext` 提供的 `ScrollData`,保持历史分页和滚动容器行为。
* - 内容区仍按原顺序渲染 welcome、变量表单和聊天记录列表。
* - 底部输入区、workorder、home 欢迎页和发送/停止逻辑都不进入本组件,继续由 `index.tsx`
* 编排,避免 UI 主区域拆分时改变输入或运行时行为。
*/
const AppChatMain = ({
ScrollData,
ScrollContainerRef,
welcomeText,
chatStarted,
chatForm,
chatType,
recordsListProps
}: AppChatMainProps) => {
return (
<ScrollData
ScrollContainerRef={ScrollContainerRef}
flex={'1 0 0'}
h={0}
w={'100%'}
overflow={'overlay'}
px={[4, 0]}
pb={6}
>
<Box maxW={['100%', '92%']} h={'100%'} mx={'auto'}>
{!!welcomeText && <WelcomeBox welcomeText={welcomeText} />}
<Box id="variable-input">
<VariableInputForm chatStarted={chatStarted} chatForm={chatForm} chatType={chatType} />
</Box>
<ChatRecordsList {...recordsListProps} />
</Box>
</ScrollData>
);
};
export default React.memo(AppChatMain);
import dynamic from 'next/dynamic';
import type { AdminFbkType } from '@fastgpt/global/core/chat/type';
import type { AdminMarkType } from './SelectMarkCollection';
const FeedbackModal = dynamic(() => import('./FeedbackModal'));
const SelectMarkCollection = dynamic(() => import('./SelectMarkCollection'));
type AdminMarkState = AdminMarkType & { dataId: string };
type ChatBoxModalsProps = {
appId?: string;
chatId?: string;
feedbackId?: string;
adminMarkData?: AdminMarkState;
onCloseFeedback: () => void;
onFeedbackSuccess: (content: string) => void;
onCloseAdminMark: () => void;
onAdminMarkChange: (adminMarkData: AdminMarkState) => void;
onAdminMarkSuccess: (adminFeedback: AdminFbkType) => void;
};
/**
* 集中渲染 ChatBox 的弹窗层。
*
* 本组件只负责把已有的 modal JSX 从 `ChatBox/index.tsx` 中移出,不持有业务状态,
* 也不直接调用 feedback/admin mark API。modal 的打开状态、关闭动作、提交成功后的 records
* 回写都仍由 `useChatFeedbackActions` 提供,避免 UI 组件拆分时重新改变反馈行为。
*
* 渲染边界:
* - `FeedbackModal` 需要同时具备 `feedbackId` 和 `chatId` 才能提交用户点踩反馈。
* - `SelectMarkCollection` 只依赖 `adminMarkData`;其中的 `dataId` 必须在 dataset/collection
* 多步选择过程中保留,所以 `onAdminMarkChange` 会继续补回当前 `dataId`。
*/
const ChatBoxModals = ({
appId,
chatId,
feedbackId,
adminMarkData,
onCloseFeedback,
onFeedbackSuccess,
onCloseAdminMark,
onAdminMarkChange,
onAdminMarkSuccess
}: ChatBoxModalsProps) => {
return (
<>
{!!feedbackId && appId && chatId && (
<FeedbackModal
appId={appId}
chatId={chatId}
dataId={feedbackId}
onClose={onCloseFeedback}
onSuccess={onFeedbackSuccess}
/>
)}
{!!adminMarkData && (
<SelectMarkCollection
adminMarkData={adminMarkData}
setAdminMarkData={(e) => onAdminMarkChange({ ...e, dataId: adminMarkData.dataId })}
onClose={onCloseAdminMark}
onSuccess={onAdminMarkSuccess}
/>
)}
</>
);
};
export default ChatBoxModals;
...@@ -5,7 +5,7 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; ...@@ -5,7 +5,7 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { formatChatValue2InputType } from '../utils'; import { formatChatValue2InputType } from '../utils/chatValue';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { ChatBoxContext } from '../Provider'; import { ChatBoxContext } from '../Provider';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
......
...@@ -3,7 +3,7 @@ import React, { useMemo, useState, useRef } from 'react'; ...@@ -3,7 +3,7 @@ import React, { useMemo, useState, useRef } from 'react';
import ChatController, { type ChatControllerProps } from './ChatController'; import ChatController, { type ChatControllerProps } from './ChatController';
import ChatAvatar from './ChatAvatar'; import ChatAvatar from './ChatAvatar';
import { MessageCardStyle } from '../constants'; import { MessageCardStyle } from '../constants';
import { formatChatValue2InputType } from '../utils'; import { formatChatValue2InputType } from '../utils/chatValue';
import Markdown from '@/components/Markdown'; import Markdown from '@/components/Markdown';
import styles from '../index.module.scss'; import styles from '../index.module.scss';
import markdownStyles from '@/components/Markdown/index.module.scss'; import markdownStyles from '@/components/Markdown/index.module.scss';
......
import React, { type ChangeEvent, type MutableRefObject } from 'react';
import { Box, Checkbox } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import type { ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import ChatBoxDivider from '../../../Divider';
import DeletedItemsCollapse from '../../DeletedItemsCollapse';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { ChatSiteItemType } from '../type';
import ChatItem from './ChatItem';
import TimeBox from './TimeBox';
export type ChatRecordsListProps = {
records: ChatSiteItemType[];
expandedDeletedGroups: Set<string>;
itemRefs: MutableRefObject<Map<string, HTMLElement | null>>;
userAvatar?: string;
appAvatar?: string;
showVoiceIcon: boolean;
showMarkIcon: boolean;
statusBoxData:
| {
status: `${ChatStatusEnum}`;
name: string;
}
| undefined;
questionGuides: string[];
onToggleDeletedGroup: (dataIds: string[]) => void;
onRetry: (dataId?: string) => (() => Promise<void>) | undefined;
onDelete: (dataId: string) => () => void;
onMark: (chat: ChatSiteItemType, q?: string) => (() => void) | undefined;
onAddUserLike: (chat: ChatSiteItemType) => (() => void) | undefined;
onAddUserDislike: (chat: ChatSiteItemType) => (() => void) | undefined;
onCloseCustomFeedback: (
chat: ChatSiteItemType,
index: number
) => (e: ChangeEvent<HTMLInputElement>) => void;
onToggleFeedbackReadStatus: (chat: ChatSiteItemType) => (() => Promise<void>) | undefined;
};
const shouldShowTimeDivider = ({
records,
item,
index
}: {
records: ChatSiteItemType[];
item: ChatSiteItemType;
index: number;
}) => {
if (index === 0 || !item.time || records[index - 1].time === undefined) return false;
return (
new Date(item.time).getTime() - new Date(records[index - 1].time!).getTime() > 10 * 60 * 1000
);
};
/**
* 渲染 ChatBox 的聊天记录列表。
*
* 本组件只接收已经预处理好的 `records`,不负责 log 模式 deleted group 的计算,也不直接
* 调用删除、重试、反馈、标注 API。所有动作都由上层 hook 生成后作为 props 注入,组件内部
* 只负责把 human/AI 记录、折叠按钮、时间分隔、自定义反馈和 admin mark 展示拼成原来的 JSX。
*
* 设计边界:
* - `expandedDeletedGroups` 只用于判断 deleted record 是否渲染,展开/收起状态更新仍在父组件。
* - `itemRefs` 继续由父级 context 持有,本组件只在每条可见记录渲染时登记 DOM 节点。
* - AI 的 q 默认值仍取上一条 processed record 的文本,保持 admin mark 默认问题内容不变。
*/
const ChatRecordsList = ({
records,
expandedDeletedGroups,
itemRefs,
userAvatar,
appAvatar,
showVoiceIcon,
showMarkIcon,
statusBoxData,
questionGuides,
onToggleDeletedGroup,
onRetry,
onDelete,
onMark,
onAddUserLike,
onAddUserDislike,
onCloseCustomFeedback,
onToggleFeedbackReadStatus
}: ChatRecordsListProps) => {
const { t } = useTranslation();
return (
<Box id={'history'}>
{records.map((item, index) => {
const shouldRender = !item.deleteTime || expandedDeletedGroups.has(item.dataId);
return (
<Box key={item.dataId}>
{item.collapseTop && (
<DeletedItemsCollapse
count={item.collapseTop.count}
isExpanded={item.collapseTop.isExpanded}
onToggle={() => onToggleDeletedGroup(item.collapseTop!.dataIds)}
position="top"
/>
)}
{shouldRender && (
<Box
ref={(e) => {
itemRefs.current.set(item.dataId, e);
}}
>
{shouldShowTimeDivider({ records, item, index }) && <TimeBox time={item.time!} />}
<Box py={item.hideInUI ? 0 : 6}>
{item.obj === ChatRoleEnum.Human && !item.hideInUI && (
<ChatItem
avatar={userAvatar}
chat={item}
onRetry={onRetry(item.dataId)}
onDelete={onDelete(item.dataId)}
isLastChild={index === records.length - 1}
/>
)}
{item.obj === ChatRoleEnum.AI && (
<ChatItem
avatar={appAvatar}
chat={item}
isLastChild={index === records.length - 1}
{...{
showVoiceIcon,
statusBoxData,
questionGuides,
onMark: onMark(
item,
formatChatValue2InputType(records[index - 1]?.value)?.text
),
onAddUserLike: onAddUserLike(item),
onAddUserDislike: onAddUserDislike(item),
onToggleFeedbackReadStatus: onToggleFeedbackReadStatus(item)
}}
>
{item.customFeedbacks && item.customFeedbacks.length > 0 && (
<Box>
<ChatBoxDivider
icon={'core/app/customFeedback'}
text={t('common:core.app.feedback.Custom feedback')}
/>
{item.customFeedbacks.map((text, i) => (
<Box key={i}>
<MyTooltip
label={t('common:core.app.feedback.close custom feedback')}
>
<Checkbox
onChange={onCloseCustomFeedback(item, i)}
icon={<MyIcon name={'common/check'} w={'12px'} />}
>
{text}
</Checkbox>
</MyTooltip>
</Box>
))}
</Box>
)}
{showMarkIcon && item.adminFeedback && (
<Box fontSize={'sm'}>
<ChatBoxDivider
icon="core/app/markLight"
text={t('common:core.chat.Admin Mark Content')}
/>
<Box whiteSpace={'pre-wrap'}>
<Box color={'black'}>{item.adminFeedback.q}</Box>
<Box color={'myGray.600'}>{item.adminFeedback.a}</Box>
</Box>
</Box>
)}
</ChatItem>
)}
</Box>
</Box>
)}
{item.collapseBottom && item.collapseBottom.isExpanded && (
<DeletedItemsCollapse
count={item.collapseBottom.count}
isExpanded={item.collapseBottom.isExpanded}
onToggle={() => onToggleDeletedGroup(item.collapseBottom!.dataIds)}
position="bottom"
/>
)}
</Box>
);
})}
</Box>
);
};
export default React.memo(ChatRecordsList);
import { useState, type ChangeEvent } from 'react';
import { useContextSelector } from 'use-context-selector';
import { useMemoizedFn } from 'ahooks';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { AdminFbkType } from '@fastgpt/global/core/chat/type';
import {
closeCustomFeedback,
updateChatAdminFeedback,
updateChatUserFeedback,
updateFeedbackReadStatus
} from '@/web/core/chat/feedback/api';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import type { AdminMarkType } from '../components/SelectMarkCollection';
import { ChatTypeEnum, FeedbackTypeEnum } from '../constants';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { ChatSiteItemType } from '../type';
type UseChatFeedbackActionsProps = {
feedbackType?: `${FeedbackTypeEnum}`;
showMarkIcon: boolean;
chatType: `${ChatTypeEnum}`;
onTriggerRefresh?: () => void;
};
type AdminMarkState = AdminMarkType & { dataId: string };
/**
* 管理 ChatBox 中反馈、标注和反馈已读状态相关动作。
*
* 这组逻辑和 PR 5 的 record actions 不同:它不会删除或重试消息,而是修改消息上的
* feedback 字段、custom feedback 列表、admin mark 数据以及 log 模式的反馈已读状态。
*
* 设计边界:
* - hook 负责动作和 modal 状态,`FeedbackModal`、`SelectMarkCollection` 的 JSX 仍留在
* `ChatBox/index.tsx`,避免本 PR 同时进入 UI 组件拆分。
* - 用户点赞/取消点赞、取消点踩、关闭 custom feedback 都沿用原来的乐观更新策略:
* 先更新本地 `chatRecords`,API 异常保持静默,不在本次拆分里新增 toast 或回滚。
* - admin mark 的弹窗流程需要跨三步选择 dataset、collection、输入数据,因此 hook 只保存
* 当前 modal 数据和 success 回写逻辑,不改 `SelectMarkCollection` 内部交互。
*/
export const useChatFeedbackActions = ({
feedbackType,
showMarkIcon,
chatType,
onTriggerRefresh
}: UseChatFeedbackActionsProps) => {
const [feedbackId, setFeedbackId] = useState<string>();
const [adminMarkData, setAdminMarkData] = useState<AdminMarkState>();
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
/**
* 生成 admin mark 入口回调。
*
* 只有开启 `showMarkIcon` 且目标消息是 AI 时才允许标注。已有 adminFeedback 时进入编辑态,
* 会带回原 dataset/collection/dataId;没有 adminFeedback 时用上一条 human 文本作为 q,
* 用当前 AI 文本作为 a,保持原来的默认标注内容。
*/
const onMark = useMemoizedFn((chat: ChatSiteItemType, q = '') => {
if (!showMarkIcon || chat.obj !== ChatRoleEnum.AI) return;
return () => {
if (!chat.dataId) return;
if (chat.adminFeedback) {
setAdminMarkData({
dataId: chat.dataId,
datasetId: chat.adminFeedback.datasetId,
collectionId: chat.adminFeedback.collectionId,
feedbackDataId: chat.adminFeedback.feedbackDataId,
q: chat.adminFeedback.q || q || '',
a: chat.adminFeedback.a
});
} else {
setAdminMarkData({
dataId: chat.dataId,
q,
a: formatChatValue2InputType(chat.value).text
});
}
};
});
/**
* 生成用户点赞回调。
*
* 点赞只在 user feedback 模式、AI 消息、且当前没有点踩内容时可用。再次点击已点赞消息会取消
* `userGoodFeedback`,本地和服务端都写入 undefined,保留原有“点击切换”语义。
*/
const onAddUserLike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userBadFeedback
)
return;
return () => {
if (!chat.dataId || !chatId || !appId) return;
const isGoodFeedback = !!chat.userGoodFeedback;
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === chat.dataId
? {
...chatItem,
userGoodFeedback: isGoodFeedback ? undefined : 'yes'
}
: chatItem
)
);
try {
updateChatUserFeedback({
appId,
chatId,
dataId: chat.dataId,
userGoodFeedback: isGoodFeedback ? undefined : 'yes',
...outLinkAuthData
});
} catch {}
};
});
/**
* 生成用户点踩回调。
*
* 没有点踩内容时返回打开 `FeedbackModal` 的回调,由弹窗收集具体原因;已有点踩内容时返回
* 取消点踩回调,清空本地 `userBadFeedback` 并同步服务端。存在点赞时不允许点踩,保持互斥。
*/
const onAddUserDislike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userGoodFeedback
) {
return;
}
if (chat.userBadFeedback) {
return () => {
if (!chat.dataId || !chatId || !appId) return;
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === chat.dataId ? { ...chatItem, userBadFeedback: undefined } : chatItem
)
);
try {
updateChatUserFeedback({
appId,
chatId,
dataId: chat.dataId,
...outLinkAuthData
});
} catch {}
};
}
return () => setFeedbackId(chat.dataId);
});
/**
* 生成关闭 custom feedback 的 checkbox change 回调。
*
* 原逻辑只在 checkbox 被勾选时关闭指定 custom feedback;这里保持同样的 DOM 驱动语义。
* 服务端通过 index unset/pull,本地则过滤同一 index,避免关闭后仍在当前消息下展示。
*/
const onCloseCustomFeedback = useMemoizedFn((chat: ChatSiteItemType, i: number) => {
return (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.checked && appId && chatId && chat.dataId) {
closeCustomFeedback({
appId,
chatId,
dataId: chat.dataId,
index: i
});
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.obj === ChatRoleEnum.AI && chatItem.dataId === chat.dataId
? {
...chatItem,
customFeedbacks: chatItem.customFeedbacks?.filter((_, index) => index !== i)
}
: chatItem
)
);
}
};
});
/**
* 生成 log 模式下反馈已读状态切换回调。
*
* 该动作只对 log 模式 AI 消息生效。成功写入服务端后再更新本地 `isFeedbackRead`,
* 并触发 `onTriggerRefresh` 刷新外层统计,保持原有“服务端成功后同步 UI”的行为。
*/
const onToggleFeedbackReadStatus = useMemoizedFn((chat: ChatSiteItemType) => {
if (chatType !== ChatTypeEnum.log || chat.obj !== ChatRoleEnum.AI) return;
return async () => {
if (!appId || !chatId || !chat.dataId) return;
const newReadStatus = !chat.isFeedbackRead;
try {
await updateFeedbackReadStatus({
appId,
chatId,
dataId: chat.dataId,
isRead: newReadStatus
});
setChatRecords((state) =>
state.map((item) =>
item.dataId === chat.dataId
? {
...item,
isFeedbackRead: newReadStatus
}
: item
)
);
onTriggerRefresh?.();
} catch {}
};
});
/**
* FeedbackModal 提交成功后的本地回写。
*
* 点踩具体内容由 modal 内部提交到服务端;ChatBox 只需要把成功返回的内容写回当前消息,
* 并关闭 modal,避免等待下一次 records reload 才看到反馈状态。
*/
const onFeedbackSuccess = useMemoizedFn((content: string) => {
setChatRecords((state) =>
state.map((item) =>
item.dataId === feedbackId ? { ...item, userBadFeedback: content } : item
)
);
setFeedbackId(undefined);
});
/**
* SelectMarkCollection 提交成功后的服务端同步和本地回写。
*
* `adminMarkData.dataId` 是当前被标注的 AI 消息 id;如果基础上下文缺失则直接跳过,
* 和原实现一致,不额外弹错。成功后把完整 adminFeedback 写回本地消息用于即时展示。
*/
const onAdminMarkSuccess = useMemoizedFn((adminFeedback: AdminFbkType) => {
if (!appId || !chatId || !adminMarkData?.dataId) return;
updateChatAdminFeedback({
appId,
chatId,
dataId: adminMarkData.dataId,
...adminFeedback
});
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === adminMarkData.dataId
? {
...chatItem,
adminFeedback
}
: chatItem
)
);
});
return {
feedbackId,
setFeedbackId,
adminMarkData,
setAdminMarkData,
onMark,
onAddUserLike,
onAddUserDislike,
onCloseCustomFeedback,
onToggleFeedbackReadStatus,
onFeedbackSuccess,
onAdminMarkSuccess
};
};
import { type MutableRefObject } from 'react';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
import { useMemoizedFn } from 'ahooks';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import type {
AIChatItemValueItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import {
ChatGenerateStatusEnum,
ChatRoleEnum,
ChatStatusEnum
} from '@fastgpt/global/core/chat/constants';
import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt';
import {
mergeChatResponseData,
getChatTitleFromChatMessage
} from '@fastgpt/global/core/chat/utils';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { ChatBoxContext } from '../Provider';
import { formatChatValue2InputType, stripChatValueFileUrls } from '../utils/chatValue';
import {
getInteractiveByHistories,
refreshSubmittedFormInteractiveValues,
rewriteHistoriesByInteractiveResponse
} from '../utils/interactive';
import { shouldAppendResumeInteractive } from '../utils/resume';
import { formatChatRequestVariables } from '../utils/requestVariables';
import type { ChatSiteItemType, ChatBoxInputType, SendPromptFnType } from '../type';
import type { StartChatFnProps, generatingMessageProps } from '../../type';
import { cloneDeep } from 'lodash';
type HumanChatSiteItemType = Extract<ChatSiteItemType, { obj: ChatRoleEnum.Human }>;
type SyncSidebarChatGenerateStatus = (
status: ChatGenerateStatusEnum,
options?: {
hasBeenRead?: boolean;
targetAppId?: string;
targetChatId?: string;
title?: string;
}
) => void;
type FinishChatGenerateStatus = (params: {
status: ChatGenerateStatusEnum;
finishedInActiveChat: boolean;
targetAppId?: string;
targetChatId?: string;
shouldUpdateChatBoxData?: (state: { appId?: string; chatId?: string }) => boolean;
}) => void;
type UseChatGenerateProps = {
onStartChat?: (e: StartChatFnProps) => Promise<{ responseText: string; isNewChat?: boolean }>;
isRoundPending: boolean;
chatControllerRef: MutableRefObject<AbortController>;
questionGuideControllerRef: MutableRefObject<AbortController>;
pluginControllerRef: MutableRefObject<AbortController>;
resumeControllerRef: MutableRefObject<AbortController | undefined>;
resumedChatTargetRef: MutableRefObject<string | undefined>;
activeChatIdRef: MutableRefObject<string | undefined>;
TextareaDom: MutableRefObject<HTMLTextAreaElement | null>;
resetInputVal: (value: ChatBoxInputType) => void;
setQuestionGuide: (guides: string[]) => void;
createQuestionGuide: () => Promise<void>;
scrollToBottom: (behavior?: 'smooth' | 'auto', delay?: number) => void;
generatingScroll: (force?: boolean) => void;
syncSidebarChatGenerateStatus: SyncSidebarChatGenerateStatus;
finishChatGenerateStatus: FinishChatGenerateStatus;
};
const isAbortByLeave = (reason: unknown) => {
return reason === 'leave' || (reason instanceof Error && reason.message === 'leave');
};
/**
* 管理 ChatBox 的普通发送和 SSE 增量生成流程。
*
* 这个 hook 承接原 `ChatBox/index.tsx` 中最核心的运行时逻辑:
* - `generatingMessage`:按 SSE event 增量更新最后一条 AI 消息。
* - `sendPrompt`:校验输入、创建 human/AI placeholder、调用 `onStartChat`、处理完成和失败。
* - `abortRequest`:统一中断 chat、question guide、plugin 和 resume 请求。
*
* 输入约定:
* - refs 仍由 ChatBox 持有,保证停止按钮、页面切换、恢复生成共用同一组 controller。
* - `resetInputVal`、`createQuestionGuide`、`scrollToBottom`、`generatingScroll` 都来自前面 PR
* 已抽出的基础 hook,`useChatGenerate` 只编排它们,不重新实现输入或滚动细节。
* - `syncSidebarChatGenerateStatus` 负责侧边栏历史状态,本 hook 只在普通发送开始/完成/失败时调用它。
*
* 边界行为:
* - `generatingMessage` 仍只更新最后一条 AI 消息;如果最后一条不是 AI,则直接跳过。
* - interactive 输入会改写上一轮交互结果,不创建新的普通会话轮次。
* - 发送失败且后端没有返回 `responseText` 时恢复用户输入,并回滚本轮 human/AI placeholder。
* - `abortRequest('leave')` 触发的异常不 toast,也不额外标记错误状态,保持页面切换语义。
*/
export const useChatGenerate = ({
onStartChat,
isRoundPending,
chatControllerRef,
questionGuideControllerRef,
pluginControllerRef,
resumeControllerRef,
resumedChatTargetRef,
activeChatIdRef,
TextareaDom,
resetInputVal,
setQuestionGuide,
createQuestionGuide,
scrollToBottom,
generatingScroll,
syncSidebarChatGenerateStatus,
finishChatGenerateStatus
}: UseChatGenerateProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const { isPc } = useSystem();
const { setNotSufficientModalType } = useSystemStore();
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const variablesForm = useContextSelector(ChatItemContext, (v) => v.variablesForm);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const variableList = useContextSelector(ChatBoxContext, (v) => v.variableList);
const startSegmentedAudio = useContextSelector(ChatBoxContext, (v) => v.startSegmentedAudio);
const finishSegmentedAudio = useContextSelector(ChatBoxContext, (v) => v.finishSegmentedAudio);
const setAudioPlayingChatId = useContextSelector(ChatBoxContext, (v) => v.setAudioPlayingChatId);
const splitText2Audio = useContextSelector(ChatBoxContext, (v) => v.splitText2Audio);
const generatingMessage = useMemoizedFn(
({
responseValueId,
event,
text = '',
reasoningText,
status,
name,
tool,
interactive,
plan,
planStatus,
sandboxStatus,
skill,
variables,
nodeResponse,
durationSeconds,
autoTTSResponse
}: generatingMessageProps & { autoTTSResponse?: boolean }) => {
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) {
splitText2Audio(formatChatValue2InputType(item.value).text || '');
}
const updateIndex = (() => {
if (!responseValueId) return item.value.length - 1;
const index = item.value.findIndex((item) => item.id === responseValueId);
if (index !== -1) return index;
return item.value.length - 1;
})();
const updateValue: AIChatItemValueItemType = cloneDeep(item.value[updateIndex]);
if (event === SseResponseEventEnum.flowNodeResponse && nodeResponse) {
return {
...item,
responseData: item.responseData
? [...item.responseData, nodeResponse]
: [nodeResponse]
};
}
if (event === SseResponseEventEnum.flowNodeStatus && status) {
return {
...item,
status,
moduleName: name
};
}
if (event === SseResponseEventEnum.sandboxStatus && sandboxStatus) {
const getSandboxPhaseLabel = (): string => {
const { phase, isWarmStart, skillName } = sandboxStatus;
if (phase === 'deployingSkills') {
return t('chat:sandbox_status_deployingSkills', { skillName: skillName ?? '' });
}
if (
phase === 'downloadingPackage' ||
phase === 'uploadingPackage' ||
phase === 'extractingPackage'
) {
return t(`chat:sandbox_status_${phase}` as any, { skillName: skillName ?? '' });
}
if (phase === 'ready') {
return t(
isWarmStart ? 'chat:sandbox_status_ready_warm' : 'chat:sandbox_status_ready_cold'
);
}
return t(`chat:sandbox_status_${phase}` as any);
};
return {
...item,
status: 'loading' as const,
moduleName: getSandboxPhaseLabel()
};
}
if (event === SseResponseEventEnum.skillCall && skill) {
const alreadyExists = item.value.some(
(v) =>
v.id === responseValueId &&
v.skills?.some((s) => s.skillMdPath === skill.skillMdPath)
);
if (alreadyExists) return item;
const skillId = skill.id || responseValueId || getNanoid(10);
const val: AIChatItemValueItemType = {
id: responseValueId,
skills: [
{
id: skillId,
skillName: skill.skillName,
skillAvatar: skill.skillAvatar || '',
description: skill.description,
skillMdPath: skill.skillMdPath
}
]
};
return {
...item,
value: [...item.value, val]
};
}
if (event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) {
const answerUpdateIndex = (() => {
if (responseValueId) {
return item.value.findIndex((item) => item.id === responseValueId);
}
const latestIndex = item.value.length - 1;
const latestValue = item.value[latestIndex];
// reason 流只能续写“纯 reasoning”占位;否则说明上一段回答已成型,需要新起一段。
if (
reasoningText &&
latestValue?.reasoning &&
!latestValue.text?.content &&
!latestValue.tools?.length &&
!latestValue.tool &&
!latestValue.skills?.length &&
!latestValue.interactive &&
!latestValue.plan &&
!latestValue.planStatus &&
!latestValue.agentPlanUpdate &&
!latestValue.agentAsk &&
!latestValue.agentStopGate &&
!latestValue.contextCheckpoint
) {
return latestIndex;
}
if (text) return latestIndex;
return -1;
})();
const answerUpdateValue: AIChatItemValueItemType | undefined =
answerUpdateIndex >= 0 ? cloneDeep(item.value[answerUpdateIndex]) : undefined;
const replaceUpdateValue = (nextValue: AIChatItemValueItemType) => ({
...item,
value: [
...item.value.slice(0, answerUpdateIndex),
nextValue,
...item.value.slice(answerUpdateIndex + 1)
]
});
if (reasoningText) {
if (answerUpdateValue?.reasoning) {
answerUpdateValue.reasoning.content += reasoningText;
return replaceUpdateValue(answerUpdateValue);
} else if (answerUpdateValue?.text && !answerUpdateValue.text.content) {
answerUpdateValue.reasoning = {
content: reasoningText
};
return replaceUpdateValue(answerUpdateValue);
} else {
const val: AIChatItemValueItemType = {
id: responseValueId,
reasoning: {
content: reasoningText
}
};
return {
...item,
value: [...item.value, val]
};
}
}
if (text) {
if (answerUpdateValue?.text) {
answerUpdateValue.text.content += text;
return replaceUpdateValue(answerUpdateValue);
} else if (answerUpdateValue?.reasoning) {
answerUpdateValue.text = {
content: text
};
return replaceUpdateValue(answerUpdateValue);
} else {
const newValue: AIChatItemValueItemType = {
id: responseValueId,
text: {
content: text
}
};
return {
...item,
value: item.value.concat(newValue)
};
}
}
}
if (event === SseResponseEventEnum.toolCall && tool) {
const val: AIChatItemValueItemType = {
id: responseValueId || tool.id,
tools: [tool]
};
return {
...item,
value: [...item.value, val]
};
}
if (event === SseResponseEventEnum.toolParams && tool && updateValue.tools) {
if (tool.params) {
updateValue.tools = updateValue.tools.map((item) =>
item.id === tool.id
? { ...item, params: `${item.params || ''}${tool.params}` }
: item
);
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
};
}
return item;
}
if (event === SseResponseEventEnum.toolResponse && tool && updateValue.tools) {
if (tool.response) {
updateValue.tools = updateValue.tools.map((item) =>
item.id === tool.id
? { ...item, response: `${item.response || ''}${tool.response}` }
: item
);
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
};
}
return item;
}
if (event === SseResponseEventEnum.planStatus && planStatus) {
const planStatusIndex = item.value.findIndex(
(value) => !!value.planStatus || (!!responseValueId && value.id === responseValueId)
);
const nextPlanStatusValue: AIChatItemValueItemType = {
id: responseValueId,
planStatus
};
if (planStatusIndex >= 0) {
return {
...item,
value: [
...item.value.slice(0, planStatusIndex),
{
...item.value[planStatusIndex],
...nextPlanStatusValue
},
...item.value.slice(planStatusIndex + 1)
]
};
}
return {
...item,
value: [...item.value, nextPlanStatusValue]
};
}
if (event === SseResponseEventEnum.plan && plan) {
const planIndex = item.value.findIndex(
(value) =>
(!!responseValueId && value.id === responseValueId) ||
!!value.planStatus ||
(value.plan?.planId && value.plan.planId === plan.planId)
);
const nextPlanValue = {
id: responseValueId || plan.planId,
plan,
planStatus: undefined
};
if (planIndex >= 0) {
return {
...item,
value: [
...item.value.slice(0, planIndex),
{
...item.value[planIndex],
...nextPlanValue
},
...item.value.slice(planIndex + 1)
]
};
}
return {
...item,
value: [...item.value, nextPlanValue]
};
}
if (event === SseResponseEventEnum.updateVariables && variables) {
resetVariables({ variables });
}
if (event === SseResponseEventEnum.interactive && interactive) {
if (
!shouldAppendResumeInteractive({
existingValues: item.value,
incomingInteractive: interactive
})
) {
return item;
}
const val: AIChatItemValueItemType = {
interactive
};
return {
...item,
value: item.value.concat(val)
};
}
if (event === SseResponseEventEnum.workflowDuration && durationSeconds) {
return {
...item,
durationSeconds: item.durationSeconds
? +(item.durationSeconds + durationSeconds).toFixed(2)
: durationSeconds
};
}
return item;
});
});
const forceScroll = event === SseResponseEventEnum.interactive;
generatingScroll(forceScroll);
}
);
const abortRequest = useMemoizedFn((reason: string = 'stop') => {
chatControllerRef.current?.abort(new Error(reason));
questionGuideControllerRef.current?.abort(new Error(reason));
pluginControllerRef.current?.abort(new Error(reason));
resumeControllerRef.current?.abort(new Error(reason));
});
const sendPrompt = useMemoizedFn<SendPromptFnType>(
({
text = '',
files = [],
history = chatRecords,
interactive,
autoTTSResponse = false,
hideInUI = false
}) => {
variablesForm.handleSubmit(
async ({ variables = {} }) => {
if (!onStartChat) return;
if (isRoundPending) {
if (!hideInUI) {
toast({
title: t('chat:is_chatting'),
status: 'warning'
});
}
return;
}
questionGuideControllerRef.current?.abort(new Error('stop'));
text = text.trim();
if (!text && files.length === 0) {
toast({
title: t('chat:content_empty'),
status: 'warning'
});
return;
}
const requestVariables = formatChatRequestVariables({ variableList, variables });
const humanChatId = getNanoid(24);
const responseChatId = getNanoid(24);
if (autoTTSResponse) {
await startSegmentedAudio();
setAudioPlayingChatId(responseChatId);
}
const currentHumanChat: HumanChatSiteItemType = {
id: humanChatId,
dataId: humanChatId,
obj: ChatRoleEnum.Human,
time: new Date(),
hideInUI,
value: [
...files.map((file) => ({
file: {
type: file.type,
name: file.name,
url: file.url,
key: file.key || ''
}
})),
...(text
? [
{
text: {
content: text
}
}
]
: [])
] as UserChatItemValueItemType[],
status: ChatStatusEnum.finish
};
const newChatList: ChatSiteItemType[] = [
...history,
currentHumanChat,
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: ''
}
}
],
status: ChatStatusEnum.loading
}
];
const temporaryHistoryTitle = getChatTitleFromChatMessage(currentHumanChat);
resumedChatTargetRef.current = `${appId}:${chatId}`;
setChatBoxData((state) =>
state.chatId === chatId
? {
...state,
title: temporaryHistoryTitle,
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
}
: state
);
syncSidebarChatGenerateStatus(ChatGenerateStatusEnum.generating, {
hasBeenRead: false,
title: temporaryHistoryTitle
});
setChatRecords(
interactive
? rewriteHistoriesByInteractiveResponse({
histories: newChatList,
interactive,
interactiveVal: text
})
: newChatList
);
resetInputVal({});
setQuestionGuide([]);
scrollToBottom('smooth', 100);
const abortSignal = new AbortController();
try {
chatControllerRef.current = abortSignal;
const messages = chats2GPTMessages({
messages: newChatList.slice(0, -1).map((item) => {
if (item.obj === ChatRoleEnum.Human) {
return {
...item,
value: stripChatValueFileUrls(item.value)
};
}
return item;
}),
reserveId: true,
reserveTool: true
});
const { responseText } = await onStartChat({
messages,
responseChatItemId: responseChatId,
controller: abortSignal,
generatingMessage: (e) => generatingMessage({ ...e, autoTTSResponse }),
variables: requestVariables
});
let newChatHistories: ChatSiteItemType[] = [];
setChatRecords((state) => {
newChatHistories = state.map((item, index) => {
if (index !== state.length - 1) return item;
const responseData = mergeChatResponseData(item.responseData || []);
if (!abortSignal?.signal?.aborted) {
const uncaughtErr = responseData.find((r) => r.error)?.error;
const err = uncaughtErr ?? responseData[responseData.length - 1]?.errorText;
if (err) {
toast({
title: t(getErrText(err)),
status: 'warning'
});
}
}
return {
...item,
status: ChatStatusEnum.finish,
time: new Date(),
responseData
};
});
const { interactive: lastInteractive } = getInteractiveByHistories(state);
if (lastInteractive?.type === 'paymentPause' && !lastInteractive.params.continue) {
setNotSufficientModalType(TeamErrEnum.aiPointsNotEnough);
}
return newChatHistories;
});
setTimeout(() => {
if (!getInteractiveByHistories(newChatHistories).interactive) {
createQuestionGuide();
}
generatingScroll(true);
if (isPc) {
TextareaDom.current?.focus();
}
}, 100);
if (autoTTSResponse) {
splitText2Audio(responseText, true);
}
const finishedInActiveChat = activeChatIdRef.current === chatId;
finishChatGenerateStatus({
status: ChatGenerateStatusEnum.done,
finishedInActiveChat,
shouldUpdateChatBoxData: (state) => state.chatId === chatId
});
} catch (err: any) {
if (isAbortByLeave(err)) {
return;
}
toast({
title: t(getErrText(err, t('common:core.chat.error.Chat error') as any)),
status: 'error',
duration: 5000,
isClosable: true
});
setChatRecords((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
time: new Date(),
status: ChatStatusEnum.finish
};
})
);
if (!err?.responseText) {
resetInputVal({ text, files });
setChatRecords(newChatList.slice(0, newChatList.length - 2));
}
const finishedInActiveChat = activeChatIdRef.current === chatId;
finishChatGenerateStatus({
status: ChatGenerateStatusEnum.error,
finishedInActiveChat,
shouldUpdateChatBoxData: (state) => state.chatId === chatId
});
}
if (autoTTSResponse) {
finishSegmentedAudio();
}
},
() => {}
)();
}
);
return {
abortRequest,
generatingMessage,
sendPrompt
};
};
import { useMemo, type RefObject } from 'react';
import { useForm } from 'react-hook-form';
import { useDebounceEffect, useMemoizedFn } from 'ahooks';
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { ChatTypeEnum, textareaMinH } from '../constants';
import type { ChatBoxInputFormType, ChatBoxInputType } from '../type';
/**
* 管理 ChatBox 输入表单、输入草稿和“对话是否已开始”的派生状态。
*
* 这个 hook 是 ChatBox 输入区的状态边界,刻意不处理发送请求、变量表单校验和
* chatRecords 写入。调用方只需要从这里拿到 `chatForm`、`chatStarted` 和
* `resetInputVal`,再决定是否允许触发真正的发送流程。
*
* 输入约定:
* - `appId/chatId` 来自当前运行时上下文,用于区分不同 app 和不同会话。
* - `chatBoxAppId` 来自当前 ChatBox 数据,用于避免 app 切换过程复用旧表单状态。
* - `chatRecordsLength` 只用长度判断会话是否已有记录,避免把完整 records 传进输入 hook。
* - `TextareaDom` 只在重置输入后恢复 textarea 高度,不参与渲染状态计算。
*
* 输出约定:
* - `chatStarted` 表示当前会话已经可以展示输入区或直接发送。
* - `chatStartedWatch` 保留给原有 UI 分支使用,表示用户是否手动点过开始。
* - `resetInputVal` 会同时重置文本、文件、草稿和 textarea 高度。
*
* 关键边界:
* - 草稿按 `chatInput_${chatId}` 存储,避免不同会话之间串输入内容。
* - `chatStarted` 必须先确认 app 匹配;否则 app 切换时旧 records 或旧表单可能让新会话误判已开始。
* - custom 变量属于外部变量输入,internal 变量不需要用户填写,二者都不应当按普通变量阻塞开始。
*/
export const useChatInputForm = ({
appId,
chatId,
chatBoxAppId,
chatRecordsLength,
chatType,
variableList,
TextareaDom
}: {
appId?: string;
chatId?: string;
chatBoxAppId?: string;
chatRecordsLength: number;
chatType: ChatTypeEnum;
variableList: VariableItemType[];
TextareaDom: RefObject<HTMLTextAreaElement>;
}) => {
// 只有这些聊天入口会在 ChatBox 内展示外部变量;其它入口即使存在 custom 变量,
// 也不应该用这里的判断改变输入区启动状态。
const showExternalVariable = useMemo(() => {
const map: Record<string, boolean> = {
[ChatTypeEnum.log]: true,
[ChatTypeEnum.test]: true,
[ChatTypeEnum.chat]: true,
[ChatTypeEnum.home]: true
};
return map[chatType] && variableList.some((item) => item.type === VariableInputEnum.custom);
}, [variableList, chatType]);
const chatForm = useForm<ChatBoxInputFormType>({
defaultValues: {
// react-hook-form 的 defaultValues 只在初始化时读取一次。这里沿用原逻辑:
// 首次进入某个 chatId 时恢复草稿,后续切换由 ChatBox 重新挂载/状态流驱动。
input: sessionStorage.getItem(`chatInput_${chatId}`) || '',
files: [],
chatStarted: false
}
});
const { setValue, watch } = chatForm;
const chatStartedWatch = watch('chatStarted');
const inputValue = watch('input');
useDebounceEffect(
() => {
// 输入过程中写 sessionStorage 会比较频繁,保持 debounce 可以减少同步 IO。
// 空值直接删除 key,避免用户清空输入后下次进入会话仍恢复空草稿。
if (inputValue) {
sessionStorage.setItem(`chatInput_${chatId}`, inputValue);
} else {
sessionStorage.removeItem(`chatInput_${chatId}`);
}
},
[inputValue, chatId],
{ wait: 300 }
);
const commonVariableList = useMemo(
() =>
// internal 变量由系统注入,custom 变量走外部变量输入区;只有剩余变量才代表
// 需要用户在普通变量表单里完成填写,进而影响 `chatStarted`。
variableList.filter(
(item) => item.type !== VariableInputEnum.custom && item.type !== VariableInputEnum.internal
),
[variableList]
);
// 原 ChatBox 语义:同一个 app 下,有历史记录、用户手动开始,或没有需要填写的变量时,
// 都认为对话已经开始。这里不检查变量值是否有效,真正的变量校验仍由发送流程负责。
const chatStarted =
chatBoxAppId === appId &&
(chatRecordsLength > 0 ||
chatStartedWatch ||
(commonVariableList.length === 0 && !showExternalVariable));
/**
* 重置输入框内容。
*
* 调用场景包括发送成功后清空、编辑历史问题、发送失败后恢复用户输入。
* 它必须同步更新 react-hook-form、sessionStorage 草稿和 textarea DOM 高度:
* - form 值决定 ChatInput 当前展示内容。
* - 草稿删除可以避免已发送内容在刷新后再次出现。
* - textarea 高度是 DOM 计算结果,不能只靠 form state 自动恢复。
*/
const resetInputVal = useMemoizedFn(({ text = '', files = [] }: ChatBoxInputType) => {
if (!TextareaDom.current) return;
setValue('files', files);
setValue('input', text);
sessionStorage.removeItem(`chatInput_${chatId}`);
setTimeout(() => {
/* 回到最小高度 */
if (TextareaDom.current) {
TextareaDom.current.style.height =
text === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`;
}
}, 100);
});
return {
chatForm,
setValue,
chatStarted,
chatStartedWatch,
resetInputVal
};
};
import { useState } from 'react';
import { useContextSelector } from 'use-context-selector';
import { useMemoizedFn } from 'ahooks';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { delChatRecordById } from '@/web/core/chat/record/api';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { formatChatValue2InputType } from '../utils/chatValue';
import type { SendPromptFnType } from '../type';
type UseChatRecordActionsProps = {
sendPrompt: SendPromptFnType;
onDeleteChatItem?: (contentId: string, delFile?: boolean) => Promise<void>;
};
/**
* 管理 ChatBox 中“记录级动作”的副作用和本地 records 更新。
*
* 这里的“记录级动作”特指会直接改变 chatRecords 的删除、重试等操作:
* - `retryInput` 会删除目标 human 消息及其后续记录,再用旧输入重新发送。
* - `delOneMessage` 会删除一条 human 消息,并顺带删除紧随其后的 AI 回复。
* - `onDelMessage` 是 hook 内部的删除通道,优先走外部覆盖的 `onDeleteChatItem`,
* 否则使用默认 `delChatRecordById` API,并自动带上当前 app/chat/outLink 鉴权信息。
*
* 设计边界:
* - 本 hook 只处理删除和重试,不处理点赞、点踩、admin mark、log read status。
* 这些反馈类动作会进入后续 `useChatFeedbackActions`,避免阶段四的单个 PR 过大。
* - `sendPrompt` 由生成 hook 提供,本 hook 不关心普通发送的 placeholder、SSE 合并、
* TTS、问题引导等细节,只在重试时把恢复出的输入和裁剪后的 history 交还给生成链路。
* - 本地 `chatRecords` 会先按现有逻辑乐观更新;远端删除失败时只对重试流程 toast,
* 单条删除仍保持原来的 fire-and-forget 行为,不在本次拆分里改变用户可见语义。
*/
export const useChatRecordActions = ({
sendPrompt,
onDeleteChatItem
}: UseChatRecordActionsProps) => {
const { toast } = useToast();
const [isRecordActionLoading, setIsRecordActionLoading] = useState(false);
const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords);
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
/**
* 删除一条服务端聊天记录。
*
* `delFile` 默认是 true,表示删除消息时一起删除关联文件;重试流程会传 false,
* 因为同一轮历史可能需要继续复用原文件输入,不能在删除旧记录时把文件也删掉。
*/
const onDelMessage = useMemoizedFn((contentId: string, delFile = true) => {
if (onDeleteChatItem) {
return onDeleteChatItem(contentId, delFile);
}
return delChatRecordById({
appId,
chatId,
contentId,
delFile,
...outLinkAuthData
});
});
/**
* 生成某条 human 消息的“重试”回调。
*
* 重试不是简单地重新发送当前文本,而是先定位目标 `dataId`:
* 1. 删除该消息以及它之后的所有服务端记录,避免新回答和旧后续历史并存。
* 2. 将本地 records 裁剪到目标消息之前,让生成链路看到正确的 history。
* 3. 从被删除的第一条记录恢复 text/files,并交给 `sendPrompt` 重新发送。
*
* 如果没有传入 `dataId`,返回 undefined,让调用方不渲染无效动作。
*/
const retryInput = useMemoizedFn((dataId?: string) => {
if (!dataId) return;
return async () => {
setIsRecordActionLoading(true);
const index = chatRecords.findIndex((item) => item.dataId === dataId);
const delHistory = chatRecords.slice(index);
try {
await Promise.all(
delHistory.map((item) => {
if (item.dataId) {
return onDelMessage(item.dataId, false);
}
})
);
setChatRecords((state) => (index === 0 ? [] : state.slice(0, index)));
sendPrompt({
...formatChatValue2InputType(delHistory[0].value),
history: chatRecords.slice(0, index)
});
} catch (error) {
toast({
status: 'warning',
title: getErrText(error, 'Retry failed')
});
}
setIsRecordActionLoading(false);
};
});
/**
* 生成单条 human 消息的删除回调。
*
* ChatBox 的一轮普通对话通常是 human 后面紧跟 AI 回复,因此删除 human 时需要同步删除
* 紧随其后的 AI 记录,避免界面只剩下一条失去问题上下文的回答。这里只删除“紧随其后”
* 且仍有 `dataId` 的 AI,避免误删更远处的历史或还未落库的临时消息。
*/
const delOneMessage = useMemoizedFn((dataId: string) => {
return () => {
setChatRecords((state) => {
let aiIndex = -1;
return state.filter((chat, i) => {
if (chat.dataId === dataId) {
aiIndex = i + 1;
onDelMessage(dataId);
return false;
} else if (aiIndex === i && chat.obj === ChatRoleEnum.AI && chat.dataId) {
onDelMessage(chat.dataId);
return false;
}
return true;
});
});
};
});
return {
isRecordActionLoading,
retryInput,
delOneMessage
};
};
import { useEffect, type MutableRefObject } from 'react';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { getErrText } from '@fastgpt/global/common/error/utils';
import {
ChatGenerateStatusEnum,
ChatRoleEnum,
ChatStatusEnum
} from '@fastgpt/global/core/chat/constants';
import { mergeChatResponseData } from '@fastgpt/global/core/chat/utils';
import { streamResumeFetch, type ResumeStreamErrorType } from '@/web/common/api/fetch';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { ChatBoxContext } from '../Provider';
import {
hasMeaningfulAiOutput,
mergeResumeCompletedChatRecords,
shouldCreateResumeAiPlaceholder,
shouldReplaceResumeAiValue,
shouldResetResumeAiPlaceholder
} from '../utils/resume';
import type { ChatSiteItemType } from '../type';
import type { generatingMessageProps } from '../../type';
type FinishChatGenerateStatus = (params: {
status: ChatGenerateStatusEnum;
finishedInActiveChat: boolean;
targetAppId?: string;
targetChatId?: string;
shouldUpdateChatBoxData?: (state: { appId?: string; chatId?: string }) => boolean;
}) => void;
type UseChatResumeProps = {
enableAutoResume: boolean;
isReady: boolean;
resumeTargetAiDataId?: string;
activeAppIdRef: MutableRefObject<string | undefined>;
activeChatIdRef: MutableRefObject<string | undefined>;
resumedChatTargetRef: MutableRefObject<string | undefined>;
resumeControllerRef: MutableRefObject<AbortController | undefined>;
generatingMessage: (message: generatingMessageProps) => void;
scrollToBottom: (behavior?: 'smooth' | 'auto', delay?: number) => void;
finishChatGenerateStatus: FinishChatGenerateStatus;
};
const isAbortByLeave = (reason: unknown) => {
return reason === 'leave' || (reason instanceof Error && reason.message === 'leave');
};
/**
* 恢复服务端仍在生成中的会话。
*
* 这个 hook 承接原 `ChatBox/index.tsx` 中的 auto resume effect。它只处理恢复生成:
* - 判断当前会话是否需要恢复。
* - 调用 `streamResumeFetch` 接收恢复流。
* - 在本地补齐或复用 AI placeholder。
* - 用调用方传入的 `generatingMessage` 复用现有 SSE 增量合并逻辑。
* - 收尾同步当前 ChatBox 状态、侧边栏状态和已读状态。
*
* 输入约定:
* - `generatingMessage` 仍由 ChatBox 提供,确保普通发送和恢复生成继续共享同一套
* answer/reasoning/tool/plan/interactive 合并逻辑。
* - `activeAppIdRef/activeChatIdRef` 保存当前页面真实目标,用于防止恢复流异步返回后
* 写入已经切走的会话。
* - `resumedChatTargetRef` 记录本轮已经尝试恢复的 app/chat,避免同一个 generating
* 会话在多次 render 后重复发起恢复请求。
* - `resumeControllerRef` 写回当前恢复请求的 AbortController,让 `abortRequest('leave')`
* 能在页面切换时中断恢复流。
*
* 关键边界:
* - 只有 records 已加载、当前 ChatBox 数据和 runtime app/chat 对齐,并且状态仍为
* generating 时才恢复。
* - 恢复流可能先到达 SSE 增量,再拿到完整 completedChat;因此需要按可见事件提前
* 创建 AI placeholder,保证 `generatingMessage` 仍然只更新最后一条 AI 消息。
* - 用户离开页面触发的 abort 不应把会话标记为 done/error,也不应弹出错误 toast。
*/
export const useChatResume = ({
enableAutoResume,
isReady,
resumeTargetAiDataId,
activeAppIdRef,
activeChatIdRef,
resumedChatTargetRef,
resumeControllerRef,
generatingMessage,
scrollToBottom,
finishChatGenerateStatus
}: UseChatResumeProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const chatBoxAppId = useContextSelector(ChatItemContext, (v) => v.chatBoxData.appId);
const chatBoxChatId = useContextSelector(ChatItemContext, (v) => v.chatBoxData.chatId);
const chatGenerateStatus = useContextSelector(
ChatItemContext,
(v) => v.chatBoxData.chatGenerateStatus
);
const isChatRecordsLoaded = useContextSelector(ChatRecordContext, (v) => v.isChatRecordsLoaded);
const setChatRecords = useContextSelector(ChatRecordContext, (v) => v.setChatRecords);
const isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting);
useEffect(() => {
if (
!enableAutoResume ||
!isReady ||
!isChatRecordsLoaded ||
!appId ||
!chatId ||
isChatting ||
chatBoxAppId !== appId ||
chatBoxChatId !== chatId ||
chatGenerateStatus !== ChatGenerateStatusEnum.generating ||
resumedChatTargetRef.current === `${appId}:${chatId}`
) {
return;
}
resumedChatTargetRef.current = `${appId}:${chatId}`;
const resumeForAppId = appId;
const resumeForChatId = chatId;
const responseChatId = resumeTargetAiDataId ?? getNanoid(24);
const controller = new AbortController();
resumeControllerRef.current = controller;
scrollToBottom('auto');
scrollToBottom('auto', 100);
let resumeFinalStatus = ChatGenerateStatusEnum.done;
let hasPreparedResumeAiRecord = false;
let hasReceivedResumeOutput = false;
const isActiveResumeTarget = ({ appId, chatId }: { appId: string; chatId: string }) =>
activeAppIdRef.current === appId && activeChatIdRef.current === chatId;
const getResumeUnavailablePlaceholderText = () => t('chat:resume_placeholder_generating');
const upsertResumeAiPlaceholder = (
responseChatId: string,
text = '',
status: `${ChatStatusEnum}` = ChatStatusEnum.loading,
options?: { resetExistingValue?: boolean }
) => {
setChatRecords((state) => {
const lastItem = state[state.length - 1];
if (lastItem?.dataId === responseChatId && lastItem.obj === ChatRoleEnum.AI) {
const shouldReplaceValue = shouldReplaceResumeAiValue({
hasExistingAiOutput: hasMeaningfulAiOutput(lastItem as ChatSiteItemType),
text,
resetExistingValue: options?.resetExistingValue
});
if (!shouldReplaceValue && lastItem.status === status) {
return state;
}
return state.map((item, index) =>
index !== state.length - 1
? item
: {
...item,
...(shouldReplaceValue
? {
value: [
{
text: {
content: text
}
}
],
responseData: options?.resetExistingValue ? [] : item.responseData
}
: {}),
status,
...(status === ChatStatusEnum.finish ? { time: new Date() } : {})
}
);
}
return [
...state,
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: text
}
}
],
status,
...(status === ChatStatusEnum.finish ? { time: new Date() } : {})
}
];
});
};
(async () => {
try {
const { responseText, completedChat, resumeUnavailable } = await streamResumeFetch({
appId,
chatId,
outLinkAuthData,
controller,
onResumeUnavailable: () => {
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
resumeFinalStatus = ChatGenerateStatusEnum.generating;
upsertResumeAiPlaceholder(
responseChatId,
getResumeUnavailablePlaceholderText(),
ChatStatusEnum.loading
);
},
onmessage: (message) => {
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
if (shouldCreateResumeAiPlaceholder(message.event)) {
upsertResumeAiPlaceholder(responseChatId, '', ChatStatusEnum.loading, {
resetExistingValue: shouldResetResumeAiPlaceholder({
hasPreparedResumeAiRecord,
hasReceivedResumeOutput
})
});
hasPreparedResumeAiRecord = true;
}
generatingMessage(message);
hasReceivedResumeOutput = true;
}
});
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
if (completedChat) {
resumeFinalStatus = completedChat.chatGenerateStatus;
setChatRecords((state) =>
mergeResumeCompletedChatRecords({
currentRecords: state,
completedRecords: completedChat.records.list.map((item) => ({
...item,
status: ChatStatusEnum.finish
})),
responseChatId
})
);
scrollToBottom('auto');
scrollToBottom('auto', 100);
return;
}
if (resumeUnavailable) {
resumeFinalStatus = ChatGenerateStatusEnum.generating;
upsertResumeAiPlaceholder(
responseChatId,
getResumeUnavailablePlaceholderText(),
ChatStatusEnum.loading
);
return;
}
setChatRecords((state) => {
const currentLastItem = state[state.length - 1];
if (
currentLastItem?.dataId !== responseChatId ||
currentLastItem.obj !== ChatRoleEnum.AI
) {
return state;
}
const next = state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: ChatStatusEnum.finish,
time: new Date(),
responseData: mergeChatResponseData(item.responseData || [])
};
});
const updatedLastItem = next[next.length - 1];
if (
updatedLastItem?.dataId === responseChatId &&
!hasMeaningfulAiOutput(updatedLastItem as ChatSiteItemType) &&
!responseText
) {
return next.slice(0, -1);
}
return next;
});
scrollToBottom('auto');
} catch (error) {
if (controller.signal.aborted) return;
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
const isStreamError = (error as ResumeStreamErrorType | undefined)?.isStreamError === true;
resumeFinalStatus = isStreamError
? ChatGenerateStatusEnum.error
: ChatGenerateStatusEnum.done;
setChatRecords((state) => {
const currentLastItem = state[state.length - 1];
if (
currentLastItem?.dataId !== responseChatId ||
currentLastItem.obj !== ChatRoleEnum.AI
) {
return state;
}
const next = state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: ChatStatusEnum.finish,
time: new Date()
};
});
const updatedLastItem = next[next.length - 1];
if (
updatedLastItem?.dataId === responseChatId &&
!hasMeaningfulAiOutput(updatedLastItem as ChatSiteItemType)
) {
return next.slice(0, -1);
}
return next;
});
scrollToBottom('auto');
if (isStreamError) {
toast({
title: t(getErrText(error, t('common:core.chat.error.Chat error') as any)),
status: 'error',
duration: 5000,
isClosable: true
});
}
} finally {
if (resumeControllerRef.current === controller) {
resumeControllerRef.current = undefined;
}
const finishedInActiveChat = isActiveResumeTarget({
appId: resumeForAppId,
chatId: resumeForChatId
});
const leftWhileResuming =
controller.signal.aborted && isAbortByLeave(controller.signal.reason);
if (leftWhileResuming) {
return;
}
finishChatGenerateStatus({
status: resumeFinalStatus,
finishedInActiveChat,
targetAppId: resumeForAppId,
targetChatId: resumeForChatId
});
}
})();
}, [
enableAutoResume,
isReady,
isChatRecordsLoaded,
appId,
chatId,
isChatting,
chatBoxAppId,
chatBoxChatId,
chatGenerateStatus,
generatingMessage,
outLinkAuthData,
resumeTargetAiDataId,
scrollToBottom,
setChatRecords,
finishChatGenerateStatus,
t,
toast,
activeAppIdRef,
activeChatIdRef,
resumedChatTargetRef,
resumeControllerRef
]);
};
import { useRef } from 'react';
import { useMemoizedFn, useThrottleFn } from 'ahooks';
import { shouldFollowGeneratingScroll } from '../utils/scrollUtils';
/**
* 管理 ChatBox 的滚动容器和生成中跟随底部逻辑。
*
* 这个 hook 只提供滚动能力,不决定哪些业务时机要滚动。records loaded、发送消息、
* 恢复生成、问题引导等流程仍由调用方判断时机后调用 `scrollToBottom` 或
* `generatingScroll`。
*
* 输出约定:
* - `ScrollContainerRef` 绑定到聊天记录滚动容器。
* - `scrollToBottom` 用于明确要求滚到底部,支持 smooth/auto 和延迟。
* - `generatingScroll` 用于流式生成中“条件跟随底部”,避免打断用户查看历史。
*
* 关键边界:
* - `scrollToBottom` 保留 DOM 未挂载时的延迟重试,因为 ChatBox 有动态加载记录、
* home/chat 分支切换和恢复生成占位消息,调用时机可能早于滚动容器真实出现。
* - `generatingScroll` 复用 `shouldFollowGeneratingScroll`,只有用户接近底部或调用方
* 传入 `force` 时才跟随;这能避免用户向上查看历史时被流式 token 拉回底部。
*/
export const useChatScroll = () => {
const ScrollContainerRef = useRef<HTMLDivElement>(null);
/**
* 滚动到底部。
*
* `delay` 用于等待 React 渲染新消息或新高度;内部 `runScroll` 的二次重试用于等待
* scroll 容器挂载完成。这里不把失败暴露给调用方,因为原 ChatBox 行为就是尽力滚动。
*/
const scrollToBottom = useMemoizedFn((behavior: 'smooth' | 'auto' = 'smooth', delay = 0) => {
const runScroll = () => {
if (!ScrollContainerRef.current) {
setTimeout(runScroll, 500);
return;
}
ScrollContainerRef.current.scrollTo({
top: ScrollContainerRef.current.scrollHeight,
behavior
});
};
setTimeout(() => {
runScroll();
}, delay);
});
const { run: generatingScroll } = useThrottleFn(
(force?: boolean) => {
if (!ScrollContainerRef.current) return;
// 流式响应会高频触发,先判断用户是否仍在底部附近,再决定是否滚动。
// `force` 用于发送新消息、恢复占位等明确需要贴底的场景。
const isBottom = shouldFollowGeneratingScroll({
scrollTop: ScrollContainerRef.current.scrollTop,
clientHeight: ScrollContainerRef.current.clientHeight,
scrollHeight: ScrollContainerRef.current.scrollHeight,
force
});
if (isBottom) {
scrollToBottom('auto');
}
},
{
wait: 100
}
);
return {
ScrollContainerRef,
scrollToBottom,
generatingScroll
};
};
...@@ -17,7 +17,7 @@ import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch ...@@ -17,7 +17,7 @@ import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch
import { getUploadChatFilePresignedUrl } from '@/web/common/file/api'; import { getUploadChatFilePresignedUrl } from '@/web/common/file/api';
import { getUploadFileType } from '@fastgpt/global/core/app/constants'; import { getUploadFileType } from '@fastgpt/global/core/app/constants';
import { putFileToS3 } from '@fastgpt/web/common/file/utils'; import { putFileToS3 } from '@fastgpt/web/common/file/utils';
import { getUploadChatFileType } from '../utils'; import { getUploadChatFileType } from '../utils/file';
type UseFileUploadOptions = { type UseFileUploadOptions = {
fileSelectConfig: AppFileSelectConfigType; fileSelectConfig: AppFileSelectConfigType;
......
import { useCallback, type MutableRefObject } from 'react';
import type { AppQGConfigType } from '@fastgpt/global/core/app/type';
import { postQuestionGuide } from '@/web/core/ai/api';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
/**
* 创建回答后的问题引导。
*
* 这个 hook 只封装 question guide 请求本身,不决定触发时机。调用方仍然负责在一次
* AI 回答完成、且没有 interactive 等更高优先级 UI 时调用返回的 `createQuestionGuide`。
*
* 输入约定:
* - `appId/chatId/outLinkAuthData` 组成请求目标和鉴权上下文。
* - `questionGuide` 是 app 的问题引导配置,hook 只读取 `open` 和请求所需配置。
* - `chatControllerRef` 指向主聊天请求,用于判断当前回答是否已经被用户停止。
* - `questionGuideControllerRef` 指向问题引导自己的请求,用于让 `abortRequest` 可以同时停止它。
*
* 输出约定:
* - 返回一个稳定的 async 函数;成功拿到字符串数组时写入 `setQuestionGuide`。
* - 请求失败保持静默,沿用原 ChatBox 行为,不在本 PR 改变 toast 或错误上报策略。
*
* 关键边界:
* - 主聊天请求已经 abort 时不再生成问题引导,否则用户点击停止后仍可能看到新推荐问题。
* - 每次请求前都会创建新的 AbortController,并写回 ref,让后续 stop/leave 能中断当前请求。
* - 结果写入后延迟滚动到底部,给推荐问题组件一次渲染高度的时间。
*/
export const useQuestionGuide = ({
appId,
chatId,
questionGuide,
outLinkAuthData,
chatControllerRef,
questionGuideControllerRef,
setQuestionGuide,
scrollToBottom
}: {
appId: string;
chatId: string;
questionGuide: AppQGConfigType;
outLinkAuthData?: OutLinkChatAuthProps;
chatControllerRef: MutableRefObject<AbortController>;
questionGuideControllerRef: MutableRefObject<AbortController>;
setQuestionGuide: (guides: string[]) => void;
scrollToBottom: (behavior?: 'smooth' | 'auto', delay?: number) => void;
}) => {
return useCallback(async () => {
// 保留拆分前语义:只用主聊天请求的 abort 状态阻止回答结束后的问题引导。
// question guide 自身的旧 controller 可能在新一轮发送开始时被 abort,不能阻断新请求。
if (!questionGuide.open || chatControllerRef.current?.signal?.aborted) {
return;
}
try {
// question guide 独立于主聊天请求,但需要被同一个 abortRequest 管理。
// 因此创建新 controller 后必须写回 ref,而不是只保存在局部变量里。
const abortSignal = new AbortController();
questionGuideControllerRef.current = abortSignal;
const result = await postQuestionGuide(
{
appId,
chatId,
questionGuide,
...outLinkAuthData
},
abortSignal
);
if (Array.isArray(result)) {
setQuestionGuide(result);
// 推荐问题渲染会增加底部高度,延迟滚动可以避免在 DOM 高度更新前滚动失败。
setTimeout(() => {
scrollToBottom();
}, 100);
}
} catch {}
}, [
questionGuide,
chatControllerRef,
questionGuideControllerRef,
appId,
chatId,
outLinkAuthData,
setQuestionGuide,
scrollToBottom
]);
};
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import { WorkflowRuntimeContext } from '../../context/workflowRuntimeContext';
import { useContextSelector } from 'use-context-selector';
import { useTranslation } from 'next-i18next';
import { useMemoizedFn } from 'ahooks';
/**
* 同步侧边栏历史会话的生成状态。
*
* ChatBox 内部会同时维护当前会话详情和左侧历史列表。发送、恢复生成完成或失败时,
* 当前 `chatBoxData.chatGenerateStatus` 会更新,但侧边栏历史列表也需要同步展示
* generating/done/error 和已读状态。
*
* 这个 hook 只处理侧边栏历史列表,不直接修改当前 ChatBox 数据:
* - 当前会话详情由调用方通过 `setChatBoxData` 更新。
* - 历史列表由这里通过 `setHistories` 局部更新。
* - 当历史列表里找不到目标会话时,先补一条本地历史,并异步触发 `loadHistories`
* 让服务端数据随后校准。
*
* 输入约定:
* - 默认同步当前 runtime 的 `appId/chatId`。
* - 恢复生成可能在异步完成时已经切换页面,因此允许通过 `targetAppId/targetChatId`
* 明确指定要同步的历史项。
*
* 边界行为:
* - 如果目标 app 已不是当前 app,直接跳过,避免跨 app 把历史状态写错。
* - `hasBeenRead` 未显式传入时,只有 generating 默认未读,其它状态默认已读。
*/
export const useSidebarChatGenerateStatus = () => {
const { t } = useTranslation();
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const chatTitle = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.title);
const setHistories = useContextSelector(ChatContext, (v) => v.setHistories);
const loadHistories = useContextSelector(ChatContext, (v) => v.loadHistories);
return useMemoizedFn(
(
status: ChatGenerateStatusEnum,
options?: {
hasBeenRead?: boolean;
targetAppId?: string;
targetChatId?: string;
title?: string;
}
) => {
const targetAppId = options?.targetAppId ?? appId;
if (targetAppId !== appId) return;
const targetChatId = options?.targetChatId ?? chatId;
if (!targetChatId) return;
setHistories((prev) => {
const idx = prev.findIndex((h) => h.chatId === targetChatId && h.appId === targetAppId);
if (idx === -1) {
queueMicrotask(loadHistories);
return [
{
chatId: targetChatId,
appId: targetAppId,
title: options?.title || chatTitle || t('common:core.chat.New Chat'),
customTitle: '',
top: false,
updateTime: new Date(),
chatGenerateStatus: status,
hasBeenRead: options?.hasBeenRead ?? status !== ChatGenerateStatusEnum.generating
},
...prev
];
}
return prev.map((h) =>
h.chatId === targetChatId && h.appId === targetAppId
? {
...h,
chatGenerateStatus: status,
updateTime: new Date(),
...(options?.hasBeenRead !== undefined ? { hasBeenRead: options.hasBeenRead } : {})
}
: h
);
});
}
);
};
import { useEffect, type RefObject } from 'react';
/**
* 监听聊天滚动容器,判断变量输入区是否处于可视区域。
*
* ChatBox 外层需要知道变量表单是否可见,用于同步其它入口的变量展示状态。
* 这个 hook 只负责 DOM 可见性观测,不负责渲染变量表单,也不改变变量值。
*
* 输入约定:
* - `ScrollContainerRef` 必须绑定聊天滚动容器。
* - 变量表单节点继续沿用 `#variable-input` 作为查询锚点,避免 PR 2 同时改动 UI 结构。
* - `setIsVariableVisible` 来自 ChatItemContext,负责把可见性同步给外层。
*
* 边界行为:
* - 容器未挂载、变量表单不存在或变量表单高度为 0 时,不更新外层状态。
* - 初次挂载时立即检查一次,之后只在滚动事件中重新计算。
* - effect cleanup 会移除当前容器上的 listener,避免切换 chat 或卸载时残留监听。
*/
export const useVariableInputVisibility = ({
ScrollContainerRef,
setIsVariableVisible
}: {
ScrollContainerRef: RefObject<HTMLDivElement>;
setIsVariableVisible: (visible: boolean) => void;
}) => {
useEffect(() => {
const checkVariableVisibility = () => {
if (!ScrollContainerRef.current) return;
const container = ScrollContainerRef.current;
// 继续使用现有 DOM id 作为边界,避免变量表单组件被迫感知这个 hook。
const variableInput = container.querySelector('#variable-input');
if (!variableInput) return;
const containerRect = container.getBoundingClientRect();
const elementRect = variableInput.getBoundingClientRect();
// 高度为 0 通常表示变量区域被折叠或尚未完成布局,此时不写 false,
// 避免短暂布局状态把外层可见性误置为不可见。
if (elementRect.height === 0) return;
setIsVariableVisible(
containerRect.top < elementRect.bottom && containerRect.bottom > elementRect.top
);
};
const container = ScrollContainerRef.current;
if (container) {
checkVariableVisibility();
container.addEventListener('scroll', checkVariableVisibility);
return () => {
container.removeEventListener('scroll', checkVariableVisibility);
};
}
}, [ScrollContainerRef, setIsVariableVisible]);
};
...@@ -4,134 +4,59 @@ import React, { ...@@ -4,134 +4,59 @@ import React, {
useState, useState,
useMemo, useMemo,
useImperativeHandle, useImperativeHandle,
useEffect useEffect,
useLayoutEffect
} from 'react'; } from 'react';
import Script from 'next/script'; import Script from 'next/script';
import type { import { Box, Flex } from '@chakra-ui/react';
AIChatItemValueItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import type { ChatSiteItemType } from './type';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { Box, Checkbox, Flex } from '@chakra-ui/react';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus'; import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt';
import { useForm } from 'react-hook-form';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import {
closeCustomFeedback,
updateChatAdminFeedback,
updateChatUserFeedback,
updateFeedbackReadStatus
} from '@/web/core/chat/feedback/api';
import { delChatRecordById } from '@/web/core/chat/record/api';
import { postMarkChatRead } from '@/web/core/chat/history/api'; import { postMarkChatRead } from '@/web/core/chat/history/api';
import type { MarkChatReadBodyType } from '@fastgpt/global/openapi/core/chat/history/api'; import type { MarkChatReadBodyType } from '@fastgpt/global/openapi/core/chat/history/api';
import type { AdminMarkType } from './components/SelectMarkCollection';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { postQuestionGuide } from '@/web/core/ai/api';
import { postStopV2Chat } from '@/web/core/chat/api'; import { postStopV2Chat } from '@/web/core/chat/api';
import type { import type { ChatBoxInputType, StopChatFnResult } from './type';
ChatBoxInputType, import type { StartChatFnProps } from '../type';
ChatBoxInputFormType,
SendPromptFnType,
StopChatFnResult
} from './type';
import type { StartChatFnProps, generatingMessageProps } from '../type';
import ChatInput from './Input/ChatInput'; import ChatInput from './Input/ChatInput';
import ChatBoxDivider from '../../Divider';
import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat'; import { type OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { import {
getInteractiveByHistories, ChatGenerateStatusEnum,
formatChatValue2InputType, ChatRoleEnum,
mergeResumeCompletedChatRecords, ChatStatusEnum
refreshSubmittedFormInteractiveValues, } from '@fastgpt/global/core/chat/constants';
rewriteHistoriesByInteractiveResponse, import { getInteractiveByHistories } from './utils/interactive';
shouldAppendResumeInteractive, import { ChatTypeEnum, FeedbackTypeEnum } from './constants';
shouldReplaceResumeAiValue,
shouldResetResumeAiPlaceholder,
stripChatValueFileUrls
} from './utils';
import { ChatTypeEnum, textareaMinH } from './constants';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import ChatProvider, { ChatBoxContext, type ChatProviderProps } from './Provider'; import ChatProvider, { ChatBoxContext, type ChatProviderProps } from './Provider';
import { WorkflowRuntimeContext } from '../context/workflowRuntimeContext'; import { WorkflowRuntimeContext } from '../context/workflowRuntimeContext';
import ChatItem from './components/ChatItem';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { import { type StreamResponseType } from '@/web/common/api/fetch';
streamResumeFetch,
type ResumeStreamErrorType,
type StreamResponseType
} from '@/web/common/api/fetch';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useCreation, useDebounceEffect, useMemoizedFn } from 'ahooks';
import { useCreation, useDebounceEffect, useMemoizedFn, useThrottleFn } from 'ahooks';
import MyIcon from '@fastgpt/web/components/common/Icon';
import {
getChatTitleFromChatMessage,
mergeChatResponseData
} from '@fastgpt/global/core/chat/utils';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext'; import { ChatRecordContext } from '@/web/core/chat/context/chatRecordContext';
import { ChatContext } from '@/web/core/chat/context/chatContext';
import { ChatItemContext } from '@/web/core/chat/context/chatItemContext'; import { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import TimeBox from './components/TimeBox';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants'; import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { cloneDeep } from 'lodash'; import { getChatScrollTargetKey, shouldForceScrollAfterRecordsLoaded } from './utils/scrollUtils';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants'; import { isChatRoundPending } from './utils/chatStatus';
import { import { getProcessedChatRecords } from './utils/recordGroups';
getChatScrollTargetKey, import { useChatInputForm } from './hooks/useChatInputForm';
shouldFollowGeneratingScroll, import { useChatScroll } from './hooks/useChatScroll';
shouldForceScrollAfterRecordsLoaded import { useVariableInputVisibility } from './hooks/useVariableInputVisibility';
} from './scrollUtils'; import { useQuestionGuide } from './hooks/useQuestionGuide';
import { isChatRoundPending } from './chatStatus'; import { useSidebarChatGenerateStatus } from './hooks/useSidebarChatGenerateStatus';
import { useChatResume } from './hooks/useChatResume';
import { useChatGenerate } from './hooks/useChatGenerate';
import { useChatRecordActions } from './hooks/useChatRecordActions';
import { useChatFeedbackActions } from './hooks/useChatFeedbackActions';
import ChatBoxModals from './components/ChatBoxModals';
import type { ChatRecordsListProps } from './components/ChatRecordsList';
import AppChatMain from './components/AppChatMain';
const FeedbackModal = dynamic(() => import('./components/FeedbackModal'));
const SelectMarkCollection = dynamic(() => import('./components/SelectMarkCollection'));
const WelcomeBox = dynamic(() => import('./components/WelcomeBox'));
const VariableInputForm = dynamic(() => import('./components/VariableInputForm'));
const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm')); const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm'));
const WelcomeHomeBox = dynamic(() => import('./components/home/WelcomeHomeBox')); const WelcomeHomeBox = dynamic(() => import('./components/home/WelcomeHomeBox'));
const QuickApps = dynamic(() => import('./components/home/QuickApps')); const QuickApps = dynamic(() => import('./components/home/QuickApps'));
const WorkorderEntrance = dynamic(() => import('@/pageComponents/chat/WorkorderEntrance')); const WorkorderEntrance = dynamic(() => import('@/pageComponents/chat/WorkorderEntrance'));
const DeletedItemsCollapse = dynamic(() => import('../DeletedItemsCollapse'));
enum FeedbackTypeEnum {
user = 'user',
admin = 'admin',
hidden = 'hidden'
}
type HumanChatSiteItemType = Extract<ChatSiteItemType, { obj: ChatRoleEnum.Human }>;
const isAbortByLeave = (reason: unknown) => {
return reason === 'leave' || (reason instanceof Error && reason.message === 'leave');
};
const shouldCreateResumeAiPlaceholder = (event: SseResponseEventEnum) => {
return [
SseResponseEventEnum.flowNodeResponse,
SseResponseEventEnum.flowNodeStatus,
SseResponseEventEnum.answer,
SseResponseEventEnum.fastAnswer,
SseResponseEventEnum.toolCall,
SseResponseEventEnum.toolParams,
SseResponseEventEnum.toolResponse,
SseResponseEventEnum.interactive,
SseResponseEventEnum.plan,
SseResponseEventEnum.planStatus,
SseResponseEventEnum.workflowDuration
].includes(event);
};
type Props = OutLinkChatAuthProps & type Props = OutLinkChatAuthProps &
ChatProviderProps & { ChatProviderProps & {
...@@ -175,11 +100,7 @@ const ChatBox = ({ ...@@ -175,11 +100,7 @@ const ChatBox = ({
onStopChat, onStopChat,
onMarkChatRead onMarkChatRead
}: Props) => { }: Props) => {
const ScrollContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast();
const { setNotSufficientModalType } = useSystemStore();
const { isPc } = useSystem();
const TextareaDom = useRef<HTMLTextAreaElement>(null); const TextareaDom = useRef<HTMLTextAreaElement>(null);
const chatController = useRef(new AbortController()); const chatController = useRef(new AbortController());
const questionGuideController = useRef(new AbortController()); const questionGuideController = useRef(new AbortController());
...@@ -188,19 +109,15 @@ const ChatBox = ({ ...@@ -188,19 +109,15 @@ const ChatBox = ({
const resumedChatTargetRef = useRef<string>(); const resumedChatTargetRef = useRef<string>();
const lastRecordsLoadedScrollTargetRef = useRef<string>(); const lastRecordsLoadedScrollTargetRef = useRef<string>();
const [isLoading, setIsLoading] = useState(false);
const [feedbackId, setFeedbackId] = useState<string>();
const [adminMarkData, setAdminMarkData] = useState<AdminMarkType & { dataId: string }>();
const [questionGuides, setQuestionGuide] = useState<string[]>([]); const [questionGuides, setQuestionGuide] = useState<string[]>([]);
const [expandedDeletedGroups, setExpandedDeletedGroups] = useState<Set<string>>(new Set()); const [expandedDeletedGroups, setExpandedDeletedGroups] = useState<Set<string>>(new Set());
const { ScrollContainerRef, scrollToBottom, generatingScroll } = useChatScroll();
const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app?.avatar); const appAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.app?.avatar);
const userAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.userAvatar); const userAvatar = useContextSelector(ChatItemContext, (v) => v.chatBoxData?.userAvatar);
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData); const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData); const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
const ChatBoxRef = useContextSelector(ChatItemContext, (v) => v.ChatBoxRef); const ChatBoxRef = useContextSelector(ChatItemContext, (v) => v.ChatBoxRef);
const variablesForm = useContextSelector(ChatItemContext, (v) => v.variablesForm);
const resetVariables = useContextSelector(ChatItemContext, (v) => v.resetVariables);
const setIsVariableVisible = useContextSelector(ChatItemContext, (v) => v.setIsVariableVisible); const setIsVariableVisible = useContextSelector(ChatItemContext, (v) => v.setIsVariableVisible);
const isLoadingRecords = useContextSelector(ChatRecordContext, (v) => v.isLoadingRecords); const isLoadingRecords = useContextSelector(ChatRecordContext, (v) => v.isLoadingRecords);
...@@ -213,9 +130,11 @@ const ChatBox = ({ ...@@ -213,9 +130,11 @@ const ChatBox = ({
const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId); const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId);
const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId); const chatId = useContextSelector(WorkflowRuntimeContext, (v) => v.chatId);
const activeAppIdRef = useRef<string | undefined>(appId); const activeAppIdRef = useRef<string | undefined>(appId);
activeAppIdRef.current = appId;
const activeChatIdRef = useRef<string | undefined>(chatId); const activeChatIdRef = useRef<string | undefined>(chatId);
useLayoutEffect(() => {
activeAppIdRef.current = appId;
activeChatIdRef.current = chatId; activeChatIdRef.current = chatId;
}, [appId, chatId]);
const chatScrollTargetKey = useMemo( const chatScrollTargetKey = useMemo(
() => getChatScrollTargetKey({ appId, chatId }), () => getChatScrollTargetKey({ appId, chatId }),
[appId, chatId] [appId, chatId]
...@@ -224,10 +143,6 @@ const ChatBox = ({ ...@@ -224,10 +143,6 @@ const ChatBox = ({
const welcomeText = useContextSelector(ChatBoxContext, (v) => v.welcomeText); const welcomeText = useContextSelector(ChatBoxContext, (v) => v.welcomeText);
const variableList = useContextSelector(ChatBoxContext, (v) => v.variableList); const variableList = useContextSelector(ChatBoxContext, (v) => v.variableList);
const questionGuide = useContextSelector(ChatBoxContext, (v) => v.questionGuide); const questionGuide = useContextSelector(ChatBoxContext, (v) => v.questionGuide);
const startSegmentedAudio = useContextSelector(ChatBoxContext, (v) => v.startSegmentedAudio);
const finishSegmentedAudio = useContextSelector(ChatBoxContext, (v) => v.finishSegmentedAudio);
const setAudioPlayingChatId = useContextSelector(ChatBoxContext, (v) => v.setAudioPlayingChatId);
const splitText2Audio = useContextSelector(ChatBoxContext, (v) => v.splitText2Audio);
const isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting); const isChatting = useContextSelector(ChatBoxContext, (v) => v.isChatting);
const isRoundPending = isChatRoundPending({ const isRoundPending = isChatRoundPending({
isChatting, isChatting,
...@@ -238,55 +153,7 @@ const ChatBox = ({ ...@@ -238,55 +153,7 @@ const ChatBox = ({
lastChat: chatRecords[chatRecords.length - 1] lastChat: chatRecords[chatRecords.length - 1]
}); });
const setHistories = useContextSelector(ChatContext, (v) => v.setHistories); const syncSidebarChatGenerateStatus = useSidebarChatGenerateStatus();
const loadHistories = useContextSelector(ChatContext, (v) => v.loadHistories);
const syncSidebarChatGenerateStatus = useMemoizedFn(
(
status: ChatGenerateStatusEnum,
options?: {
hasBeenRead?: boolean;
targetAppId?: string;
targetChatId?: string;
title?: string;
}
) => {
const targetAppId = options?.targetAppId ?? appId;
if (targetAppId !== appId) return;
const targetChatId = options?.targetChatId ?? chatId;
if (!targetChatId) return;
setHistories((prev) => {
const idx = prev.findIndex((h) => h.chatId === targetChatId && h.appId === targetAppId);
if (idx === -1) {
queueMicrotask(loadHistories);
return [
{
chatId: targetChatId,
appId: targetAppId,
title: options?.title || chatBoxData.title || t('common:core.chat.New Chat'),
customTitle: '',
top: false,
updateTime: new Date(),
chatGenerateStatus: status,
hasBeenRead: options?.hasBeenRead ?? status !== ChatGenerateStatusEnum.generating
},
...prev
];
}
return prev.map((h) =>
h.chatId === targetChatId && h.appId === targetAppId
? {
...h,
chatGenerateStatus: status,
updateTime: new Date(),
...(options?.hasBeenRead !== undefined ? { hasBeenRead: options.hasBeenRead } : {})
}
: h
);
});
}
);
const markChatRead = useMemoizedFn(async (data: MarkChatReadBodyType) => { const markChatRead = useMemoizedFn(async (data: MarkChatReadBodyType) => {
if (!enableMarkChatRead) return; if (!enableMarkChatRead) return;
...@@ -309,7 +176,6 @@ const ChatBox = ({ ...@@ -309,7 +176,6 @@ const ChatBox = ({
completed: result.completed completed: result.completed
}; };
}); });
const finishChatGenerateStatus = useMemoizedFn( const finishChatGenerateStatus = useMemoizedFn(
({ ({
status, status,
...@@ -365,1054 +231,93 @@ const ChatBox = ({ ...@@ -365,1054 +231,93 @@ const ChatBox = ({
const resumeTargetAiDataId = useMemo(() => { const resumeTargetAiDataId = useMemo(() => {
for (let i = chatRecords.length - 1; i >= 0; i--) { for (let i = chatRecords.length - 1; i >= 0; i--) {
const row = chatRecords[i]; const row = chatRecords[i];
if (row.obj === ChatRoleEnum.AI && row.dataId) { if (row.obj === ChatRoleEnum.AI && row.dataId) {
return row.dataId as string; return row.dataId as string;
}
}
return undefined;
}, [chatRecords]);
// Workflow running, there are user input or selection
const { interactive: lastInteractive, canSendQuery } = useMemo(
() => getInteractiveByHistories(chatRecords),
[chatRecords]
);
const showExternalVariable = useMemo(() => {
const map: Record<string, boolean> = {
[ChatTypeEnum.log]: true,
[ChatTypeEnum.test]: true,
[ChatTypeEnum.chat]: true,
[ChatTypeEnum.home]: true
};
return map[chatType] && variableList.some((item) => item.type === VariableInputEnum.custom);
}, [variableList, chatType]);
// compute variable input is finish.
const chatForm = useForm<ChatBoxInputFormType>({
defaultValues: {
input: sessionStorage.getItem(`chatInput_${chatId}`) || '',
files: [],
chatStarted: false
}
});
const { setValue, watch } = chatForm;
const chatStartedWatch = watch('chatStarted');
const inputValue = watch('input');
useDebounceEffect(
() => {
if (inputValue) {
sessionStorage.setItem(`chatInput_${chatId}`, inputValue);
} else {
sessionStorage.removeItem(`chatInput_${chatId}`);
}
},
[inputValue, chatId],
{ wait: 300 }
);
const commonVariableList = variableList.filter(
(item) => item.type !== VariableInputEnum.custom && item.type !== VariableInputEnum.internal
);
/*
对话已经开始的标记:
1. 保证 appId 一致。
2. 有对话记录/手动点了开始/默认没有需要填写的变量。
*/
const chatStarted =
chatBoxData?.appId === appId &&
(chatRecords.length > 0 ||
chatStartedWatch ||
(commonVariableList.length === 0 && !showExternalVariable));
// 滚动到底部
const scrollToBottom = useMemoizedFn((behavior: 'smooth' | 'auto' = 'smooth', delay = 0) => {
setTimeout(() => {
if (!ScrollContainerRef.current) {
setTimeout(() => {
scrollToBottom(behavior);
}, 500);
} else {
ScrollContainerRef.current.scrollTo({
top: ScrollContainerRef.current.scrollHeight,
behavior
});
}
}, delay);
});
// 聊天信息生成中……获取当前滚动条位置,判断是否需要滚动到底部
const { run: generatingScroll } = useThrottleFn(
(force?: boolean) => {
if (!ScrollContainerRef.current) return;
const isBottom = shouldFollowGeneratingScroll({
scrollTop: ScrollContainerRef.current.scrollTop,
clientHeight: ScrollContainerRef.current.clientHeight,
scrollHeight: ScrollContainerRef.current.scrollHeight,
force
});
if (isBottom) {
scrollToBottom('auto');
}
},
{
wait: 100
}
);
const generatingMessage = useMemoizedFn(
({
responseValueId,
event,
text = '',
reasoningText,
status,
name,
tool,
interactive,
plan,
planStatus,
sandboxStatus,
skill,
variables,
nodeResponse,
durationSeconds,
autoTTSResponse
}: generatingMessageProps & { autoTTSResponse?: boolean }) => {
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) {
splitText2Audio(formatChatValue2InputType(item.value).text || '');
}
const updateIndex = (() => {
if (!responseValueId) return item.value.length - 1;
const index = item.value.findIndex((item) => item.id === responseValueId);
if (index !== -1) return index;
return item.value.length - 1;
})();
const updateValue: AIChatItemValueItemType = cloneDeep(item.value[updateIndex]);
if (event === SseResponseEventEnum.flowNodeResponse && nodeResponse) {
return {
...item,
responseData: item.responseData
? [...item.responseData, nodeResponse]
: [nodeResponse]
};
}
if (event === SseResponseEventEnum.flowNodeStatus && status) {
return {
...item,
status,
moduleName: name
};
}
if (event === SseResponseEventEnum.sandboxStatus && sandboxStatus) {
const getSandboxPhaseLabel = (): string => {
const { phase, isWarmStart, skillName } = sandboxStatus;
if (phase === 'deployingSkills') {
return t('chat:sandbox_status_deployingSkills', { skillName: skillName ?? '' });
}
if (
phase === 'downloadingPackage' ||
phase === 'uploadingPackage' ||
phase === 'extractingPackage'
) {
return t(`chat:sandbox_status_${phase}` as any, { skillName: skillName ?? '' });
}
if (phase === 'ready') {
return t(
isWarmStart ? 'chat:sandbox_status_ready_warm' : 'chat:sandbox_status_ready_cold'
);
}
return t(`chat:sandbox_status_${phase}` as any);
};
return {
...item,
status: 'loading' as const,
moduleName: getSandboxPhaseLabel()
};
}
if (event === SseResponseEventEnum.skillCall && skill) {
// 去重检查:避免同一个 skill 在同一响应中重复展示
const alreadyExists = item.value.some(
(v) =>
v.id === responseValueId &&
v.skills?.some((s) => s.skillMdPath === skill.skillMdPath)
);
if (alreadyExists) return item;
const skillId = skill.id || responseValueId || getNanoid(10);
const val: AIChatItemValueItemType = {
id: responseValueId,
skills: [
{
id: skillId,
skillName: skill.skillName,
skillAvatar: skill.skillAvatar || '',
description: skill.description,
skillMdPath: skill.skillMdPath
}
]
};
return {
...item,
value: [...item.value, val]
};
}
if (event === SseResponseEventEnum.answer || event === SseResponseEventEnum.fastAnswer) {
const replaceUpdateValue = (nextValue: AIChatItemValueItemType) => ({
...item,
value: [
...item.value.slice(0, updateIndex),
nextValue,
...item.value.slice(updateIndex + 1)
]
});
if (reasoningText) {
if (updateValue?.reasoning) {
updateValue.reasoning.content += reasoningText;
return replaceUpdateValue(updateValue);
} else if (updateValue?.text && !updateValue.text.content) {
updateValue.reasoning = {
content: reasoningText
};
return replaceUpdateValue(updateValue);
} else {
const val: AIChatItemValueItemType = {
id: responseValueId,
reasoning: {
content: reasoningText
}
};
return {
...item,
value: [...item.value, val]
};
}
}
if (text) {
if (updateValue?.text) {
updateValue.text.content += text;
return replaceUpdateValue(updateValue);
} else if (updateValue?.reasoning) {
updateValue.text = {
content: text
};
return replaceUpdateValue(updateValue);
} else {
const newValue: AIChatItemValueItemType = {
id: responseValueId,
text: {
content: text
}
};
return {
...item,
value: item.value.concat(newValue)
};
}
}
}
// Tool call
if (event === SseResponseEventEnum.toolCall && tool) {
const val: AIChatItemValueItemType = {
id: responseValueId || tool.id,
tools: [tool]
};
return {
...item,
value: [...item.value, val]
};
}
if (event === SseResponseEventEnum.toolParams && tool && updateValue.tools) {
if (tool.params) {
updateValue.tools = updateValue.tools.map((item) =>
item.id === tool.id
? { ...item, params: `${item.params || ''}${tool.params}` }
: item
);
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
};
}
return item;
}
if (event === SseResponseEventEnum.toolResponse && tool && updateValue.tools) {
if (tool.response) {
updateValue.tools = updateValue.tools.map((item) =>
item.id === tool.id
? { ...item, response: `${item.response || ''}${tool.response}` }
: item
);
return {
...item,
value: [
...item.value.slice(0, updateIndex),
updateValue,
...item.value.slice(updateIndex + 1)
]
};
}
return item;
}
// Agent
if (event === SseResponseEventEnum.planStatus && planStatus) {
const planStatusIndex = item.value.findIndex(
(value) => !!value.planStatus || (!!responseValueId && value.id === responseValueId)
);
const nextPlanStatusValue: AIChatItemValueItemType = {
id: responseValueId,
planStatus
};
if (planStatusIndex >= 0) {
return {
...item,
value: [
...item.value.slice(0, planStatusIndex),
{
...item.value[planStatusIndex],
...nextPlanStatusValue
},
...item.value.slice(planStatusIndex + 1)
]
};
}
return {
...item,
value: [...item.value, nextPlanStatusValue]
};
}
if (event === SseResponseEventEnum.plan && plan) {
const planIndex = item.value.findIndex(
(value) =>
(!!responseValueId && value.id === responseValueId) ||
!!value.planStatus ||
(value.plan?.planId && value.plan.planId === plan.planId)
);
const nextPlanValue = {
id: responseValueId || plan.planId,
plan,
planStatus: undefined
};
if (planIndex >= 0) {
return {
...item,
value: [
...item.value.slice(0, planIndex),
{
...item.value[planIndex],
...nextPlanValue
},
...item.value.slice(planIndex + 1)
]
};
}
return {
...item,
value: [...item.value, nextPlanValue]
};
}
if (event === SseResponseEventEnum.updateVariables && variables) {
resetVariables({ variables });
}
if (event === SseResponseEventEnum.interactive && interactive) {
if (
!shouldAppendResumeInteractive({
existingValues: item.value,
incomingInteractive: interactive
})
) {
return item;
}
const val: AIChatItemValueItemType = {
interactive
};
return {
...item,
value: item.value.concat(val)
};
}
if (event === SseResponseEventEnum.workflowDuration && durationSeconds) {
return {
...item,
durationSeconds: item.durationSeconds
? +(item.durationSeconds + durationSeconds).toFixed(2)
: durationSeconds
};
}
return item;
});
});
const forceScroll = event === SseResponseEventEnum.interactive;
generatingScroll(forceScroll);
}
);
// 重置输入内容
const resetInputVal = useMemoizedFn(({ text = '', files = [] }: ChatBoxInputType) => {
if (!TextareaDom.current) return;
setValue('files', files);
setValue('input', text);
sessionStorage.removeItem(`chatInput_${chatId}`);
setTimeout(() => {
/* 回到最小高度 */
if (TextareaDom.current) {
TextareaDom.current.style.height =
text === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`;
}
}, 100);
});
// create question guide
const createQuestionGuide = useCallback(async () => {
if (!questionGuide.open || chatController.current?.signal?.aborted) return;
try {
const abortSignal = new AbortController();
questionGuideController.current = abortSignal;
const result = await postQuestionGuide(
{
appId,
chatId,
questionGuide,
...outLinkAuthData
},
abortSignal
);
if (Array.isArray(result)) {
setQuestionGuide(result);
setTimeout(() => {
scrollToBottom();
}, 100);
}
} catch (error) {}
}, [questionGuide, appId, chatId, outLinkAuthData, scrollToBottom]);
/* Abort chat completions, questionGuide */
const abortRequest = useMemoizedFn((reason: string = 'stop') => {
chatController.current?.abort(new Error(reason));
questionGuideController.current?.abort(new Error(reason));
pluginController.current?.abort(new Error(reason));
resumeController.current?.abort(new Error(reason));
});
const hasMeaningfulAiOutput = useMemoizedFn((chat?: ChatSiteItemType) => {
if (!chat || chat.obj !== ChatRoleEnum.AI) return false;
if (chat.responseData?.length) return true;
return chat.value.some((item) => {
if (item.text?.content) return true;
if (item.reasoning?.content) return true;
if (item.tool?.params || item.tool?.response) return true;
if (item.tools?.some((tool) => tool.params || tool.response)) return true;
if (item.skills?.length) return true;
if (item.plan || item.interactive) return true;
return false;
});
});
const isActiveResumeTarget = useMemoizedFn(
({ appId, chatId }: { appId: string; chatId: string }) =>
activeAppIdRef.current === appId && activeChatIdRef.current === chatId
);
const getResumeUnavailablePlaceholderText = useMemoizedFn(() =>
t('chat:resume_placeholder_generating')
);
const upsertResumeAiPlaceholder = useMemoizedFn(
(
responseChatId: string,
text = '',
status: `${ChatStatusEnum}` = ChatStatusEnum.loading,
options?: { resetExistingValue?: boolean }
) => {
setChatRecords((state) => {
const lastItem = state[state.length - 1];
if (lastItem?.dataId === responseChatId && lastItem.obj === ChatRoleEnum.AI) {
const shouldReplaceValue = shouldReplaceResumeAiValue({
hasExistingAiOutput: hasMeaningfulAiOutput(lastItem),
text,
resetExistingValue: options?.resetExistingValue
});
if (!shouldReplaceValue && lastItem.status === status) {
return state;
}
return state.map((item, index) =>
index !== state.length - 1
? item
: {
...item,
...(shouldReplaceValue
? {
value: [
{
text: {
content: text
}
}
],
responseData: options?.resetExistingValue ? [] : item.responseData
}
: {}),
status,
...(status === ChatStatusEnum.finish ? { time: new Date() } : {})
}
);
}
return [
...state,
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: text
}
}
],
status,
...(status === ChatStatusEnum.finish ? { time: new Date() } : {})
}
];
});
}
);
/**
* user confirm send prompt
*/
const sendPrompt = useMemoizedFn<SendPromptFnType>(
({
text = '',
files = [],
history = chatRecords,
interactive,
autoTTSResponse = false,
hideInUI = false
}) => {
variablesForm.handleSubmit(
async ({ variables = {} }) => {
if (!onStartChat) return;
if (isRoundPending) {
!hideInUI &&
toast({
title: t('chat:is_chatting'),
status: 'warning'
});
return;
}
// Abort the previous request
questionGuideController.current?.abort(new Error('stop'));
text = text.trim();
if (!text && files.length === 0) {
toast({
title: t('chat:content_empty'),
status: 'warning'
});
return;
}
// Only declared variables are kept
const requestVariables: Record<string, any> = {};
variableList?.forEach((item) => {
let val =
variables[item.key] === '' ||
variables[item.key] === undefined ||
variables[item.key] === null
? item.defaultValue
: variables[item.key];
if (item.type === VariableInputEnum.timePointSelect && val) {
val = formatTime2YMDHMS(new Date(val));
} else if (item.type === VariableInputEnum.timeRangeSelect && val) {
val = val.map((item: string) => (item ? formatTime2YMDHMS(new Date(item)) : ''));
}
requestVariables[item.key] = valueTypeFormat(val, item.valueType);
});
const humanChatId = getNanoid(24);
const responseChatId = getNanoid(24);
// set auto audio playing
if (autoTTSResponse) {
await startSegmentedAudio();
setAudioPlayingChatId(responseChatId);
}
const currentHumanChat: HumanChatSiteItemType = {
id: humanChatId,
dataId: humanChatId,
obj: ChatRoleEnum.Human,
time: new Date(),
hideInUI,
value: [
...files.map((file) => ({
file: {
type: file.type,
name: file.name,
url: file.url,
key: file.key || ''
}
})),
...(text
? [
{
text: {
content: text
}
}
]
: [])
] as UserChatItemValueItemType[],
status: ChatStatusEnum.finish
};
const newChatList: ChatSiteItemType[] = [
...history,
currentHumanChat,
{
id: responseChatId,
dataId: responseChatId,
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: ''
}
}
],
status: ChatStatusEnum.loading
}
];
const temporaryHistoryTitle = getChatTitleFromChatMessage(currentHumanChat);
resumedChatTargetRef.current = `${appId}:${chatId}`;
setChatBoxData((state) =>
state.chatId === chatId
? {
...state,
title: temporaryHistoryTitle,
chatGenerateStatus: ChatGenerateStatusEnum.generating,
hasBeenRead: false
}
: state
);
syncSidebarChatGenerateStatus(ChatGenerateStatusEnum.generating, {
hasBeenRead: false,
title: temporaryHistoryTitle
});
// Update histories(Interactive input does not require new session rounds)
setChatRecords(
interactive
? // 把交互的结果存储到对话记录中,交互模式下,不需要新的会话轮次
rewriteHistoriesByInteractiveResponse({
histories: newChatList,
interactive,
interactiveVal: text
})
: newChatList
);
// 清空输入内容
resetInputVal({});
setQuestionGuide([]);
scrollToBottom('smooth', 100);
const abortSignal = new AbortController();
try {
// create abort obj
chatController.current = abortSignal;
// 这里,无论是否为交互模式,都要确保最后一条消息都是 Human 的消息。
const messages = chats2GPTMessages({
messages: newChatList.slice(0, -1).map((item) => {
if (item.obj === ChatRoleEnum.Human) {
return {
...item,
value: stripChatValueFileUrls(item.value)
};
}
return item;
}),
reserveId: true,
reserveTool: true
});
const { responseText } = await onStartChat({
messages,
responseChatItemId: responseChatId,
controller: abortSignal,
generatingMessage: (e) => generatingMessage({ ...e, autoTTSResponse }),
variables: requestVariables
});
// Set last chat finish status
let newChatHistories: ChatSiteItemType[] = [];
setChatRecords((state) => {
newChatHistories = state.map((item, index) => {
if (index !== state.length - 1) return item;
const responseData = mergeChatResponseData(item.responseData || []);
// Check node response error
if (!abortSignal?.signal?.aborted) {
// `.error` is dispatcher-injected only on uncaught failures — scan all items
// so uncaught errors in nested/mid-workflow nodes still surface. Last-entry
// `.errorText` covers the misconfigured "catchError=true, no handler wired"
// case where only errorText was set.
const uncaughtErr = responseData.find((r) => r.error)?.error;
const err = uncaughtErr ?? responseData[responseData.length - 1]?.errorText;
if (err) {
toast({
title: t(getErrText(err)),
status: 'warning'
});
}
}
return {
...item,
status: ChatStatusEnum.finish,
time: new Date(),
responseData
};
});
const { interactive: lastInteractive } = getInteractiveByHistories(state);
if (lastInteractive?.type === 'paymentPause' && !lastInteractive.params.continue) {
setNotSufficientModalType(TeamErrEnum.aiPointsNotEnough);
}
return newChatHistories;
});
setTimeout(() => {
// If there is no interactive mode, create a question guide
if (!getInteractiveByHistories(newChatHistories).interactive) {
createQuestionGuide();
}
generatingScroll(true);
isPc && TextareaDom.current?.focus();
}, 100);
// tts audio
autoTTSResponse && splitText2Audio(responseText, true);
const finishedInActiveChat = activeChatIdRef.current === chatId;
finishChatGenerateStatus({
status: ChatGenerateStatusEnum.done,
finishedInActiveChat,
shouldUpdateChatBoxData: (state) => state.chatId === chatId
});
} catch (err: any) {
if (isAbortByLeave(err)) {
return;
}
toast({
title: t(getErrText(err, t('common:core.chat.error.Chat error') as any)),
status: 'error',
duration: 5000,
isClosable: true
});
// set finish status
setChatRecords((state) =>
state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
time: new Date(),
status: ChatStatusEnum.finish
};
})
);
if (!err?.responseText) {
resetInputVal({ text, files });
// 这里的 newChatList 没包含用户交互输入的内容,所以重置后刚好是正确的。
setChatRecords(newChatList.slice(0, newChatList.length - 2));
}
const finishedInActiveChat = activeChatIdRef.current === chatId;
finishChatGenerateStatus({
status: ChatGenerateStatusEnum.error,
finishedInActiveChat,
shouldUpdateChatBoxData: (state) => state.chatId === chatId
});
}
autoTTSResponse && finishSegmentedAudio();
},
() => {}
)();
}
);
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) => {
if (onDeleteChatItem) {
return onDeleteChatItem(contentId, delFile);
}
return delChatRecordById({
appId,
chatId,
contentId,
delFile,
...outLinkAuthData
});
},
[appId, chatId, outLinkAuthData, onDeleteChatItem]
);
const retryInput = useMemoizedFn((dataId?: string) => {
if (!dataId || !onDelMessage) return;
return async () => {
setIsLoading(true);
const index = chatRecords.findIndex((item) => item.dataId === dataId);
const delHistory = chatRecords.slice(index);
try {
await Promise.all(
delHistory.map((item) => {
if (item.dataId) {
return onDelMessage(item.dataId, false);
}
})
);
setChatRecords((state) => (index === 0 ? [] : state.slice(0, index)));
sendPrompt({
...formatChatValue2InputType(delHistory[0].value),
history: chatRecords.slice(0, index)
});
} catch (error) {
toast({
status: 'warning',
title: getErrText(error, 'Retry failed')
});
}
setIsLoading(false);
};
});
// delete one message(One human and the ai response)
const delOneMessage = useMemoizedFn((dataId: string) => {
return () => {
setChatRecords((state) => {
let aiIndex = -1;
return state.filter((chat, i) => {
if (chat.dataId === dataId) {
aiIndex = i + 1;
onDelMessage(dataId);
return false;
} else if (aiIndex === i && chat.obj === ChatRoleEnum.AI && chat.dataId) {
onDelMessage(chat.dataId);
return false;
}
return true;
});
});
};
});
// admin mark
const onMark = useMemoizedFn((chat: ChatSiteItemType, q = '') => {
if (!showMarkIcon || chat.obj !== ChatRoleEnum.AI) return;
return () => {
if (!chat.dataId) return;
if (chat.adminFeedback) {
setAdminMarkData({
dataId: chat.dataId,
datasetId: chat.adminFeedback.datasetId,
collectionId: chat.adminFeedback.collectionId,
feedbackDataId: chat.adminFeedback.feedbackDataId,
q: chat.adminFeedback.q || q || '',
a: chat.adminFeedback.a
});
} else {
setAdminMarkData({
dataId: chat.dataId,
q,
a: formatChatValue2InputType(chat.value).text
});
}
};
});
const onAddUserLike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userBadFeedback
)
return;
return () => {
if (!chat.dataId || !chatId || !appId) return;
const isGoodFeedback = !!chat.userGoodFeedback;
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === chat.dataId
? {
...chatItem,
userGoodFeedback: isGoodFeedback ? undefined : 'yes'
}
: chatItem
)
);
try {
updateChatUserFeedback({
appId,
chatId,
dataId: chat.dataId,
userGoodFeedback: isGoodFeedback ? undefined : 'yes',
...outLinkAuthData
});
} catch (error) {}
};
});
const onAddUserDislike = useMemoizedFn((chat: ChatSiteItemType) => {
if (
feedbackType !== FeedbackTypeEnum.user ||
chat.obj !== ChatRoleEnum.AI ||
chat.userGoodFeedback
) {
return;
} }
if (chat.userBadFeedback) { }
return () => { return undefined;
if (!chat.dataId || !chatId || !appId) return; }, [chatRecords]);
setChatRecords((state) =>
state.map((chatItem) => // Workflow running, there are user input or selection
chatItem.dataId === chat.dataId ? { ...chatItem, userBadFeedback: undefined } : chatItem const { interactive: lastInteractive, canSendQuery } = useMemo(
) () => getInteractiveByHistories(chatRecords),
[chatRecords]
); );
try {
updateChatUserFeedback({ const { chatForm, setValue, chatStarted, chatStartedWatch, resetInputVal } = useChatInputForm({
appId, appId,
chatId, chatId,
dataId: chat.dataId, chatBoxAppId: chatBoxData?.appId,
...outLinkAuthData chatRecordsLength: chatRecords.length,
}); chatType,
} catch (error) {} variableList,
}; TextareaDom
} else {
return () => setFeedbackId(chat.dataId);
}
}); });
const onCloseCustomFeedback = useMemoizedFn((chat: ChatSiteItemType, i: number) => { const createQuestionGuide = useQuestionGuide({
return (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked && appId && chatId && chat.dataId) {
closeCustomFeedback({
appId, appId,
chatId, chatId,
dataId: chat.dataId, questionGuide,
index: i outLinkAuthData,
}); chatControllerRef: chatController,
// update dom questionGuideControllerRef: questionGuideController,
setChatRecords((state) => setQuestionGuide,
state.map((chatItem) => scrollToBottom
chatItem.obj === ChatRoleEnum.AI && chatItem.dataId === chat.dataId
? {
...chatItem,
customFeedbacks: chatItem.customFeedbacks?.filter((_, index) => index !== i)
}
: chatItem
)
);
}
};
}); });
const onToggleFeedbackReadStatus = useMemoizedFn((chat: ChatSiteItemType) => {
if (chatType !== ChatTypeEnum.log || chat.obj !== ChatRoleEnum.AI) return;
return async () => {
if (!appId || !chatId || !chat.dataId) return;
const newReadStatus = !chat.isFeedbackRead; const { abortRequest, generatingMessage, sendPrompt } = useChatGenerate({
onStartChat,
try { isRoundPending,
await updateFeedbackReadStatus({ chatControllerRef: chatController,
appId, questionGuideControllerRef: questionGuideController,
chatId, pluginControllerRef: pluginController,
dataId: chat.dataId, resumeControllerRef: resumeController,
isRead: newReadStatus resumedChatTargetRef,
activeChatIdRef,
TextareaDom,
resetInputVal,
setQuestionGuide,
createQuestionGuide,
scrollToBottom,
generatingScroll,
syncSidebarChatGenerateStatus,
finishChatGenerateStatus
}); });
setChatRecords((state) => const handleStopSettled = useMemoizedFn((status: ChatGenerateStatusEnum, completed: boolean) => {
state.map((item) => const nextStatus = completed ? status : ChatGenerateStatusEnum.generating;
item.dataId === chat.dataId setChatBoxData((state) =>
state.chatId === chatId && state.appId === appId
? { ? {
...item, ...state,
isFeedbackRead: newReadStatus chatGenerateStatus: nextStatus,
hasBeenRead: false
} }
: item : state
)
); );
syncSidebarChatGenerateStatus(nextStatus, { hasBeenRead: false });
});
onTriggerRefresh?.(); const { isRecordActionLoading, retryInput, delOneMessage } = useChatRecordActions({
} catch (error) {} sendPrompt,
}; onDeleteChatItem
});
const {
feedbackId,
setFeedbackId,
adminMarkData,
setAdminMarkData,
onMark,
onAddUserLike,
onAddUserDislike,
onCloseCustomFeedback,
onToggleFeedbackReadStatus,
onFeedbackSuccess,
onAdminMarkSuccess
} = useChatFeedbackActions({
feedbackType,
showMarkIcon,
chatType,
onTriggerRefresh
}); });
const statusBoxData = useCreation(() => { const statusBoxData = useCreation(() => {
...@@ -1428,6 +333,8 @@ const ChatBox = ({ ...@@ -1428,6 +333,8 @@ const ChatBox = ({
// page change and abort request // page change and abort request
useEffect(() => { useEffect(() => {
// Reset local UI state when switching chats.
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset on chat switch
setQuestionGuide([]); setQuestionGuide([]);
setValue('chatStarted', false); setValue('chatStarted', false);
resumedChatTargetRef.current = undefined; resumedChatTargetRef.current = undefined;
...@@ -1453,247 +360,18 @@ const ChatBox = ({ ...@@ -1453,247 +360,18 @@ const ChatBox = ({
scrollToBottom('auto'); scrollToBottom('auto');
}, [chatScrollTargetKey, isChatRecordsLoaded, scrollToBottom]); }, [chatScrollTargetKey, isChatRecordsLoaded, scrollToBottom]);
useEffect(() => { useChatResume({
if (
!enableAutoResume ||
!isReady ||
!isChatRecordsLoaded ||
!appId ||
!chatId ||
isChatting ||
chatBoxData.appId !== appId ||
chatBoxData.chatId !== chatId ||
chatBoxData.chatGenerateStatus !== ChatGenerateStatusEnum.generating ||
resumedChatTargetRef.current === `${appId}:${chatId}`
) {
return;
}
resumedChatTargetRef.current = `${appId}:${chatId}`;
const resumeForAppId = appId;
const resumeForChatId = chatId;
const responseChatId = resumeTargetAiDataId ?? getNanoid(24);
const controller = new AbortController();
resumeController.current = controller;
scrollToBottom('auto');
scrollToBottom('auto', 100);
let resumeFinalStatus = ChatGenerateStatusEnum.done;
let hasPreparedResumeAiRecord = false;
let hasReceivedResumeOutput = false;
(async () => {
try {
const { responseText, completedChat, resumeUnavailable } = await streamResumeFetch({
appId,
chatId,
outLinkAuthData,
controller,
onResumeUnavailable: () => {
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
resumeFinalStatus = ChatGenerateStatusEnum.generating;
upsertResumeAiPlaceholder(
responseChatId,
getResumeUnavailablePlaceholderText(),
ChatStatusEnum.loading
);
},
onmessage: (message) => {
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
if (shouldCreateResumeAiPlaceholder(message.event)) {
upsertResumeAiPlaceholder(responseChatId, '', ChatStatusEnum.loading, {
resetExistingValue: shouldResetResumeAiPlaceholder({
hasPreparedResumeAiRecord,
hasReceivedResumeOutput
})
});
hasPreparedResumeAiRecord = true;
}
generatingMessage(message);
hasReceivedResumeOutput = true;
}
});
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
if (completedChat) {
resumeFinalStatus = completedChat.chatGenerateStatus;
setChatRecords((state) =>
mergeResumeCompletedChatRecords({
currentRecords: state,
completedRecords: completedChat.records.list.map((item) => ({
...item,
status: ChatStatusEnum.finish
})),
responseChatId
})
);
scrollToBottom('auto');
scrollToBottom('auto', 100);
return;
}
if (resumeUnavailable) {
resumeFinalStatus = ChatGenerateStatusEnum.generating;
upsertResumeAiPlaceholder(
responseChatId,
getResumeUnavailablePlaceholderText(),
ChatStatusEnum.loading
);
return;
}
setChatRecords((state) => {
const currentLastItem = state[state.length - 1];
if (
currentLastItem?.dataId !== responseChatId ||
currentLastItem.obj !== ChatRoleEnum.AI
) {
return state;
}
const next = state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: ChatStatusEnum.finish,
time: new Date(),
responseData: mergeChatResponseData(item.responseData || [])
};
});
const updatedLastItem = next[next.length - 1];
if (
updatedLastItem?.dataId === responseChatId &&
!hasMeaningfulAiOutput(updatedLastItem) &&
!responseText
) {
return next.slice(0, -1);
}
return next;
});
scrollToBottom('auto');
} catch (error) {
if (controller.signal.aborted) return;
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
const isStreamError = (error as ResumeStreamErrorType | undefined)?.isStreamError === true;
resumeFinalStatus = isStreamError
? ChatGenerateStatusEnum.error
: ChatGenerateStatusEnum.done;
setChatRecords((state) => {
const currentLastItem = state[state.length - 1];
if (
currentLastItem?.dataId !== responseChatId ||
currentLastItem.obj !== ChatRoleEnum.AI
) {
return state;
}
const next = state.map((item, index) => {
if (index !== state.length - 1) return item;
return {
...item,
status: ChatStatusEnum.finish,
time: new Date()
};
});
const updatedLastItem = next[next.length - 1];
if (
updatedLastItem?.dataId === responseChatId &&
!hasMeaningfulAiOutput(updatedLastItem)
) {
return next.slice(0, -1);
}
return next;
});
scrollToBottom('auto');
if (isStreamError) {
toast({
title: t(getErrText(error, t('common:core.chat.error.Chat error') as any)),
status: 'error',
duration: 5000,
isClosable: true
});
}
} finally {
if (resumeController.current === controller) {
resumeController.current = undefined;
}
const finishedInActiveChat = isActiveResumeTarget({
appId: resumeForAppId,
chatId: resumeForChatId
});
const leftWhileResuming =
controller.signal.aborted && isAbortByLeave(controller.signal.reason);
if (leftWhileResuming) {
return;
}
setChatBoxData((state) =>
state.appId === resumeForAppId && state.chatId === resumeForChatId
? {
...state,
chatGenerateStatus: resumeFinalStatus,
hasBeenRead: finishedInActiveChat
}
: state
);
if (finishedInActiveChat) {
void postMarkChatRead({
appId: resumeForAppId,
chatId: resumeForChatId,
...outLinkAuthData
})
.catch(() => {})
.finally(() => {
syncSidebarChatGenerateStatus(resumeFinalStatus, {
targetAppId: resumeForAppId,
hasBeenRead: true,
targetChatId: resumeForChatId
});
});
} else {
syncSidebarChatGenerateStatus(resumeFinalStatus, {
targetAppId: resumeForAppId,
hasBeenRead: false,
targetChatId: resumeForChatId
});
}
}
})();
}, [
enableAutoResume, enableAutoResume,
isReady, isReady,
isChatRecordsLoaded,
appId,
chatId,
isChatting,
chatBoxData.appId,
chatBoxData.chatId,
chatBoxData.chatGenerateStatus,
generatingMessage,
hasMeaningfulAiOutput,
isActiveResumeTarget,
getResumeUnavailablePlaceholderText,
outLinkAuthData,
resumeTargetAiDataId, resumeTargetAiDataId,
activeAppIdRef,
activeChatIdRef,
resumedChatTargetRef,
resumeControllerRef: resumeController,
generatingMessage,
scrollToBottom, scrollToBottom,
setChatBoxData, finishChatGenerateStatus
setChatRecords, });
finishChatGenerateStatus,
syncSidebarChatGenerateStatus,
upsertResumeAiPlaceholder,
t,
toast
]);
const canRenderChatInput = onStartChat && chatStarted && active && canSendQuery; const canRenderChatInput = onStartChat && chatStarted && active && canSendQuery;
const canSendPrompt = canRenderChatInput && !isRoundPending; const canSendPrompt = canRenderChatInput && !isRoundPending;
...@@ -1774,34 +452,7 @@ const ChatBox = ({ ...@@ -1774,34 +452,7 @@ const ChatBox = ({
} }
})); }));
// Visibility check useVariableInputVisibility({ ScrollContainerRef, setIsVariableVisible });
useEffect(() => {
const checkVariableVisibility = () => {
if (!ScrollContainerRef.current) return;
const container = ScrollContainerRef.current;
const variableInput = container.querySelector('#variable-input');
if (!variableInput) return;
const containerRect = container.getBoundingClientRect();
const elementRect = variableInput.getBoundingClientRect();
if (elementRect.height === 0) return;
setIsVariableVisible(
containerRect.top < elementRect.bottom && containerRect.bottom > elementRect.top
);
};
const container = ScrollContainerRef.current;
if (container) {
checkVariableVisibility();
container.addEventListener('scroll', checkVariableVisibility);
return () => {
container.removeEventListener('scroll', checkVariableVisibility);
};
}
}, [ScrollContainerRef, setIsVariableVisible]);
// Home chat, and no chat records // Home chat, and no chat records
const isHomeRender = useMemo(() => { const isHomeRender = useMemo(() => {
...@@ -1827,240 +478,50 @@ const ChatBox = ({ ...@@ -1827,240 +478,50 @@ const ChatBox = ({
}, []); }, []);
// 预处理聊天记录:Log 模式下扩展 chatRecords,添加折叠信息 // 预处理聊天记录:Log 模式下扩展 chatRecords,添加折叠信息
const processedRecords = useMemoEnhance(() => { const processedRecords = useMemoEnhance(
// 非 Log 类型:直接返回原始数据 () => getProcessedChatRecords({ chatType, chatRecords, expandedDeletedGroups }),
if (chatType !== ChatTypeEnum.log) { [chatType, chatRecords, expandedDeletedGroups]
return chatRecords; );
} const recordsListProps: ChatRecordsListProps = useMemo(
() => ({
// Log 类型:一次遍历完成删除分组信息计算和 chatRecords 扩展 records: processedRecords,
const result: ChatSiteItemType[] = []; expandedDeletedGroups,
let currentGroup: { itemRefs,
items: ChatSiteItemType[]; userAvatar,
dataIds: string[]; appAvatar,
} | null = null;
chatRecords.forEach((item, index) => {
const isDeleted = !!item.deleteTime;
const prevIsDeleted = index > 0 ? !!chatRecords[index - 1].deleteTime : false;
const nextIsDeleted =
index < chatRecords.length - 1 ? !!chatRecords[index + 1].deleteTime : false;
if (isDeleted && !prevIsDeleted) {
// 开始新的删除组
currentGroup = {
items: [],
dataIds: []
};
}
if (currentGroup && isDeleted) {
// 收集当前删除组的 item 和 dataId
currentGroup.items.push(item);
currentGroup.dataIds.push(item.dataId);
}
if (currentGroup && (!nextIsDeleted || index === chatRecords.length - 1)) {
// 删除组结束,处理整组
const isExpanded = currentGroup.dataIds.every((id) => expandedDeletedGroups.has(id));
const count = currentGroup.dataIds.length;
currentGroup.items.forEach((groupItem, groupIndex) => {
const extendedItem: ChatSiteItemType = { ...groupItem };
// 第一个 item 添加 collapseTop
if (groupIndex === 0) {
extendedItem.collapseTop = {
count,
dataIds: currentGroup!.dataIds,
isExpanded
};
}
// 最后一个 item 添加 collapseBottom
if (groupIndex === currentGroup!.items.length - 1) {
extendedItem.collapseBottom = {
count,
dataIds: currentGroup!.dataIds,
isExpanded
};
}
result.push(extendedItem);
});
currentGroup = null;
} else if (!isDeleted) {
// 非删除 item,直接添加
result.push(item);
}
});
return result;
}, [chatType, chatRecords, expandedDeletedGroups]);
//chat history
const RecordsBox = useMemo(() => {
return (
<Box id={'history'}>
{processedRecords.map((item, index) => {
// 判断是否应该渲染:非删除 或 dataId 在展开集合中
const shouldRender = !item.deleteTime || expandedDeletedGroups.has(item.dataId);
return (
<Box key={item.dataId}>
{/* 顶部折叠按钮:有 collapseTop 信息时显示 */}
{item.collapseTop && (
<DeletedItemsCollapse
count={item.collapseTop.count}
isExpanded={item.collapseTop.isExpanded}
onToggle={() => toggleDeletedGroup(item.collapseTop!.dataIds)}
position="top"
/>
)}
{/* 渲染消息:根据 shouldRender 判断 */}
{shouldRender && (
<Box
ref={(e) => {
itemRefs.current.set(item.dataId, e);
}}
>
{/* 时间间隔显示逻辑:相邻消息时间差超过10分钟则显示时间 */}
{index !== 0 &&
item.time &&
processedRecords[index - 1].time !== undefined &&
new Date(item.time).getTime() -
new Date(processedRecords[index - 1].time!).getTime() >
10 * 60 * 1000 && <TimeBox time={item.time} />}
<Box py={item.hideInUI ? 0 : 6}>
{item.obj === ChatRoleEnum.Human && !item.hideInUI && (
<ChatItem
avatar={userAvatar}
chat={item}
onRetry={retryInput(item.dataId)}
onDelete={delOneMessage(item.dataId)}
isLastChild={index === processedRecords.length - 1}
/>
)}
{item.obj === ChatRoleEnum.AI && (
<ChatItem
avatar={appAvatar}
chat={item}
isLastChild={index === processedRecords.length - 1}
{...{
showVoiceIcon, showVoiceIcon,
showMarkIcon,
statusBoxData, statusBoxData,
questionGuides, questionGuides,
onMark: onMark( onToggleDeletedGroup: toggleDeletedGroup,
item, onRetry: retryInput,
formatChatValue2InputType(processedRecords[index - 1]?.value)?.text onDelete: delOneMessage,
), onMark,
onAddUserLike: onAddUserLike(item), onAddUserLike,
onAddUserDislike: onAddUserDislike(item), onAddUserDislike,
onToggleFeedbackReadStatus: onToggleFeedbackReadStatus(item) onCloseCustomFeedback,
}} onToggleFeedbackReadStatus
> }),
{item.customFeedbacks && item.customFeedbacks.length > 0 && ( [
<Box>
<ChatBoxDivider
icon={'core/app/customFeedback'}
text={t('common:core.app.feedback.Custom feedback')}
/>
{item.customFeedbacks.map((text, i) => (
<Box key={i}>
<MyTooltip
label={t('common:core.app.feedback.close custom feedback')}
>
<Checkbox
onChange={onCloseCustomFeedback(item, i)}
icon={<MyIcon name={'common/check'} w={'12px'} />}
>
{text}
</Checkbox>
</MyTooltip>
</Box>
))}
</Box>
)}
{showMarkIcon && item.adminFeedback && (
<Box fontSize={'sm'}>
<ChatBoxDivider
icon="core/app/markLight"
text={t('common:core.chat.Admin Mark Content')}
/>
<Box whiteSpace={'pre-wrap'}>
<Box color={'black'}>{item.adminFeedback.q}</Box>
<Box color={'myGray.600'}>{item.adminFeedback.a}</Box>
</Box>
</Box>
)}
</ChatItem>
)}
</Box>
</Box>
)}
{/* 底部折叠按钮:有 collapseBottom 信息且展开时显示 */}
{item.collapseBottom && item.collapseBottom.isExpanded && (
<DeletedItemsCollapse
count={item.collapseBottom.count}
isExpanded={item.collapseBottom.isExpanded}
onToggle={() => toggleDeletedGroup(item.collapseBottom!.dataIds)}
position="bottom"
/>
)}
</Box>
);
})}
</Box>
);
}, [
processedRecords, processedRecords,
expandedDeletedGroups, expandedDeletedGroups,
toggleDeletedGroup,
itemRefs, itemRefs,
userAvatar, userAvatar,
appAvatar, appAvatar,
showVoiceIcon, showVoiceIcon,
showMarkIcon,
statusBoxData, statusBoxData,
questionGuides, questionGuides,
showMarkIcon, toggleDeletedGroup,
t,
retryInput, retryInput,
delOneMessage, delOneMessage,
onMark, onMark,
onAddUserLike, onAddUserLike,
onAddUserDislike, onAddUserDislike,
onToggleFeedbackReadStatus, onCloseCustomFeedback,
onCloseCustomFeedback onToggleFeedbackReadStatus
]); ]
// Child box
const AppChatRenderBox = useMemo(() => {
return (
<ScrollData
ScrollContainerRef={ScrollContainerRef}
flex={'1 0 0'}
h={0}
w={'100%'}
overflow={'overlay'}
px={[4, 0]}
pb={6}
>
<Box maxW={['100%', '92%']} h={'100%'} mx={'auto'}>
{!!welcomeText && <WelcomeBox welcomeText={welcomeText} />}
{/* variable input */}
<Box id="variable-input">
<VariableInputForm chatStarted={chatStarted} chatForm={chatForm} chatType={chatType} />
</Box>
{RecordsBox}
</Box>
</ScrollData>
); );
}, [ScrollData, welcomeText, chatStarted, chatForm, chatType, RecordsBox]);
const HomeChatRenderBox = useMemo(() => { const HomeChatRenderBox = useMemo(() => {
return ( return (
<> <>
...@@ -2075,7 +536,7 @@ const ChatBox = ({ ...@@ -2075,7 +536,7 @@ const ChatBox = ({
return ( return (
<MyBox <MyBox
isLoading={isLoading} isLoading={isRecordActionLoading}
display={'flex'} display={'flex'}
flexDirection={'column'} flexDirection={'column'}
h={'100%'} h={'100%'}
...@@ -2115,7 +576,15 @@ const ChatBox = ({ ...@@ -2115,7 +576,15 @@ const ChatBox = ({
</MyBox> </MyBox>
) : ( ) : (
<> <>
{AppChatRenderBox} <AppChatMain
ScrollData={ScrollData}
ScrollContainerRef={ScrollContainerRef}
welcomeText={welcomeText}
chatStarted={chatStarted}
chatForm={chatForm}
chatType={chatType}
recordsListProps={recordsListProps}
/>
{canRenderChatInput && ( {canRenderChatInput && (
<Box <Box
px={[3, 5]} px={[3, 5]}
...@@ -2141,52 +610,17 @@ const ChatBox = ({ ...@@ -2141,52 +610,17 @@ const ChatBox = ({
</> </>
)} )}
{/* user feedback modal */} <ChatBoxModals
{!!feedbackId && chatId && (
<FeedbackModal
appId={appId} appId={appId}
chatId={chatId} chatId={chatId}
dataId={feedbackId} feedbackId={feedbackId}
onClose={() => setFeedbackId(undefined)}
onSuccess={(content: string) => {
setChatRecords((state) =>
state.map((item) =>
item.dataId === feedbackId ? { ...item, userBadFeedback: content } : item
)
);
setFeedbackId(undefined);
}}
/>
)}
{/* admin mark data */}
{!!adminMarkData && (
<SelectMarkCollection
adminMarkData={adminMarkData} adminMarkData={adminMarkData}
setAdminMarkData={(e) => setAdminMarkData({ ...e, dataId: adminMarkData.dataId })} onCloseFeedback={() => setFeedbackId(undefined)}
onClose={() => setAdminMarkData(undefined)} onFeedbackSuccess={onFeedbackSuccess}
onSuccess={(adminFeedback) => { onCloseAdminMark={() => setAdminMarkData(undefined)}
if (!appId || !chatId || !adminMarkData.dataId) return; onAdminMarkChange={setAdminMarkData}
updateChatAdminFeedback({ onAdminMarkSuccess={onAdminMarkSuccess}
appId,
chatId,
dataId: adminMarkData.dataId,
...adminFeedback
});
// update dom
setChatRecords((state) =>
state.map((chatItem) =>
chatItem.dataId === adminMarkData.dataId
? {
...chatItem,
adminFeedback
}
: chatItem
)
);
}}
/> />
)}
</MyBox> </MyBox>
); );
}; };
......
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 {
ChatFileTypeEnum,
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,
mergeChatResponseData
} from '@fastgpt/global/core/chat/utils';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { normalizeFormInputResultFile } from '../../components/FormInputResult';
export const getUploadChatFileType = (file: File) => {
if (file.type.includes('image')) return ChatFileTypeEnum.image;
if (file.type.includes('audio')) return ChatFileTypeEnum.audio;
if (file.type.includes('video')) return ChatFileTypeEnum.video;
return ChatFileTypeEnum.file;
};
export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): ChatBoxInputType => {
if (!value) {
return { text: '', files: [] };
}
if (!Array.isArray(value)) {
console.error('value is error', value);
return { text: '', files: [] };
}
const text = value
.filter((item) => item.text?.content)
.map((item) => item.text?.content || '')
.join('');
const files =
(value
?.map((item) =>
'file' in item && item.file
? {
id: item.file.url,
type: item.file.type,
name: item.file.name,
icon: getFileIcon(item.file.name),
url: item.file.url,
key: item.file.key
}
: undefined
)
.filter(Boolean) as UserInputFileItemType[]) || [];
return {
text,
files
};
};
export const stripChatValueFileUrls = (value: UserChatItemValueItemType[] = []) =>
value.map((item) => {
if ('file' in item && item.file?.key) {
return {
...item,
file: {
...item.file,
url: ''
}
};
}
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[]
): {
interactive: WorkflowInteractiveResponseType | undefined;
canSendQuery: boolean;
} => {
const lastInreactive = getLastInteractiveValue(chatHistories);
if (!lastInreactive) {
return {
interactive: undefined,
canSendQuery: true
};
}
const finalInteractive = extractDeepestInteractive(lastInreactive);
// 如果用户选择了,则不认为是交互模式(可能是上一轮以交互结尾,发起的新的一轮对话)
if (finalInteractive.type === 'userSelect' && !finalInteractive.params.userSelectedVal) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'userInput' && !finalInteractive.params.submitted) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'paymentPause' && !finalInteractive.params.continue) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'agentPlanAskQuery') {
return {
interactive: finalInteractive,
canSendQuery: true
};
}
return {
interactive: undefined,
canSendQuery: true
};
};
export const rewriteHistoriesByInteractiveResponse = ({
histories,
interactiveVal,
interactive
}: {
histories: ChatSiteItemType[];
interactiveVal: string;
interactive: WorkflowInteractiveResponseType;
}): ChatSiteItemType[] => {
const status = checkInteractiveResponseStatus({
interactive,
input: interactiveVal
});
const formatHistories = (() => {
if (status === 'query') {
return histories;
}
return histories.slice(0, -2);
})();
const newHistories = formatHistories.map((item, i) => {
if (i !== formatHistories.length - 1) return item;
const value = item.value.map((val, i) => {
if (i !== item.value.length - 1) {
return val;
}
if (!('interactive' in val) || !val.interactive) return val;
const finalInteractive = extractDeepestInteractive(val.interactive);
if (finalInteractive.type === 'userSelect') {
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
userSelectedVal: finalInteractive.params.userSelectOptions.find(
(item) => item.value === interactiveVal
)?.value
}
}
};
}
if (finalInteractive.type === 'userInput') {
const submittedData: Record<string, any> = (() => {
try {
return JSON.parse(interactiveVal);
} catch (error) {
return {};
}
})();
// 更新 inputForm 中的 value
const updatedInputForm = finalInteractive.params.inputForm.map((item) => ({
...item,
value: submittedData[item.key] ?? item.value
}));
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
inputForm: updatedInputForm,
submitted: true
}
}
};
}
if (finalInteractive.type === 'paymentPause') {
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
continue: true
}
}
};
}
return val;
});
return {
...item,
status: ChatStatusEnum.loading,
value
} as ChatSiteItemType;
});
return newHistories;
};
import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatSiteItemType } from './type'; import type { ChatSiteItemType } from '../type';
type ChatRoundStatusItem = Pick<ChatSiteItemType, 'obj' | 'status'>; type ChatRoundStatusItem = Pick<ChatSiteItemType, 'obj' | 'status'>;
......
import type {
ChatItemValueItemType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import { type ChatBoxInputType, type UserInputFileItemType } from '../type';
import { getFileIcon } from '@fastgpt/global/common/file/icon';
export const formatChatValue2InputType = (value?: ChatItemValueItemType[]): ChatBoxInputType => {
if (!value) {
return { text: '', files: [] };
}
if (!Array.isArray(value)) {
console.error('value is error', value);
return { text: '', files: [] };
}
const text = value
.filter((item) => item.text?.content)
.map((item) => item.text?.content || '')
.join('');
const files =
(value
?.map((item) =>
'file' in item && item.file
? {
id: item.file.url,
type: item.file.type,
name: item.file.name,
icon: getFileIcon(item.file.name),
url: item.file.url,
key: item.file.key
}
: undefined
)
.filter(Boolean) as UserInputFileItemType[]) || [];
return {
text,
files
};
};
export const stripChatValueFileUrls = (value: UserChatItemValueItemType[] = []) =>
value.map((item) => {
if ('file' in item && item.file?.key) {
return {
...item,
file: {
...item.file,
url: ''
}
};
}
return item;
});
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
export const getUploadChatFileType = (file: File) => {
if (file.type.includes('image')) return ChatFileTypeEnum.image;
if (file.type.includes('audio')) return ChatFileTypeEnum.audio;
if (file.type.includes('video')) return ChatFileTypeEnum.video;
return ChatFileTypeEnum.file;
};
import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type';
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 { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { normalizeFormInputResultFile } from '../../../components/FormInputResult';
import type { ChatSiteItemType } from '../type';
/**
* 恢复流收到 `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;
};
// 用于判断当前对话框状态。如果是 child interactive,需要递归找到最深层交互。
export const getInteractiveByHistories = (
chatHistories: ChatSiteItemType[]
): {
interactive: WorkflowInteractiveResponseType | undefined;
canSendQuery: boolean;
} => {
const lastInreactive = getLastInteractiveValue(chatHistories);
if (!lastInreactive) {
return {
interactive: undefined,
canSendQuery: true
};
}
const finalInteractive = extractDeepestInteractive(lastInreactive);
// 如果用户已经完成选择,则不认为是交互模式,允许发起新一轮对话。
if (finalInteractive.type === 'userSelect' && !finalInteractive.params.userSelectedVal) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'userInput' && !finalInteractive.params.submitted) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'paymentPause' && !finalInteractive.params.continue) {
return {
interactive: finalInteractive,
canSendQuery: false
};
} else if (finalInteractive.type === 'agentPlanAskQuery') {
return {
interactive: finalInteractive,
canSendQuery: true
};
}
return {
interactive: undefined,
canSendQuery: true
};
};
export const rewriteHistoriesByInteractiveResponse = ({
histories,
interactiveVal,
interactive
}: {
histories: ChatSiteItemType[];
interactiveVal: string;
interactive: WorkflowInteractiveResponseType;
}): ChatSiteItemType[] => {
const status = checkInteractiveResponseStatus({
interactive,
input: interactiveVal
});
const formatHistories = (() => {
if (status === 'query') {
return histories;
}
return histories.slice(0, -2);
})();
const newHistories = formatHistories.map((item, i) => {
if (i !== formatHistories.length - 1) return item;
const value = item.value.map((val, i) => {
if (i !== item.value.length - 1) {
return val;
}
if (!('interactive' in val) || !val.interactive) return val;
const finalInteractive = extractDeepestInteractive(val.interactive);
if (finalInteractive.type === 'userSelect') {
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
userSelectedVal: finalInteractive.params.userSelectOptions.find(
(item) => item.value === interactiveVal
)?.value
}
}
};
}
if (finalInteractive.type === 'userInput') {
const submittedData: Record<string, any> = (() => {
try {
return JSON.parse(interactiveVal);
} catch {
return {};
}
})();
// 更新 inputForm 中的 value。
const updatedInputForm = finalInteractive.params.inputForm.map((item) => ({
...item,
value: submittedData[item.key] ?? item.value
}));
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
inputForm: updatedInputForm,
submitted: true
}
}
};
}
if (finalInteractive.type === 'paymentPause') {
return {
...val,
interactive: {
...finalInteractive,
params: {
...finalInteractive.params,
continue: true
}
}
};
}
return val;
});
return {
...item,
status: ChatStatusEnum.loading,
value
} as ChatSiteItemType;
});
return newHistories;
};
import { ChatTypeEnum } from '../constants';
import type { ChatSiteItemType } from '../type';
/**
* 为 log 模式的连续删除消息补充折叠元信息。
*
* 输入输出约定:
* - 非 log 模式不需要删除记录折叠,直接返回原 records 引用,避免无意义重算。
* - log 模式会把相邻的 `deleteTime` 消息视为一个删除组,并在组首补
* `collapseTop`、组尾补 `collapseBottom`,供列表渲染折叠开关。
* - 删除组内的消息会被浅拷贝,避免把折叠展示字段写回原始 `chatRecords`。
* - 非删除消息仍保持原对象引用,减少渲染层不必要的对象变化。
*
* 边界行为:
* - 单条删除消息会同时拥有 `collapseTop` 和 `collapseBottom`。
* - 只有删除组内所有 dataId 都在 `expandedDeletedGroups` 中时,该组才算展开。
* - 连续删除组可以出现在列表开头、结尾或中间,统一通过前后 deleteTime 判断边界。
*/
export const getProcessedChatRecords = ({
chatType,
chatRecords,
expandedDeletedGroups
}: {
chatType: ChatTypeEnum;
chatRecords: ChatSiteItemType[];
expandedDeletedGroups: Set<string>;
}): ChatSiteItemType[] => {
if (chatType !== ChatTypeEnum.log) {
return chatRecords;
}
const result: ChatSiteItemType[] = [];
let currentGroup: {
items: ChatSiteItemType[];
dataIds: string[];
} | null = null;
chatRecords.forEach((item, index) => {
const isDeleted = !!item.deleteTime;
const prevIsDeleted = index > 0 ? !!chatRecords[index - 1].deleteTime : false;
const nextIsDeleted =
index < chatRecords.length - 1 ? !!chatRecords[index + 1].deleteTime : false;
// 当前 deleted item 的前一条不是 deleted,说明进入了一个新的连续删除组。
if (isDeleted && !prevIsDeleted) {
currentGroup = {
items: [],
dataIds: []
};
}
// 删除组内保留原始 item,等组结束时统一浅拷贝并补充首尾折叠信息。
if (currentGroup && isDeleted) {
currentGroup.items.push(item);
currentGroup.dataIds.push(item.dataId);
}
// 当前 deleted item 后面不再是 deleted,或已经到列表末尾,说明删除组结束。
if (currentGroup && (!nextIsDeleted || index === chatRecords.length - 1)) {
const isExpanded = currentGroup.dataIds.every((id) => expandedDeletedGroups.has(id));
const count = currentGroup.dataIds.length;
currentGroup.items.forEach((groupItem, groupIndex) => {
const extendedItem: ChatSiteItemType = { ...groupItem };
// 组首渲染顶部折叠入口;单条删除消息也会命中该分支。
if (groupIndex === 0) {
extendedItem.collapseTop = {
count,
dataIds: currentGroup!.dataIds,
isExpanded
};
}
// 组尾渲染底部折叠入口;单条删除消息会同时拥有 top 和 bottom。
if (groupIndex === currentGroup!.items.length - 1) {
extendedItem.collapseBottom = {
count,
dataIds: currentGroup!.dataIds,
isExpanded
};
}
result.push(extendedItem);
});
currentGroup = null;
} else if (!isDeleted) {
// 非删除消息不参与折叠分组,保持原对象引用。
result.push(item);
}
});
return result;
};
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { valueTypeFormat } from '@fastgpt/global/core/workflow/runtime/utils';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
/**
* 将变量表单值转换为发送聊天请求时的 variables。
*
* 输入输出约定:
* - `variableList` 是唯一可信的变量声明来源,返回值只包含声明过的 key。
* - `variables` 是 react-hook-form 收集到的用户输入,可能包含空值或额外字段。
* - 空字符串、null、undefined 统一回退到变量配置里的 `defaultValue`。
* - 返回值会按变量的 `valueType` 走 `valueTypeFormat`,与 workflow runtime 期望的
* string/number/boolean/object/array 类型对齐。
*
* 时间变量的特殊处理:
* - timePointSelect 和 timeRangeSelect 在表单层可能是 Date 可解析值。
* - 发给 workflow 前要先转成 `YYYY-MM-DD HH:mm:ss` 字符串,再做 valueType 格式化。
* - timeRangeSelect 中的空字符串保留为空字符串,用于表达未选择的边界。
*/
export const formatChatRequestVariables = ({
variableList,
variables = {}
}: {
variableList?: VariableItemType[];
variables?: Record<string, any>;
}) => {
const requestVariables: Record<string, any> = {};
variableList?.forEach((item) => {
// 只处理变量配置声明过的 key;未声明字段不会进入 requestVariables。
let val =
variables[item.key] === '' ||
variables[item.key] === undefined ||
variables[item.key] === null
? item.defaultValue
: variables[item.key];
// 时间变量先统一成 workflow 可直接消费的本地时间字符串。
if (item.type === VariableInputEnum.timePointSelect && val) {
val = formatTime2YMDHMS(new Date(val));
} else if (item.type === VariableInputEnum.timeRangeSelect && val) {
val = val.map((item: string) => (item ? formatTime2YMDHMS(new Date(item)) : ''));
}
// 最后按 valueType 收敛类型,避免表单字符串直接进入 workflow runtime。
requestVariables[item.key] = valueTypeFormat(val, item.valueType);
});
return requestVariables;
};
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import type {
AIChatItemValueItemType,
ChatHistoryItemResType
} from '@fastgpt/global/core/chat/type';
import { mergeChatResponseData } from '@fastgpt/global/core/chat/utils';
import { extractDeepestInteractive } from '@fastgpt/global/core/workflow/runtime/utils';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { ChatSiteItemType } from '../type';
/**
* 判断恢复流中是否需要先补一个 AI placeholder。
*
* 恢复生成时,前端可能先拿到 SSE 增量事件,而历史记录中还没有本轮 AI 消息。
* 对于会产生可见回答内容、运行状态或响应详情的事件,需要先创建一个稳定的
* AI placeholder,后续 `generatingMessage` 才能继续沿用“更新最后一条 AI 消息”
* 的约束。
*
* 不在这里处理 error/updateVariables 等控制类事件:
* - error 只影响最终状态和 toast,不应该制造空 AI 气泡。
* - updateVariables 会回写变量,不代表有可展示的 AI 输出。
*
* 该函数必须和 `generatingMessage` 支持的可见 SSE 事件保持同步。
*/
export const shouldCreateResumeAiPlaceholder = (event: SseResponseEventEnum) => {
return [
SseResponseEventEnum.flowNodeResponse,
SseResponseEventEnum.flowNodeStatus,
SseResponseEventEnum.answer,
SseResponseEventEnum.fastAnswer,
SseResponseEventEnum.toolCall,
SseResponseEventEnum.toolParams,
SseResponseEventEnum.toolResponse,
SseResponseEventEnum.interactive,
SseResponseEventEnum.plan,
SseResponseEventEnum.planStatus,
SseResponseEventEnum.workflowDuration
].includes(event);
};
/**
* 判断 AI 消息是否已经包含可保留的输出。
*
* 恢复生成会提前插入 AI placeholder。恢复完成或失败后,如果这个 placeholder
* 没有任何可见内容或运行详情,就应当被移除,避免聊天列表残留空 AI 气泡。
*
* 可保留输出包括:
* - responseData:节点响应详情,即使没有文本也需要保留用于详情查看。
* - text/reasoning:直接展示给用户的回答或推理内容。
* - tool/tools:工具调用参数或响应,只有出现 params/response 才算有展示价值。
* - skills/plan/interactive:Agent 技能、计划和交互节点都会在 UI 中展示。
*
* 非 AI 消息永远返回 false,因为该函数只用于判断恢复生成产生的 AI placeholder。
*/
export const hasMeaningfulAiOutput = (chat?: ChatSiteItemType) => {
if (!chat || chat.obj !== ChatRoleEnum.AI) return false;
if (chat.responseData?.length) return true;
return chat.value.some((item) => {
// 文本和推理内容允许分块增量追加,只要已有内容就需要保留。
if (item.text?.content) return true;
if (item.reasoning?.content) return true;
// 兼容旧的单 tool 字段和新的 tools 数组字段。
if (item.tool?.params || item.tool?.response) return true;
if (item.tools?.some((tool) => tool.params || tool.response)) return true;
// 技能、计划和交互本身就是可见 UI 块,不要求额外文本。
if (item.skills?.length) return true;
if (item.plan || item.interactive) return true;
return false;
});
};
/**
* 判断是否应清空恢复流开始前的 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` 交互节点里经 `refreshSubmittedFormInteractiveValues` 回填的 `inputForm.value`。
*
* 合并策略:
* - `responseData`:仅对 `dataId === responseChatId` 的 AI 消息,把 current 中多出的节点响应追加进去(去重);
* - 交互值:优先按 `dataId` 匹配 current AI record;匹配不到时,从所有 current AI record 中
* 按 `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
};
});
};
/**
* 恢复流 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
);
};
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;
};
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]);
};
...@@ -17,7 +17,6 @@ import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai ...@@ -17,7 +17,6 @@ import type { AgentPlanStatusType, AgentPlanType } from '@fastgpt/global/core/ai
export type generatingMessageProps = { export type generatingMessageProps = {
event: SseResponseEventEnum; event: SseResponseEventEnum;
responseValueId?: string; responseValueId?: string;
stepId?: string;
text?: string; text?: string;
reasoningText?: string; reasoningText?: string;
......
import React from 'react'; import React from 'react';
import type { HelperBotChatItemSiteType } from '@fastgpt/global/core/chat/helperBot/type'; import { formatChatValue2InputType } from '../../ChatContainer/ChatBox/utils/chatValue';
import { formatChatValue2InputType } from '../../ChatContainer/ChatBox/utils'; import { Box, Flex } from '@chakra-ui/react';
import { Box, Card, Flex } from '@chakra-ui/react';
import Markdown from '@/components/Markdown';
import FileBlock from '../../ChatContainer/ChatBox/components/FilesBox'; import FileBlock from '../../ChatContainer/ChatBox/components/FilesBox';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import type { UserChatItemType } from '@fastgpt/global/core/chat/type'; import type { UserChatItemType } from '@fastgpt/global/core/chat/type';
import ChatAvatar from '@/components/core/chat/ChatContainer/ChatBox/components/ChatAvatar'; import ChatAvatar from '@/components/core/chat/ChatContainer/ChatBox/components/ChatAvatar';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
...@@ -15,8 +9,6 @@ import { useUserStore } from '@/web/support/user/useUserStore'; ...@@ -15,8 +9,6 @@ import { useUserStore } from '@/web/support/user/useUserStore';
import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils';
const HumanItem = ({ chat }: { chat: UserChatItemType }) => { const HumanItem = ({ chat }: { chat: UserChatItemType }) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
const { text, files = [] } = formatChatValue2InputType(chat.value); const { text, files = [] } = formatChatValue2InputType(chat.value);
const userInfo = useUserStore((state) => state.userInfo); const userInfo = useUserStore((state) => state.userInfo);
const humanAvatar = userInfo?.avatar || getWebReqUrl('/imgs/botClosed.svg'); const humanAvatar = userInfo?.avatar || getWebReqUrl('/imgs/botClosed.svg');
......
...@@ -17,7 +17,7 @@ import type { HelperBotTypeEnumType } from '@fastgpt/global/core/chat/helperBot/ ...@@ -17,7 +17,7 @@ import type { HelperBotTypeEnumType } from '@fastgpt/global/core/chat/helperBot/
import { getHelperBotFilePresign } from '../api'; import { getHelperBotFilePresign } from '../api';
import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { putFileToS3 } from '@fastgpt/web/common/file/utils'; import { putFileToS3 } from '@fastgpt/web/common/file/utils';
import { getUploadChatFileType } from '../../ChatContainer/ChatBox/utils'; import { getUploadChatFileType } from '../../ChatContainer/ChatBox/utils/file';
type UseFileUploadOptions = { type UseFileUploadOptions = {
fileSelectConfig?: AppFileSelectConfigType; fileSelectConfig?: AppFileSelectConfigType;
......
...@@ -63,7 +63,6 @@ const shouldSendStreamResumeHeader = (url: string) => ...@@ -63,7 +63,6 @@ const shouldSendStreamResumeHeader = (url: string) =>
type CommonResponseType = { type CommonResponseType = {
responseValueId?: string; responseValueId?: string;
stepId?: string;
}; };
type ResponseQueueItemType = CommonResponseType & type ResponseQueueItemType = CommonResponseType &
( (
...@@ -124,7 +123,7 @@ function handleEventSourceData(params: HandleEventSourceDataParams) { ...@@ -124,7 +123,7 @@ function handleEventSourceData(params: HandleEventSourceDataParams) {
const parsed: any = JSON.parse(data); const parsed: any = JSON.parse(data);
if (typeof parsed !== 'object') throw new Error('Invalid JSON'); if (typeof parsed !== 'object') throw new Error('Invalid JSON');
const { responseValueId, stepId, ...obj } = parsed; const { responseValueId, ...obj } = parsed;
switch (event) { switch (event) {
case SseResponseEventEnum.toolCall: case SseResponseEventEnum.toolCall:
...@@ -134,22 +133,22 @@ function handleEventSourceData(params: HandleEventSourceDataParams) { ...@@ -134,22 +133,22 @@ function handleEventSourceData(params: HandleEventSourceDataParams) {
case SseResponseEventEnum.plan: case SseResponseEventEnum.plan:
case SseResponseEventEnum.planStatus: case SseResponseEventEnum.planStatus:
case SseResponseEventEnum.skillCall: { case SseResponseEventEnum.skillCall: {
enqueue({ responseValueId, stepId, event, ...obj }); enqueue({ responseValueId, event, ...obj });
break; break;
} }
case SseResponseEventEnum.answer: { case SseResponseEventEnum.answer: {
const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || ''; const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || '';
enqueue({ responseValueId, stepId, event, reasoningText }); enqueue({ responseValueId, event, reasoningText });
const content = obj.choices?.[0]?.delta?.content || ''; const content = obj.choices?.[0]?.delta?.content || '';
if (splitAnswerTextByCharacter) { if (splitAnswerTextByCharacter) {
for (const item of content) { for (const item of content) {
enqueue({ responseValueId, stepId, event, text: item }); enqueue({ responseValueId, event, text: item });
} }
} else { } else {
enqueue({ responseValueId, stepId, event, text: content }); enqueue({ responseValueId, event, text: content });
} }
break; break;
...@@ -157,10 +156,10 @@ function handleEventSourceData(params: HandleEventSourceDataParams) { ...@@ -157,10 +156,10 @@ function handleEventSourceData(params: HandleEventSourceDataParams) {
case SseResponseEventEnum.fastAnswer: { case SseResponseEventEnum.fastAnswer: {
const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || ''; const reasoningText = obj.choices?.[0]?.delta?.reasoning_content || '';
enqueue({ responseValueId, stepId, event, reasoningText }); enqueue({ responseValueId, event, reasoningText });
const text = obj.choices?.[0]?.delta?.content || ''; const text = obj.choices?.[0]?.delta?.content || '';
enqueue({ responseValueId, stepId, event, text }); enqueue({ responseValueId, event, text });
break; break;
} }
......
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatGenerateStatusEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { isChatRoundPending } from '@/components/core/chat/ChatContainer/ChatBox/chatStatus'; import { isChatRoundPending } from '@/components/core/chat/ChatContainer/ChatBox/utils/chatStatus';
describe('isChatRoundPending', () => { describe('isChatRoundPending', () => {
it('returns true while the local chat is streaming', () => { it('returns true while the local chat is streaming', () => {
......
import { describe, expect, it, vi } from 'vitest';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import {
formatChatValue2InputType,
stripChatValueFileUrls
} from '@/components/core/chat/ChatContainer/ChatBox/utils/chatValue';
import type { ChatItemValueItemType } from '@fastgpt/global/core/chat/type';
describe('formatChatValue2InputType', () => {
it('joins text fragments and converts file values into input files', () => {
const value: ChatItemValueItemType[] = [
{
file: {
type: ChatFileTypeEnum.image,
name: 'image.png',
url: 'https://example.com/image.png',
key: 'chat/image.png'
}
},
{
text: {
content: 'hello '
}
},
{
text: {
content: 'FastGPT'
}
}
];
const result = formatChatValue2InputType(value);
expect(result.text).toBe('hello FastGPT');
expect(result.files).toHaveLength(1);
expect(result.files[0]).toMatchObject({
id: 'https://example.com/image.png',
type: ChatFileTypeEnum.image,
name: 'image.png',
url: 'https://example.com/image.png',
key: 'chat/image.png'
});
expect(result.files[0].icon).toBeTruthy();
});
it('returns an empty input for missing values', () => {
expect(formatChatValue2InputType()).toEqual({
text: '',
files: []
});
});
it('guards against non-array values', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(formatChatValue2InputType({ text: { content: 'bad' } } as any)).toEqual({
text: '',
files: []
});
expect(consoleSpy).toHaveBeenCalledWith('value is error', {
text: {
content: 'bad'
}
});
consoleSpy.mockRestore();
});
});
describe('stripChatValueFileUrls', () => {
it('removes signed urls only from keyed files before sending messages', () => {
const value: ChatItemValueItemType[] = [
{
file: {
type: ChatFileTypeEnum.image,
name: 'image.png',
key: 'chat/files/image.png',
url: 'https://preview.example.com/image.png'
}
},
{
file: {
type: ChatFileTypeEnum.file,
name: 'external.pdf',
url: 'https://external.example.com/external.pdf'
}
},
{
text: {
content: 'hello'
}
}
];
expect(stripChatValueFileUrls(value)).toEqual([
{
file: {
type: ChatFileTypeEnum.image,
name: 'image.png',
key: 'chat/files/image.png',
url: ''
}
},
{
file: {
type: ChatFileTypeEnum.file,
name: 'external.pdf',
url: 'https://external.example.com/external.pdf'
}
},
{
text: {
content: 'hello'
}
}
]);
expect(value[0].file.url).toBe('https://preview.example.com/image.png');
});
it('returns an empty array for missing values', () => {
expect(stripChatValueFileUrls()).toEqual([]);
});
});
import { describe, expect, it } from 'vitest';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import { getUploadChatFileType } from '@/components/core/chat/ChatContainer/ChatBox/utils/file';
describe('getUploadChatFileType', () => {
it('detects image, audio and video mime types', () => {
expect(getUploadChatFileType({ type: 'image/png' } as File)).toBe(ChatFileTypeEnum.image);
expect(getUploadChatFileType({ type: 'audio/mpeg' } as File)).toBe(ChatFileTypeEnum.audio);
expect(getUploadChatFileType({ type: 'video/mp4' } as File)).toBe(ChatFileTypeEnum.video);
});
it('falls back to common file type for unknown or empty mime types', () => {
expect(getUploadChatFileType({ type: 'application/pdf' } as File)).toBe(ChatFileTypeEnum.file);
expect(getUploadChatFileType({ type: '' } as File)).toBe(ChatFileTypeEnum.file);
});
});
import { describe, expect, it } from 'vitest';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { WorkflowIOValueTypeEnum } from '@fastgpt/global/core/workflow/constants';
import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import {
getInteractiveByHistories,
rewriteHistoriesByInteractiveResponse
} from '@/components/core/chat/ChatContainer/ChatBox/utils/interactive';
const baseInteractive = {
entryNodeIds: ['node-1'],
memoryEdges: [],
nodeOutputs: [],
usageId: 'usage-1'
};
const createAiRecord = (
interactive?: WorkflowInteractiveResponseType,
override: Partial<ChatSiteItemType> = {}
): ChatSiteItemType =>
({
id: override.id ?? 'ai-1',
dataId: override.dataId ?? 'ai-1',
obj: ChatRoleEnum.AI,
status: ChatStatusEnum.finish,
value: interactive
? [
{
interactive
}
]
: [
{
text: {
content: 'done'
}
}
],
...override
}) as ChatSiteItemType;
const createHumanRecord = (id = 'human-1'): ChatSiteItemType =>
({
id,
dataId: id,
obj: ChatRoleEnum.Human,
status: ChatStatusEnum.finish,
value: [
{
text: {
content: id
}
}
]
}) as ChatSiteItemType;
const createAiPlaceholder = (id = 'ai-placeholder'): ChatSiteItemType =>
createAiRecord(undefined, {
id,
dataId: id,
status: ChatStatusEnum.loading,
value: [
{
text: {
content: ''
}
}
]
});
const createUserSelectInteractive = (userSelectedVal?: string): WorkflowInteractiveResponseType =>
({
...baseInteractive,
type: 'userSelect',
params: {
description: 'choose one',
userSelectOptions: [
{
key: 'A',
value: 'A'
},
{
key: 'B',
value: 'B'
}
],
userSelectedVal
}
}) as WorkflowInteractiveResponseType;
const createUserInputInteractive = (submitted = false): WorkflowInteractiveResponseType =>
({
...baseInteractive,
type: 'userInput',
params: {
description: 'fill form',
submitted,
inputForm: [
{
type: FlowNodeInputTypeEnum.input,
key: 'name',
label: 'Name',
value: '',
valueType: WorkflowIOValueTypeEnum.string,
required: false
}
]
}
}) as WorkflowInteractiveResponseType;
describe('getInteractiveByHistories', () => {
it('allows normal chat when no pending interactive exists', () => {
expect(getInteractiveByHistories([createAiRecord()])).toEqual({
interactive: undefined,
canSendQuery: true
});
});
it('blocks normal query for unselected userSelect interactive', () => {
const interactive = createUserSelectInteractive();
expect(getInteractiveByHistories([createAiRecord(interactive)])).toEqual({
interactive,
canSendQuery: false
});
});
it('allows normal query after userSelect has been answered', () => {
expect(getInteractiveByHistories([createAiRecord(createUserSelectInteractive('A'))])).toEqual({
interactive: undefined,
canSendQuery: true
});
});
it('allows sending a query while preserving agent plan ask interactive', () => {
const interactive = {
...baseInteractive,
type: 'agentPlanAskQuery',
params: {
content: 'Need more detail',
options: ['A', 'B', 'C']
}
} as WorkflowInteractiveResponseType;
expect(getInteractiveByHistories([createAiRecord(interactive)])).toEqual({
interactive,
canSendQuery: true
});
});
});
describe('rewriteHistoriesByInteractiveResponse', () => {
it('writes userSelect answer into the previous interactive and removes temporary round records', () => {
const interactive = createUserSelectInteractive();
const result = rewriteHistoriesByInteractiveResponse({
histories: [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()],
interactive,
interactiveVal: 'B'
});
expect(result).toHaveLength(1);
expect(result[0].status).toBe(ChatStatusEnum.loading);
expect((result[0].value[0] as any).interactive.params.userSelectedVal).toBe('B');
});
it('writes parsed userInput values into the submitted form', () => {
const interactive = createUserInputInteractive();
const result = rewriteHistoriesByInteractiveResponse({
histories: [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()],
interactive,
interactiveVal: JSON.stringify({
name: 'FastGPT'
})
});
expect(result).toHaveLength(1);
expect((result[0].value[0] as any).interactive.params.submitted).toBe(true);
expect((result[0].value[0] as any).interactive.params.inputForm[0].value).toBe('FastGPT');
});
it('marks paymentPause as continued and removes temporary round records', () => {
const interactive = {
...baseInteractive,
type: 'paymentPause',
params: {
description: 'insufficient points'
}
} as WorkflowInteractiveResponseType;
const result = rewriteHistoriesByInteractiveResponse({
histories: [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()],
interactive,
interactiveVal: ''
});
expect(result).toHaveLength(1);
expect((result[0].value[0] as any).interactive.params.continue).toBe(true);
});
it('keeps the temporary user round when agentPlanAskQuery becomes a normal query', () => {
const interactive = {
...baseInteractive,
type: 'agentPlanAskQuery',
params: {
content: 'Need more detail',
options: ['A', 'B', 'C']
}
} as WorkflowInteractiveResponseType;
const histories = [createAiRecord(interactive), createHumanRecord(), createAiPlaceholder()];
const result = rewriteHistoriesByInteractiveResponse({
histories,
interactive,
interactiveVal: 'new user question'
});
expect(result).toHaveLength(3);
expect(result[1]).toBe(histories[1]);
expect(result[2]).toEqual({
...histories[2],
status: ChatStatusEnum.loading
});
});
});
import { describe, expect, it } from 'vitest';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import { getProcessedChatRecords } from '@/components/core/chat/ChatContainer/ChatBox/utils/recordGroups';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
const createRecord = ({
dataId,
deleteTime
}: {
dataId: string;
deleteTime?: Date | null;
}): ChatSiteItemType => ({
id: dataId,
dataId,
obj: ChatRoleEnum.Human,
value: [
{
text: {
content: dataId
}
}
],
status: ChatStatusEnum.finish,
deleteTime
});
describe('getProcessedChatRecords', () => {
it('returns original records for non-log chat types', () => {
const records = [createRecord({ dataId: 'normal-1', deleteTime: new Date() })];
const result = getProcessedChatRecords({
chatType: ChatTypeEnum.chat,
chatRecords: records,
expandedDeletedGroups: new Set()
});
expect(result).toBe(records);
expect(result[0].collapseTop).toBeUndefined();
});
it('adds collapse metadata to each continuous deleted group in log mode', () => {
const deletedAt = new Date('2026-05-19T00:00:00.000Z');
const records = [
createRecord({ dataId: 'normal-1' }),
createRecord({ dataId: 'deleted-1', deleteTime: deletedAt }),
createRecord({ dataId: 'deleted-2', deleteTime: deletedAt }),
createRecord({ dataId: 'normal-2' }),
createRecord({ dataId: 'deleted-3', deleteTime: deletedAt })
];
const result = getProcessedChatRecords({
chatType: ChatTypeEnum.log,
chatRecords: records,
expandedDeletedGroups: new Set(['deleted-1', 'deleted-2'])
});
expect(result).toHaveLength(records.length);
expect(result[0]).toBe(records[0]);
expect(result[3]).toBe(records[3]);
expect(result[1]).not.toBe(records[1]);
expect(result[1].collapseTop).toEqual({
count: 2,
dataIds: ['deleted-1', 'deleted-2'],
isExpanded: true
});
expect(result[1].collapseBottom).toBeUndefined();
expect(result[2]).not.toBe(records[2]);
expect(result[2].collapseTop).toBeUndefined();
expect(result[2].collapseBottom).toEqual({
count: 2,
dataIds: ['deleted-1', 'deleted-2'],
isExpanded: true
});
expect(result[4].collapseTop).toEqual({
count: 1,
dataIds: ['deleted-3'],
isExpanded: false
});
expect(result[4].collapseBottom).toEqual({
count: 1,
dataIds: ['deleted-3'],
isExpanded: false
});
expect(records[1].collapseTop).toBeUndefined();
expect(records[2].collapseBottom).toBeUndefined();
});
it('marks a deleted group as collapsed until every item in the group is expanded', () => {
const deletedAt = new Date('2026-05-19T00:00:00.000Z');
const records = [
createRecord({ dataId: 'deleted-1', deleteTime: deletedAt }),
createRecord({ dataId: 'deleted-2', deleteTime: deletedAt })
];
const result = getProcessedChatRecords({
chatType: ChatTypeEnum.log,
chatRecords: records,
expandedDeletedGroups: new Set(['deleted-1'])
});
expect(result[0].collapseTop?.isExpanded).toBe(false);
expect(result[1].collapseBottom?.isExpanded).toBe(false);
});
});
import { describe, expect, it } from 'vitest';
import type { VariableItemType } from '@fastgpt/global/core/app/type';
import {
VariableInputEnum,
WorkflowIOValueTypeEnum
} from '@fastgpt/global/core/workflow/constants';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import { formatChatRequestVariables } from '@/components/core/chat/ChatContainer/ChatBox/utils/requestVariables';
const createVariable = (override: Partial<VariableItemType>): VariableItemType =>
({
key: 'key',
label: 'label',
description: '',
type: VariableInputEnum.input,
valueType: WorkflowIOValueTypeEnum.string,
required: false,
defaultValue: '',
...override
}) as VariableItemType;
describe('formatChatRequestVariables', () => {
it('keeps only declared variables and formats values by valueType', () => {
const result = formatChatRequestVariables({
variableList: [
createVariable({
key: 'name',
valueType: WorkflowIOValueTypeEnum.string
}),
createVariable({
key: 'age',
type: VariableInputEnum.numberInput,
valueType: WorkflowIOValueTypeEnum.number
}),
createVariable({
key: 'enabled',
type: VariableInputEnum.switch,
valueType: WorkflowIOValueTypeEnum.boolean
})
],
variables: {
name: 'FastGPT',
age: '18',
enabled: 'true',
ignored: 'not declared'
}
});
expect(result).toEqual({
name: 'FastGPT',
age: 18,
enabled: true
});
});
it('uses defaultValue for empty string, null and undefined form values', () => {
const result = formatChatRequestVariables({
variableList: [
createVariable({
key: 'emptyText',
defaultValue: 'fallback'
}),
createVariable({
key: 'nullNumber',
type: VariableInputEnum.numberInput,
valueType: WorkflowIOValueTypeEnum.number,
defaultValue: '42'
}),
createVariable({
key: 'missingArray',
type: VariableInputEnum.multipleSelect,
valueType: WorkflowIOValueTypeEnum.arrayString,
defaultValue: ['a', 'b']
})
],
variables: {
emptyText: '',
nullNumber: null
}
});
expect(result).toEqual({
emptyText: 'fallback',
nullNumber: 42,
missingArray: ['a', 'b']
});
});
it('formats time point and time range variables before value type conversion', () => {
const startAtInput = '2026-05-19T01:02:03.000Z';
const periodStartInput = '2026-05-19T03:04:05.000Z';
const result = formatChatRequestVariables({
variableList: [
createVariable({
key: 'startAt',
type: VariableInputEnum.timePointSelect,
valueType: WorkflowIOValueTypeEnum.string
}),
createVariable({
key: 'period',
type: VariableInputEnum.timeRangeSelect,
valueType: WorkflowIOValueTypeEnum.arrayString
})
],
variables: {
startAt: startAtInput,
period: [periodStartInput, '']
}
});
expect(result).toEqual({
startAt: formatTime2YMDHMS(new Date(startAtInput)),
period: [formatTime2YMDHMS(new Date(periodStartInput)), '']
});
});
it('returns an empty object when variableList is missing', () => {
expect(formatChatRequestVariables({ variables: { name: 'FastGPT' } })).toEqual({});
});
});
import { describe, expect, it } from 'vitest';
import { ChatRoleEnum, ChatStatusEnum } from '@fastgpt/global/core/chat/constants';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import {
hasMeaningfulAiOutput,
shouldCreateResumeAiPlaceholder
} from '@/components/core/chat/ChatContainer/ChatBox/utils/resume';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
const createAiRecord = (override: Partial<ChatSiteItemType>): ChatSiteItemType =>
({
id: 'ai-1',
dataId: 'ai-1',
obj: ChatRoleEnum.AI,
value: [],
status: ChatStatusEnum.loading,
...override
}) as ChatSiteItemType;
describe('shouldCreateResumeAiPlaceholder', () => {
it('returns true for visible resume stream events', () => {
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.flowNodeResponse)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.answer)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.fastAnswer)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.toolCall)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.toolParams)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.toolResponse)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.interactive)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.plan)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.planStatus)).toBe(true);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.workflowDuration)).toBe(true);
});
it('returns false for stream control events that do not create chat content', () => {
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.error)).toBe(false);
expect(shouldCreateResumeAiPlaceholder(SseResponseEventEnum.updateVariables)).toBe(false);
});
});
describe('hasMeaningfulAiOutput', () => {
it('returns false when the record is missing, human, or empty AI placeholder', () => {
expect(hasMeaningfulAiOutput()).toBe(false);
expect(
hasMeaningfulAiOutput({
id: 'human-1',
dataId: 'human-1',
obj: ChatRoleEnum.Human,
value: [],
status: ChatStatusEnum.finish
} as ChatSiteItemType)
).toBe(false);
expect(hasMeaningfulAiOutput(createAiRecord({ value: [] }))).toBe(false);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
text: {
content: ''
}
}
]
})
)
).toBe(false);
});
it('returns true for AI records with response data or visible value content', () => {
expect(
hasMeaningfulAiOutput(
createAiRecord({
responseData: [{} as any]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
text: {
content: 'answer'
}
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
reasoning: {
content: 'reasoning'
}
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
tools: [
{
id: 'tool-1',
name: 'tool',
params: '{}'
} as any
]
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
skills: [
{
id: 'skill-1',
skillName: 'skill',
skillAvatar: '',
description: '',
skillMdPath: '/tmp/skill.md'
}
]
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
plan: {} as any
}
]
})
)
).toBe(true);
expect(
hasMeaningfulAiOutput(
createAiRecord({
value: [
{
interactive: {} as any
}
]
})
)
).toBe(true);
});
});
...@@ -4,7 +4,7 @@ import { ...@@ -4,7 +4,7 @@ import {
getChatScrollTargetKey, getChatScrollTargetKey,
shouldFollowGeneratingScroll, shouldFollowGeneratingScroll,
shouldForceScrollAfterRecordsLoaded shouldForceScrollAfterRecordsLoaded
} from '@/components/core/chat/ChatContainer/ChatBox/scrollUtils'; } from '@/components/core/chat/ChatContainer/ChatBox/utils/scrollUtils';
describe('ChatBox scrollUtils', () => { describe('ChatBox scrollUtils', () => {
it('should build stable scroll target keys only when appId and chatId exist', () => { it('should build stable scroll target keys only when appId and chatId exist', () => {
......
...@@ -3,14 +3,14 @@ import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/consta ...@@ -3,14 +3,14 @@ import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/consta
import type { ChatItemValueItemType } from '@fastgpt/global/core/chat/type'; import type { ChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type'; import type { ChatSiteItemType } from '@/components/core/chat/ChatContainer/ChatBox/type';
import { stripChatValueFileUrls } from '@/components/core/chat/ChatContainer/ChatBox/utils/chatValue';
import { refreshSubmittedFormInteractiveValues } from '@/components/core/chat/ChatContainer/ChatBox/utils/interactive';
import { import {
mergeResumeCompletedChatRecords, mergeResumeCompletedChatRecords,
refreshSubmittedFormInteractiveValues,
shouldAppendResumeInteractive, shouldAppendResumeInteractive,
shouldReplaceResumeAiValue, shouldReplaceResumeAiValue,
shouldResetResumeAiPlaceholder, shouldResetResumeAiPlaceholder
stripChatValueFileUrls } from '@/components/core/chat/ChatContainer/ChatBox/utils/resume';
} from '@/components/core/chat/ChatContainer/ChatBox/utils';
describe('stripChatValueFileUrls', () => { describe('stripChatValueFileUrls', () => {
it('removes signed urls from keyed files before sending messages', () => { it('removes signed urls from keyed files before sending messages', () => {
......
...@@ -19,8 +19,7 @@ ...@@ -19,8 +19,7 @@
"exclude": [ "exclude": [
"**/*.test.ts", "**/*.test.ts",
"**/*.test.tsx", "**/*.test.tsx",
"../../packages/**/vitest.config.ts", "../../packages/**/vitest*.config.ts",
"../../packages/**/vitest.integration.config.ts",
"../../packages/**/test/**", "../../packages/**/test/**",
".next", ".next",
"dist", "dist",
......
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