FEATURED · 精选文章

Vue3实现AI聊天流式输出与打字机效果:从SSE到交互优化

发布时间 / 2026/9/3 7:44:22
来源 / 创域科博编辑部
栏目 / 资讯中心
Vue3实现AI聊天流式输出与打字机效果:从SSE到交互优化 简介这是一套面向AI算法工程师、大模型应用开发者及前端工程师的Vue3轻量级AI聊天界面实现专为解决模型调试演示缺可视化界面、自研系统缺乏专业交互体验、Markdown富文本渲染难等实际痛点而设计。资源共319个文件以61个Vue组件为核心构建聊天逻辑与UI结构72个JS文件封装流式响应处理与打字机效果135个SVG图标保障深色/浅色模式下的图标一致性配合SCSS/Less样式体系实现DeepSeek官网级布局与响应式适配整体包体仅1.17MB开箱即用。已有849人学习下载提供完整可运行环境——无需登录仅需修改配置文件中的后端IP与API参数即可对接自有大模型服务路由模块高度解耦支持直接复制粘贴至现有Vue3项目中复用附带normalize.css与多环境HTML模板兼顾兼容性与开发效率。1. 项目缘起从“能用”到“好用”的AI交互体验鸿沟最近在折腾一个内部用的AI模型调试工具核心需求很简单让团队里的产品、算法和测试同学能快速验证不同模型、不同参数下的对话效果。市面上现成的工具不少但要么太重集成了太多我们用不上的功能要么太简陋交互体验一言难尽。特别是当模型返回长篇大论或者代码块时那种“一坨”文字瞬间涌出来的感觉不仅看着难受调试时想定位某个特定回复也极其不便。这让我想起了DeepSeek官网的聊天界面。虽然它是个面向公众的服务但其交互的流畅度和细节处理恰恰是我们这类内部工具最需要的。核心就两点流式输出和打字机效果。流式输出解决了“等待焦虑”让用户能实时看到模型“思考”的过程尤其是在生成长文本时不至于对着空白的输入框干等十几秒。而打字机效果则是在流式输出的基础上加了一层视觉上的“缓冲”让文字逐个字符出现模拟真人打字的感觉极大地提升了对话的“拟真感”和可读性。所以我的目标很明确基于Vue3这个现代前端框架复刻一套类似DeepSeek官网那样清爽、高效的聊天界面。它不只是一个UI壳子更要成为一个让模型调试和演示变得简单高效的“工作台”。这意味着除了基础的对话展示我们还需要考虑消息管理、模型切换、参数调节、历史记录等调试环节的便捷性。而这一切的体验基石就是那个“丝滑”的输出过程。2. 技术选型与架构设计为什么是Vue3 原生SSE确定了目标接下来就是技术栈的敲定。前端框架毫无悬念地选择了Vue3。原因有几个首先是其组合式API带来的逻辑组织灵活性。聊天应用涉及的状态不少——消息列表、当前模型、连接状态、用户输入等。使用ref和reactive可以很清晰地将这些状态与对应的操作发送消息、清空历史、处理流式数据封装在同一个useChat组合式函数里代码的复用性和可读性远胜于Vue2的Options API。其次Vue3的响应式系统对数组和嵌套对象的追踪更精准这对于频繁增删改的消息列表操作来说性能更有保障。最后其良好的TypeScript支持能为后续的复杂功能迭代提供坚实的类型安全基础。而对于流式输出的实现社区方案很多比如使用WebSocket或者第三方库如fetch-event-source。但我最终选择了原生的Server-Sent Events。这里需要解释一下为什么。WebSocket是双向通信协议功能强大但对于我们“客户端发送请求服务器持续返回流”这个典型的请求-响应变体场景来说有点杀鸡用牛刀。它的连接建立和维护相对复杂。而SSE是HTML5标准的一部分本质上是一个长连接的HTTP流。它的优势非常贴合我们的场景简单轻量基于HTTP/HTTPS无需额外的协议。浏览器端使用标准的EventSourceAPI即可连接服务器端只需在响应头中设置Content-Type: text/event-stream并以特定格式data: ...\n\n发送数据流。自动重连EventSource内置了连接断开后的重试机制对于不稳定的网络环境更友好。单向性正好符合“服务器向客户端推送数据”的模型概念清晰。当然原生EventSource有个局限它不支持自定义请求头如Authorization这在需要传递API密钥的场景下是致命的。不过我们可以用fetchAPI来模拟SSE从而绕过这个限制。这就是为什么我们没有直接使用new EventSource(url)而是采用fetch配合手动解析流的方式。虽然代码量稍多但获得了完全的请求控制权。基础架构设计如下状态层使用Vue3的ref和reactive集中管理应用状态消息列表、加载状态、错误信息等。逻辑层创建一个useChat组合式函数封装所有与聊天相关的业务逻辑包括建立SSE连接、处理流式数据、发送消息、管理历史等。视图层组件化拆分。主聊天窗口、消息气泡、输入框、侧边栏模型/参数配置等均为独立组件通过状态和事件进行通信。通信层基于fetch实现一个可定制的createEventSource函数负责与后端流式API的通信、数据解析和事件分发。这个架构的核心思想是关注点分离和响应式驱动。状态变化自动更新视图用户交互触发逻辑函数逻辑函数处理数据并更新状态形成一个清晰的数据流闭环。3. 核心实现拆解流式输出与打字机效果3.1 构建健壮的流式数据管道流式输出的核心在于如何处理从服务器端持续到来的数据块。我们的后端API通常返回一个ReadableStream。前端的任务就是读取这个流并实时地将数据片段更新到UI上。首先我们实现一个通用的、支持自定义请求头的类SSE连接器// utils/stream.js export async function createEventSource(url, options) { const { onMessage, onError, onDone, ...fetchOptions } options; try { const response await fetch(url, { ...fetchOptions, headers: { Content-Type: application/json, Accept: text/event-stream, ...fetchOptions.headers, }, }); if (!response.ok || !response.body) { throw new Error(HTTP error! status: ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) { onDone?.(); break; } buffer decoder.decode(value, { stream: true }); // 处理可能包含多个SSE事件的数据块 const lines buffer.split(\n); buffer lines.pop(); // 最后一行可能是不完整的放回缓冲区 for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); // 去掉data: 前缀 if (data [DONE]) { onDone?.(); return; } try { const parsed JSON.parse(data); onMessage?.(parsed); } catch (e) { console.warn(Failed to parse SSE data:, data, e); // 有时服务器可能直接返回文本而非JSON onMessage?.({ content: data }); } } // 忽略其他类型的事件行如 event: , id: , retry: } } } catch (error) { onError?.(error); } }这个createEventSource函数是关键。它使用fetch发起请求然后通过response.body.getReader()获取一个可读流阅读器。在一个循环中我们不断读取数据块value用TextDecoder解码成字符串并累加到buffer中。接着我们按换行符分割缓冲区按照SSE格式data: ...解析出有效数据。这里特别处理了[DONE]这个特殊事件它通常标志着流的结束。解析出的数据通过onMessage回调实时抛给上层业务逻辑。注意这里有一个常见的坑。服务器返回的流数据可能在任意字节处被切割。因此decoder.decode必须使用{ stream: true }选项确保能正确解码多字节字符如中文、Emoji避免出现乱码。同时缓冲区处理逻辑要能应对一个数据块包含多个事件或一个事件被分割到多个数据块的情况。3.2 在Vue3中集成流式逻辑与状态管理有了底层的数据管道接下来就是在Vue3的组合式函数中消费它并驱动UI更新。// composables/useChat.js import { ref, reactive, computed } from vue; import { createEventSource } from /utils/stream; export function useChat(apiEndpoint, options {}) { const messages ref([]); // 消息列表 const inputText ref(); // 用户输入 const isLoading ref(false); // 加载状态 const error ref(null); // 错误信息 const currentModel ref(options.defaultModel || gpt-3.5-turbo); // 当前模型 // 当前会话的最后一个AI消息用于追加流式内容 const currentAiMessage computed(() { const msgs messages.value; const lastMsg msgs[msgs.length - 1]; return lastMsg?.role assistant ? lastMsg : null; }); const sendMessage async () { if (!inputText.value.trim() || isLoading.value) return; const userMessage { role: user, content: inputText.value }; const aiMessage { role: assistant, content: }; // 初始为空用于流式追加 // 更新消息列表 messages.value [...messages.value, userMessage, aiMessage]; inputText.value ; isLoading.value true; error.value null; // 构建请求体 const requestBody { model: currentModel.value, messages: [...messages.value.slice(0, -1), userMessage], // 只包含历史消息和最新用户消息 stream: true, // 关键要求服务器返回流 ...options.extraParams, }; try { await createEventSource(apiEndpoint, { method: POST, headers: { Authorization: Bearer ${options.apiKey}, }, body: JSON.stringify(requestBody), onMessage: (data) { // 假设后端返回OpenAI兼容格式{ choices: [{ delta: { content: ... } }] } const chunk data.choices?.[0]?.delta?.content || ; if (chunk) { // 关键步骤更新最后一个AI消息的内容 const index messages.value.length - 1; messages.value[index].content chunk; } }, onError: (err) { error.value 流式请求错误: ${err.message}; isLoading.value false; }, onDone: () { isLoading.value false; // 流结束可以做一些清理或标记完成状态 if (currentAiMessage.value) { currentAiMessage.value.isComplete true; } }, }); } catch (err) { error.value 请求发送失败: ${err.message}; isLoading.value false; // 如果请求失败移除那个空的AI消息气泡 messages.value.pop(); } }; return { messages, inputText, isLoading, error, currentModel, sendMessage, // 还可以提供清空历史、切换模型等方法 }; }这个useChat函数封装了聊天的核心状态和逻辑。当用户发送消息时它会立即在消息列表中添加一个用户消息和一个内容为空的AI消息。然后它调用createEventSource发起流式请求。在onMessage回调中我们拿到每一个数据块chunk并将其追加到最后一个AI消息即currentAiMessage的content属性上。由于Vue3的响应式系统messages.value[index].content chunk这行代码会自动触发UI更新。这就是流式输出的核心增量更新。UI不再等待整个响应完成再渲染而是每收到一个数据块就更新一次视图。3.3 实现“打字机”视觉效果流式输出解决了数据实时性的问题但直接追加文本文字是瞬间出现的。要模拟打字机效果我们需要控制文本的显示速度让它们一个字一个字地“打”出来。我们不会在数据接收层做延迟那样会阻塞数据流。正确的做法是在展示层做动画。我们将创建一个专门的组件来渲染AI消息内容并实现打字机效果。!-- components/ChatMessage.vue -- template div classmessage :classmessage-${message.role} !-- ... 头像、元信息等 ... -- div classcontent !-- 关键使用一个span来包裹要动画的内容并绑定ref -- span refcontentEl v-htmlformattedContent/span span v-ifisStreaming !isComplete classcursor|/span /div /div /template script setup import { ref, watch, onMounted, onUnmounted, nextTick } from vue; import { marked } from marked; // 用于Markdown渲染 const props defineProps({ message: Object, // { role, content, isComplete? } streamSpeed: { type: Number, default: 20 }, // 打字速度毫秒/字符 }); const contentEl ref(null); const displayedText ref(); const isStreaming ref(false); const isComplete ref(false); let animationInterval null; let currentIndex 0; // 监听message.content的变化 watch(() props.message.content, (newContent, oldContent) { // 如果内容变长说明有新的流式数据到来 if (newContent.length oldContent.length) { startTypingAnimation(newContent, oldContent); } else if (newContent.length 0 oldContent.length 0) { // 内容被清空如开始新会话 resetAnimation(); } }, { immediate: true }); // 监听完成状态 watch(() props.message.isComplete, (complete) { if (complete) { isComplete.value true; stopAnimation(); // 确保最终显示全部内容 displayedText.value props.message.content; } }); const startTypingAnimation (fullText, previousText) { if (isComplete.value) return; // 停止之前的动画 stopAnimation(); // 从已显示的长度开始继续打 const startFrom previousText.length; const textToAppend fullText.slice(startFrom); currentIndex 0; isStreaming.value true; animationInterval setInterval(() { if (currentIndex textToAppend.length) { displayedText.value previousText textToAppend.slice(0, currentIndex 1); currentIndex; // 滚动到视野内可选 scrollToBottom(); } else { stopAnimation(); } }, props.streamSpeed); }; const stopAnimation () { if (animationInterval) { clearInterval(animationInterval); animationInterval null; } isStreaming.value false; }; const resetAnimation () { stopAnimation(); displayedText.value ; currentIndex 0; isComplete.value false; }; // 将Markdown格式的文本转换为HTML const formattedContent computed(() { return marked(displayedText.value || , { breaks: true }); }); // 一个简单的滚动到底部函数确保新内容可见 const scrollToBottom () { nextTick(() { if (contentEl.value) { contentEl.value.scrollIntoView({ behavior: smooth, block: nearest }); } }); }; onUnmounted(() { stopAnimation(); }); /script style scoped .cursor { animation: blink 1s infinite; color: #666; font-weight: bold; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } /style这个组件的逻辑是监听内容变化当message.content增长时即收到新的流式数据触发startTypingAnimation。增量动画动画不是从零开始打全部内容而是从“已显示文本”的长度开始只对新来的文本部分进行逐字动画。这保证了即使网络有波动动画也能从中断处自然衔接。定时器控制使用setInterval控制每个字符出现的时间间隔形成打字节奏。光标效果在流式输出未完成时显示一个闪烁的光标增强“正在输入”的感知。Markdown渲染使用marked库将AI返回的Markdown格式文本常见于代码块、列表等实时渲染为HTML提升可读性。实操心得打字机动画的间隔时间streamSpeed需要仔细调校。太快了像抽搐太慢了用户会着急。通常20-50毫秒是一个比较舒适的范围。另外一定要在组件销毁时onUnmounted清除定时器防止内存泄漏。4. 界面与交互细节打造DeepSeek级别的体验有了核心的流式与打字机功能界面和交互的打磨决定了工具的“高级感”。DeepSeek官网的聊天界面有几个细节值得借鉴4.1 消息列表与滚动管理聊天界面最怕两件事一是消息多了卡顿二是新消息发出后看不到。对于第一点Vue3本身的性能足够好但对于成百上千条消息可以考虑使用虚拟滚动库如vue-virtual-scroller。对于内部调试工具消息量通常不会爆炸直接用v-for渲染即可但需要为每条消息设置唯一的:key通常使用消息ID或时间戳索引的组合。滚动管理是关键。我们需要在以下时机自动滚动到底部用户发送新消息后。AI消息的流式输出过程中每打几个字就滚一次体验不好可以做个节流。AI消息输出完成时。我们在ChatMessage组件里已经有一个简单的scrollToBottom函数。更好的做法是在父组件聊天容器中管理滚动使用一个ref指向容器元素并监听messages数组的变化。!-- components/ChatWindow.vue -- template div classchat-window div classmessages-container refmessagesContainer ChatMessage v-for(msg, index) in messages :key${msg.id || msg.timestamp}_${index} :messagemsg completeonMessageComplete / /div !-- 输入区域 -- ChatInput sendhandleSend :disabledisLoading / /div /template script setup import { ref, watch, nextTick } from vue; import ChatMessage from ./ChatMessage.vue; import ChatInput from ./ChatInput.vue; const props defineProps([messages, isLoading]); const messagesContainer ref(null); // 标记是否需要滚动到底部 const shouldScrollToBottom ref(true); // 监听消息列表变化 watch(() props.messages.length, () { // 当有新消息添加时标记需要滚动 shouldScrollToBottom.value true; scheduleScroll(); }); // 监听最后一条消息的完成状态从子组件发射的事件 const onMessageComplete () { shouldScrollToBottom.value true; scheduleScroll(); }; // 使用nextTick和requestAnimationFrame确保DOM更新后再滚动 const scheduleScroll () { nextTick(() { if (shouldScrollToBottom.value messagesContainer.value) { requestAnimationFrame(() { messagesContainer.value.scrollTop messagesContainer.value.scrollHeight; shouldScrollToBottom.value false; }); } }); }; // 初始化和窗口大小变化时也检查一下 onMounted(scheduleScroll); /script4.2 输入框与交互优化输入框不能只是一个简单的textarea。DeepSeek的输入框支持多行输入、自适应高度并且有便捷的发送方式CmdEnter或CtrlEnter。我们来实现一个增强版输入组件。!-- components/ChatInput.vue -- template div classchat-input-wrapper div refeditableDiv classinput-area contenteditabletrue :placeholderplaceholder inputhandleInput keydownhandleKeydown pastehandlePaste /div button classsend-button :disabled!hasText || disabled clickhandleSendClick svg!-- 发送图标 --/svg /button /div /template script setup import { ref, watch, onMounted, nextTick } from vue; const props defineProps({ disabled: Boolean, placeholder: { type: String, default: 输入消息... }, }); const emit defineEmits([send]); const editableDiv ref(null); const internalText ref(); const hasText computed(() internalText.value.trim().length 0); const handleInput (e) { internalText.value e.target.innerText || ; // 自适应高度简单版 if (editableDiv.value) { editableDiv.value.style.height auto; const newHeight Math.min(editableDiv.value.scrollHeight, 200); // 最大高度200px editableDiv.value.style.height ${newHeight}px; } }; const handleKeydown (e) { // 支持 CtrlEnter 或 CmdEnter 发送 if ((e.ctrlKey || e.metaKey) e.key Enter hasText.value) { e.preventDefault(); submit(); } // 禁止在内容为空时按Enter换行可选 if (e.key Enter !e.shiftKey hasText.value) { e.preventDefault(); submit(); } }; const handlePaste (e) { e.preventDefault(); const text e.clipboardData.getData(text/plain); document.execCommand(insertText, false, text); }; const handleSendClick () { if (hasText.value) { submit(); } }; const submit () { const textToSend internalText.value.trim(); if (textToSend) { emit(send, textToSend); clearInput(); } }; const clearInput () { internalText.value ; if (editableDiv.value) { editableDiv.value.innerText ; editableDiv.value.style.height auto; } // 保持焦点 nextTick(() { editableDiv.value?.focus(); }); }; // 暴露一个清空方法给父组件可选 defineExpose({ clearInput }); /script这个输入组件使用contenteditable的div模拟文本域实现了高度自适应。它支持纯文本粘贴通过handlePaste处理避免粘贴带格式的HTML并设置了熟悉的快捷键发送逻辑。4.3 状态反馈与错误处理良好的用户体验离不开清晰的状态反馈。我们需要在UI上明确告知用户当前发生了什么。加载状态当isLoading为true时除了禁用发送按钮还可以在输入框附近显示一个微妙的加载指示器或者将发送按钮的图标替换为加载动画。流式输出状态通过ChatMessage组件中的闪烁光标来指示AI“正在输入”。错误状态当error不为空时需要在界面醒目但不突兀的位置显示错误信息。可以是一个顶部的横幅通知Toast点击可关闭。错误信息应尽可能友好例如将“HTTP 429”转换为“请求过于频繁请稍后再试”。!-- 在ChatWindow.vue中添加 -- template div classchat-window !-- 错误提示 -- div v-iferror classerror-toast {{ error }} button clickerror null×/button /div !-- ... 消息容器 ... -- !-- 输入区域传递loading状态 -- ChatInput sendhandleSend :disabledisLoading / !-- 加载指示器可以放在输入框旁 -- div v-ifisLoading classloading-indicator 正在思考... /div /div /template5. 进阶功能与调试面板设计一个纯粹的聊天界面对于演示或许足够但对于调试我们需要更多的控制力和可见性。这就是为什么我们的项目定位是“模型调试和演示工具”。5.1 集成模型参数调节在侧边栏或输入框上方我们可以添加一个可折叠的面板用于调节模型参数。!-- components/DebugPanel.vue -- template div classdebug-panel h3 clickcollapsed !collapsed调试参数/h3 div v-if!collapsed classpanel-content div classparam-row label formodel模型/label select idmodel v-modellocalParams.model option valuedeepseek-chatDeepSeek Chat/option option valuegpt-3.5-turboGPT-3.5 Turbo/option option valueclaude-3-haikuClaude 3 Haiku/option /select /div div classparam-row label fortemperature温度 (Temperature): {{ localParams.temperature }}/label input idtemperature typerange min0 max2 step0.1 v-model.numberlocalParams.temperature / span classparam-desc值越高输出越随机、有创造性。/span /div div classparam-row label formaxTokens最大生成长度 (Max Tokens): {{ localParams.max_tokens }}/label input idmaxTokens typerange min1 max4096 step100 v-model.numberlocalParams.max_tokens / /div div classparam-row label input typecheckbox v-modellocalParams.stream / 启用流式输出 /label /div button clickapplyParams应用参数/button button clickresetParams重置/button /div /div /template script setup import { ref, watch } from vue; const props defineProps({ params: Object }); const emit defineEmits([update:params]); const collapsed ref(true); const localParams ref({ ...props.params }); watch(() props.params, (newVal) { localParams.value { ...newVal }; }, { deep: true }); const applyParams () { emit(update:params, { ...localParams.value }); }; const resetParams () { localParams.value { model: deepseek-chat, temperature: 0.7, max_tokens: 2048, stream: true }; applyParams(); }; /script这个面板通过v-model将参数与父组件的状态绑定。当用户点击“应用”时参数会更新到useChat的请求配置中。这样在下次发送消息时就会使用新的参数。5.2 请求/响应信息查看对于调试能看到原始的请求和响应数据尤其是流式响应的每个chunk非常有帮助。我们可以添加一个“调试视图”开关。在useChat函数中我们可以增加一个debugLog的响应式数组来存储每次交互的元数据。// 在 useChat.js 中 const debugLog ref([]); const sendMessage async () { // ... 之前的准备代码 ... const requestId Date.now(); const debugEntry { id: requestId, request: requestBody, responseChunks: [], startTime: new Date(), }; debugLog.value.push(debugEntry); try { await createEventSource(apiEndpoint, { // ... 配置 ... onMessage: (data) { const chunk data.choices?.[0]?.delta?.content || ; if (chunk) { // ... 更新UI ... // 记录调试信息 const entry debugLog.value.find(e e.id requestId); if (entry) { entry.responseChunks.push({ timestamp: new Date(), data: chunk }); } } }, onDone: () { // ... 更新状态 ... const entry debugLog.value.find(e e.id requestId); if (entry) { entry.endTime new Date(); entry.duration entry.endTime - entry.startTime; } }, }); } catch (err) { // ... 错误处理 ... const entry debugLog.value.find(e e.id requestId); if (entry) { entry.error err.message; } } };然后在UI上可以提供一个按钮来切换显示这个调试日志面板里面以可折叠的形式展示每次请求的ID、耗时、请求体、以及接收到的所有数据块。这对于分析流式响应是否完整、延迟在哪里至关重要。5.3 会话管理与历史记录调试往往需要对比不同参数下的对话结果。因此会话管理功能很有必要。我们可以将messages数组与会话元数据如会话ID、名称、使用的模型参数一起保存到localStorage或IndexedDB中。// composables/useChatSessions.js import { ref, watch } from vue; export function useChatSessions() { const sessions ref(JSON.parse(localStorage.getItem(ai_chat_sessions) || [])); const currentSessionId ref(null); const createNewSession (name 新会话) { const newSession { id: Date.now().toString(), name, createdAt: new Date().toISOString(), messages: [], params: { model: deepseek-chat, temperature: 0.7 }, }; sessions.value.unshift(newSession); // 新会话放在最前面 currentSessionId.value newSession.id; saveSessions(); return newSession; }; const saveCurrentSession (messages, params) { const session sessions.value.find(s s.id currentSessionId.value); if (session) { session.messages messages; session.params params; session.updatedAt new Date().toISOString(); saveSessions(); } }; const saveSessions () { localStorage.setItem(ai_chat_sessions, JSON.stringify(sessions.value)); }; // 自动保存会话防抖 let saveTimeout; const autoSaveSession (messages, params) { clearTimeout(saveTimeout); saveTimeout setTimeout(() saveCurrentSession(messages, params), 2000); }; return { sessions, currentSessionId, createNewSession, saveCurrentSession, autoSaveSession, }; }在useChat中我们可以集成这个会话管理功能。当发送消息或修改参数时调用autoSaveSession进行延迟保存。在侧边栏渲染一个会话列表点击即可切换当前会话实现快速对比和回溯。6. 部署、优化与踩坑实录6.1 构建与部署注意事项项目开发完成后使用npm run build假设基于Vite进行构建。由于我们使用了现代JavaScript特性需要注意目标浏览器的兼容性。在vite.config.js中可以配置build.target为[es2020, edge88, firefox78, chrome87, safari14]以平衡兼容性和体积。部署时前端静态资源可以放在任何Web服务器上如Nginx, Apache, Netlify, Vercel。但流式请求的后端API必须支持CORS。你需要确保后端响应头包含Access-Control-Allow-Origin: [你的前端域名] Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Methods: POST, OPTIONS对于开发环境Vite的代理服务器可以很好地解决这个问题。6.2 性能优化点消息列表虚拟滚动如前所述如果消息量极大虚拟滚动是必须的。vue-virtual-scroller是一个不错的选择。Markdown渲染优化marked库在每次流式更新时都会重新渲染整个消息文本。对于长消息这可能导致卡顿。可以考虑使用marked的异步版本或者更轻量的库如markdown-it。另一个思路是只对新增的Markdown片段进行解析和拼接但这实现起来较复杂。一个折中方案是使用computed属性配合watch并对其设置防抖避免过于频繁的渲染。流式连接管理确保在组件卸载或开始新请求时正确关闭之前的SSE连接。在createEventSource函数中可以返回一个abort函数在useChat的sendMessage里用AbortController来取消上一个未完成的请求。状态持久化防抖会话自动保存到localStorage的操作一定要做防抖处理避免高频写入影响性能。6.3 实际踩过的坑与解决方案坑1流式响应中断但UI显示不完整现象网络波动导致SSE连接提前关闭AI消息显示到一半就停了没有错误提示。排查检查createEventSource的onError和onDone回调。发现网络错误触发了onError但UI只是停止了打字动画内容却留在了“不完整”的状态。解决在onError回调中不仅设置错误状态还应该标记当前AI消息为“不完整”或“出错”并在UI上给予明确提示比如在消息末尾显示“响应中断”。同时提供一个“重试”按钮允许用户重新发送上一条消息。坑2快速连续发送消息导致状态混乱现象用户快速点击发送前一个请求的流还在输出后一个请求又开始了导致两个响应的内容混杂在同一个消息气泡里。排查sendMessage函数没有对isLoading状态进行有效的锁保护或者在收到第一个数据块后过早地将isLoading设为false。解决强化状态锁。在sendMessage开始时如果isLoading为true直接返回。并且只有在流完全结束onDone或出错时才将isLoading设为false。更稳健的做法是使用一个请求ID确保onMessage回调只更新对应ID的消息。坑3移动端输入框聚焦后视图被键盘遮挡现象在手机浏览器中点击输入框键盘弹出但输入框被键盘挡住。排查这是移动端Web的经典问题。浏览器视口高度变化但我们的布局没有适配。解决有几种方案。简单的可以用CSSviewport单位配合calc()。更通用的方法是监听window的resize事件或visualViewport的resize事件在键盘弹出时主动滚动消息容器到最底部。一些UI库如Vant的输入框组件内置了此处理。坑4复制聊天内容时包含Markdown样式和光标现象用户想复制AI的回复但复制出来的文本包含了HTML标签或闪烁的光标字符“|”。排查因为消息内容是用v-html渲染的且光标是一个独立的span元素。解决为消息气泡容器添加CSS属性user-select: text;确保文字可选。对于光标可以设置user-select: none;防止被选中。更好的做法是提供一个“复制”按钮在每条消息旁点击后使用navigator.clipboard.writeText写入纯文本内容需先通过marked等库将HTML转换回Markdown或提取纯文本。这个基于Vue3的AI聊天界面项目从满足一个具体的调试需求出发逐步打磨核心的流式输出和打字机体验并扩展出参数调试、会话管理等实用功能。它不仅仅是一个UI组件更是一个可复用的前端解决方案模板。在实际集成到不同后端API时你可能需要调整createEventSource函数中的数据解析逻辑以适配你后端返回的SSE数据格式。但整体的架构思路——响应式状态管理、组合式逻辑封装、展示层动画分离——是通用的希望能为你构建自己的AI对话应用提供一个扎实的起点。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