用React Hook封装AI对话状态

结论先放这:AI对话框那套消息列表、loading、流式打字机状态,别再散在组件里 useState 一把抓了,抽成一个 useChat Hook,组件层干干净净,后面换模型、加重发、改 UI 都不连坐。下面是我自己项目里跑了俩月的版本。

先说我当时多狼狈

我给公司内部做了个查规章的小工具,前端就一个聊天框。第一版我图快,所有状态全堆在组件里:

scss 复制代码
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState(false)
// ...还有 error、abortController、当前流式那条的 id

七八个 useState 摞一起,onSubmit 函数写到四十多行。后来要加个"停止生成"按钮,我得在三个地方同时改状态,改完忘了重置 streaming,按钮一直转圈------测试小姐姐截图发我群里,配文"它卡了"。那一下我是真有点脸热。

问题根上就一个:对话这件事的状态是有生命周期的(空闲→发送中→流式接收→完成/报错),我却把它拍平成一堆互不相关的布尔值,全靠手动同步。

Hook 怎么设计

我把状态收口到一个 reducer 里,对外只暴露动作。核心就三块状态:messages(消息列表)、status(用一个枚举代替散落的 loading/streaming)、error

go 复制代码
type Status = 'idle' | 'loading' | 'streaming' | 'error'

interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
}

function chatReducer(state, action) {
  switch (action.type) {
    case 'send':
      return {
        ...state,
        status: 'loading',
        messages: [...state.messages, action.userMsg],
      }
    case 'chunk': // 流式来一段就拼一段
      return {
        ...state,
        status: 'streaming',
        messages: patchLast(state.messages, action.delta),
      }
    case 'done':  return { ...state, status: 'idle' }
    case 'error': return { ...state, status: 'error', error: action.error }
    default: return state
  }
}

patchLast 就是找到最后那条 assistant 消息、把新来的 token 续上去,流式打字机效果不用组件操心。

外层 Hook 长这样,把 fetch + ReadableStream 的读取藏进去:

php 复制代码
export function useChat(api: string) {
  const [state, dispatch] = useReducer(chatReducer, {
    messages: [], status: 'idle', error: null,
  })
  const ctrl = useRef<AbortController>()

  const send = useCallback(async (text: string) => {
    ctrl.current = new AbortController()
    dispatch({ type: 'send', userMsg: { id: uid(), role: 'user', content: text } })
    // 先塞一条空的 assistant 占位,流式往里灌
    dispatch({ type: 'seed', botId: uid() })

    try {
      const res = await fetch(api, {
        method: 'POST',
        body: JSON.stringify({ prompt: text }),
        signal: ctrl.current.signal,
      })
      const reader = res.body!.getReader()
      const decoder = new TextDecoder()
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        dispatch({ type: 'chunk', delta: decoder.decode(value) })
      }
      dispatch({ type: 'done' })
    } catch (e) {
      if (e.name !== 'AbortError') dispatch({ type: 'error', error: e })
    }
  }, [api])

  const stop = () => ctrl.current?.abort()

  return { ...state, send, stop }
}

组件里就剩这么点

javascript 复制代码
function Chat() {
  const { messages, status, send, stop } = useChat('/api/chat')
  const busy = status === 'loading' || status === 'streaming'

  return (
    <>
      <MessageList items={messages} />
      {status === 'loading' && <Typing />} {/* 还没吐字时的三个点 */}
      <Composer
        disabled={busy}
        onSend={send}
        onStop={busy ? stop : undefined}
      />
    </>
  )
}

"停止生成"这次就一个 stop(),abort 一调,catch 里识别 AbortError 直接吞掉、状态回 idle,不会再卡转圈。那个让我脸热的 bug,换成这套结构之后压根没法复现------因为没有第二个地方能把状态改乱。

几个踩过的坑

  • seed 那条空占位很关键,不然第一个 chunk 到的时候找不到 assistant 消息可以 patch,我一开始漏了,流式直接报 undefined。
  • status 别用两个布尔,我吃过亏:loading 和 streaming 同时为 true 的中间态会闪一下双重提示。一个枚举管所有。
  • 别在 Hook 里存 input,受控输入留给组件自己,Hook 只管对话域的状态,边界清楚后复用才舒服。

后端那条 /api/chat 我没自己写模型逻辑------对话的脏活(挂大模型、接知识库做 RAG、再发布成一个 HTTP 接口)我是在一个零代码搭智能体的平台上拖一拖配一配出来的。我给它喂了部门那几十页规章 PDF,配了个"内部规章问答"的小助手,半小时不到就有了能调的接口,前端这个 useChat 直接对着它打。说实话第一版回答挺干、像背条文,我又往知识库补了几篇 FAQ 才像样;它也就老老实实干检索问答这点杂活,别指望它替你想交互。但"不写后端、不部署模型,自己就把一个能用的 AI 小助手搭起来"这件事,确实把我从样板代码里捞出来了。

前端这层我反而更踏实------状态收口成一个 Hook 之后,换 UI、加重发、做多会话,改的都是同一处。你们封装 AI 对话状态是用 reducer 还是直接 zustand?评论区聊聊,我那个 patchLast 的写法我自己都觉得有点丑。

(模型/接口我走的讯飞 MaaS,现成大模型直接调,没自己部署算力。)

相关推荐
Goodbye1 小时前
从 Token 到 Embedding:LLM 核心基础深度解析
javascript·人工智能
阿瑞IT1 小时前
AI Agent 在甘特计划变更场景中的动态响应工程实践
人工智能
Goodbye1 小时前
从函数到智能:LLM Tool Use 深度解析
javascript·人工智能
半个落月1 小时前
大模型到底是怎么“调用工具”的?从一个 Node.js Demo 看懂 Tool Use
javascript·人工智能
MingXin2 小时前
Claude Code 对接 DeepSeek 完整使用教程(2026 最新版)
人工智能
Oo9202 小时前
LLM 分词与嵌入:从文本到向量,模型如何"读懂"你的输入
人工智能
Databend3 小时前
在 AWS 中国峰会逛了一天,我在 Databend 展台看到了 Agent 数据基础设施的新思路
数据库·人工智能·agent
米小虾3 小时前
从 Prompt 到 Loop:2026 年 AI 工程师必须掌握的 Loop Engineering 实战指南
人工智能·agent
Bigger4 小时前
我写了一个AI图像视频生成工具,免费API+本地部署,分享给大家
人工智能·图像识别·音视频开发