08-SSE流式传输:实现打字机效果的AI回复

55 阅读5分钟

SSE流式传输:实现打字机效果的AI回复

前言

打字机效果能显著提升AI对话的用户体验。本文将详细介绍如何使用SSE(Server-Sent Events)实现流式传输,打造流畅的实时对话体验。

适合读者: 前端开发者、全栈工程师


一、SSE基础

1.1 什么是SSE

SSE (Server-Sent Events)
- 服务器向客户端推送数据的技术
- 基于HTTP协议
- 单向通信(服务器→客户端)
- 自动重连
- 文本数据传输

1.2 SSE消息格式

event: message_type
data: {"key": "value"}
id: unique_id
retry: 3000


二、前端SSE实现

2.1 原生EventSource

// 原生EventSource API
const eventSource = new EventSource('/api/chat/stream')

eventSource.onmessage = (event) => {
  console.log('收到消息:', event.data)
}

eventSource.onerror = (error) => {
  console.error('连接错误:', error)
  eventSource.close()
}

// 关闭连接
eventSource.close()

2.2 使用fetch-event-source

// 安装依赖
// npm install @microsoft/fetch-event-source

import { fetchEventSource } from '@microsoft/fetch-event-source'

await fetchEventSource('/api/chat/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`
  },
  body: JSON.stringify({
    conversation_id: 'conv_123',
    message: '你好'
  }),
  
  onopen(response) {
    if (response.ok) {
      console.log('连接已建立')
    } else {
      throw new Error(`HTTP ${response.status}`)
    }
  },
  
  onmessage(event) {
    console.log('事件类型:', event.event)
    console.log('数据:', event.data)
  },
  
  onerror(err) {
    console.error('错误:', err)
    throw err
  },
  
  onclose() {
    console.log('连接已关闭')
  }
})

三、打字机效果实现

3.1 基础打字机组件

// components/TypewriterText.tsx
'use client'

import { useState, useEffect } from 'react'

interface TypewriterTextProps {
  text: string
  speed?: number
  onComplete?: () => void
}

export function TypewriterText({ 
  text, 
  speed = 30,
  onComplete 
}: TypewriterTextProps) {
  const [displayText, setDisplayText] = useState('')
  const [currentIndex, setCurrentIndex] = useState(0)

  useEffect(() => {
    if (currentIndex < text.length) {
      const timer = setTimeout(() => {
        setDisplayText(prev => prev + text[currentIndex])
        setCurrentIndex(prev => prev + 1)
      }, speed)

      return () => clearTimeout(timer)
    } else if (currentIndex === text.length && onComplete) {
      onComplete()
    }
  }, [currentIndex, text, speed, onComplete])

  return (
    <span>
      {displayText}
      {currentIndex < text.length && (
        <span className="inline-block w-0.5 h-4 ml-0.5 bg-current animate-pulse" />
      )}
    </span>
  )
}

3.2 实时流式打字机

// components/StreamingMessage.tsx
'use client'

import { useState, useEffect, useRef } from 'react'

interface StreamingMessageProps {
  conversationId: string
  message: string
  onComplete?: (fullText: string) => void
}

