Skip to content

聊天 UI 标准实现

要点

  • 在 Streaming 里,手写了一个 useStreamChat Hook,70 多行代码干了这些事
  • useChat() 返回的对象里有这些字段
  • sendMessage 的参数是 CreateUIMessage,有好几种写法
  • status 是做出好体验的关键
  • DefaultChatTransport 默认把整个 messages 数组作为请求体发给 api URL

内容

1. 从手写 Hook 到 useChat

Streaming 里,手写了一个 useStreamChat Hook,70 多行代码干了这些事:

  • 管理 4 种状态(idle / thinking / generating / done
  • fetch 请求、用 reader.read() 消费流
  • 手写 parseSSEEvents 切分事件
  • switch 里分发 thinking / token / done / error 事件
  • 处理没收到 done 的容错

这一篇的目标是用 @ai-sdk/reactuseChat 把它替换掉,把同等体验压到 10 行以内,顺便拿到一些原来没有的能力(工具调用可视化、思考态、引用来源、多段 parts)。

先装前端包:

shellscript
// index.bash
yarn add @ai-sdk/react ai

一个可运行的 Chat 组件:

tsx
// chat-min.tsx
'use client'

import { useChat } from '@ai-sdk/react'

import { DefaultChatTransport } from 'ai'

import { useState } from 'react'

export function Chat() {

  const [input, setInput] = useState('')

  const { messages, sendMessage, status } = useChat({

    transport: new DefaultChatTransport({ api: '/api/chat' }),

  })

  return (

    <div>

      <ul>

        {messages.map((m) => (

          <li key={m.id}>

            <strong>{m.role}:</strong>

            {m.parts.map((p, i) =>

              p.type === 'text' ? <span key={i}>{p.text}</span> : null,

            )}

          </li>

        ))}

      </ul>

      <form

        onSubmit={(e) => {

          e.preventDefault()

          if (!input.trim()) return

          sendMessage({ text: input })

          setInput('')

        }}

      >

        <input value={input} onChange={(e) => setInput(e.target.value)} />

        <button type="submit" disabled={status !== 'ready'}>

          发送

        </button>

      </form>

    </div>

  )

}

后端(Hono):

typescript
// api-chat.ts
import { streamText, convertToModelMessages } from 'ai'

import { Hono } from 'hono'

const app = new Hono()

app.post('/api/chat', async (c) => {

  const { messages } = await c.req.json()

  const result = streamText({

    model: models.chat,

    messages: convertToModelMessages(messages),

  })

  return result.toUIMessageStreamResponse()

})

跑起来之后:用户打字、按发送、消息进入 messages 数组、回复一段段流出来、完成后状态回到 ready。这一切都是 useChat 内部完成的。

2. useChat 的完整返回值

useChat() 返回的对象里有这些字段:

字段类型用途
messagesUIMessage[]完整的消息历史,前端渲染的唯一数据源
setMessages(msgs) => void直接替换消息数组(编辑历史、清空、重放)
sendMessage(msg) => Promise发送一条新消息,触发后端调用
regenerate() => Promise重新生成最后一条 assistant 回复
stop() => void中止当前流(触发 AbortSignal)
resumeStream() => void断线重连、恢复流(需要后端配合)
status'ready''submitted'
errorErrorundefined
idstringchat 的稳定 ID(可作为后端 session 标识)
addToolResult(args) => voidHITL 场景:手动提供工具执行结果

状态机是这样流转的:

text
// status.txt
ready      ← 初始,或上一次流结束

  ↓ sendMessage()

submitted  ← 请求已发,还没收到首字节

  ↓ 第一个 chunk 到达

streaming  ← 流在逐 token 推送

  ↓ 流结束

ready

  ↓ 异常

error

和手写版对照着看:

  • 手写的 status: 'idle' | 'thinking' | 'generating' | 'done' 对应 useChat'ready' | 'submitted' | 'streaming' | 'error',语义对等,但多了正式的 error 状态
  • 手写的 thinkingNode 对应 useChatreasoning / tool-invocation part,用 parts 数组更通用
  • 手写的 reply 字符串对应 messages[last].parts,多段分片

3. sendMessage 的几种形态

sendMessage 的参数是 CreateUIMessage,有好几种写法:

tsx
// send-message.tsx
// 最简:纯文本

sendMessage({ text: '你好' })

// 多段 parts(图片 + 文本)

sendMessage({

  parts: [

    { type: 'text', text: '描述这张图:' },

    { type: 'file', url: 'https://.../photo.jpg', mediaType: 'image/jpeg' },

  ],

})

// 带元数据(业务字段)

sendMessage({ text: '你好' }, {

  body: {

    sessionId: 'sess_123',

    mood: 'tired',

  },

})

// 第二参数 body 会合并进请求体 —— 后端可以读

用户上传图片 + 问题的多模态场景:

tsx
// multimodal-input.tsx
function ImageUploadChat() {

  const { messages, sendMessage } = useChat({ transport: ... })

  async function handleUpload(file: File, question: string) {

    const url = await uploadToR2(file) // [Cloudflare R2](/14-cloudflare-r2) 讲过 R2

    sendMessage({

      parts: [

        { type: 'text', text: question },

        { type: 'file', url, mediaType: file.type },

      ],

    })

  }

  // ...

}

后端 convertToModelMessages(messages) 会自动把 file part 翻译成 ModelMessage 的 image / file content,不用额外处理。

4. status 配合 UI

status 是做出好体验的关键。几个常见的 UI 模式。

根据 status 切换发送按钮的文案和可用性:

tsx
// disable-send.tsx
<button type="submit" disabled={status !== 'ready'}>

  {status === 'submitted' && '发送中...'}

  {status === 'streaming' && '生成中'}

  {status === 'ready' && '发送'}

  {status === 'error' && '重试'}

</button>

「停止生成」按钮只在 streaming 时显示。点击 stop() 后前端立刻回到 ready;后端如果正确传递了 abortSignalUIMessageStream 讲过),LLM 请求也会同时被取消:

