ai-hooks 开源地址:github.com/Gijela/ai-h...
你是否也曾想在项目中轻松实现类似 ChatGPT 的流式对话界面,却被繁琐的状态管理、消息拼接、错误处理和生命周期控制所困扰?上期我们精读了 fetch-event-source 库的源码,其虽为我们提供了坚实的底层能力,但直接在业务组件中使用它,就如同给了你一台强大的引擎,却还需要自己动手组装车身、方向盘和仪表盘。
useSse hook 正是为此而生。它是一把精心打造的"瑞士军刀",专门用于在 Vue3/React 环境下,优雅地处理服务器推送事件 (SSE)。它不仅封装了 fetch-event-source 的所有功能,更在 Vue/React 的响应式系统之上,构建了一套完整的、专为对话式 AI 应用设计的状态管理和交互模型。
本文将以 Vue3 为例,介绍 useSse,你将深入理解:
- 为何需要 useSse :了解它如何从 fetch-event-source的底层能力中升华,解决在 Vue 应用中的实际痛点。
- "响应式状态机"的设计 :剖析 useSse如何巧妙地利用ref将连接状态 (connecting,streaming,completed,error等) 与 UI 渲染无缝对接。
- 消息流的生命周期管理 :学习 append,stop等核心方法如何精巧地控制从用户发送消息到接收、渲染、完成或中断的整个流程。
- 高阶抽象的威力 :通过对比原生 fetch-event-source的用法,体会useSse这个高阶 Hook 如何大幅简化代码、提升开发效率和可维护性。
- 最佳实践范例:获得一个即插即用的实战指南,快速在你的项目中集成 AI 对话能力。
准备好用一种更优雅的方式来驾驭 SSE 了吗?让我们开始吧!
缘起:直接用 fetch-event-source 不够香吗?
fetch-event-source 是一个出色的库,它解决了原生 EventSource 的诸多限制。然而,在构建一个功能完备的 Vue 对话界面时,我们面临的挑战远不止建立一个 SSE 连接那么简单:
- 状态管理之痛 :加载状态 (loading)、错误信息 (error)、消息列表 (messages),这些都需要在 Vue 的响应式系统中进行管理,并与组件的生命周期同步。
- 消息格式化之繁 :从服务器收到的原始数据块 (datachunk) 需要被解析、拼接,并转换成结构化的消息对象,才能被 UI 正确渲染。
- 用户交互之杂:需要处理用户输入、发送消息、中断流式响应、重试等多种交互逻辑。
- 生命周期之忧:当组件被卸载时,必须确保 SSE 连接被可靠地关闭,以防止内存泄漏和不必要的后台请求。
直接在组件中处理这些逻辑,会导致代码臃肿、状态分散、难以维护。useSse 的核心使命,就是将这些通用的、复杂的逻辑封装起来,提供一个干净、声明式且高度可复用的接口。
核心思想:一个 Hook,承包整个 AI 对话界面的状态管理
useSse 的设计哲学非常清晰:它不仅仅是一个网络请求的封装,而是一个完整的、面向对话场景的状态管理器。
它将一个典型的 AI 对话流程抽象为以下几个核心要素:
- messages: 一个响应式的消息数组,是整个对话历史的"唯一事实来源"。
- input: 一个响应式的字符串,用于双向绑定输入框。
- loading: 一个响应式的布尔值,用于控制加载状态的 UI(如禁用发送按钮、显示加载动画)。
- error: 一个响应式的错误对象,用于展示连接或解析过程中的异常。
- append/- stop/- handleSubmit: 一系列精心设计的方法,用于驱动状态的流转和处理用户交互。
通过将这些状态和方法聚合在一个独立的 Hook 中,我们将复杂的业务逻辑从视图组件中剥离,使得组件只需关注如何渲染这些状态,从而实现了"逻辑"与"视图"的完美分离。
架构之美:响应式状态机与生命周期管理
useSse 的内部实现,可以看作一个围绕 SSE 连接生命周期构建的"响应式状态机"。其状态转换的核心,体现在 AssistantSseMessage 的 status 字段上。
status (定义于 SseStatusEnum) 是驱动 UI 动态变化的关键。它精确地描述了助理消息从诞生到终结的每一个阶段:
- idle: 初始状态。
- connecting: 连接已发起,等待服务器响应。
- streaming-content: 正在接收并渲染内容。
- completed: 消息流正常结束。
- aborted: 被用户手动中断。
- error: 发生错误。
useSse 内部的 _sseFetch 函数通过监听 fetch-event-source 的 onopen, onmessage, onclose, onerror 等事件,在关键节点更新最后一条助理消息的 status,从而自动触发 Vue 组件的重新渲染。
此外,通过 onUnmounted 生命周期钩子,useSse 确保了当使用它的组件被销毁时,会自动调用 stop() 方法来中止任何正在进行的连接,有效防止了资源泄漏。
API 详解
useSse 的 API 设计简洁而强大,分为"配置选项"和"返回值"两部分。
useSse(options: UseSseOptions)
初始化 Hook 时,你需要传入一个配置对象。
| 参数 | 类型 | 必需 | 描述 | 
|---|---|---|---|
| initialInput | string | 否 | 输入框的初始值。 | 
| initialMessages | SseMessage[] | 否 | 初始的消息列表。 | 
| formatMessage | (data, message) => AssistantSseMessage | 是 | 核心函数 。用于将 SSE onmessage事件中的原始data字符串,解析并合并到上一条助理消息中,返回更新后的消息对象。 | 
| onOpen | (response: Response) => void | 否 | 连接成功建立时的回调,可用于检查响应头。 | 
| onOpenJson | (json: any) => void | 否 | 当服务器返回非流式 JSON 响应(通常是错误信息)时的回调。 | 
| onMessage | (data: string) => void | 否 | 每次收到原始 data字符串时的回调。 | 
| onFinish | (message: SseMessage) => void | 否 | 消息流正常结束 ( [DONE]) 时的回调。 | 
| onClose | () => void | 否 | 连接关闭时的回调(无论正常或异常)。 | 
| onError | (error: any) => void | 否 | 发生任何错误时的回调。 | 
返回值 (Returned Values)
调用 useSse 会返回一个包含响应式状态和方法的对象。
| 名称 | 类型 | 描述 | 
|---|---|---|
| messages | Ref<SseMessage[]> | 响应式的消息数组,可以直接在模板中 v-for渲染。 | 
| error | Ref<any> | 响应式的错误对象。 | 
| loading | Ref<boolean> | 响应式的加载状态。 | 
| input | Ref<string> | 响应式的输入框内容,可使用 v-model进行绑定。 | 
| append | (prompt, options) => Promise<void> | 核心方法。追加一条用户消息和一条空的助理消息,并立即开始 SSE 请求。 | 
| stop | () => void | 核心方法。手动中止当前的 SSE 连接。 | 
| setMessages | (messages: SseMessage[]) => void | 手动设置消息列表。 | 
| handleInputChange | (e: Event) => void | 用于原生 <input>的onchange事件处理器,与inputRef 配合使用。 | 
| handleSubmit | (e: Event, options) => void | 便捷的表单提交处理器。它会阻止默认事件,调用 append,并清空输入框。 | 
快速上手:构建一个简单的 AI 聊天机器人
下面是一个在 Vue 3 <script setup> 组件中使用 useSse 的完整示例。
            
            
              vue
              
              
            
          
          <script setup lang="ts">