export function StreamingMessage({
  conversationId,
  message,
  onComplete
}: StreamingMessageProps) {
  const [displayText, setDisplayText] = useState('')
  const [isStreaming, setIsStreaming] = useState(true)
  const [sources, setSources] = useState<any[]>([])
  const abortControllerRef = useRef<AbortController>()

  useEffect(() => {
    startStreaming()
    
    return () => {
      abortControllerRef.current?.abort()
    }
  }, [conversationId, message])

  const startStreaming = async () => {
    abortControllerRef.current = new AbortController()
    
    try {
      await fetchEventSource('/api/chat/stream', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${getToken()}`
        },
        body: JSON.stringify({
          conversation_id: conversationId,
          message: message
        }),
        signal: abortControllerRef.current.signal,
        
        onmessage(event) {
          const data = JSON.parse(event.data)
          
          switch (event.event) {
            case 'thinking':
              console.log('AI正在思考...')
              break
              
            case 'sources':
              setSources(data.sources || [])
              break
              
            case 'token':
              setDisplayText(prev => prev + data.token)
              break
              
            case 'done':
              setIsStreaming(false)
              onComplete?.(displayText)
              break
              
            case 'error':
              console.error('流式错误:', data.error)
              setIsStreaming(false)
              break
          }
        },
        
        onerror(err) {
          console.error('SSE错误:', err)
          setIsStreaming(false)
          throw err
        }
      })
    } catch (error) {
      console.error('流式传输失败:', error)
      setIsStreaming(false)
    }
  }

  return (
    <div className="space-y-2">
      {/* 来源信息 */}
      {sources.length > 0 && (
        <div className="text-xs text-gray-6">
          参考了 {sources.length} 条相关文档
        </div>
      )}
      
      {/* 流式文本 */}
      <div className="text-sm whitespace-pre-wrap break-words">
        {displayText}
        {isStreaming && (
          <span className="inline-block w-0.5 h-4 ml-0.5 bg-blue-6 animate-pulse" />
        )}
      </div>
    </div>
  )
}

四、完整聊天实现

4.1 聊天服务封装

// services/streaming-chat.service.ts
import { fetchEventSource } from '@microsoft/fetch-event-source'

export interface StreamCallbacks {
  onThinking?: (data: any) => void
  onSources?: (data: any) => void
  onToken?: (token: string) => void
  onDone?: (fullText: string) => void
  onError?: (error: string) => void
}

export class StreamingChatService {
  private abortController: AbortController | null = null

  async sendMessage(
    conversationId: string,
    message: string,
    callbacks: StreamCallbacks
  ): Promise<void> {
    this.abortController = new AbortController()
    let fullText = ''

    try {
      await fetchEventSource(`${API_URL}/api/chat/stream`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${getToken()}`
        },
        body: JSON.stringify({
          conversation_id: conversationId,
          message: message
        }),
        signal: this.abortController.signal,
        
        async onopen(response) {
          if (!response.ok) {
            throw new Error(`HTTP ${response.status}`)
          }
        },
        
        onmessage(event) {
          try {
            const data = JSON.parse(event.data)
            
            switch (event.event) {
              case 'thinking':
                callbacks.onThinking?.(data)
                break
                
              case 'sources':
                callbacks.onSources?.(data)
                break
                
              case 'token':
                fullText += data.token
                callbacks.onToken?.(data.token)
                break
                
              case 'done':
                callbacks.onDone?.(fullText)
                break
                
              case 'error':
                callbacks.onError?.(data.error)
                break
            }
          } catch (err) {
            console.error('解析消息失败:', err)
          }
        },
        
        onerror(err) {
          callbacks.onError?.(err.message)
          throw err
        }
      })
    } catch (error: any) {
      if (error.name !== 'AbortError') {
        callbacks.onError?.(error.message)
      }
    }
  }

  abort() {
    this.abortController?.abort()
  }
}

export const streamingChatService = new StreamingChatService()

4.2 聊天组件

// components/ChatInterface.tsx
'use client'

import { useState, useRef, useEffect } from 'react'
import { streamingChatService } from '@/services/streaming-chat.service'

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

export function ChatInterface({ conversationId }: { conversationId: string }) {
  const [messages, setMessages] = useState<Message[]>([])
  const [inputValue, setInputValue] = useState('')
  const [isThinking, setIsThinking] = useState(false)
  const [currentStreamingText, setCurrentStreamingText] = useState('')
  const messagesEndRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages, currentStreamingText])

  const handleSend = async () => {
    if (!inputValue.trim()) return

    const userMessage: Message = {
      id: Date.now().toString(),
      role: 'user',
      content: inputValue
    }

    setMessages(prev => [...prev, userMessage])
    setInputValue('')
    setIsThinking(true)
    setCurrentStreamingText('')

    try {
      await streamingChatService.sendMessage(
        conversationId,
        userMessage.content,
        {
          onThinking: (data) => {
            console.log('思考中:', data)
          },

          onSources: (data) => {
            console.log('检索到', data.count, '条文档')
            setIsThinking(false)
          },

          onToken: (token) => {
            setCurrentStreamingText(prev => prev + token)
          },

          onDone: (fullText) => {
            const assistantMessage: Message = {
              id: Date.now().toString(),
              role: 'assistant',
              content: fullText
            }
            setMessages(prev => [...prev, assistantMessage])
            setCurrentStreamingText('')
          },

          onError: (error) => {
            console.error('错误:', error)
            setIsThinking(false)
            setCurrentStreamingText('')
          }
        }
      )
    } catch (error) {
      console.error('发送失败:', error)
    }
  }

  return (
    <div className="flex flex-col h-full">
      {/* 消息列表 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg) => (
          <MessageBubble key={msg.id} message={msg} />
        ))}

        {/* 实时流式消息 */}
        {currentStreamingText && (
          <MessageBubble
            message={{
              id: 'streaming',
              role: 'assistant',
              content: currentStreamingText,
              isStreaming: true
            }}
          />
        )}

        {/* 思考指示器 */}
        {isThinking && <ThinkingIndicator />}

        <div ref={messagesEndRef} />
      </div>

      {/* 输入框 */}
      <div className="p-4 border-t">
        <div className="flex space-x-2">
          <input
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
            onKeyPress={(e) => {
              if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault()
                handleSend()
              }
            }}
            placeholder="输入消息..."
            className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:border-blue-6"
          />
          <button
            onClick={handleSend}
            disabled={!inputValue.trim() || isThinking}
            className="px-6 py-2 bg-blue-6 text-white rounded-lg hover:bg-blue-7 disabled:opacity-50"
          >
            发送
          </button>
        </div>
      </div>
    </div>
  )
}