tsx
// stop-button.tsx
{status === 'streaming' && (

  <button onClick={stop}>停止生成</button>

)}

思考指示器用在 submitted 已经发出但还没收到第一个 chunk 的时候。这对应手写版的 thinking 状态,但语义更通用——submitted 可能是在思考,也可能是在调工具。真正的思考可视化交给 parts 里的 reasoning / tool-invocationUI Message Parts 会展开:

tsx
// thinking-indicator.tsx
{status === 'submitted' && <ThinkingDots />}

status 为 error 时展示错误信息和重试按钮:

tsx
// error-retry.tsx
{status === 'error' && (

  <div className="error">

    出错了:{error?.message}

    <button onClick={() => regenerate()}>重试</button>

  </div>

)}

5. Transport 与持久化

DefaultChatTransport 默认把整个 messages 数组作为请求体发给 api URL。要自定义(比如只发最新一条、加自定义 headers、走 WebSocket),用 prepareSendMessagesRequest

typescript
// transport-custom.ts
import { DefaultChatTransport } from 'ai'

const transport = new DefaultChatTransport({

  api: '/api/chat',

  // 每次发请求的自定义 headers

  headers: () => ({

    'x-session-id': getSessionId(),

    'authorization': `Bearer ${getToken()}`,

  }),

  // 自定义请求体

  prepareSendMessagesRequest: ({ id, messages, body }) => {

    return {

      body: {

        chatId: id,

        // 只发最新一条

        message: messages[messages.length - 1],

        // 合并调用方传的 body

        ...body,

      },

    }

  },

})

// 使用

const chat = useChat({ transport })

AI 伴侣项目就是用这个能力做「只发最新一条 + 后端从 D1 加载历史」的长会话模式(重构端到端 AI Chat 实战会展开)。

本地持久化

useChat 默认不做持久化,刷新页面消息就没了。两种做法。

受控 messages,自己管状态:

tsx
// controlled-messages.tsx
const [persistedMessages, setPersistedMessages] = useState<UIMessage[]>(

  () => JSON.parse(localStorage.getItem('chat-history') ?? '[]'),

)

const { messages, sendMessage, setMessages } = useChat({

  messages: persistedMessages,

  transport: ...,

  onFinish: ({ message }) => {

    setPersistedMessages((prev) => [...prev, message])

    localStorage.setItem('chat-history', JSON.stringify([...persistedMessages, message]))

  },

})

或者后端持久化(AI 伴侣走的就是这条路):前端不存,后端每次 onFinish 写 D1,下次刷新时前端通过 API 拉回来注入 messages

断线重连:resumeStream

用户刷新、网络断了、切标签页——流就中断了。resumeStream() 允许恢复:

typescript
// resume.ts
import { useChat } from '@ai-sdk/react'

const { resumeStream } = useChat({

  transport: ...,

  // 触发恢复的时机由你决定

})

