一、问题引入:为什么 GPT 能暂停而你不行?
ChatGPT 和豆包都有一个共同的能力:生成到一半可以点停止,甚至还能基于同一个问题重新生成。
实现这三个功能看似简单,实则涉及 5 个状态的精确管理:空闲、加载中、已暂停、已完成、重新生成中。任何一个状态判断错误,就会出现"点了暂停,UI 还继续蹦字"或者"点了继续,重新发了一个请求"的 Bug。
| 功能 | 用户操作 | 技术实现 |
|---|---|---|
| 暂停 | 点击停止 | AbortController.abort() 取消当前流 |
| 继续 | 点击继续 | 缓存 lastUserQuestion,重新发起请求 |
| 重新生成 | 点击按钮 | 移除 AI 最后一条消息,重新请求 |
二、状态机设计
┌─────────────┐
用户发送消息 │ isIdle │
┌─────────→ │ (空闲) │ ←─────────┐
│ └──────┬──────┘ │
│ │ startGenerate() │
│ ▼ │
│ ┌─────────────┐ │
│ 用户点暂停 │ isGenerating │ 流自然结束 │
│ ←─────────│ (生成中) │───────────→│
│ pause() └──────┬──────┘ │
│ │ │
▼ │ │
┌─────────────┐ 重新生成 │ │
│ isPaused │ ←─────────┤ │
│ (已暂停) │ regenerate() │
└──────┬──────┘ │
│ resume() = startGenerate(lastQuestion) │
└─────────────────────────────────────────┘
三、完整可运行代码
import { useState, useRef, useEffect, useTransition } from 'react'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
// ---- 消息类型 ----
interface Message {
id: number
role: 'user' | 'assistant'
content: string
generating?: boolean // 是否正在流式生成
}
// ---- 状态枚举 ----
type ChatState = 'idle' | 'generating' | 'paused' | 'completed'
export default function AIChatPanel() {
// ========== 核心状态 ==========
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [chatState, setChatState] = useState<ChatState>('idle')
const [, startTransition] = useTransition()
// ========== Refs(跨渲染保活) ==========
const abortRef = useRef<AbortController | null>(null)
const lastUserQuestion = useRef('') // 缓存最后一条用户问题
const currentAiMsgId = useRef<number | null>(null) // 当前 AI 消息 ID
// 清理:组件卸载时取消请求
useEffect(() => {
return () => { abortRef.current?.abort() }
}, [])
// ========================
// 1. 发送消息 / 开始生成
// ========================
const startGenerate = async (userContent: string) => {
if (!userContent.trim() || chatState === 'generating') return
// ★ 保存用户问题(用于继续/重新生成)
lastUserQuestion.current = userContent
setChatState('generating')
try {
// 1.1 创建 AbortController(用于暂停)
abortRef.current = new AbortController()
// 1.2 追加用户消息
const userMsg: Message = {
id: Date.now(),
role: 'user',
content: userContent,
}
// 1.3 追加空 AI 消息占位
const aiMsgId = Date.now() + 1
currentAiMsgId.current = aiMsgId
const aiMsg: Message = {
id: aiMsgId,
role: 'assistant',
content: '',
generating: true,
}
setMessages(prev => [...prev, userMsg, aiMsg])
// 1.4 发起流式请求
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userContent }),
signal: abortRef.current.signal, // ★ 绑定 AbortController
})
if (!res.ok || !res.body) throw new Error('请求失败')
// 1.5 流式读取
const reader = res.body
.pipeThrough(new TextDecoderStream())
.getReader()
let fullText = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 解析 SSE 格式
const lines = value.split('
')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6).trim()
if (jsonStr === '[DONE]') continue
try {
const data = JSON.parse(jsonStr)
const token = data.choices?.[0]?.delta?.content || ''
if (!token) continue
fullText += token
// ★ 低优先级更新(打字机效果不卡 UI)
startTransition(() => {
setMessages(prev =>
prev.map(m =>
m.id === aiMsgId ? { ...m, content: fullText } : m
)
)
})
} catch { /* 忽略解析错误,继续读下一帧 */ }
}
}
// 1.6 生成完成
setMessages(prev =>
prev.map(m =>
m.id === aiMsgId ? { ...m, generating: false } : m
)
)
setChatState('completed')
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
// ★ 用户主动暂停:保持 paused 状态,标记生成完成
setChatState('paused')
setMessages(prev =>
prev.map(m =>
m.id === currentAiMsgId.current
? { ...m, generating: false }
: m
)
)
return
}
// 网络错误:标记生成完成,UI 展示错误
console.error('流式错误:', err)
setChatState('completed')
}
}
// ========================
// 2. 暂停生成
// ========================
const pauseGenerate = () => {
if (chatState !== 'generating') return
abortRef.current?.abort()
// AbortError 后 catch 块会自动将 state 设为 'paused'
}
// ========================
// 3. 继续生成
// ========================
const resumeGenerate = () => {
if (chatState === 'generating' || !lastUserQuestion.current) return
// 用缓存的问题重新发送
startGenerate(lastUserQuestion.current)
}
// ========================
// 4. 重新生成
// ========================
const regenerate = () => {
if (!lastUserQuestion.current) return
// 4.1 取消当前流(如果有)
abortRef.current?.abort()
// 4.2 移除最后一条 AI 消息
setMessages(prev => {
const temp = [...prev]
if (temp.length >= 1 && temp.at(-1)!.role === 'assistant') {
temp.pop()
}
return temp
})
// 4.3 用同样的问题重新生成
setTimeout(() => {
startGenerate(lastUserQuestion.current)
}, 100) // 微延迟让 UI 先更新
}
// ========================
// 辅助:渲染 Markdown
// ========================
const renderMarkdown = (text: string) => {
return DOMPurify.sanitize(marked.parse(text) as string)
}
// ========================
// UI 渲染
// ========================
return (
<div style={{ maxWidth: 750, margin: '40px auto', padding: 20 }}>
<h2>AI 聊天助手</h2>
{/* 对话区 */}
<div style={{
border: '1px solid #eee', borderRadius: 12, padding: 20,
minHeight: 450, maxHeight: 600, overflowY: 'auto', marginBottom: 16
}}>
{messages.map(msg => (
<div key={msg.id} style={{
marginBottom: 16,
textAlign: msg.role === 'user' ? 'right' : 'left'
}}>
<div style={{
display: 'inline-block', padding: '10px 14px',
borderRadius: 10, maxWidth: '75%', textAlign: 'left',
backgroundColor: msg.role === 'user' ? '#0077ff' : '#f6f6f6',
color: msg.role === 'user' ? '#fff' : '#333',
}}>
{msg.generating && !msg.content && (
<div style={{ fontSize: 12, opacity: 0.6 }}>思考中...</div>
)}
<div
dangerouslySetInnerHTML={{
__html: renderMarkdown(msg.content)
}}
/>
</div>
</div>
))}
</div>
{/* 控制按钮 */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
<button
onClick={pauseGenerate}
disabled={chatState !== 'generating'}
>
暂停
</button>
<button
onClick={resumeGenerate}
disabled={chatState === 'generating'}
>
继续
</button>
<button
onClick={regenerate}
disabled={
chatState === 'generating' || !lastUserQuestion.current
}
>
重新生成
</button>
</div>
{/* 输入框 */}
<div style={{ display: 'flex', gap: 10 }}>
<input
style={{
flex: 1, padding: 12, borderRadius: 8,
border: '1px solid #ddd'
}}
value={input}
onChange={e => setInput(e.target.value)}
placeholder="输入问题..."
onKeyDown={e => {
if (e.key === 'Enter') {
startGenerate(input)
setInput('')
}
}}
/>
<button onClick={() => {
startGenerate(input)
setInput('')
}}>
发送
</button>
</div>
</div>
)
}
四、逐行精讲
4.1 lastUserQuestion 是整个机制的核心
const lastUserQuestion = useRef('')
为什么用 useRef 而不是 useState?
useRef的值在 re-render 时保持:暂停后点"继续",resumeGenerate需要用原来的问题重新请求,而useState的值可能被后续操作覆盖。
4.2 AbortError 必须单独处理
if (err instanceof Error && err.name === 'AbortError') {
setChatState('paused')
return
}
AbortController.abort() 会让 fetch 抛出 AbortError。这个错误不是真正的错误——它是用户的主动操作。如果不做特殊处理,就会被当成网络错误,出现错误提示和暂停冲突。
4.3 重新生成的微延迟
setTimeout(() => {
startGenerate(lastUserQuestion.current)
}, 100)
为什么需要 100ms 延迟?因为 setMessages 是异步的。如果立刻调用 startGenerate,React 可能还没完成移除 AI 消息的重渲染,新消息就会追加到旧消息后面导致 UI 闪烁。
五、常见问题
Q1:暂停后"继续",为啥不是从断点续传而是重新请求?
A:SSE 流被 abort 后无法恢复读取位置。要"真·断点续传"需要在后端记录生成进度并支持 offset 参数。前端方案(重新请求)是最低成本实现。
Q2:连续点暂停+继续太快会怎样?
A:旧的 AbortController 还在 abort 过程中,新的请求已经发出。解决方案:resumeGenerate 里判断 chatState !== 'generating' 才允许继续。
六、决策框架
你的 AI 聊天需要什么体验?
├── 只需发送+展示 → 基础流式对话即可
│
├── 需要可以停 → 加 AbortController + pause
│ └── 停后还想要 → 加 lastUserQuestion + resume
│ └── 还想换个回答 → 加 regenerate(本文完整方案)
│
└── 需要关联历史 → 加 sessionId + 多轮对话
七、面试速记
Q:AI 聊天的暂停/继续/重新生成怎么实现?
A:暂停 = AbortController.abort();继续 = 用 useRef 缓存的问题重新 fetch;重新生成 = 移除最后 AI 消息 + 重新请求。核心是用 ref 而非 state 保存 lastUserQuestion 防止值丢失。
Q:为什么用 useTransition?
A:startTransition 将流式文本更新降为低优先级(不阻塞用户点击停止按钮),避免"打字机效果"卡住 UI 交互。
八、总结
- 暂停 =
AbortController.abort(),AbortError 需单独处理。 - 继续 = 缓存
lastUserQuestion(用useRef,别用useState)。 - 重新生成 = 移除最后 AI 消息 + 原问题重新请求 + 100ms 微延迟。
useTransition保证打字机效果不阻塞暂停按钮的交互。
Comments 留言讨论
还没有评论,来抢个沙发,聊聊你的看法~