五、性能优化

5.1 防抖和节流

// hooks/useDebounce.ts
import { useState, useEffect } from 'react'

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

// 使用示例
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('')
  const debouncedSearchTerm = useDebounce(searchTerm, 500)

  useEffect(() => {
    if (debouncedSearchTerm) {
      // 执行搜索
      performSearch(debouncedSearchTerm)
    }
  }, [debouncedSearchTerm])

  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="搜索..."
    />
  )
}

5.2 虚拟滚动优化

// 使用react-window实现虚拟滚动
import { FixedSizeList } from 'react-window'

function VirtualMessageList({ messages }: { messages: Message[] }) {
  const Row = ({ index, style }: { index: number; style: any }) => (
    <div style={style}>
      <MessageBubble message={messages[index]} />
    </div>
  )

  return (
    <FixedSizeList
      height={600}
      itemCount={messages.length}
      itemSize={100}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  )
}

5.3 批量更新优化

// 批量更新Token以减少渲染次数
function useBatchedTokens(interval = 50) {
  const [tokens, setTokens] = useState<string[]>([])
  const bufferRef = useRef<string[]>([])
  const timerRef = useRef<NodeJS.Timeout>()

  const addToken = (token: string) => {
    bufferRef.current.push(token)

    if (!timerRef.current) {
      timerRef.current = setTimeout(() => {
        setTokens(prev => [...prev, ...bufferRef.current])
        bufferRef.current = []
        timerRef.current = undefined
      }, interval)
    }
  }

  const flush = () => {
    if (bufferRef.current.length > 0) {
      setTokens(prev => [...prev, ...bufferRef.current])
      bufferRef.current = []
    }
    if (timerRef.current) {
      clearTimeout(timerRef.current)
      timerRef.current = undefined
    }
  }

  return { tokens, addToken, flush }
}

六、错误处理

6.1 重连机制

// services/resilient-stream.service.ts
export class ResilientStreamService {
  private maxRetries = 3
  private retryDelay = 1000

  async sendMessageWithRetry(
    conversationId: string,
    message: string,
    callbacks: StreamCallbacks,
    retryCount = 0
  ): Promise<void> {
    try {
      await streamingChatService.sendMessage(
        conversationId,
        message,
        callbacks
      )
    } catch (error) {
      if (retryCount < this.maxRetries) {
        console.log(`重试 ${retryCount + 1}/${this.maxRetries}`)
        
        await new Promise(resolve => 
          setTimeout(resolve, this.retryDelay * (retryCount + 1))
        )
        
        return this.sendMessageWithRetry(
          conversationId,
          message,
          callbacks,
          retryCount + 1
        )
      }
      
      throw error
    }
  }
}

6.2 超时处理

// utils/timeout.ts
export function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number
): Promise<T> {
  return Promise.race([
    promise,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error('请求超时')), timeoutMs)
    )
  ])
}

// 使用示例
try {
  await withTimeout(
    streamingChatService.sendMessage(conversationId, message, callbacks),
    30000  // 30秒超时
  )
} catch (error) {
  if (error.message === '请求超时') {
    console.error('请求超时,请重试')
  }
}

七、用户体验优化

7.1 加载状态

// components/LoadingStates.tsx
export function ThinkingIndicator() {
  return (
    <div className="flex items-center space-x-2 text-gray-6">
      <div className="flex space-x-1">
        <div className="w-2 h-2 bg-gray-4 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
        <div className="w-2 h-2 bg-gray-4 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
        <div className="w-2 h-2 bg-gray-4 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
      </div>
      <span className="text-sm">AI正在思考...</span>
    </div>
  )
}

