结论先放这: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,现成大模型直接调,没自己部署算力。)