简介面向 Vue3 初学者的 AI 智能聊天演示项目基于 vue3 Element Plus Sass TypeScript Pinia 技术栈后期引入 MateChat 后实现移动端自适应可对接私域应用打造智能聊天场景。资源共 84 个文件压缩包 6.39MB以 ts/js 逻辑文件、vue 组件、scss/css 样式、json 配置为主另有 glb 三维模型、wasm 解码器等素材结构覆盖源码、路由模块、状态管理、接口封装、i18n、环境配置及构建产物 dist便于完整学习项目分层与工程化组织。已有 167 人学习下载。通过该演示项目可了解 vue3 组合式 API 与 TS 类型约束、Pinia 持久化、动态路由与布局组件、HTTP 模块封装等关键写法同时可参考 MateChat 集成方式、移动端响应式适配及打包部署配置适合边练边理解前端工程化入门知识。1. 先说明白话Vue3 MateChat 的 AI 聊天工程到底在做什么很多人把 AI 智能聊天当成「调一个接口就完事」真正动手才发现消息列表的自动滚动、Markdown 渲染、流式输出的打字机效果、多轮上下文拼接每一项都能吃掉半天时间。MateChat 这类面向 Vue3 的聊天 UI 组件库做的事情就是把这一层收拢成组件属性和插槽让开发者只需要关心「请求怎么写、消息怎么存、异常怎么处理」。这篇文章从一个最小可运行工程出发把 Vue3 组合式 API 配合 MateChat 构建 AI 智能聊天的完整链路拆开讲清楚。适合刚接触大模型应用开发、想快速把原型跑起来的新人也适合想评估组件库边界、再做技术选型的中级工程师。2. 从零初始化Vite、Vue3 与 MateChat 的最小可运行工程2.1 用 Vite 起一个 Vue3 TS 工程AI 聊天 Demo 的起步通常不是从某个企业级脚手架复制而是用 Vite 直接生成。我习惯加vue-ts模板因为聊天消息的数据结构、流式解析函数都需要类型约束纯 JavaScript 写到最后一定会因为消息字段不一致而排查半天。npm create vitelatest mate-chat-demo -- --template vue-ts cd mate-chat-demo npm install--template vue-ts的含义是生成带 TypeScript 支持的 Vue3 工程.vue组件和.ts文件都会被类型检查。这里把项目名定为mate-chat-demo你完全可以换成自己的目录名。安装依赖后不需要立刻删掉默认的 HelloWorld 组件先用它验证开发服务器能正常启动再逐步替换成聊天界面。2.2 安装 MateChat 组件库并接好样式MateChat 是面向 Vue3 的聊天场景组件库npm 包名按你实际装的那个为准下面示例用mate-chat作为占位名称。这一步最容易被忽略的是样式文件组件逻辑引入了样式没引入页面会渲染成一堆没有边框、没有间距的裸标签而且控制台不会报任何错误。npm install mate-chat安装完成后在src/main.ts里做全局接入。我的习惯是先把样式引入放在组件注册旁边避免后续排查「为什么界面怪怪的」这种玄学问题。import { createApp } from vue import App from ./App.vue import { Chat } from mate-chat import mate-chat/dist/style.css createApp(App).mount(#app)这里的Chat是 MateChat 暴露的核心组件多数同类库还会导出MessageList、InputArea等子组件。如果组件库支持按需引入只需要在用到的地方引入对应组件如果只提供全量引入则在入口引入一次即可。样式文件路径mate-chat/dist/style.css是常见约定你装完后去node_modules/mate-chat/dist下确认一下实际文件名。2.3 先跑通一个本地假消息的聊天界面在接入真实 AI 接口之前先把界面跑起来。这个阶段的目标不是功能完整而是确认组件、样式、数据流三者已经打通。我直接在App.vue里写一个最小版本。script setup langts import { Chat } from mate-chat const messages [ { id: 1, role: user, content: 你好介绍一下你自己 }, { id: 2, role: assistant, content: 你好我是一个 AI 助手可以帮你处理问题。 }, ] /script template div classchat-page Chat :messagesmessages / /div /template style scoped .chat-page { height: 100vh; border: 1px solid #e5e7eb; } /style注意.chat-page必须有明确高度聊天组件内部的消息列表高度默认继承父容器如果父容器没有高度你只会在页面上看到一片空白。messages数组里的每个对象至少要有id、role、content三个字段role只有user和assistant两种取值id必须稳定且唯一后续虚拟滚动会拿它当 key 使用。此时页面应该能渲染出两条消息和一个输入框输入框先不用管下一步接入真实请求。3. 接入 AI 接口消息模型、SSE 流式解析与暂停继续3.1 先把消息数据模型定死AI 聊天的数据模型核心就两个角色用户和助手。很多人在项目写了一半时才发现还需要存 token 用量、消息时间、模型名、工具调用结果于是开始来回改类型。我的建议是一开始就把模型定义得有扩展性但又不过度设计。export type ChatRole user | assistant export interface ChatMessage { id: string role: ChatRole content: string createdAt?: number meta?: Recordstring, any }id用crypto.randomUUID()生成或者在本地回显时从存储里恢复保证同一条消息在会话中始终使用同一个 id。content保存的是纯文本不保存 Markdown 源码以外的渲染结果渲染交给组件层的插槽处理。meta是给后续扩展留的口子比如模型返回的reasoning内容、工具调用的参数都可以塞进去而不用改消息结构。确定模型后在组件里引入类型const messages refChatMessage[]([])这样后续所有对消息的读写都能被 TypeScript 提醒到位。3.2 用 fetch 解析流式返回流式返回是 AI 聊天和普通接口最不一样的地方。大部分大模型服务走的是 SSE 协议返回内容长这样data: {content:你} data: {content:好} data: [DONE]每一组以空行结束data:后面是 JSON 字符串最后用[DONE]标记结束。我在接入时直接用原生fetch不引入额外的 SSE 库原因是可控性强而且中断、错误处理都能自己掌握。export async function streamChat( messages: ChatMessage[], handlers: { onUpdate: (delta: string) void onDone: (fullContent: string) void signal?: AbortSignal }, ) { const res await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), signal: handlers.signal, }) if (!res.ok) { throw new Error(HTTP ${res.status}) } const reader res.body!.getReader() const decoder new TextDecoder(utf-8) let buffer let full while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) const parts buffer.split(\n\n) buffer parts.pop() ?? for (const part of parts) { const line part.split(\n)[0] if (!line.startsWith(data:)) continue const payload line.slice(5).trim() if (payload [DONE]) { handlers.onDone(full) return } try { const json JSON.parse(payload) const delta json.content ?? full delta handlers.onUpdate(delta) } catch { // 忽略不完整的 JSON等待下一个数据块拼接 } } } handlers.onDone(full) }这里有两个关键点。decoder.decode(value, { stream: true })表示按流式解码防止中文字符被拆在两个二进制块里导致乱码buffer按空行\n\n切分后最后一段必须放回下一个循环拼接因为一个完整的 SSE 事件可能被拆成两次网络传输直接丢弃会丢失内容。如果你的后端不是 SSE 而是逐字返回 JSON 行把切分逻辑从\n\n改成\n即可。onUpdate每次收到的只是增量文本组件拿到后追加到消息末尾就形成了打字机效果。3.3 用 AbortController 做「停止生成」聊天界面几乎都要求用户能中止 AI 回复。这个需求看起来简单实际上一半的 Demo 都做错过有人把按钮做成disabled结果只是不能点了后端的计算还在继续有人用全局变量做标记但fetch根本不会理会这个标记。正确做法是把AbortSignal传给fetch调用abort()时浏览器会真正中断网络请求。let controller: AbortController | null null function stopGenerate() { controller?.abort() controller null }在发送消息时先取消上一个未完成的请求再创建新的控制器function send(text: string) { controller?.abort() controller new AbortController() streamChat( messages.value, { signal: controller.signal, onUpdate: (delta) { // 把 delta 追加给 assistant 消息 }, onDone: () { controller null }, }, ).catch((err) { if (err.name AbortError) return // 在这里处理网络错误 }) }AbortError是用户主动停止时的正常异常需要单独过滤掉不要把它当成真正的错误弹出提示。如果后端支持「继续生成」可以在停止时保存当前已生成的完整内容下次发送时把它作为上下文的一部分传给接口。4. MateChat 落地配置虚拟滚动、自动滚动与 Markdown 插槽4.1 必调的三个组件参数MateChat 这类组件库的参数命名各有差异但思路一致。下面是我在实际项目里必调的三个配置项分别对应长对话性能、打字机体验、输入提示。参数作用我的推荐值virtual-list或enable-virtual开启虚拟滚动只渲染可视区域内的消息项消息超过 200 条时开启auto-scroll-to-bottom新消息出现时自动滚到底部truescroll-behavior滚动方式auto适合流式输出smooth适合手动查看历史autotemplate Chat :messagesmessages :virtual-listmessages.length 200 auto-scroll-to-bottom scroll-behaviorauto placeholder输入问题回车发送 / /template虚拟滚动不要一上来就开。当消息只有几十条时虚拟滚动反而会因为动态计算可视项和占位高度引入不必要的复杂度甚至导致快速滚动时出现白屏抖动。超过 200 条再开体感上几乎没有差别。auto-scroll-to-bottom要做成由用户行为控制的否则用户往上翻看历史时新内容一到就把他强行拉回底部这是聊天工具里最容易挨骂的反人类设计。组件库如果提供scroll-to-bottom事件或滚动状态回调用它来判断用户是否正在查看历史消息。4.2 用插槽接管消息内容渲染AI 返回的内容几乎都是 Markdown代码块、列表、加粗混在一起。组件库自带的简单文本渲染满足不了这个需求所以我强烈建议通过插槽接管内容渲染而不是期望组件内置所有功能。script setup langts import MarkdownIt from markdown-it import { Chat } from mate-chat const md new MarkdownIt({ html: false, linkify: true, breaks: true, }) /script template Chat :messagesmessages template #message-content{ message } !-- 注意这里必须用 v-html 渲染 Markdown 编译结果 -- div v-htmlmd.render(message.content) / /template /Chat /template插槽的契约一般是message对象作为 slot prop里面包含content、role等字段。html: false是安全底线AI 生成的文本里如果带着script标签MarkdownIt 会把它当普通文本处理不会被浏览器解析执行。代码高亮可以用 highlight.js 配合 MarkdownIt 的highlight选项在渲染时把代码块追加高亮 class。把渲染逻辑放在插槽里的另一个好处是后续想换成其他 Markdown 解析器或者想对特定消息做单独样式都不需要改组件库的源码。4.3 mock 与真实接口切换的工程习惯AI 接口在开发早期往往不稳定或者还没拿到 token。我一般会写一个统一的消息发送入口开发环境走 mock生产环境走真实接口避免在组件里到处写if (import.meta.env.DEV)。export async function chat(messages: ChatMessage[], signal?: AbortSignal) { if (import.meta.env.DEV) { return mockChat(messages, signal) } return realChat(messages, signal) }mockChat可以用setInterval每 60ms 吐一个字模拟流式效果。这样前端开发完全不受后端进度限制打字机、中断、滚动这些交互可以先全部调好。后端联调时只需要保证真实接口的响应格式和 mock 一致前端代码一行都不用改。这个习惯在很多项目里帮我省掉了「前端等后端、后端等前端」的互相等待。5. 避坑MateChat 流式聊天常见 5 个翻车点5.1 打字机效果变成一次性输出现象点击发送后界面半天没反应然后整段文字突然全部出现。原因有两个。第一个是响应式失效onUpdate里修改的是普通对象属性而不是响应式数据第二个是流解析时把增量一路到局部变量最后才赋值给消息。解决确认messages是refonUpdate里每次都对message.content进行追加另外检查流解析代码不要在循环结束后才把结果写到组件里。一个简单的自测是打印每条 delta 的长度如果控制台有增量输出但界面不动问题一定出在响应式链路上。5.2 历史消息回显顺序错乱现象从本地存储恢复会话后旧的 AI 回复跑到最新消息的下面虚拟滚动时顺序还会跳变。原因通常是恢复数据时重新生成了id组件内部按id排序或做 diff 时无法识别消息的原始顺序。解决持久化时把消息的id、createdAt一起存下来恢复时不重新生成同时按createdAt做一次稳定排序。messages.value.sort((a, b) (a.createdAt ?? 0) - (b.createdAt ?? 0))createdAt可选的排序键如果后端返回的消息没有时间字段就退化为按数组原始顺序不要自作主张加Math.random()之类的逻辑。5.3 快速连续发送导致旧响应覆盖新消息现象用户发了「问题 A」马上又发「问题 B」A 的回应还没结束结果 A 的内容被追加到 B 的回答里或者 A 的最终内容覆盖了 B 的答案。原因是两个流式请求并发后启动的请求没有取消前一个。解决每次发送前主动abort上一个请求并且用序列号判断过期的回调。let requestSeq 0 async function send(text: string) { const seq requestSeq controller?.abort() controller new AbortController() // 推送用户消息和空的 assistant 消息 try { await streamChat(context, { signal: controller.signal, onUpdate: (delta) { if (seq ! requestSeq) return assistant.content delta }, }) } catch (err) { if ((err as Error).name AbortError) return console.error(err) } }seq的作用是标记当前最新的请求序号任何旧的回调只要发现序号不匹配就直接丢弃从根源上避免跨请求污染。这个做法比「只 abort 上一个请求」更稳妥因为 abort 本身也有竞态窗口。5.4 长对话上下文越攒越大现象聊到 30 轮以后每次发送的第一字响应明显变慢甚至触发接口的 token 上限报错。原因在于把所有历史消息都当作上下文发给模型请求体和计算量线性增长。解决发送前做一次上下文裁剪保留最近的若干条同时把 system 消息单独拎出来始终保留。function buildContext(messages: ChatMessage[], maxChars 6000) { let total 0 const picked: ChatMessage[] [] for (const item of [...messages].reverse()) { total item.content.length if (total maxChars) break picked.unshift(item) } return picked }这个函数按字符数估算不是真正的 token 计数但对大多数 Demo 够用。正式项目建议接入 tokenizer或者直接根据每条消息的meta.tokens字段精确计算。裁剪策略要保留最近对话因为用户的当前问题通常只依赖最后几轮上下文。5.5 外层组件被销毁重建导致滚动归零现象每次发送消息后消息列表滚动条都跳回顶部或者聊天界面的输入框闪一下。原因是我在组件外层套了v-if做状态切换每次切换都让Chat组件重新挂载滚动位置全部丢失。解决用v-show替代v-if或者给Chat一个稳定的key。template !-- v-show 保证 Chat 一直挂载只切显示隐藏 -- Chat keychat-panel v-showpanelVisible :messagesmessages / /template稳定key的意义是告诉 Vue 不要复用错乱的组件实例。真正要做的是让 Chat 的生命周期和页面容器保持一致除非用户切换会话否则不要主动销毁它。6. 把聊天 Demo 升级成可迭代的工程验证、持久化与下一步6.1 一个能手动触发模拟场景的验证入口我最后会在页面上挂一个隐藏的调试面板包含三个按钮模拟慢网络、模拟中途报错、模拟空回复。慢网络用setInterval每 200ms 输出一个字报错在 mock 的第三秒抛出 HTTP 500。这样每次改动交互逻辑后不用真的等接口就能验证打字机是否流畅、错误提示是否合理、停止按钮是否在中断后恢复状态。6.2 本地持久化与多会话切换聊天 Demo 最容易被用户吐槽的点是刷新后内容全丢。我会把会话列表存到localStorage每次增删消息时防抖保存切换会话时不重新请求接口直接读缓存。消息量大的时候把每个会话拆成单独 key避免一次写入超大字符串。function saveSessions() { localStorage.setItem(chat-sessions, JSON.stringify(sessions.value)) }6.3 更值得投入的方向继续做下去优先把上下文管理抽象成独立模块承载令牌计算、摘要压缩、工具调用结果注入而不是继续往组件里塞逻辑。我在多个聊天项目里踩过同一个坑组件越写越厚最后连流式解析、上下文裁剪、会话存储都堆进一个.vue文件改一个需求要翻几百行。把请求层、状态层、渲染层分开哪怕 Demo 很小也按这个结构组织后续换模型、换组件库的成本都会低很多。希望这几个配置和排查习惯能帮你少走几步弯路希望帮到你。本文还有配套的精品资源点击获取