基于Vue 3与SSE实现OpenAI流式对话打字机效果

基于Vue 3与SSE实现OpenAI流式对话打字机效果 1. 项目概述与核心价值最近在折腾一个需要集成AI对话能力的项目核心需求是希望用户能像在ChatGPT官网上那样看到回答内容一个字一个字“流”出来的感觉而不是等AI全部生成完再一次性显示。这种“打字机”效果不仅能让等待过程变得不那么枯燥更重要的是它能实时反馈AI的思考过程提升交互的沉浸感和响应感。市面上虽然有很多封装好的SDK但要么功能臃肿要么定制性差很难满足这种对前端交互细节有较高要求的场景。于是我决定自己动手基于OpenAI官方的Chat Completions API配合Vue 3和TypeScript从零搭建一个轻量级、高定制化的流式对话前端实现。这个项目的核心目标非常明确第一要能稳定、高效地处理来自API的流式数据Server-Sent Events第二要在前端精准地控制每个字符的渲染时机实现流畅可调的打字机动画第三要能智能地处理并高亮显示对话中的代码块等格式化内容。最终产出的不仅是一个可运行的Demo更是一套清晰、可复用的技术方案无论是想快速集成类似功能还是想深入学习流式传输与前端渲染的结合都能从中获得直接的参考。2. 技术栈选型与架构设计思路2.1 为什么选择Vue 3 TypeScript Vite在技术选型上我几乎没有犹豫就锁定了Vue 3 TypeScript Vite这套组合拳。原因很简单效率、类型安全和开发体验。Vue 3的Composition API提供了比Options API更灵活的逻辑组织方式。在处理流式数据这种异步、状态变化频繁的场景时使用ref、reactive和computed来管理对话列表、当前回复内容、加载状态等逻辑可以封装在自定义Hook如useChatStream里做到高度内聚和复用。TypeScript则是大型项目或复杂交互的“安全带”。OpenAI API返回的数据结构、前端定义的消息类型、事件流的处理函数这些地方一旦类型定义清晰就能在编码阶段规避大量潜在的类型错误特别是当项目后续需要扩展或团队协同时收益巨大。Vite作为新一代构建工具其基于ES Module的快速冷启动和热更新对于需要频繁调试网络请求和UI渲染的项目来说能极大提升开发效率。整个前端架构采用典型的单向数据流。视图层Vue SFC组件负责渲染和用户交互逻辑层Composition Functions负责管理状态、调用API和处理业务逻辑服务层API Client专注于与OpenAI服务的网络通信。这种分离使得每一层的职责都非常清晰易于测试和维护。2.2 流式传输方案SSE vs WebSocket实现“打字机”效果的前提是后端能源源不断地送来数据片段。OpenAI的Chat Completions API支持通过设置stream: true参数来开启流式响应。它返回的不是一个完整的JSON而是一个遵循Server-Sent EventsSSE规范的数据流。这里有一个关键的技术选择为什么不直接用更强大的WebSocket对于这个场景SSE其实是更合适的选择。首先对话交互本质上是客户端发起请求服务器单向推送数据流AI的回复属于典型的“一发一收”模式SSE的单一方向性正好匹配。其次SSE基于普通的HTTP协议不需要像WebSocket那样单独的握手和协议升级实现更简单兼容性也更好。最后OpenAI官方API设计如此我们自然遵循。我们的任务就是在前端正确创建一个EventSource连接并监听message事件来接收数据块。注意一些老的教程或库可能会使用fetch然后手动拼接流数据这非常繁琐且容易出错。现代浏览器对EventSource的支持已经很好对于不支持的环境如某些旧版浏览器或需要更多自定义需求的场景也有优秀的polyfill库如eventsource可用但在这个项目中我们直接使用浏览器原生支持。3. 核心实现流式数据的接收与处理3.1 构建健壮的API请求模块与OpenAI API通信的底层模块是整个项目的基石必须保证其健壮性和可配置性。我创建了一个独立的apiClient.ts文件。// src/services/apiClient.ts import { CreateChatCompletionRequest, CreateChatCompletionResponse } from openai; // 定义消息类型与OpenAI接口对齐 export interface ChatMessage { role: system | user | assistant; content: string; } // 定义流式响应的事件类型 export type StreamEventType message | error | done; export class OpenAIClient { private apiKey: string; private baseURL: string; constructor(apiKey: string, baseURL: string https://api.openai.com/v1) { this.apiKey apiKey; this.baseURL baseURL; } async createChatCompletionStream( messages: ChatMessage[], options: PartialCreateChatCompletionRequest {} ): PromiseReadableStreamDefaultReaderUint8Array { const payload: CreateChatCompletionRequest { model: options.model || gpt-3.5-turbo, messages, stream: true, // 关键开启流式传输 temperature: options.temperature ?? 0.7, ...options, }; const response await fetch(${this.baseURL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.apiKey}, }, body: JSON.stringify(payload), }); if (!response.ok || !response.body) { const errorText await response.text(); throw new Error(OpenAI API Error: ${response.status} ${errorText}); } // 返回原始ReadableStream的Reader将控制权交给上层 return response.body.getReader(); } }这里有几个关键点类型安全我导入了OpenAI官方的类型定义需安装openai包确保请求体和响应体的结构正确。错误处理对HTTP状态码非200的情况进行了统一处理并尝试读取错误信息方便调试。返回Reader函数返回的是ReadableStream的Reader对象而不是直接处理数据。这样设计的好处是将“数据读取”和“数据解析/消费”的逻辑分离上层可以更灵活地控制读取节奏例如配合前端动画速度。3.2 解析SSE数据流从Reader中读取到的是原始的Uint8Array字节流我们需要将其转换为文本并按照SSE格式进行解析。SSE的每个事件由若干行组成以两个换行符\n\n分隔。数据行以data:开头。我创建了一个通用的流解析器// src/utils/streamParser.ts export async function* parseSSE(reader: ReadableStreamDefaultReaderUint8Array): AsyncGeneratorstring { const decoder new TextDecoder(utf-8); let buffer ; try { while (true) { const { done, value } await reader.read(); if (done) break; // 将字节流解码为文本并追加到缓冲区 buffer decoder.decode(value, { stream: true }); // 按行分割并处理完整的事件 const lines buffer.split(\n); buffer lines.pop() || ; // 最后一行可能是不完整的放回缓冲区 for (const line of lines) { if (line.startsWith(data: )) { const eventData line.slice(6); // 去掉data: 前缀 if (eventData [DONE]) { return; // 流结束标志 } try { // 每个data块是一个独立的JSON字符串 yield eventData; } catch (e) { console.error(Failed to parse SSE data:, eventData, e); } } // 忽略其他行如 event: ... 或 id: ...本例中OpenAI未使用 } } } finally { reader.releaseLock(); } }这个parseSSE函数是一个异步生成器它会持续从流中读取数据每当积累到一个完整的data:行就将其中的JSON字符串yield出来。使用生成器的好处是可以用for await...of循环来优雅地迭代每个数据块代码非常清晰。3.3 提取并累积Delta内容OpenAI流式返回的每个data块是一个包含如下结构的JSON对象{ id: chatcmpl-xxx, object: chat.completion.chunk, created: 1680000000, model: gpt-3.5-turbo-0301, choices: [ { index: 0, delta: { content: 你 // 本次流式返回的文本增量 }, finish_reason: null } ] }我们需要从每个块中提取choices[0].delta.content并将它们累积起来形成完整的回复。我将在下一章结合Vue响应式状态管理来实现这个累积过程并加入打字机效果。4. 前端状态管理与打字机效果实现4.1 使用Composition API管理对话状态在Vue 3中我使用ref和reactive来创建响应式的对话状态。核心状态包括消息列表、当前是否正在生成、以及可能发生的错误。// src/composables/useChatStream.ts import { ref, reactive } from vue; import { OpenAIClient, type ChatMessage } from /services/apiClient; import { parseSSE } from /utils/streamParser; interface UseChatStreamOptions { apiKey: string; onChunk?: (chunk: string) void; onFinish?: (fullContent: string) void; onError?: (error: Error) void; } export function useChatStream(options: UseChatStreamOptions) { const messages reactiveChatMessage[]([]); const isLoading ref(false); const error refError | null(null); const client new OpenAIClient(options.apiKey); // 核心的发送消息函数 const sendMessage async (userInput: string, systemPrompt?: string) { if (isLoading.value) return; // 防止重复提交 isLoading.value true; error.value null; // 1. 将用户输入添加到消息历史 const userMessage: ChatMessage { role: user, content: userInput }; messages.push(userMessage); // 2. 创建用于本次回复的Assistant消息占位符 const assistantMessage: ChatMessage { role: assistant, content: }; messages.push(assistantMessage); const assistantMessageIndex messages.length - 1; // 3. 构建请求消息数组可包含系统提示 const requestMessages: ChatMessage[] []; if (systemPrompt) { requestMessages.push({ role: system, content: systemPrompt }); } requestMessages.push(...messages.slice(0, -1)); // 不包含刚添加的占位符 try { const reader await client.createChatCompletionStream(requestMessages); const streamParser parseSSE(reader); let fullContent ; // 4. 迭代处理每一个流数据块 for await (const dataChunk of streamParser) { const parsed JSON.parse(dataChunk); const contentDelta parsed.choices[0]?.delta?.content || ; if (contentDelta) { fullContent contentDelta; // 更新响应式数据触发视图更新 messages[assistantMessageIndex].content fullContent; // 调用外部回调可用于实现打字机节流 options.onChunk?.(contentDelta); } } // 5. 流式传输完成 options.onFinish?.(fullContent); } catch (err) { error.value err as Error; options.onError?.(err as Error); // 出错时可以移除占位符或显示错误信息 messages[assistantMessageIndex].content [生成出错: ${(err as Error).message}]; } finally { isLoading.value false; } }; return { messages, isLoading, error, sendMessage, }; }这个自定义Hook封装了所有流式对话的逻辑。它维护消息列表并在用户发送消息时先添加一个内容为空的assistant消息作为“画布”然后随着流式数据的到来不断更新这个画布的内容。onChunk回调是关键钩子它会在收到每一个数据增量时被调用这正是我们注入打字机效果的入口。4.2 实现可控速度的打字机效果如果直接像上面那样每次收到增量就立刻更新DOM那么渲染速度完全取决于网络返回数据的速度会很快且不均匀。打字机效果的精髓在于“控制”我们需要一个缓冲区和一个定时器来模拟人打字的速度。我创建了一个useTypewriter组合式函数// src/composables/useTypewriter.ts import { ref, onUnmounted } from vue; interface UseTypewriterOptions { speed?: number; // 打字速度字符/毫秒 onUpdate?: (currentText: string) void; } export function useTypewriter(options: UseTypewriterOptions {}) { const { speed 50 } options; // 默认每50毫秒打一个字 const displayedText ref(); const buffer refstring[]([]); // 待显示的字符缓冲区 let timer: number | null null; // 接收新的内容增量 const feed (chunk: string) { buffer.value.push(...chunk.split()); if (!timer) { startTyping(); } }; const startTyping () { timer window.setInterval(() { if (buffer.value.length 0) { // 从缓冲区取出一个字符显示 const nextChar buffer.value.shift()!; displayedText.value nextChar; options.onUpdate?.(displayedText.value); } else { // 缓冲区为空停止定时器 stopTyping(); } }, speed); }; const stopTyping () { if (timer) { clearInterval(timer); timer null; } }; const reset () { stopTyping(); displayedText.value ; buffer.value []; }; onUnmounted(stopTyping); return { displayedText, feed, reset, isTyping: () timer ! null, }; }现在我们将两者结合起来。在组件或主逻辑中// 在组件setup中 const { messages, isLoading, sendMessage } useChatStream({ apiKey: your-api-key, }); const { displayedText: typewriterText, feed: feedToTypewriter, reset: resetTypewriter } useTypewriter({ speed: 30, // 可以调整速度 }); // 修改sendMessage的调用将onChunk指向打字机 const handleSend async (input: string) { resetTypewriter(); // 开始新回复前重置打字机 await sendMessage(input, undefined, (chunk) { // 将收到的数据块喂给打字机缓冲区 feedToTypewriter(chunk); }); }; // 在模板中使用typewriterText而不是原始的message.content这样无论网络返回多快字符都会按照设定的速度如每秒20-30个字符平稳地“打”在屏幕上。你还可以扩展这个useTypewriter加入光标闪烁、退格删除等更复杂的动画效果。4.3 代码块的高亮与复制功能AI回复中经常包含代码直接以纯文本显示体验很差。我们需要检测并高亮代码块。我选择使用highlight.js这个成熟的库。首先在接收到完整的消息内容后或打字机显示完成后需要解析内容中的代码块。Markdown中代码块通常被 language 和 包裹。// src/utils/codeBlockParser.ts import hljs from highlight.js; import highlight.js/styles/atom-one-dark.css; // 引入一个喜欢的样式 export interface CodeBlock { language: string; code: string; highlightedHtml: string; } export function parseAndHighlightCodeBlocks(text: string): { html: string; codeBlocks: CodeBlock[] } { const codeBlockRegex /(\w)?\n([\s\S]*?)/g; const codeBlocks: CodeBlock[] []; let html text; let match; let index 0; while ((match codeBlockRegex.exec(text)) ! null) { const fullMatch match[0]; const language match[1] || plaintext; const code match[2].trim(); let highlightedCode code; try { if (hljs.getLanguage(language)) { highlightedCode hljs.highlight(code, { language }).value; } else { highlightedCode hljs.highlightAuto(code).value; } } catch (e) { console.warn(Failed to highlight code for language ${language}:, e); } const blockId code-block-${index}; codeBlocks.push({ language, code, highlightedHtml: highlightedCode }); // 将原始代码块替换为一个占位符后续在模板中渲染 const replacement div classcode-block-wrapper>!-- 简化后的消息渲染组件 -- template div classmessage-content template v-for(segment, idx) in parsedSegments :keyidx span v-ifsegment.type text v-htmlsegment.content/span pre v-else-ifsegment.type code classhljs code :classlanguage- segment.language v-htmlsegment.highlightedHtml/code button classcopy-btn clickcopyCode(segment.rawCode)复制/button /pre /template /div /template script setup langts import { computed } from vue; import { parseAndHighlightCodeBlocks } from /utils/codeBlockParser; const props defineProps{ content: string }(); const parsedSegments computed(() { const { html, codeBlocks } parseAndHighlightCodeBlocks(props.content); // 这里需要将混合的html字符串和代码块对象解析成结构化数组 // 具体实现略可用DOMParser或正则拆分 return segments; // 返回结构如 [{type:text, content:...}, {type:code, language:js, ...}] }); const copyCode (code: string) { navigator.clipboard.writeText(code).then(() { // 可以添加一个“已复制”的提示 }); }; /script复制功能通过navigator.clipboard.writeText实现这是一个现代浏览器支持的API简单易用。记得在按钮上提供清晰的反馈比如点击后按钮文字短暂变为“已复制”。5. 项目配置、运行与深度优化5.1 环境配置与安全注意事项项目的入口配置通常放在根目录或src目录下的一个配置文件里例如src/config.ts。这里涉及最敏感的信息——OpenAI API Key。// src/config.ts // 警告切勿将此文件提交到版本控制系统如Git // 应将其添加到 .gitignore 中。 export const config { openai: { // 从环境变量中读取或在此处直接填写仅用于开发 apiKey: import.meta.env.VITE_OPENAI_API_KEY || your-api-key-here, // 可选配置代理或自定义端点如果使用第三方代理服务 // baseURL: import.meta.env.VITE_OPENAI_BASE_URL, }, };安全是重中之重绝对不要将真实的API Key硬编码在代码中并上传到公开的代码仓库如GitHub。这会导致密钥泄露产生巨额费用。正确做法是使用环境变量。在Vite项目中可以创建.env.local文件已加入.gitignoreVITE_OPENAI_API_KEYsk-your-actual-key-here在前端代码中通过import.meta.env.VITE_OPENAI_API_KEY访问。对于生产环境更安全的架构是通过你自己的后端服务器来中转请求。前端将消息发送到你的后端后端再使用API Key调用OpenAI。这样Key完全不会暴露给浏览器。本项目作为前端演示采用了直接调用方式请务必仅用于学习和开发。5.2 项目运行与脚本使用Vite初始化项目后package.json中的脚本通常如下{ scripts: { dev: vite, build: vue-tsc vite build, preview: vite preview } }npm run dev: 启动开发服务器通常在本地的http://localhost:5173支持热重载。npm run build: 使用TypeScript编译器检查类型并打包生产环境代码输出到dist目录。npm run preview: 本地预览打包后的生产构建检查最终效果。在运行前确保已安装依赖npm install。然后修改config.ts或配置环境变量填入有效的API Key即可运行npm run dev开始体验。5.3 性能与体验优化实践在实际使用中我发现了几个可以显著提升体验的优化点1. 流式中断与重试机制用户可能在AI生成中途就发现方向错了需要提供“停止生成”按钮。这需要我们在逻辑层保留对ReadableStream的Reader的引用并提供一个取消方法。// 在 useChatStream 中 let currentStreamReader: ReadableStreamDefaultReader | null null; const sendMessage async (userInput: string) { // ... 准备逻辑 ... try { const reader await client.createChatCompletionStream(...); currentStreamReader reader; // 保存引用 // ... 处理流 ... } catch (err) { // ... 错误处理 ... } finally { currentStreamReader null; isLoading.value false; } }; // 新增停止函数 const stopGenerating () { if (currentStreamReader) { currentStreamReader.cancel(); // 取消流读取 currentStreamReader null; isLoading.value false; // 可选在消息中标记为[已中断] } };2. 上下文长度管理与总结OpenAI模型有token数量限制。长时间对话后消息历史会很长。我们需要在发送前处理上下文。简单截断只保留最近N条消息。智能总结更优的方案是当历史消息过长时调用一次API请求模型将之前的对话总结成一条更短的“系统”消息然后用总结后的消息和最近的对话作为新的上下文。这需要额外的逻辑和API调用但能极大提升长对话能力。3. 打字机速度的自适应固定的打字速度可能不适合所有场景。对于代码块用户可能希望快速跳过对于重要结论可能希望慢一点。可以设计一个根据内容类型如检测到\n\n可能分段检测到“”可能开始代码块动态调整速度的算法或者允许用户实时拖动速度滑块。4. 错误处理与用户反馈网络错误、API额度不足、模型过载等都会导致失败。除了在控制台打印错误必须给用户清晰的反馈。例如在消息气泡旁显示一个错误图标鼠标悬停时显示具体错误信息并提供“重试”按钮。6. 常见问题排查与调试技巧在实际开发和部署中你肯定会遇到各种问题。下面是我踩过坑后总结的排查清单。6.1 流式连接失败或无响应症状页面一直显示“正在生成...”但没有任何内容流出或者控制台出现网络错误。检查API Key首先确认config.ts或环境变量中的API Key是否正确是否有空格。可以在简单的curl命令中测试Key是否有效。检查网络连接确认你的网络环境可以访问api.openai.com。如果你在国内可能需要检查网络设置。请注意根据中国法律法规所有网络访问必须合法合规。开发者应确保其应用及API调用符合相关规定使用经批准的网络通道。检查请求格式在浏览器开发者工具的“网络”选项卡中查看发送的请求Payload。确保stream字段为truemessages数组格式正确。查看响应头成功的流式响应其Content-Type应为text/event-stream。如果不是说明API没有以流式方式响应可能是请求参数有误。6.2 打字机效果卡顿或不流畅症状字符输出一顿一顿或者大量字符突然一起蹦出来。缓冲区与定时器冲突确保useTypewriter的feed函数和定时器是协同工作的。如果网络数据块很大比如一次返回一句话而定时器速度很慢缓冲区会堆积。可以适当提高打字速度减小speed值或者在feed时如果缓冲区过长临时加快定时器频率。DOM更新性能如果消息非常长频繁更新displayedText并触发Vue的响应式更新和DOM渲染可能导致卡顿。可以考虑使用requestAnimationFrame来节流DOM更新或者对于超长内容分段更新innerHTML需注意XSS防护。检查控制台错误是否有JavaScript错误中断了定时器或流处理循环6.3 代码块高亮异常或布局错乱症状代码没有高亮或者高亮后样式崩坏复制按钮位置不对。Highlight.js语言检测失败highlight.js的highlightAuto有时会检测错误语言。如果已知语言最好在代码块标记中指定如 javascript。也可以在parseAndHighlightCodeBlocks函数中为未知语言提供一个默认的高亮处理比如plaintext。CSS样式冲突你引入的Highlight.js主题CSS可能会与你项目自身的CSS产生冲突特别是pre和code标签的margin、padding、background等属性。在浏览器开发者工具中检查元素样式覆盖冲突的样式。复制按钮定位确保复制按钮的CSS使用了position: absolute并定位在代码块的右上角且其父元素pre设置了position: relative。6.4 生产环境构建后空白页或功能异常症状npm run dev时一切正常但npm run build后部署到服务器页面空白或功能失效。环境变量问题Vite在构建时会将import.meta.env中的变量静态替换。确保生产环境也有正确的环境变量配置例如在Dockerfile、服务器平台的环境配置中设置VITE_OPENAI_API_KEY。再次强调生产环境建议使用后端中转避免前端暴露Key。路由/资源路径问题如果项目使用了路由如Vue Router且部署在非根路径下需要配置vite.config.ts中的base选项和路由的history模式。检查浏览器控制台构建后的代码经过压缩混淆错误信息可能不直观但控制台的网络请求和JavaScript错误依然是首要的排查线索。这个项目从核心原理到实战细节涵盖了构建一个类ChatGPT流式对话前端的主要环节。它不仅仅是一个玩具Demo其模块化的设计分离的API Client、流解析器、打字机Hook、代码高亮器使得每个部分都可以被独立替换、升级或集成到更大的应用中。希望这份详尽的拆解能帮助你不仅实现功能更能理解其背后的设计思路和最佳实践。