UI 状态怎么管:流式回答为什么总让聊天页面乱跳
上一篇《AI UI 怎么选:先看三类状态归谁管》把 AI 应用拆成了 UI、对话和业务三类状态。这一篇只回答第一类:页面此刻长什么样,用户正在怎样操作它。
消息有没有生成完成,属于下一篇的对话状态;订单有没有真的删除,属于业务状态。即使某个库三层都覆盖,本文也只检查它的 UI 部分。
🎯 本文目标
读完你应该能:用“去掉页面后是否仍然成立”判断 UI 状态;拆清输入草稿、附件临时态、消息呈现和视口状态;修掉流式回答抢滚动的典型 Bug;最后只从 UI 责任出发,比较 Ant Design X、AI Elements、MateChat、Deep Chat、NLUX、ChatScope,以及 assistant-ui 的 UI primitives。
本篇故意不讲什么
一条判断线:去掉页面以后,它还成立吗
同一条消息会同时产生不同层的状态:
| 看到的东西 | 真正描述的事实 | 归属 |
|---|---|---|
| 回答正在逐字生成 | 本轮生成尚未结束 | 对话状态 |
| 页面是否跟随最新文字滚动 | 当前用户的阅读意图 | UI 状态 |
| 思考内容本身 | 消息中的 reasoning part | 对话状态 |
| 思考内容是否展开 | 当前浏览器的展示偏好 | UI 状态 |
| 附件已经成为消息的一部分 | 对话记录包含一个文件引用 | 对话状态 |
| 文件正在拖入、显示本地预览 | 提交前的界面操作 | UI 状态 |
| Tool Call 等待执行 | Agent 交互停在工具阶段 | 对话状态 |
| Tool 卡片是否折叠 | 当前页面怎样展示它 | UI 状态 |
判断方法很朴素:
把页面关掉,这个事实还需要存在吗?
- “思考面板展开了”关页后没有意义,是 UI 状态;
- “消息生成失败了”关页后仍要恢复,是对话状态;
- “客户已经删除”即使聊天页不存在也成立,是业务状态。
这个判断比“它是不是保存在 React state 里”可靠。对话状态也可能暂存在 React state,UI 草稿也可能写入 localStorage;存储位置不是分类标准,状态描述的事实才是。
UI 状态不是一种,而是四个不同生命周期
“UI 状态”只是大类,内部仍要按生命周期拆开:
| 状态域 | 示例 | 什么时候创建 | 什么时候重置 | 是否值得恢复 |
|---|---|---|---|---|
| 输入器 | 草稿、光标、输入法组合态、发送快捷键 | 用户开始编辑 | 发送成功或主动清空 | 草稿通常值得按 thread 暂存 |
| 附件临时态 | 拖拽高亮、本地预览、上传进度、单文件错误 | 选择或拖入文件 | 提交完成、删除或取消 | 上传中的任务按产品需求决定 |
| 消息呈现 | 展开、复制反馈、引用浮层、图片灯箱 | 用户操作消息 | 页面卸载或消息删除 | 多数无需跨设备恢复 |
| 视口 | near-bottom、阅读锚点、未读数、虚拟窗口 | 打开线程或滚动 | 切换页面;有时按 thread 缓存 | 长会话可恢复阅读位置 |
注意,“当前线程 ID”不在这张表里。它决定加载哪组消息、后续请求带哪段上下文,属于对话状态。UI 层拥有的是:
- 线程侧栏是否展开;
- 移动端线程抽屉是否打开;
- 某个 thread ID 对应的本地草稿;
- 线程切换后视口定位到底部还是上次阅读位置。
以 thread ID 作为 key,不会把草稿变成对话状态。它仍然只是尚未发送的界面输入。
type ChatUiState = {
draftsByThread: Record<string, string>
attachmentDraftsByThread: Record<string, LocalAttachment[]>
expandedPartIds: Set<string>
copiedMessageId?: string
viewportByThread: Record<string, {
isNearBottom: boolean
unreadCount: number
lastReadMessageId?: string
}>
}不要建立一个永不清理的 uiStore
这张类型只是责任地图,不代表所有字段都该进全局 store。输入法组合态适合留在 textarea 内部;复制成功提示适合组件本地定时清理;只有跨组件、跨线程切换确实需要保留的状态,才值得提升作用域。
输入器:草稿不是消息,按下发送后才跨界
输入器最容易把 UI 和对话混在一起。用户输入“删除客户 A”时,它先经历:
本地草稿(UI)
→ 用户点击发送
→ 创建用户消息(对话)
→ 清空草稿(UI)发送动作是边界。发送之前,草稿可以随时改、撤销、丢弃;发送之后,消息需要 ID、时间、状态和持久化策略。
三个常见坑
1. 发送一触发就清空草稿
如果网络请求连“用户消息已接收”都没确认,立即清空会让失败恢复很狼狈。可以先生成 client message ID,乐观加入对话,再在失败时提供恢复到输入框,而不是只弹一句“发送失败”。
2. 忽略中文输入法组合态
用户用拼音输入时,Enter 可能只是确认候选词。提交前要检查 isComposing:
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.nativeEvent.isComposing) return
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submitDraft()
}
}3. 切线程共用一个 draft
如果侧栏切换后复用同一份 input,线程 A 的草稿会出现在 B。至少按 thread ID 隔离:
const draft = draftsByThread[currentThreadId] ?? ''
function updateDraft(value: string) {
setDraftsByThread((all) => ({
...all,
[currentThreadId]: value,
}))
}这里 currentThreadId 来自对话层,draftsByThread 属于 UI 层。两层可以互相引用,不代表它们是一类状态。
附件:同一个文件会跨过三次边界
“附件状态属于哪一层”不能一刀切,因为它会变形:
| 阶段 | 数据形态 | 归属 |
|---|---|---|
| 选择后 | File、object URL、拖拽态 | UI 状态 |
| 上传中 | 进度、取消句柄、局部错误 | UI / 上传任务状态 |
| 上传完成 | fileId、URL、mime、size | 准备写入消息的对话数据 |
| 业务入库 | 发票进入报销单、合同进入档案 | 业务状态 |
因此不要把一个 File[] 从选择一直用到持久化:
type LocalAttachment = {
localId: string
file: File
previewUrl?: string
status: 'queued' | 'uploading' | 'uploaded' | 'error'
progress: number
remote?: {
fileId: string
url: string
mime: string
}
error?: string
}UI 层至少要处理:单文件删除、单文件重试、页面卸载时释放 object URL、超过大小限制、同名文件、粘贴图片,以及上传过程中切线程的策略。
上传完成不等于业务完成
文件拿到 fileId 只能说明上传服务接收了它。它是否已经成为消息的一部分,要看发送是否成功;它是否已经归档到业务对象,又要看业务接口。不要用一个绿色勾同时冒充三个成功。
消息呈现:用稳定 ID 保存偏好,不要污染消息正文
推理折叠、代码复制、引用预览、工具详情展开都属于“如何看消息”,不属于消息内容本身。
错误做法是修改消息对象:
message.reasoningExpanded = true
message.codeCopied = true这样会导致:
- UI 操作触发消息持久化;
- 服务端刷新消息时覆盖本地偏好;
- 同一消息的两个代码块无法分别反馈;
- 消息分支切换后 index 改变,状态贴到错误内容。
更稳的方式是用稳定的 message ID / part ID:
type MessagePresentation = {
expandedPartIds: Set<string>
copiedBlockId?: string
openCitationId?: string
}流式期间还要注意 part ID 不能每个 chunk 都重建。否则 React 会反复卸载组件,折叠状态、代码块滚动和复制反馈都会重置。流式更新应该更新同一个 part 的内容,而不是不断创建“新 UI”。
视口:自动滚动不是 scrollIntoView()
最常见的实现是:
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])流式响应每到一段都会修改消息,用户向上阅读旧内容时,页面便不断把他拽回底部。
真正缺少的是“用户是否愿意跟随最新内容”这个 UI 状态:
import { useLayoutEffect, useRef, useState } from 'react'
const NEAR_BOTTOM_PX = 80
function useStreamingScroll(lastContent: string) {
const viewportRef = useRef<HTMLDivElement>(null)
const shouldFollowRef = useRef(true)
const previousScrollTopRef = useRef(0)
const [isNearBottom, setIsNearBottom] = useState(true)
function onScroll() {
const el = viewportRef.current
if (!el) return
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distance <= NEAR_BOTTOM_PX
const scrollingUp = el.scrollTop < previousScrollTopRef.current
if (scrollingUp && !nearBottom) shouldFollowRef.current = false
if (nearBottom) shouldFollowRef.current = true
previousScrollTopRef.current = el.scrollTop
setIsNearBottom(nearBottom)
}
useLayoutEffect(() => {
const el = viewportRef.current
if (!el || !shouldFollowRef.current) return
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight
})
}, [lastContent])
function followLatest() {
shouldFollowRef.current = true
const el = viewportRef.current
el?.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
}
function onUserSubmit() {
shouldFollowRef.current = true
}
return { viewportRef, isNearBottom, onScroll, followLatest, onUserSubmit }
}这个最小版建立了三条规则:
- 用户发送新问题,重新跟随;
- 用户主动上滚,停止跟随;
- 用户回到底部或点击按钮,再次跟随。
生产环境还要处理:
- Markdown 图片与 Mermaid 在下一帧改变高度;
- 向上分页插入历史消息后保持视觉锚点;
- 移动端软键盘改变 visual viewport;
- 动态高度虚拟列表;
- 切线程后恢复上次位置还是跳到最新;
prefers-reduced-motion用户关闭平滑动画。
向上加载历史时,要用插入前后的高度差修正位置:
const before = viewport.scrollHeight
await prependOlderMessages()
requestAnimationFrame(() => {
viewport.scrollTop += viewport.scrollHeight - before
})UI 状态的核心不是“放哪个 Store”
这里最难的是意图:程序滚动和用户滚动谁优先、哪个事件应该重置跟随、线程切换如何恢复。状态库只能保存答案,不能替产品定义答案。
只从 UI 层比较市面方案
下面的比较只回答四个问题:
- 给了哪些可见组件?
- 输入、附件、折叠、滚动等局部行为管到哪里?
- UI 以 npm、源码还是 Web Component 交付?
- 定制和升级由谁承担?
不拿消息持久化、分支和工具权限给 UI 组件打分。
总览
| UI 方案 | 技术栈 / 交付 | UI 行为覆盖 | 主要优点 | 主要代价 |
|---|---|---|---|---|
| Ant Design X | React,npm 组件 | Bubble、Sender、附件、思考、来源、基础滚动 | Ant Design 项目接入短,中文生态好 | 接受 antd 设计体系;深度改视觉成本增加 |
| AI Elements | React 19 + Tailwind 4,源码 registry | Conversation、Prompt Input、Reasoning、Tool 等 | DOM 与源码完全可控,可按组件引入 | 上游升级要手动合并;不是对话 runtime |
| MateChat Vue | Vue 3 + DevUI,npm 组件 | 气泡、输入器、布局、Prompt 等 | Vue 项目无需切 React | 完整接入带 DevUI 与图标依赖 |
| MateChat React | React,npm 或源码 registry | AI 场景组件与 Markdown | 同时提供依赖包和源码路线 | 生态较新,先验证版本与 peer 约束 |
| Deep Chat | Web Component / React wrapper | 整页聊天、文件、语音、Markdown | 跨框架嵌入最快 | 组合粒度较粗,深度定制沿公开接口走 |
| NLUX | React 18 / Vanilla,整页组件 | 输入、消息、流式与主题 | API 集中、相对轻 | 维护节奏与新 React 兼容需先核对 |
| ChatScope | React,通用聊天组件 | 消息列表、输入、侧栏、typing | 人类聊天布局成熟 | AI reasoning/tool/source 等要自己补 |
跨层方案旁注:assistant-ui primitives
如果项目已经决定采用 assistant-ui runtime,可以直接使用它的 ThreadPrimitive.Viewport、ComposerPrimitive、MessagePrimitive 和 AttachmentPrimitive 管理 UI 行为,无需再选一套 UI 状态层。但 assistant-ui 整体不参与上面的纯 UI 方案排名:它的 thread、message、run、branch 和 persistence 属于对话状态,下一篇再展开。
安装方式
# Ant Design X:本文只讨论 UI 包
npm install @ant-design/x antd
# AI Elements:源码进入仓库
npx ai-elements@latest add conversation message prompt-input
# MateChat Vue / React
npm install vue-devui @matechat/core @devui-design/icons
npm install @matechat/react
# Deep Chat
npm install deep-chat
npm install deep-chat-react
# NLUX
npm install @nlux/react @nlux/themes
# ChatScope
npm install @chatscope/chat-ui-kit-react @chatscope/chat-ui-kit-styles
# assistant-ui primitives(跨层旁注)
npm install @assistant-ui/reactThreadPrimitive.Viewport 能处理用户上滚后的自动跟随,ComposerPrimitive 能处理输入和附件。但它们读取 assistant-ui runtime 上下文;因此它证明的是“跨层方案可以顺便管好 UI”,不是“assistant-ui 只属于 UI 层”。
Deep Chat 支持浏览器直接连接模型供应商,但官方明确提示 direct connection 只用于本地原型,因为会暴露 API Key。生产仍应指向自己的 BFF。
包体快照:只比较发布物,不冒充浏览器 bundle
2026-08-26 使用 npm pack <package>@<version> --dry-run --json 得到:
| UI 发布物 | 版本快照 | npm 压缩包 | 解压后 | 需要一起看的东西 |
|---|---|---|---|---|
@ant-design/x | 2.9.0 | 4.63 MiB | 18.92 MiB | antd 是 peer;已有 antd 项目的增量不同 |
| MateChat Vue 官方三包 | core 1.14.0 等 | 约 5.65 MiB | 约 21.72 MiB | 大头来自 DevUI 与图标;最终按需构建另测 |
@matechat/react | 1.0.0 | 0.16 MiB | 0.66 MiB | React、AI SDK、Markdown 等依赖另算 |
deep-chat-react | 2.5.0 | 4.3 KiB | 20.8 KiB | 只是 wrapper,还依赖 deep-chat |
deep-chat | 2.5.0 | 2.11 MiB | 6.56 MiB | Web Component 的完整实现位于这里 |
| NLUX React + core + theme | 2.17.1 | 约 0.27 MiB | 约 0.93 MiB | adapter 另算;先查 React 版本与维护状态 |
| ChatScope UI + styles | 2.1.1 / 1.4.0 | 约 0.18 MiB | 约 1.17 MiB | Font Awesome 等传递依赖另算 |
| AI Elements | 源码 registry | 不适用 | 不适用 | 应测复制后的源码及实际 import |
发布包大小不是首屏大小
发布包包含类型、source map、多模块格式和未引用组件;wrapper 又可能把真正实现藏在依赖里。上线体积必须在同一构建器、同一功能入口下记录 minified、gzip/brotli 和动态 chunk,不能拿这张表做“谁最轻”的排名。
assistant-ui 没放进这张 UI 包排名,因为 @assistant-ui/react 同时包含 runtime-facing primitives 与状态能力,把它的数字写成“UI 体积”会重新制造本文要避免的混淆。
UI 交付方式决定升级方式
| 交付方式 | 代表 | 升级动作 | 最容易低估的成本 |
|---|---|---|---|
| npm 组件 | Ant Design X、MateChat | 升版本、修 props、做视觉回归 | DOM/token 变化会击穿深层样式覆盖 |
| 源码复制 | AI Elements、MateChat React registry | 重新生成、做三方 diff | 改得越深,越像维护自己的 fork |
| 整页组件 | NLUX | 升包、验证 adapter 与主题 | 快速接入换来较粗的组合边界 |
| Web Component | Deep Chat | 升 element、验证属性/事件/SSR | 内部 DOM 定制受公开接口约束 |
| 通用聊天组件 | ChatScope | 升包、验证 CSS | AI 新交互长期由业务自己补 |
assistant-ui 是跨层特例:采用它以后,升级要同时处理 UI 源码 diff 与 runtime API 回归,因此不能套用纯 UI 包的成本判断。
源码组件不是“升级更容易”,只是把升级决定权交给你;npm 组件也不是“升级自动完成”,只是把实现差异集中在公开 API 后面。
最少保留四类 UI 回归测试
- 流式回答时上滚,视口不能被抢回底部;
- 带草稿切换线程,输入内容不能串线;
- 两个附件一个失败,只重试失败文件;
- 展开 reasoning、复制代码后继续流式更新,局部状态不能重置。
最后怎么选:只按 UI 约束决策
| 项目约束 | UI 起点 |
|---|---|
| React + Ant Design 后台 | Ant Design X |
| React 19 + Tailwind 4,品牌设计高度定制 | AI Elements |
| Vue 3 / DevUI | MateChat Vue |
| 多框架或旧系统快速嵌入 | Deep Chat |
| React 18,需要较轻的完整 AI Chat | NLUX,先核对维护与兼容 |
| 产品本质是人类聊天,AI 只是一个账号 | ChatScope |
| 已决定采用 assistant-ui runtime | 直接使用它的 primitives,不再另选 UI 状态层 |
这里没有 assistant-ui 与 CopilotKit 的总排名,因为那会再次混淆层级:assistant-ui 要到对话状态需求明确后再整体评估;CopilotKit 要到 AI 操作应用时再评估。
结语:UI 状态描述的是“我现在怎么看”
草稿、拖拽高亮、展开、复制反馈、滚动意图看起来都是小字段,但它们共同决定用户能不能稳定阅读和操作。UI 状态最重要的边界是:它可以被对话事件驱动,却不应该冒充对话事实。
status === 'streaming'是对话事实;要不要自动跟随是 UI 决策。- reasoning part 是对话内容;是否展开是 UI 偏好。
- 附件引用是消息内容;本地预览和上传进度是提交前 UI。
下一篇《消息数组够用吗?从 useChat 到对话运行时》会把页面拿掉,专门处理仍然必须存在的东西:消息、运行、线程、分支和持久化。到那里,assistant-ui 才会作为完整 runtime 正式登场。
📚 核对范围与官方资料
- Ant Design X 组件总览
- AI Elements Setup、Conversation、Prompt Input
- assistant-ui Thread primitive、Composer primitive
- MateChat Vue、MateChat React
- Deep Chat 安装、框架接入
- NLUX 官方文档、ChatScope 文档
- 包体数据为 2026-08-26 的
npm pack --dry-run快照,不代表未来版本或生产 bundle。