ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue2老项目接入AI问答机器人:流式SSE与非流式接口完整实战

Vue2老项目接入AI问答机器人:流式SSE与非流式接口完整实战 前阵子公司要做一个人工智能问答客服前后端一起动工。后端同事给了两个方案非流式接口一次请求等全部结果流式接口基于SSE把token一个个吐出来。项目本身是维护了两年的vue2 Element UI老项目不可能为了一个功能模块上vue3重构。我花了两天时间把AI问答机器人集成进去中间踩了不少坑比如流式解析时中文乱码、并发请求覆盖旧回答、Vue2响应式数组不触发更新等等。这篇文章把我完整的实现思路、代码细节、踩坑记录都整理出来涵盖流式与非流式两种接口方法希望能给同样在vue2项目里接AI能力的朋友省点时间。1. 项目概述与核心设计思路1.1 为什么是vue2技术栈先回答一个很多人会问的问题现在新项目当然选vue3为什么还要折腾vue2实际情况是存量项目太多。大部分公司手头维护的系统还是基于vue2 webpack4 Element UI搭建的跑了很多年业务逻辑涉及几千个组件不可能说重构就重构。我这次接手的项目是一个后台管理系统Vue版本2.6.x路由用vue-router 3.x状态管理用vuex 3.x。在这种老项目里单独加一个AI问答模块最稳妥的做法是顺着原有技术栈来而不是另开一个工程再用iframe嵌进去。iframe方案虽然隔离干净但样式的割裂感、会话状态的传递成本在小模块里都是负担。还有一个现实因素团队熟悉度。成员对vue2的选项式API、响应式机制摸得很透遇到问题能快速定位。如果强行上vue3光是Composition API的学习成本、新生态的适配就要多花不少时间。即使未来项目要升级vue3也可以从组件逻辑层开始做平滑迁移而不是直接把整个项目推翻。所以结论是在既有vue2项目里实现AI问答机器人关键不是“炫技”而是怎么在旧框架里把新体验做好。1.2 整体架构与模块划分整个AI问答模块我按功能拆成了四层接口层负责所有HTTP请求包括普通POST请求非流式和基于fetch/XMLHttpRequest的流式请求。对外暴露统一的方法如sendQuestion。状态层用vuex管理会话列表、当前对话消息数组、加载状态、是否有流式请求在进行中。为什么放在vuex而不是组件本地data因为对话记录在用户切换页面后需要保留而且多个组件会话列表、消息面板、输入框都要读写这份数据用vuex更好同步。视图层拆成三个组件ChatPanel聊天主面板、MessageList消息列表、MessageInput输入区域组件之间尽量通过store通信。工具层包括流式解析工具、消息排版工具markdown渲染、时间格式化等纯函数模块。模块拆好后非流式和流式两种模式都复用同一套状态管理和视图组件只是接口层实现不同。这一点很重要因为需求方一开始只要非流式后来才追加了流式。如果一上来就把逻辑揉在组件里后面改起来会很痛苦。我见过不少项目把AI返回的JSON直接存在组件的data里导致会话切换、消息更新都牵一发动全身这就是一开始没分层的后果。1.3 流式与非流式接口的本质差异非流式接口很好理解客户端发一次请求服务端把完整回答拼好一次性返回。数据通常是JSON格式比如{ code: 0, data: { answer: 完整的回答内容 } }这种方式的优点是逻辑简单对前端几乎没有额外要求拿到数据渲染即可。缺点是用户必须等。模型生成回答需要几秒甚至几十秒如果接口本身是同步等待的用户在界面上只能看到loading转圈体验非常急躁。我实测过一次长回答非流式从发请求到拿到完整结果需要23秒用户在第5秒就开始反复点按钮以为系统卡死了。流式接口则完全不同。服务端不再等完全生成完再响应而是生成一部分就推送一部分。主流实现是SSEServer-Sent Events基于HTTP长连接服务端可以持续往客户端推送数据。因为大模型文本是按token生成的所以流式能带来“打字机”效果文字一个个蹦出来用户感觉AI在实时思考等待焦虑大幅降低。这两种方式对前端的要求完全不是一个量级。非流式就是一个普通的ajax调用流式需要处理事件流、增量拼接、中断恢复还要考虑浏览器对EventSource的限制比如不支持自定义请求头、只支持GET。后面我会分两章详细展开。2. 非流式接口实现详解2.1 请求封装与鉴权处理先说非流式的请求封装。项目里有现成的axios实例我直接基于它扩展。核心点在于AI接口需要额外的鉴权信息比如会话token、用户身份这些要统一在拦截器里处理而不是每个调用点手动传。// api/chat.js import request from /utils/request export function sendQuestion(data) { return request({ url: /api/ai/chat, method: post, data, timeout: 120000 // AI接口慢默认超时不能按普通接口来 }) }这里有个细节axios默认超时如果是10秒问一个复杂问题时大概率超时。因为非流式接口要等模型生成完才返回可能30秒、60秒甚至更久。所以我在封装时把timeout显式调大到120秒并且和后端约定好如果服务端内部出错返回HTTP 200 业务错误码这样前端统一走业务码分支处理避免网络层和业务层混在一起。另一个细节是CancelToken。用户可能在请求过程中切换会话或者连续提问上一次请求还没返回就要把过时请求取消掉。axios支持CancelToken我一般会把每次请求的取消函数存在变量里在新请求发出前调用上一次的取消这个细节在后面讲竞态问题时还会展开。2.2 对话记录管理与数据模型非流式实现里数据模型的稳定性决定整个模块的健壮性。我设计了一条消息的数据结构// store/modules/chat.js const state { sessions: [], // 会话列表 currentSessionId: , messages: [], // 当前会话的消息列表 loading: false, // 非流式请求中 streaming: false // 流式请求中 } // 一条消息的结构 // { // id: uuid, // role: user | assistant, // content: 文本内容, // status: pending | success | error, // createdAt: 时间戳, // errorMsg: // }为什么要用status字段而不是简单用loading布尔值因为消息粒度不同。一个会话里可能穿插多条消息每条消息独立的pending/error状态渲染时才方便控制样式比如用户消息右侧气泡、AI消息左侧气泡、出错时显示重试按钮。如果只用一个全局loading出错时根本不知道是哪条消息出了问题。vue2的data与return之间的关系在这里也有体现vuex的state直接就是对象组件里data则必须写成函数return一个对象否则组件复用时会共享数据。我遇到过不止一次新人写的data不带return结果多个组件实例的聊天记录互相串了。这个坑在AI问答这种强交互组件里特别致命直接导致用户A的提问出现在用户B的会话里排查起来相当麻烦。2.3 消息列表渲染与自动滚动消息列表渲染看着简单实际上有个非常关键的体验点自动滚动。聊天场景必须保持新消息可见否则用户还要自己往下滑体验会很割裂。template div refmessageList classmessage-list div v-formsg in messages :keymsg.id classmessage-item :classmsg.role div classavatar{{ msg.role user ? 我 : AI }}/div div classbubble div v-ifmsg.status pending classloading-dots.../div div v-else{{ msg.content }}/div /div /div /div /template自动滚动的正确姿势不是watch整个messages数组而是watch messages的长度和最后一条消息的content长度。因为流式接口下content会不断变化如果watch整个数组每次增量都会触发滚动计算容易抖动。我一般这样写watch: { messages.length: scrollToBottom, lastMessage.content: scrollToBottom }, computed: { lastMessage() { return this.messages[this.messages.length - 1] || {} } }, methods: { scrollToBottom() { this.$nextTick(() { const el this.$refs.messageList if (el) { el.scrollTop el.scrollHeight } }) } }注意一定要用$nextTick因为数据更新到DOM渲染是异步的不加nextTick多半滚不到底。另外如果消息内容很大连续调用滚动可能会有轻微性能问题可以在后面加节流比如每200ms最多触发一次。2.4 非流式接口的适用场景与局限非流式适合什么场景我的判断是回答长度短、响应速度快比如知识库关键词匹配而不是大模型生成或者客户端环境对长连接不友好比如某些内网代理会掐断长时间不响应的连接。它最大的问题不是技术本身而是体验。用户等待时看不到任何进展一旦超过5秒焦虑感指数上升很多人会以为卡死。所以我的实战建议是如果后端能提供流式优先用流式如果暂时只有非流式前端一定要做好“等待反馈”发送后立即把用户消息渲染出来AI气泡里放一个动效loading同时给出“正在思考通常需要10-30秒”之类的提示。这个小细节能显著降低用户流失。3. 流式接口实现详解3.1 SSE流式响应原理流式接口我这次用的是SSE。SSE的协议非常简单服务端返回Content-Type: text/event-stream之后按固定格式往响应体里写数据。data: {content: 你好} data: {content: } data: {content: 世界}每行以data:开头每条消息以空行分隔。前端要做的就是把这个流一行行读出来解析成JSON再拼接到当前AI消息的content上。SSE本质上就是一段持续输出的HTTP响应所以代理服务器、负载均衡都要保证不缓冲响应否则流式就成了一坨一次性返回。SSE不是唯一方案WebSocket也能实现流式。但SSE更简单它是单向的服务端往客户端推正好匹配AI回答的场景并且基于普通HTTP网关、代理的配置成本低。WebSocket需要额外维护连接状态、心跳、重连杀鸡用牛刀了。不过有一个需要注意的点EventSource API不支持自定义请求头而我们的接口要求带Authorization所以不能直接new EventSource必须用fetch或XMLHttpRequest来读流。3.2 fetch流式解析与事件处理直接在浏览器里用fetch读取流式数据关键是response.body.getReader()。下面是我封装好的方法// utils/sse.js export async function fetchSSE(url, options, handlers) { const { onMessage, onError, onFinally } handlers const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: getToken() }, body: JSON.stringify(options.data), signal: options.signal // 用于中断 }) if (!response.ok) { throw new Error(HTTP error: ${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) break buffer decoder.decode(value, { stream: true }) // 按SSE格式切分 const lines buffer.split(\n\n) buffer lines.pop() // 最后一段可能不完整留到下一次 for (const line of lines) { const dataLine line.trim().startsWith(data:) ? line.trim().slice(5).trim() : if (dataLine dataLine ! [DONE]) { try { const parsed JSON.parse(dataLine) onMessage(parsed) } catch (e) { console.warn(解析流式数据失败:, dataLine, e) } } } } onFinally onFinally() }这里有几个坑必须强调不能用response.text()一次性读那会等所有数据到齐和普通接口没区别。必须用reader.read()逐块读。一定要用TextDecoder(utf-8)并且传{ stream: true }否则中文多字节字符在分块边界可能被截断产生乱码。我一开始没加stream选项结果回答里老有乱码排查了很久才发现是这个原因。提示SSE数据行之间以空行分隔但网络分块可能刚好把一个完整消息切成两半所以要维护buffer缓冲区。同时对[A个字符]这种特殊标记要单独判断通常[!DONE]是OpenAI风格的结束标记遇到它说明生成结束。3.3 增量渲染与用户中断流式解析拿到每个片段后要做的是追加到当前正在渲染的AI消息上。这里有一个Vue2响应式的经典坑如果用push方式拼接字符串没问题但如果你把每段文本先放进数组再join由于Vue2的数组响应式限制必须用splice或者整体替换才能触发视图更新。// 正确做法直接字符串拼接利用字符串的重新赋值触发响应式 state.streamingContent state.streamingContent delta // 错误示范数组中push视图可能不更新 // state.contentParts.push(delta) // Vue2中直接push能触发是因为Vue重写了数组方法 // 但如果做的是直接按索引赋值如 arr[0]xxx就不会触发更隐蔽的问题是用户中断。用户点击“停止生成”时光停掉UI是不够的必须真正取消网络请求。我用的是AbortController在fetch调用时传入signal点击停止时调用abort()。注意AbortController在vue2老项目里的兼容性如果项目最低支持IE这个方案不可行需要换XMLHttpRequest加xhr.abort()。我们项目已经放弃IE了所以用AbortController简单干净。停止之后还要把当前AI消息里不完整的末尾做处理。比如最后停在空格或换行不用清理但要把状态从“生成中”改成“已中断”并给一个“继续生成”按钮前提是后端支持续传。我们当时后端不支持续传所以中断后按钮只提示“回答已中断可重新提问”。3.4 错误处理与断线重连策略流式连接是长连接比普通请求更容易出问题。我总结了四类错误网络中断响应流读到一半断开reader.read()会抛异常。服务端错误可能前面已经推送了几个token然后报错需要把HTTP状态码加上流内错误码一起判断。鉴权失败如果token过期fetch还没读到流就返回401要引导重新登录。超时虽然没有普通请求那么明显但如果长时间没有收到任何数据块也要触发超时逻辑。我的处理策略分两种已收到部分内容的把错误信息作为一条系统消息追加显示并保留已生成的内容不让用户白等一个数据块都没收到的视为完全失败AI气泡里显示“生成失败点击重试”并提供重试按钮。断线重连这块我当时没有做自动重连。原因在于服务端没有设计续传接口重连只能从第一个token重新生成反而浪费资源。但如果你的后端支持“按请求ID续传”前端可以做记录请求ID断线后用相同ID重新发起服务端从断点继续推送。这个扩展点是后端能力决定的前端需要做的就是记录请求ID并在重连时带上。4. 两种接口方式的对比与选型建议4.1 体验与性能全面对比我把两种方式横向对比一下方便你直接复制拿去给需求方讲对比维度非流式接口流式接口首字返回时间等待完整生成通常数秒到数十秒200ms-1s内出第一个字用户体感长等待loading易焦虑打字机效果接近真人前端复杂度低普通ajax即可高需处理流、中断、重连请求状态判断一次结束状态分明长连接需自行判断结束服务端实现成本低略高但主流框架都有方案适合场景短回答、内部系统、弱网长回答、面向用户、体验敏感从我的实测数据看同样一个复杂问题大概600字回答非流式需要23秒流式首个token是0.8秒全部结束也是23秒左右。差别不在于总时长而在于“用户感知等待”的分布。流式把23秒的空白等待切成了23秒的持续反馈用户的耐心上限直接拉高。对AI客服、智能助手这类产品来说“看起来快”有时候比“真的快”更重要。4.2 双模式支持的设计思路我的项目里做了双模式自动降级如果流式接口报错比如网关不支持SSE自动切回非流式接口。实现上在接口层加了一个开关// config/index.js export const AI_CHAT_MODE { STREAM: stream, NORMAL: normal } // 调用时根据配置或运行时状态切换 async function sendMessage(payload) { if (AI_CHAT_MODE stream supportsSSE()) { return sendStreamMessage(payload) } return sendNormalMessage(payload) }这个设计建议你一开始就做哪怕后端暂时只支持非流式。因为AI相关需求迭代非常快流式几乎是必定会上的接口层留好切换口子后面就不用动组件代码。我在后来接入流式时整个视图层和vuex状态几乎没有改动只新增了一个流式工具模块和一个接口方法这都得益于最开始的分层。4.3 vue2与vue3差异对实现的影响很多人问vue2和vue3在AI问答场景下有什么需要注意的差异。我从实战角度说几个响应式原理vue2用Object.definePropertyvue3用Proxy。在流式增量更新场景下vue3对频繁修改对象和数组更友好。vue2里如果想给某条消息新增一个字段比如status必须用Vue.set或this.$set才能保证响应式直接this.msg.status xxx不会更新视图这个我在调试中吃过亏。data的写法vue2要求data是函数vue3选项式下也要求函数但组合式下用ref/reactive更自然。AI聊天数据本来就是强响应式场景ref的.value写起来比this更自由但也更容易漏写.value各有利弊。组合式API的复用流式解析逻辑、中断逻辑在vue3里可以抽成useChatStream()的hook在vue2里只能写成mixin或者工具函数。所以我建议起步时就把逻辑放在纯函数或工具层而不是组件里以后迁移vue3能省一半工作量。异步渲染差异vue3的nextTick和Suspense机制更先进但vue2只要规范使用$nextTick流式场景下也能做到不丢帧。核心结论是vue2完全能实现AI问答机器人的良好体验前提是把代码放在“可迁移”的位置。4.4 跨端扩展HBuilderX与uniapp场景补充如果你不只做Web端还想把AI问答能力复用到App或小程序热门方案是用HBuilderX开发uniapp项目。uniapp默认支持vue2语法而且从vue2的组件迁移到vue3整体思路相对平滑。但流式接口在跨端场景要格外注意小程序里没有浏览器原生的fetch流式读取ReadableStream能力有限往往需要改用小程序自己的request接口开启enableChunked模式或者直接用WebSocket方案。HBuilderX开发时可以在浏览器里调试流式但真机预览又不一样要根据实际运行环境适配。所以如果目标是跨端架构上更要把“流式传输层”单独隔离不要和业务组件耦合这样一套业务逻辑多端切换传输层就够了。5. 核心代码实战从0到1落地5.1 非流式问答完整链路我给出一个精简但完整的非流式问答核心代码直接基于上面封装好的request。methods: { async handleSend() { const question this.inputContent.trim() if (!question || this.loading) return // 1. 追加用户消息 this.$store.commit(chat/appendMessage, { id: uid(), role: user, content: question, status: success }) // 2. 追加AI空消息状态pending const aiMsgId uid() this.$store.commit(chat/appendMessage, { id: aiMsgId, role: assistant, content: , status: pending }) // 3. 发起请求 this.loading true try { const res await sendQuestion({ question, sessionId: this.currentSessionId }) this.$store.commit(chat/updateMessage, { id: aiMsgId, content: res.data.answer, status: success }) } catch (e) { this.$store.commit(chat/updateMessage, { id: aiMsgId, content: , status: error, errorMsg: e.message }) } finally { this.loading false } } }注意updateMessage的mutation内部要用splice整体替换或者$set确保vue2响应式生效// store/modules/chat.js updateMessage(state, { id, ...patch }) { const index state.messages.findIndex(m m.id id) if (index -1) { // 整体替换对象确保vue2响应式生效 state.messages.splice(index, 1, { ...state.messages[index], ...patch }) } }5.2 流式问答完整链路流式核心动作和上面一致区别在第三步methods: { async handleSendStream() { const question this.inputContent.trim() if (!question || this.streaming) return // 追加用户消息 this.$store.commit(chat/appendMessage, { id: uid(), role: user, content: question, status: success }) // 追加AI空消息 const aiMsgId uid() this.$store.commit(chat/appendMessage, { id: aiMsgId, role: assistant, content: , status: streaming }) // 创建AbortController用于停止 this.abortController new AbortController() try { await fetchSSE(/api/ai/chat-stream, { data: { question, sessionId: this.currentSessionId }, signal: this.abortController.signal }, { onMessage: (parsed) { // 拿到delta增量拼接到当前AI消息 const delta parsed.content || parsed.delta || if (delta) { this.$store.commit(chat/updateMessage, { id: aiMsgId, content: this.currentAiContent delta, status: streaming }) } }, onFinally: () { this.$store.commit(chat/updateMessage, { id: aiMsgId, status: success }) } }) } catch (e) { // 如果是因为用户点击停止产生的cancel错误不提示 if (e.name AbortError) { this.$store.commit(chat/updateMessage, { id: aiMsgId, status: interrupted }) } else { this.$store.commit(chat/updateMessage, { id: aiMsgId, status: error, errorMsg: e.message }) } } }, handleStop() { if (this.abortController) { this.abortController.abort() } } }这里有个性能注意点onMessage每收到一个token就commit一次vue2的响应式更新虽然够用但如果消息列表很长频繁触发整个列表渲染会卡顿。我的做法是让每个消息项组件根据msg.id做shouldUpdate判断严重时还可以用requestAnimationFrame合并渲染。实测中一次回答几百个token不加优化也能跑但1万token级别就会明显卡顿建议提前加节流。5.3 可编辑JSON配置的辅助实践AI问答机器人往往需要一些可配置项比如机器人名称、引导语、知识库参数。在vue2项目里我常用vue-json-editor这类可编辑JSON格式数据插件来做“配置面板”让运营直接在页面上改JSON而不是每次都要开发改代码。template div vue-json-editor v-modelconfigJson :modetree :show-btnstrue has-erroronJsonError / el-button typeprimary clicksaveConfig保存配置/el-button /div /template script import vueJsonEditor from vue-json-editor export default { components: { vueJsonEditor }, data() { return { configJson: { greeting: 你好我是AI助手, maxTokens: 800, temperature: 0.7 } } } } /script这个插件的坑是v-model绑定的是一个字符串还是对象容易被搞混需要在watch里做一层JSON.parse处理。另外运营配置的内容要防注入保存前做白名单校验。可编辑JSON配置跟AI问答结合最大的价值是把“调整提示词/参数”从开发任务变成运营自助操作在内部系统里尤其实用。6. 常见问题排查与实操避坑6.1 请求竞态导致旧回答覆盖新问题典型场景用户连续发送“11”和“帮我写首诗”第一次请求还没返回第二次就发出去了。结果第一次的慢回答回来把第二问对应的AI气泡覆盖了。这个问题的根因在于没有做请求时序控制。我用的方案每条AI消息生成时带上请求序号seq服务端返回或流式结束时校验seq是否仍然等于当前最新seq不等就丢弃。// 在store里维护当前请求的seq state.seq 0 // 发送新问题时seq function handleSend() { const mySeq this.$store.state.chat.seq // 发起请求... sendQuestion(...).then(res { if (mySeq ! this.$store.state.chat.seq) return // 过时请求丢弃 // 更新消息 }) }流式场景同理在onMessage里校验seq避免切换会话后旧流还在写数据。这个bug如果不在前期设计好后期排查非常痛苦因为它是偶发的需要网络慢一点才容易复现。6.2 流式中断与恢复流式中断我踩过一个具体坑用户点停止后如果后端还在推数据前端abort只是断开了浏览器侧连接服务端不一定立刻感知。需要后端配合处理客户端断开的事件。前端能做的是在中断后尽快把loading状态清掉防止用户以为还在生成。另外如果页面上有多个AI组件同时发起流式请求比如同一个页面两个聊天窗口AbortController要分开管理每个窗口一个实例停止时只中断当前窗口的请求。我最初用了单一实例结果停A窗口把B窗口的回答也掐了这个低级错误希望你别再踩。6.3 Vue2响应式更新的坑Vue2数组响应式有三个限制通过索引直接设置项比如this.items[0] xxx不会更新视图。修改数组长度比如this.items.length 0不会更新视图。新增对象属性比如this.obj.newField xxx不会更新视图。在AI问答中最容易踩的是第3条从接口拿到消息对象后想在对象上动态加status、content等字段。解决方案统一用数组splice整体替换或者Vue.set千万不要直接赋值。我的建议是所有消息更新都收敛到vuex的mutation里组件里不直接改减少踩坑面。这样也方便打日志排查。6.4 流式请求超时与弱网策略弱网环境或代理超时流式请求可能长时间不返回数据。前端不能永远等下去我做了个“空闲超时”从最后一次收到数据块开始计时如果60秒内没有新数据就自动abort并提示用户。实现思路是流式读取循环里记录时间每次读到一个块就重置定时器。另外用户切换浏览器标签页后浏览器可能会节流setTimeout但fetch流式读取不受标签页节流影响因为它是IO操作所以数据通常能持续接收这点实测下来比较稳。不过要注意如果用户切走标签页很久回来时可能已经生成了大量内容需要在onMessage里控制渲染频率避免一次性渲染大量DOM导致卡顿。6.5 从vue2迁移vue3需要注意什么如果哪天要升级vue3我在本项目里的这些代码怎么迁移我总结三条把流式解析、状态管理、接口封装放在纯JS层不直接依赖this迁移时只需改组件层的调用方式。vuex转Pinia的工作量不大但vue2里commit的写法在vue3里要改成直接调用store方法消息更新的mutation可以保留为函数复用。模板里如果用了大量filtervue3不支持了要在迁移前先清理。AI消息里的markdown渲染也要换用新版本库。我用HBuilderX做uniapp跨端时也发现vue2的代码在uniapp里基本通用但流式读取要按小程序环境单独适配前面已经提过这里不重复。总之如果从一开始就把核心逻辑隔离出来迁移vue3就是一个组件层换壳的过程而不是重写。7. 写在最后的心得我这次在vue2老项目里把AI问答机器人落地最大的体会是真正的难点不在“用哪个框架”而在“怎么把流式这种新交互模式嫁接到旧的技术架构上”。非流式接口一天就能搞定流式接口的坑却踩了两天但做完之后用户体验的提升是肉眼可见的。如果你现在也准备在vue2项目里接AI能力我的建议是先把接口层和组件层解耦把流式解析放到独立的工具模块里双模式开关从一开始就预留好同时把消息模型、请求时序控制、响应式更新这几个基本功做扎实。这些都是vue2和vue3通用、跨端也复用的资产将来不管项目怎么升级你的AI问答模块都能平稳过渡。最后再分享一个小技巧流式加载时AI气泡里的光标闪烁效果用CSS动画比用JS定时器稳定而且不消耗额外资源这个小细节能让整个对话体验自然很多。
返回列表