Vue3 + EventSource 实战:手把手教你打造实时AI聊天悬浮窗(附完整代码)
最近在重构一个内部知识库系统时,我遇到了一个挺有意思的需求:用户希望在任何页面都能随时唤起AI助手进行问答,并且回答内容要像ChatGPT那样一个字一个字“流”出来,体验要足够流畅。这让我想起了很多SaaS产品里常见的客服悬浮窗,但这次要做的更复杂——它得是一个功能完整的、支持流式输出的AI对话窗口。
市面上关于Vue3和EventSource的教程不少,但大多停留在基础连接和数据显示。真正要把这个悬浮窗做得“能用”且“好用”,你会发现有一堆细节要处理:窗口拖拽时不能跑出屏幕外、移动端上手指操作要灵敏、大量流式数据渲染时页面不能卡顿、还得考虑Markdown表格和代码块的优雅展示……这些才是决定功能成败的关键。
这篇文章,我就从一个产品化的视角,带你完整实现一个高可用的实时AI聊天悬浮窗。我会重点分享那些在文档里很少提及,但在实际开发中一定会踩到的“坑”,以及对应的解决方案和性能优化技巧。文末会提供一个可以直接克隆、运行的GitHub仓库地址,你可以基于它快速搭建自己的AI助手功能。
1. 项目架构与核心技术选型
在动手写代码之前,我们先明确一下这个悬浮窗的核心目标和实现路径。我们的目标不是一个简单的演示组件,而是一个可以无缝集成到任何Vue3项目中的、生产可用的独立模块。
核心功能清单:
- 可拖拽悬浮球:作为入口,点击后展开对话窗口。
- 流式对话窗口:支持实时显示AI的“思考过程”和最终答案。
- 完整的拖拽与缩放:窗口可自由移动,并能在一定范围内调整大小。
- 多端适配:在桌面端和移动端均有良好的交互体验。
- 性能优化:处理大量、快速的流式数据更新时,保持UI流畅。
- 内容渲染:完美支持Markdown语法,包括代码块、表格、列表等。
基于这些需求,我的技术选型如下:
| 技术栈 | 具体库/API | 选型理由 |
|---|---|---|
| 前端框架 | Vue 3 (Composition API) | 更好的TypeScript支持、更灵活的逻辑复用。 |
| 流式通信 | EventSource (SSE) |
协议简单,天然支持服务端推送,非常适合AI文本流。 |
| 拖拽与缩放 | vue-draggable-resizable (v3) |
社区活跃,API稳定,支持触摸事件,省去大量底层开发。 |
| Markdown渲染 | marked + DOMPurify |
marked性能好,DOMPurify用于安全过滤,防止XSS。 |
| UI与样式 | 原生CSS / Less | 保持组件独立性,不依赖特定UI库,方便集成。 |
| 构建工具 | Vite | 快速的冷启动和热更新,提升开发体验。 |
这里重点说一下为什么选择 EventSource 而不是更常见的 WebSocket。对于AI文本流这种单向、顺序、文本为主的数据流,SSE(Server-Sent Events)协议有天然优势:
- 更简单:基于HTTP,无需额外的握手和心跳协议。
- 自动重连:浏览器内置了连接断开后的重试机制。
- 成本更低:服务端实现更简单,连接开销通常小于WebSocket。
当然,SSE是单向的(服务端到客户端)。如果你的场景需要频繁的客户端上行(如传输文件),那WebSocket更合适。但就纯聊天问答而言,SSE是绝配。
提示:如果你的后端服务不支持SSE,或者你需要更细粒度的控制(如自定义重连、更复杂的数据格式),也可以使用
fetchAPI 读取ReadableStream来实现流式接收,这在后文会提供备选方案代码。
2. 核心实现:流式数据接收与渲染
这是整个项目的灵魂。我们要实现的效果是:用户提问后,AI的回复像打字机一样逐字呈现,中间可能还会穿插“思考中...”这样的中间状态。
2.1 建立EventSource连接与事件解析
首先,我们创建一个独立的Composables函数 useEventSource,来封装所有流式连接的逻辑。这样做的好处是逻辑与UI分离,易于测试和复用。
// composables/useEventSource.js
import { ref, onUnmounted } from 'vue';
export function useEventSource(url, options = {}) {
const data = ref('');
const error = ref(null);
const isLoading = ref(false);
const eventSource = ref(null);
const connect = () => {
if (eventSource.value) {
eventSource.value.close();
}
isLoading.value = true;
error.value = null;
data.value = '';
const es = new EventSource(url);
eventSource.value = es;
es.onopen = () => {
console.log('SSE连接已建立');
isLoading.value = false;
};
es.onmessage = (event) => {
// 默认处理没有event字段的消息
try {
const parsed = JSON.parse(event.data);
// 这里可以根据业务需要处理通用数据
console.log('收到消息:', parsed);
} catch (e) {
// 如果是纯文本流
data.value += event.data;
}
};
// 处理自定义事件,例如 `event: think`, `event: answer`
es.addEventListener('think', (event) => {
try {
const parsed = JSON.parse(event.data);
// 触发一个自定义回调,由父组件处理
options.onThink?.(parsed);
} catch (e) {
console.error('解析think事件失败:', e);
}
});
es.addEventListener('answer', (event) => {
try {
const parsed = JSON.parse(event.data);
options.onAnswer?.(parsed);
} catch (e) {
console.error('解析answer事件失败:', e);
}
});
es.onerror = (err) => {
console.error('SSE连接错误:', err);
error.value = err;
isLoading.value = false;
es.close();
};
// 监听服务器端可能发送的“结束”事件
es.addEventListener('close', () => {
console.log('SSE流结束');
isLoading.value = false;
es.close();
});
};
const disconnect = () => {
if (eventSource

198

被折叠的 条评论
为什么被折叠?