export function RetrievingIndicator({ count }: { count?: number }) {
  return (
    <div className="text-xs text-gray-6 flex items-center space-x-2">
      <div className="w-3 h-3 border-2 border-blue-6 border-t-transparent rounded-full animate-spin" />
      <span>正在检索知识库{count ? ` (${count}条)` : '...'}</span>
    </div>
  )
}

7.2 停止生成按钮

// components/StopButton.tsx
export function StopGenerationButton({ 
  onStop 
}: { 
  onStop: () => void 
}) {
  return (
    <button
      onClick={onStop}
      className="px-4 py-2 bg-red-6 text-white rounded-lg hover:bg-red-7 transition-colors"
    >
      ⏹ 停止生成
    </button>
  )
}

// 在聊天组件中使用
function ChatInterface() {
  const handleStop = () => {
    streamingChatService.abort()
    setIsThinking(false)
    setCurrentStreamingText('')
  }

  return (
    <div>
      {/* ... */}
      {isThinking && <StopGenerationButton onStop={handleStop} />}
    </div>
  )
}

7.3 复制功能

// components/CopyButton.tsx
'use client'

import { useState } from 'react'

export function CopyButton({ text }: { text: string }) {
  const [copied, setCopied] = useState(false)

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(text)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    } catch (error) {
      console.error('复制失败:', error)
    }
  }

  return (
    <button
      onClick={handleCopy}
      className="p-1 text-gray-6 hover:text-blue-6 transition-colors"
      title="复制"
    >
      {copied ? '✓' : '📋'}
    </button>
  )
}

八、Markdown渲染

8.1 实时Markdown渲染

// components/MarkdownRenderer.tsx
import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'

interface MarkdownRendererProps {
  content: string
  isStreaming?: boolean
}

export function MarkdownRenderer({ 
  content, 
  isStreaming 
}: MarkdownRendererProps) {
  return (
    <div className="prose prose-sm max-w-none">
      <ReactMarkdown
        components={{
          code({ node, inline, className, children, ...props }) {
            const match = /language-(\w+)/.exec(className || '')
            
            return !inline && match ? (
              <SyntaxHighlighter
                style={vscDarkPlus}
                language={match[1]}
                PreTag="div"
                {...props}
              >
                {String(children).replace(/\n$/, '')}
              </SyntaxHighlighter>
            ) : (
              <code className="bg-gray-2 px-1 py-0.5 rounded text-xs" {...props}>
                {children}
              </code>
            )
          },
          
          p({ children }) {
            return <p className="mb-2">{children}</p>
          },
          
          ul({ children }) {
            return <ul className="list-disc pl-4 mb-2">{children}</ul>
          },
          
          ol({ children }) {
            return <ol className="list-decimal pl-4 mb-2">{children}</ol>
          }
        }}
      >
        {content}
      </ReactMarkdown>
      
      {isStreaming && (
        <span className="inline-block w-0.5 h-4 ml-0.5 bg-blue-6 animate-pulse" />
      )}
    </div>
  )
}

九、测试

9.1 单元测试

// __tests__/StreamingMessage.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { StreamingMessage } from '@/components/StreamingMessage'

describe('StreamingMessage', () => {
  it('应该逐字显示文本', async () => {
    render(
      <StreamingMessage
        conversationId="test"
        message="测试消息"
      />
    )

    await waitFor(() => {
      expect(screen.getByText(/测试消息/)).toBeInTheDocument()
    })
  })

  it('完成后应该调用onComplete', async () => {
    const onComplete = jest.fn()
    
    render(
      <StreamingMessage
        conversationId="test"
        message="测试"
        onComplete={onComplete}
      />
    )

    await waitFor(() => {
      expect(onComplete).toHaveBeenCalled()
    })
  })
})

十、总结

SSE流式传输的核心要点:

实时体验 - 打字机效果提升用户体验
错误处理 - 重连机制保证稳定性
性能优化 - 批量更新减少渲染
用户控制 - 支持停止生成
Markdown支持 - 实时渲染格式化内容

下一篇预告: 《JWT认证在Next.js中的最佳实践》


作者简介: 资深开发者,创业者。专注于视频通讯技术领域。国内首本Flutter著作《Flutter技术入门与实战》作者,另著有《Dart语言实战》及《WebRTC音视频开发》等书籍。多年从事视频会议、远程教育等技术研发,对于Android、iOS以及跨平台开发技术有比较深入的研究和应用,作为主要程序员开发了多个应用项目,涉及医疗、交通、银行等领域。

学习资料:

欢迎交流: 如有问题欢迎在评论区讨论 🚀