Skip to content

UI 状态怎么管:流式回答为什么总让聊天页面乱跳 ​

上一篇《AI UI 怎么选:先看三类状态归谁管》把 AI 应用拆成了 UI、对话和业务三类状态。这一篇只回答第一类:页面此刻长什么样,用户正在怎样操作它。

消息有没有生成完成,属于下一篇的对话状态;订单有没有真的删除,属于业务状态。即使某个库三层都覆盖,本文也只检查它的 UI 部分。

🎯 本文目标

读完你应该能:用“去掉页面后是否仍然成立”判断 UI 状态;拆清输入草稿、附件临时态、消息呈现和视口状态;修掉流式回答抢滚动的典型 Bug;最后只从 UI 责任出发,比较 Ant Design X、AI Elements、MateChat、Deep Chat、NLUX、ChatScope,以及 assistant-ui 的 UI primitives。

本篇故意不讲什么

  • 不讲 messages / run / thread / branch / persistence,它们属于对话状态篇。
  • 不讲 Tool Call 的权限、确认、幂等和事务,它们属于业务状态篇。
  • 不把 assistant-ui 整体归类为 UI 组件库;本文只引用它的 Viewport、Composer 等 primitives。
  • 不讨论 CopilotKit,因为它的核心选型价值是 Agent 与应用动作连接,不是局部 UI 状态。

一条判断线:去掉页面以后,它还成立吗 ​

同一条消息会同时产生不同层的状态:

看到的东西真正描述的事实归属
回答正在逐字生成本轮生成尚未结束对话状态
页面是否跟随最新文字滚动当前用户的阅读意图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,不会把草稿变成对话状态。它仍然只是尚未发送的界面输入。

typescript
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”时,它先经历:

text
本地草稿(UI)
  → 用户点击发送
  → 创建用户消息(对话)
  → 清空草稿(UI)

发送动作是边界。发送之前,草稿可以随时改、撤销、丢弃;发送之后,消息需要 ID、时间、状态和持久化策略。

三个常见坑 ​

1. 发送一触发就清空草稿 ​

如果网络请求连“用户消息已接收”都没确认,立即清空会让失败恢复很狼狈。可以先生成 client message ID,乐观加入对话,再在失败时提供恢复到输入框,而不是只弹一句“发送失败”。

2. 忽略中文输入法组合态 ​

用户用拼音输入时,Enter 可能只是确认候选词。提交前要检查 isComposing:

tsx
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 隔离:

typescript
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[] 从选择一直用到持久化:

typescript
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 保存偏好,不要污染消息正文 ​

推理折叠、代码复制、引用预览、工具详情展开都属于“如何看消息”,不属于消息内容本身。

错误做法是修改消息对象:

typescript
message.reasoningExpanded = true
message.codeCopied = true

这样会导致:

  • UI 操作触发消息持久化;
  • 服务端刷新消息时覆盖本地偏好;
  • 同一消息的两个代码块无法分别反馈;
  • 消息分支切换后 index 改变,状态贴到错误内容。

更稳的方式是用稳定的 message ID / part ID:

typescript
type MessagePresentation = {
  expandedPartIds: Set<string>
  copiedBlockId?: string
  openCitationId?: string
}

流式期间还要注意 part ID 不能每个 chunk 都重建。否则 React 会反复卸载组件,折叠状态、代码块滚动和复制反馈都会重置。流式更新应该更新同一个 part 的内容,而不是不断创建“新 UI”。


视口:自动滚动不是 scrollIntoView() ​

最常见的实现是:

tsx
useEffect(() => {
  bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])

流式响应每到一段都会修改消息,用户向上阅读旧内容时,页面便不断把他拽回底部。

真正缺少的是“用户是否愿意跟随最新内容”这个 UI 状态:

tsx
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 }
}

这个最小版建立了三条规则:

  1. 用户发送新问题,重新跟随;
  2. 用户主动上滚,停止跟随;
  3. 用户回到底部或点击按钮,再次跟随。

生产环境还要处理:

  • Markdown 图片与 Mermaid 在下一帧改变高度;
  • 向上分页插入历史消息后保持视觉锚点;
  • 移动端软键盘改变 visual viewport;
  • 动态高度虚拟列表;
  • 切线程后恢复上次位置还是跳到最新;
  • prefers-reduced-motion 用户关闭平滑动画。

向上加载历史时,要用插入前后的高度差修正位置:

typescript
const before = viewport.scrollHeight
await prependOlderMessages()

requestAnimationFrame(() => {
  viewport.scrollTop += viewport.scrollHeight - before
})

UI 状态的核心不是“放哪个 Store”

这里最难的是意图:程序滚动和用户滚动谁优先、哪个事件应该重置跟随、线程切换如何恢复。状态库只能保存答案,不能替产品定义答案。


只从 UI 层比较市面方案 ​

下面的比较只回答四个问题:

  1. 给了哪些可见组件?
  2. 输入、附件、折叠、滚动等局部行为管到哪里?
  3. UI 以 npm、源码还是 Web Component 交付?
  4. 定制和升级由谁承担?

不拿消息持久化、分支和工具权限给 UI 组件打分。

总览 ​

UI 方案技术栈 / 交付UI 行为覆盖主要优点主要代价
Ant Design XReact,npm 组件Bubble、Sender、附件、思考、来源、基础滚动Ant Design 项目接入短,中文生态好接受 antd 设计体系;深度改视觉成本增加
AI ElementsReact 19 + Tailwind 4,源码 registryConversation、Prompt Input、Reasoning、Tool 等DOM 与源码完全可控,可按组件引入上游升级要手动合并;不是对话 runtime
MateChat VueVue 3 + DevUI,npm 组件气泡、输入器、布局、Prompt 等Vue 项目无需切 React完整接入带 DevUI 与图标依赖
MateChat ReactReact,npm 或源码 registryAI 场景组件与 Markdown同时提供依赖包和源码路线生态较新,先验证版本与 peer 约束
Deep ChatWeb Component / React wrapper整页聊天、文件、语音、Markdown跨框架嵌入最快组合粒度较粗,深度定制沿公开接口走
NLUXReact 18 / Vanilla,整页组件输入、消息、流式与主题API 集中、相对轻维护节奏与新 React 兼容需先核对
ChatScopeReact,通用聊天组件消息列表、输入、侧栏、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 属于对话状态,下一篇再展开。

安装方式 ​

bash
# 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/react

ThreadPrimitive.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/x2.9.04.63 MiB18.92 MiBantd 是 peer;已有 antd 项目的增量不同
MateChat Vue 官方三包core 1.14.0 等约 5.65 MiB约 21.72 MiB大头来自 DevUI 与图标;最终按需构建另测
@matechat/react1.0.00.16 MiB0.66 MiBReact、AI SDK、Markdown 等依赖另算
deep-chat-react2.5.04.3 KiB20.8 KiB只是 wrapper,还依赖 deep-chat
deep-chat2.5.02.11 MiB6.56 MiBWeb Component 的完整实现位于这里
NLUX React + core + theme2.17.1约 0.27 MiB约 0.93 MiBadapter 另算;先查 React 版本与维护状态
ChatScope UI + styles2.1.1 / 1.4.0约 0.18 MiB约 1.17 MiBFont 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 ComponentDeep Chat升 element、验证属性/事件/SSR内部 DOM 定制受公开接口约束
通用聊天组件ChatScope升包、验证 CSSAI 新交互长期由业务自己补

assistant-ui 是跨层特例:采用它以后,升级要同时处理 UI 源码 diff 与 runtime API 回归,因此不能套用纯 UI 包的成本判断。

源码组件不是“升级更容易”,只是把升级决定权交给你;npm 组件也不是“升级自动完成”,只是把实现差异集中在公开 API 后面。

最少保留四类 UI 回归测试 ​

  1. 流式回答时上滚,视口不能被抢回底部;
  2. 带草稿切换线程,输入内容不能串线;
  3. 两个附件一个失败,只重试失败文件;
  4. 展开 reasoning、复制代码后继续流式更新,局部状态不能重置。

最后怎么选:只按 UI 约束决策 ​

项目约束UI 起点
React + Ant Design 后台Ant Design X
React 19 + Tailwind 4,品牌设计高度定制AI Elements
Vue 3 / DevUIMateChat Vue
多框架或旧系统快速嵌入Deep Chat
React 18,需要较轻的完整 AI ChatNLUX,先核对维护与兼容
产品本质是人类聊天,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 正式登场。


📚 核对范围与官方资料

原创声明

本文首发于我的个人博客。如需转载请注明作者及 原文链接。

👀 本文阅读 ··· 次

基于 VitePress 构建

本站总访问量 ··· 次·访客数 ··· 人