import { useSse } from '@/hooks/useSse'
import type { AssistantSseMessage } from '@/hooks/useSse/types'
// 1. 初始化 useSse Hook
const { messages, input, loading, handleSubmit, stop, handleInputChange } = useSse({
  // 2. 提供核心的消息格式化逻辑
  formatMessage: (data, lastMessage) => {
    try {
      // 假设服务器返回的是 JSON 字符串
      const jsonData = JSON.parse(data)
      // 将新的数据块追加到上一条消息的内容上
      const updatedContent = lastMessage.content + (jsonData.content || '')
      return {
        ...lastMessage,
        content: updatedContent,
        status: 'streaming-content', // 更新状态
      }
    } catch (e) {
      console.error('JSON parse error:', e)
      return {
        ...lastMessage,
        status: 'error',
      }
    }
  },
  // 可选:添加其他回调
  onError: (err) => {
    console.error('SSE Error:', err)
  },
})
// 3. 定义提交时需要传递给后端的参数
const getSseOptions = () => ({
  url: '/api/v1/chat/completions',
  method: 'POST',
  body: {
    // 假设你的 API 需要历史消息
    messages: messages.value.map(({ role, content }) => ({ role, content })),
    stream: true,
  },
})
</script>
<template>
  <div class="chat-container">
    <div class="message-list">
      <div v-for="msg in messages" :key="msg.id" class="message" :class="`message--${msg.role}`">
        <p>{{ msg.content }}</p>
        <!-- 根据 status 显示不同的 UI -->
        <div
          v-if="
            msg.role === 'assistant' && (msg as AssistantSseMessage).status === 'streaming-content'
          "
          class="typing-indicator"
        ></div>
      </div>
    </div>
    <!-- 用户可以随时中止 -->
    <button v-if="loading" @click="stop">Stop</button>
    <form @submit="(e) => handleSubmit(e, getSseOptions())">
      <input
        :value="input"
        @input="handleInputChange"
        placeholder="Type your message..."
        :disabled="loading"
      />
      <button type="submit" :disabled="loading">Send</button>
    </form>
  </div>
</template>
<style scoped>
/* ... 添加一些美化样式 ... */
.message {
  padding: 8px;
  margin-bottom: 8px;
  border-radius: 4px;
}
.message--user {
  background-color: #e1f5fe;
  text-align: right;
}
.message--assistant {
  background-color: #f1f1f1;
}
.typing-indicator {
  width: 8px;
  height: 8px;
  background-color: #333;
  border-radius: 50%;
  animation: typing 1s infinite;
}
@keyframes typing {
  0% {
    opacity: 0.2;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.2;
  }
}
</style>流程图示
下面是调用 append 方法后,useSse 内部的典型工作流程图,帮助你更直观地理解其运行机制。
通过这个精心设计的 useSse Hook,我们可以用一种极其声明式和可维护的方式,在 Vue 应用中构建出功能强大、体验流畅的 AI 对话产品。