useEffect(() => {

  // 页面恢复时尝试续流

  const onVisibilityChange = () => {

    if (document.visibilityState === 'visible') {

      resumeStream()

    }

  }

  document.addEventListener('visibilitychange', onVisibilityChange)

  return () => document.removeEventListener('visibilitychange', onVisibilityChange)

}, [resumeStream])

后端要支持恢复协议,用 createResumableStreamContext 把流持久化到 KV,客户端带着最后看到的事件 id 过来就能续上。完整实现放在 AI SDK × Hono

6. 受控 vs 非受控:input 的两种模式

上面的例子用 useState 管理输入框(受控)。这是 useChat 在 v5 的默认模式——useChat 不再管 input 状态,完全交给你。

对比早期 v4 的写法:

tsx
// v4-old.tsx
// v4 的老写法(v5 不再支持)

const { input, handleInputChange, handleSubmit } = useChat({ api: '/api/chat' })

<form onSubmit={handleSubmit}>

  <input value={input} onChange={handleInputChange} />

</form>
tsx
// v5-new.tsx
// v5 推荐

const [input, setInput] = useState('')

const { sendMessage } = useChat({ transport: ... })

<form onSubmit={(e) => {

  e.preventDefault()

  sendMessage({ text: input })

  setInput('')

}}>

  <input value={input} onChange={(e) => setInput(e.target.value)} />

</form>

v5 的设计更清晰:useChat 只管对话流,输入框是你自己的事。这样接入 Ant Design / shadcn/ui 的复杂输入组件也更容易(不用 hack handleInputChange 的类型)。

7. 和手写版的完整 diff

Streaming 里的 useStreamChatuseChat 放一起对照:

tsx
// handwritten.tsx
// 手写版(简化)

function useStreamChat() {

  const [status, setStatus] = useState<Status>('idle')

  const [thinkingNode, setThinkingNode] = useState('')

  const [reply, setReply] = useState('')

  const [metadata, setMetadata] = useState(null)

  const sendMessage = useCallback(async (message: string) => {

    setStatus('thinking')

    setReply('')

    const response = await fetch('/api/chat/stream', {

      method: 'POST',

      headers: { 'Content-Type': 'application/json' },

      body: JSON.stringify({ message, sessionId }),

    })

    const reader = response.body!.getReader()

    const decoder = new TextDecoder()

    let buffer = ''

    while (true) {

      const { done, value } = await reader.read()

      if (done) break

      buffer += decoder.decode(value, { stream: true })

      const { parsed, remaining } = parseSSEEvents(buffer)

      buffer = remaining

      for (const event of parsed) {

        switch (event.type) {

          case 'thinking': setThinkingNode(event.data.node); break

          case 'token':    setStatus('generating'); setReply(p => p + event.data.content); break

          case 'done':     setStatus('done'); setMetadata(event.data); break

          case 'error':    setStatus('done'); break

        }

      }

    }

  }, [])

  return { status, thinkingNode, reply, metadata, sendMessage }

}
tsx
// sdk-version.tsx
// SDK 版

function useSdkChat() {

  return useChat({

    transport: new DefaultChatTransport({ api: '/api/chat' }),

  })

}

代码量对比:

维度手写版SDK 版
Hook 代码70+ 行3 行
SSE 解析代码30+ 行(parseSSEEvents)0
状态管理自建 4 种 useState内置 status
工具调用可视化未覆盖原生 parts
思考态流式单事件reasoning part 流式
断线恢复未覆盖resumeStream()
中止生成要自己管理 AbortControllerstop()
重新生成要自己重放消息regenerate()

同等体验下,代码量缩减到原来的二十分之一,顺带还免费获得了一批原本要自己补的能力。

8. 小结

  • useChat 就是手写 useStreamChat 的工业化版本,核心返回 messages / sendMessage / status
  • 状态机四态:ready / submitted / streaming / error
  • sendMessage 接收 UIMessage 形态,支持多段 parts、多模态、业务元数据(第二个参数 body)
  • Transport 抽象了 HTTP 细节,自定义 headers、body 变形、WebSocket 接入都从这里改
  • v5 不再管 input 状态,输入框交给调用方
  • 持久化两种玩法:本地 localStorage,或者后端数据库;断线恢复用 resumeStream
  • 对比手写版:代码量 1/20,能力 3 倍

下一篇——UI Message Parts,真正发挥 UIMessage 分片渲染的威力。思考态气泡、工具调用卡片、RAG 引用徽章、流式 Markdown,每一种怎么渲染。

基于 MIT 协议开源