AI 探索技术手记

《AI 聊天组件:暂停、继续、重新生成的完整状态机实现》

2025年7月23日◷ 6 分钟阅读
《AI 聊天组件:暂停、继续、重新生成的完整状态机实现》

一、问题引入:为什么 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 交互。


八、总结

  1. 暂停 = AbortController.abort(),AbortError 需单独处理。
  2. 继续 = 缓存 lastUserQuestion(用 useRef,别用 useState)。
  3. 重新生成 = 移除最后 AI 消息 + 原问题重新请求 + 100ms 微延迟。
  4. useTransition 保证打字机效果不阻塞暂停按钮的交互。

Comments 留言讨论

还没有评论,来抢个沙发,聊聊你的看法~

Michael.Meng

michaelnews@126.com
用 AI 记录,用文字沉淀

© 2026 Michael Meng · 保留所有权利 · Powered by FastAPI + Nuxt