从零实现 AI 聊天助手:可直接复用的前端核心方案
引言
项目目标:使用 Vue2 + TypeScript 构建 AI 聊天助手核心功能,实现「用户发送→AI逐字流输出」(类似 ChatGPT 的流式返回)效果,提供可落地的实现方案。
核心要求:
- 视觉一致(左对齐、黑色、不斜体)
- 输入锁定(避免并发推送)
- 自动滚到底部
- 支持请求真实API(SSE/ReadableStream)
1. 效果展示
本组件实现类 ChatGPT 的流式聊天体验。
核心效果
- 用户消息实时展示
- AI 回复逐字流式输出(打字机效果)
交互体验
- 聊天区域自动滚动到底部
- 流式过程中禁用输入,防止重复发送
UI规范
- 统一干净的 UI 样式(左对齐、黑色常规字体)
完整流程
- 输入消息 → 发送 → 顶部展示用户消息
- 底部立即出现 AI 占位消息 → 逐字输出回复内容
- 输出完成后恢复输入框,可继续对话
效果动图

2. 项目文件结构
核心文件说明,明确各文件作用与修改注意事项
-
chat.vue:聊天组件核心文件(重点开发文件)
-
chat-vue-technical-doc.md:技术说明文档(自动生成,无需手动修改)
-
main.ts / App.vue:全局路由与入口文件(无需改动,仅作为项目入口)
3. Chat 组件(核心功能组件)
3.1 基础配置与核心定义
3.1.1 数据结构定义
-
定义Message接口,明确角色、内容、流式状态等核心字段。
-
接口详情:role(区分user/assistant)、content(消息完整内容)、streaming(流式状态标识)、displayedContent(流式增量展示内容)。
-
字段作用说明:用户消息直接渲染,AI消息先占位("空内容
content=''+ 流状态streaming=true+displayedContent=''")再在循环里逐字补充内容
ts
interface Message {
role: "user" | "assistant";
content: string;
streaming?: boolean;
displayedContent?: string;
}
3.1.2 关键状态变量
messages: Message[]存储所有聊天消息,管理消息状态inputMessage: string绑定输入框,存储用户待发送消息isStreaming: boolean控制UI输入锁定,保证一次仅一个流式输出
3.2 聊天流程:逐字打字机模拟
3.2.1 sendMessage 逻辑
-
输入校验:检查空输入,流式过程中禁用发送功能
-
消息处理:推送用户消息到messages数组,同时推送AI空占位消息
-
流式调用:调用simulateStreaming方法,传入AI模拟响应和消息索引
-
状态重置:模拟流式输出完成后,将isStreaming设为false,恢复输入功能
3.2.2 simulateStreaming 动画机制
-
核心逻辑:循环遍历AI响应字符串,每50ms追加一个字符到displayedContent
-
交互优化:每次追加字符后调用scrollToBottom(),保证消息实时可见
-
状态更新:流式输出结束后,切换streaming为false,将完整响应内容写入content字段
-
可扩展性说明:该逻辑支持后续替换为真实SSE/fetch stream/websocket,无需修改前端核心结构
3.3 模板与样式:可控 UI
3.3.1 模板渲染逻辑
-
角色区分渲染:根据message.role判断渲染用户/AI消息
-
流式状态渲染:AI消息流式输出时,渲染displayedContent;非流式状态渲染content
-
核心模板代码示例:区分用户、AI流式、AI非流式三种场景的渲染逻辑
html
<span v-if="message.role === 'user'">{{ message.content }}</span>
<span v-else-if="message.streaming" class="streaming-text">{{ message.displayedContent }}</span>
<span v-else>{{ message.content }}</span>
3.3.2 样式调整
-
基础样式:.message类设置text-align:left,保证所有消息左对齐
-
流式文本样式:.streaming-text设置color:#000、font-style:normal、white-space:pre-wrap,保证视觉一致
-
样式效果:避免流式文本灰色、斜体,提升阅读体验
4. 大模型 API 集成
以智谱 AI为例,前端用 fetch + ReadableStream 实现流式。
需要前往 智谱开放平台 注册并创建应用,获取 API Key.
4.1 实现目标
- 调用智谱 GLM 大模型接口
- 开启流式输出(stream: true)
- 实时接收 AI 逐字返回的内容
- 动态更新到页面上,实现打字机效果
4.2 实现细节
4.2.1 方法定义与基础配置
ts
async fetchAIStream(aiMsgIdx: number) {
const apiKey = "62e6df10d65d43b09c97bb4d3c340bce.xxxxxxxxxxxx";// 替换为你的 API Key
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
- async:标记这是异步函数,内部可以用 await 等待接口响应
- aiMsgIdx: number:接收一个消息索引,用来定位要更新的 AI 消息
- apiKey:智谱大模型的身份密钥(你自己的密钥)
- url:智谱官方的对话接口地址
sendMessage()调用fetchAIStream()时需要await,等待全部
4.2.2 发送 POST 请求给大模型
ts
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, // 身份验证
},
body: JSON.stringify({
model: "glm-4.5-flash", // 使用的模型版本
stream: true, // ✅ 核心:开启流式输出
messages: this.messages
.map((msg) => ({
role: msg.role,
content: msg.content || msg.displayedContent,
}))
.filter((m) => m.role === "user" || m.role === "assistant"),
}),
});
这部分是请求核心:
- fetch:浏览器原生 API,发送网络请求
- headers:请求头,携带身份凭证和数据格式
- stream: true:最重要的参数!告诉大模型不要一次性返回全部答案,而是逐字流式返回
- messages:对话上下文
- 只保留 user(用户)和 assistant(AI)的消息
- 取完整内容 content 或正在显示的内容 displayedContent
为什么用fetch而不是其他请求
一句话结论:可以用 axios,但不推荐!流式输出必须用 fetch.
因为 AI 流式输出(SSE / ReadableStream) 依赖一个核心能力:
读取原生数据流(stream / ReadableStream)。fetch 天生支持response.body.getReader()(可以逐块读取数据)。axios 目前完全不支持:axios 会等整个请求全部结束才把结果一次性给你,不能实时拿到每一段文字。也就是说:fetch = 边煮边喝汤(流式);axios = 煮完才能喝(一次性)。
Axios 官方文档写得很清楚:Axios does not support streaming responses. Axios 不支持流式响应。它会把整个返回缓存起来,直到结束才返回,无法实现打字机效果。
那能不能强行让 axios 支持?能,但非常麻烦:要装额外插件 axios-observable. 要配置复杂;兼容性差;不如 fetch 原生稳定、简洁。
一句话总结:AI 流式聊天 = 必须用 fetch, 普通接口(增删改查)= 随便用 axios /fetch.
4.2.3 初始化流式读取器
ts
const reader = response.body?.getReader();
const decoder = new TextDecoder("utf-8");
- response.body?.getReader():获取流式响应读取器,用来一点点接收数据
- response.body → 是一个 ReadableStream 对象(浏览器自带)
- .getReader() → 获取这个流的读取器(ReadableStreamDefaultReader)
const reader = response.body?.getReader();是浏览器原生 Fetch API + 流式响应 (ReadableStream) 标准用法
- TextDecoder("utf-8"):把二进制流解码成我们能看懂的文字
4.2.4 循环读取流式数据
ts
while (true) {
const { done, value } = await reader!.read();
if (done) break; // 数据接收完毕,退出循环
- while(true):无限循环,持续读取流数据
- reader.read():读取一段数据,返回两个值
- done:是否读取完成
- value:本次读取到的二进制数据
- 读完就 break 跳出循环
- 为什么你看不到 done?done: true 是 浏览器流 ReadableStream API 内部返回的状态,不是后端返回的文本,不会出现在 data: xxxx 里,只有流彻底关闭时,才会返回 { done: true }。所以你抓包看不到,但代码里能读到。
- 整体代码流程
cpp
1. 发送请求
2. 拿到 ReadableStream(response.body)
3. 拿读取器 reader
4. while 循环 reader.read()
→ 读一段数据
→ 解析显示
5. 后端最后发 data: [DONE]
6. 后端关闭连接
7. 浏览器自动返回 { done: true }
8. 循环 break,结束
- 观察接口返参


4.2.5 解析每一段流式数据
ts
const chunk = decoder.decode(value); // 二进制转文字
const lines = chunk.split("\n").filter((line) => line.trim());
- 解码二进制数据为文本
- 按换行符拆分(大模型流式返回的格式要求)
- 过滤空行,避免无效数据
4.2.6 处理标准 SSE 格式数据
ts
for (const line of lines) {
if (!line.startsWith("data: ")) continue; // 只处理以 data: 开头的行
const data = line.slice(6).trim();
if (data === "[DONE]") continue; // 结束标记,跳过
这是大模型流式返回的固定格式(SSE):
- 每一段数据都以 data: 开头
- 最后会返回 data: [DONE] 表示传输结束
- 这里做格式过滤,只保留有效内容
4.2.7 解析 JSON 并实时更新页面
ts
try {
const json = JSON.parse(data);
const content = json.choices[0]?.delta?.content || "";
this.messages[aiMsgIdx].displayedContent += content;
this.$nextTick(() => this.scrollToBottom());
} catch (e) {}
这是页面实时显示的核心:
- 解析 JSON 数据
- 取出流式增量内容:choices[0].delta.content
- 追加到对应 AI 消息的 displayedContent 上(不是覆盖!)
- $nextTick:Vue 异步更新 DOM 后,自动滚动到页面底部
- try/catch:捕获解析异常,避免页面报错
4.3 注意事项
- API Key 直接写在代码里不安全(生产环境要放后端)
- 依赖浏览器原生 fetch,不支持非常老的浏览器
- 必须开启 stream: true,否则无法流式接收
4.4 调用方式
这个方法一般在发送消息后调用。
ts
// 示例:发送用户消息 → 添加一条空的AI消息 → 调用流式方法填充内容
this.messages.push({ role: "user", content: "你好" });
const aiMsgIdx = this.messages.length;
this.messages.push({ role: "assistant", displayedContent: "" });
// 开始流式输出
try {
await this.fetchAIStream(aiMsgIdx);
} catch (error) {
console.error("请求出错", error);
this.messages[aiMsgIdx].content = "请求失败,请稍后重试";
} finally {
this.messages[aiMsgIdx].streaming = false;
this.messages[aiMsgIdx].content =
this.messages[aiMsgIdx].displayedContent || "没有回复内容";
this.isStreaming = false;
}
4.5 实现效果
lz

5 Markdown 格式适配
实现目标
将消息内容中的 Markdown 语法(标题、列表、代码块、引用、表格、链接、加粗斜体等) 渲染为美观的富文本样式,并支持代码高亮。
技术选型
技术选型
- marked:Markdown 解析渲染
- highlight.js:代码块语法高亮
安装依赖
marked 依赖支持所有 Markdown 语法:标题、粗体、斜体、列表、表格、引用、链接、图片;代码块高亮(几十种语言);代码高亮主题可换; Vue2 样式穿透:用 ::v-deep 让样式作用于 v-html 渲染的内容。
bash
npm install marked@4.3.0 highlight.js
Vue2 用 marked 4.x 最稳定,不会报错。
Markown渲染
html
<script lang="ts">
/* eslint-disable */
import { Component, Vue } from "vue-property-decorator";
import { marked } from "marked";
import hljs from "highlight.js";
// 代码高亮主题(可替换)
import "highlight.js/styles/github-dark.css";
....
// 渲染方法
renderMarkdown(text: string): string {
marked.setOptions({
gfm: true,
breaks: true,
highlight: function (code: string, lang?: string) {
try {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
} catch (e) {
return code;
}
},
});
return marked.parse(text) as string;
}
}
</script>
实现效果

优化建议
- 标题上下间距有点大,建议通过样式穿透重新调整间距大小。
- 渲染方法封装
5 问题修复日志
5.1 视觉跳变
问题现象
流式过程中用的是 .streaming-text 样式,流式结束后用的是普通消息样式。两个样式不一样 → 视觉跳变。


解决思路
将流式输出过程的样式添加到流失输出结束后的div上即可。
修复效果

5. 项目要点总结
-
核心原则:UI与数据分离,messages数组仅存储消息状态,不耦合渲染逻辑
-
流式核心:通过增量更新displayedContent模拟打字机效果,实现优雅的流式体验
-
交互规范:输入锁定、自动滚动、样式统一,保证用户体验一致
-
可扩展性:前端结构与后端解耦,后续替换真实API无需修改前端核心代码
-
适用场景:聊天交互、文案生成、关键进度可视化等需要流式输出的场景
6 迭代优化方向
-
功能拓展
-
新增表情、图片发送功能,丰富消息展示形式
-
拆分核心子组件(ChatMessage、ChatInput),提升组件复用性
-
抽取可复用打字机组件,单独封装为src/components/TypingMessage.vue
-
-
体验优化
-
添加加载动画、错误提示,提升异常场景体验
-
优化打字机效果,采用分块渲染(每句独立显示),搭配动态光标
-
完善异常处理,添加try/catch捕获接口报错,支持重试功能
-
-
性能优化
-
针对大量历史消息渲染优化,避免页面卡顿
-
取消全局isStreaming锁,改为消息级独立状态,支持多消息并行流式输出
-
优化渲染逻辑,减少DOM重绘与回流
-
-
规范优化
-
配合Vite/Vue CLI,统一代码规范,避免语法冲突
-
完善项目注释,提升代码可读性与可维护性
-