一、项目介绍
这是一个基于 Vue 3 + TypeScript + Vite 构建的智能问答助手项目。项目采用现代前端技术栈,具有流式对话、语音输入、富文本解析等特色功能,页面交互简洁友好,按钮防抖和数据校验,提供流畅的用户体验和良好的开发维护性。
二、项目开发背景
- 继deepseek大火后,很多公司开始推出自己的应用层AI产品,最近收到通知,要求火速上线一个AI问答产品,以彰显公司在AI领域迈出关键的一步。
- 领导要求实现一个类似于deepseek的建议版AI问答产品,输入方式支持文本和语音输入两种,需要支持pc端和移动端功能演示。
- 需求梳理:基于vue3+vite+ts+element 创建一个AI问答系统,需要移动端适配并且支持pc端展示,输入方式支持文本输入和语音输入,对话方式需支持直接输出(简单高效的)、打字机输出(一个字一个字,适合交换强的,趣味性的)、流式输出(一行一行或者一段一段,适合快速获取信息的)。
三、技术栈
- Vue 3 :使用 Vue 3 作为前端框架,采用组合式 API 和
<script setup>
语法。 - TypeScript:全面的类型支持,提供更可靠的代码质量。
- Vite:现代化构建工具,提供极速的开发体验。
- Element Plus:美观实用的 UI 组件库。
- Vue Router:灵活的路由管理方案。
- Marked + DOMPurify:安全的 Markdown 渲染。
- Highlight.js:代码语法高亮支持。
- Axios:可靠的 HTTP 请求处理。
- Fetch:SSE 请求调用。
- Trae: 不懂就问编辑器,主要是free。
四、核心功能
- 流式对话: 支持流式文本渲染,提供更自然的对话体验
- 打字机输出: 一个字一个字的,交互强,有趣味
- 语音交互: 支持语音输入,提升用户交互效率
- Markdown 渲染: 支持富文本格式和代码高亮显示
- 响应式设计: 完善的移动端适配,支持多端访问
- 智能问答: 集成 AI 模型,提供准确的招生信息咨询服务
五、效果演示、亮点
基于vue3+vite+ts+element实现,预设问题列表,消息复制,刷新,反馈,停止,消息展示方式支持打字机输出模式和流式输出,消息同时支持富文本解析,问题输入方式支持文本输入和语音输入。支持自适应移动端和pc端,具有良好的页面交互和兼容性。是一个简易轻便开箱即用的前端AI问答系统。
- 普通对话(打字机模式)、流式对话、语音识别转换成文字
- 支持移动端效果展示
六、项目结构
bash
```
├── src/ # 源代码目录
│ ├── assets/ # 静态资源(图片、样式等)
│ ├── components/ # 公共组件
│ │ ├── chat/ # 对话相关组件
│ │ │ ├── ChatContainer.vue # 聊天容器组件:管理对话界面布局和消息列表
│ │ │ ├── ChatInput.vue # 输入组件:处理用户输入和语音识别
│ │ │ ├── MessageBubble.vue # 消息气泡组件:展示对话消息内容
│ │ │ └── TypeWriter.vue # 打字机效果组件:实现文字打字机效果
│ │ └── MarkdownRenderer.vue # Markdown渲染组件
│ ├── config/ # 配置文件
│ ├── router/ # 路由配置
│ ├── services/ # API服务
│ │ ├── aiService.ts # AI服务接口
│ │ ├── streamService.ts # 流式服务
│ │ └── api.ts # 基础API
│ ├── type/ # TypeScript类型定义
│ ├── utils/ # 工具函数
│ ├── views/ # 页面视图
│ │ ├── ChatView.vue # 普通对话页面
│ │ └── StreamView.vue # 流式对话页面
│ └── main.ts # 应用入口文件
├── public/ # 公共资源目录
├── .env.* # 环境配置文件
└── vite.config.ts # Vite配置文件
```
七、主要功能模块实现
- 打字机模式: 实现思路:延时器+字符串方法slice ,隔一个时间间隔speed取一个字符。
xml
<template>
<!-- 打字机效果组件 -->
<div>
<slot :text="displayText">
<markdown-renderer :markdown="displayText" />
</slot>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from "vue";
import MarkdownRenderer from "../MarkdownRenderer.vue";
// 组件属性定义
const props = defineProps<{
text: string; // 要显示的文本
speed?: number; // 打字速度
delay?: number; // 开始延迟
}>();
// 定义事件
const emit = defineEmits<{
complete: []; // 打字完成事件
textUpdate: [text: string]; // 文本更新事件
}>();
// 组件状态
const displayText = ref("");
let currentIndex = 0;
let timer: number | null = null;
// 打字效果实现
const startTyping = () => {
if (currentIndex < props.text.length) {
displayText.value = props.text.slice(0, currentIndex + 1);
emit("textUpdate", displayText.value);
currentIndex++;
timer = window.setTimeout(startTyping, props.speed || 30);
} else {
emit("complete");
}
};
// 监听文本变化
watch(
() => props.text,
() => {
if (timer) {
clearTimeout(timer);
}
currentIndex = 0;
displayText.value = "";
timer = window.setTimeout(startTyping, props.delay || 0);
},
{ immediate: true }
);
// 组件卸载时清理定时器
onUnmounted(() => {
if (timer) {
clearTimeout(timer);
}
});
</script>
<style scoped>
.typewriter {
display: inline-block;
}
/* 确保 markdown 内容正确显示 */
:deep(.markdown-renderer) {
word-break: break-word;
}
</style>
- 流式对话: 实现思路:sse(服务端推送),客户端解析字符进行一段一段渲染数据据。fetchAPI本身支持流式读取响应体,通过response.body获取readableStream对象,创建reader,然后选好调用read()方法,直到done为true.每次读取value时,创建TextDecoder对象,处理数据,转换成字符串,然后去掉一些多余的字符,返回到页面展示。
a.流式接口:
typescript
import config from "@/config";
class StreamService {
private abortController: AbortController | null = null;
async streamChat(
message: string,
assistantId: string,
onChunk: (chunk: string) => void,
streamSuccess?: () => void,
onError?: (error: Error) => void,
onComplete?: () => void
): Promise<() => void> {
this.abortController = new AbortController();
try {
const response = await fetch(`${config.baseURL}/chat`, {
method: "POST",
signal: this.abortController.signal,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
},
body: JSON.stringify({
m: msg
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("ReadableStream not supported");
}
streamSuccess?.();
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) {
onComplete?.();
this.abortController = null;
break;
}
const chunk = decoder.decode(value);
let messages = chunk.replace(/"data:"/g, ""); // 去掉"data:"
messages = messages.replace(/""/g, "");//去掉""
messages = messages.replace(/"/g, ""); //去掉"(左引号)
messages = messages.replace(/\n\n/g, "");//去掉"(右引号)
messages = messages.replace(/\ndata:/g, "\n");//换行符替换
if (messages) onChunk(messages);
}
} catch (error: unknown) {
if (error instanceof Error && error.name === "AbortError") {
console.log("Fetch aborted");
return () => {
this.cancelStream();
onComplete?.();
};
}
console.error("Stream Error:", error);
if (onError) {
onError(error instanceof Error ? error : new Error("流式连接失败"));
}
} finally {
this.abortController = null;
}
return () => {
this.cancelStream();
onComplete?.();
};
}
cancelStream(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
}
export const streamService = new StreamService();
b.组件调用:
typescript
// 流式渲染请求
import { streamService } from "../services/fetchStreamService";
const messages = ref<Array<ChatMessage>>([]); // 聊天消息列表
const loading = ref(false); // 是否请求中
let currentCleanup: (() => void) | null = null; // 缓存当前请求 调用取消
const handleSendMessage = async (content: string) => {
loading.value = true;
// 添加用户消息
const userMessage: ChatMessage = {
chatMessageId: "user", //用户消息添加一个默认的ID
content,
role: "user",
timestamp: Date.now()
};
messages.value.push(userMessage);
const assistantMessage = {
chatMessageId: "",
role: "assistant" as const,
content: "",
timestamp: Date.now()
};
try {
const streamSuccess = () => {
messages.value.push(assistantMessage);
return assistantMessage; // 返回消息对象供后续使用
};
// 流式回调处理
const streamCallback = (chunk: string) => {
assistantMessage.content += chunk;
console.log("assistantMessage", assistantMessage.content);
// 强制更新最后一条消息的内容
messages.value = [
...messages.value.slice(0, -1),
{
...messages.value[messages.value.length - 1],
chatMessageId: assistantMessage.chatMessageId,
content: assistantMessage.content
}
];
};
try {
currentCleanup = await streamService.streamChat(
content,
assistantId.value || "",
streamCallback,
streamSuccess,
undefined,
() => {
loading.value = false;
currentCleanup = null;
}
);
} catch (error) {
console.error("Stream chat error:", error);
resetMessage();
}
} catch (error) {
resetMessage();
}
};
const resetMessage = () => {
loading.value = false;
// 请求失败添加默认信息展示
messages.value.push({
chatMessageId: "fail", //加载失败消息ID
content: "加载失败,请重试",
role: "assistant",
timestamp: Date.now()
});
};
- 语音识别: 实现思路:考虑兼容多端,使用webapi MediaRecorder录音,获取录音文件后上传到服务器生成文件地址,然后调用阿里的语音转文字接口返回字符串。
ini
// 初始化录音器
const initRecorder = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
return true;
} catch (error: any) {
console.error("初始化-录音初始化失败:", error);
let errorMessage = "无法访问麦克风";
if (error.name === "NotAllowedError") {
errorMessage = "请允许浏览器使用麦克风并刷新页面重试";
} else if (error.name === "NotFoundError") {
errorMessage = "未找到麦克风设备,请检查设备连接";
}
ElMessage.error(errorMessage);
return false;
}
};
// 开始录音
const startVoiceInput = async (event: MouseEvent | TouchEvent) => {
// 检查当前状态
if (isRecording.value || isRecognizing.value || props.disabled) {
return;
}
console.log("startVoiceInput", event instanceof MouseEvent, isMobile.value);
if (!mediaRecorder) {
const initialized = await initRecorder();
if (!initialized) return;
}
try {
isCanceling.value = false;
audioChunks = [];
mediaRecorder?.start();
isRecording.value = true;
// 设置2分钟录音限制
recordingTimer = window.setTimeout(() => {
if (isRecording.value) {
stopVoiceInput();
ElMessage.info("录音已达到30s,自动发送");
}
}, 30000); // 30 = 300000毫秒
ElMessage.success("开始录音...");
} catch (error) {
console.error("开始录音失败:", error);
ElMessage.error("开始录音失败");
}
};
//取消发送
const cancelVoiceInput = () => {
console.log("取消");
isCanceling.value = true;
stopVoiceInput();
};
// 停止语音输入
const stopVoiceInput = async () => {
console.log("stopVoiceInput-------------");
if (!mediaRecorder || !isRecording.value) return;
try {
isRecording.value = false;
// 清除录音计时器
if (recordingTimer) {
clearTimeout(recordingTimer);
recordingTimer = null;
}
// 如果是上滑取消,则不处理录音数据
if (isCanceling.value) {
mediaRecorder.stop();
audioChunks = [];
isRecognizing.value = false;
ElMessage.info("已取消录音");
return;
}
// 停止录音前确保状态正确
if (mediaRecorder.state === "inactive") return;
isRecognizing.value = true;
mediaRecorder.stop();
// 等待录音数据处理完成
const audioBlob = await new Promise<Blob>((resolve, reject) => {
let timeout = setTimeout(() => {
reject(new Error("录音数据处理超时"));
}, 3000);
mediaRecorder!.onstop = () => {
clearTimeout(timeout);
if (audioChunks.length === 0) {
reject(new Error("未获取到录音数据"));
return;
}
const blob = new Blob(audioChunks, { type: "audio/wav" });
resolve(blob);
};
});
// 验证音频大小
if (audioBlob.size < 1024) {
// 约1KB,相当于约1秒的音频
throw new Error("录音时间过短,请至少说话1秒以上");
}
// 将音频数据转换为文件并通过FormData上传
const formData = new FormData();
formData.append("file", audioBlob, "record.wav");
const uploadResponse = await API.upload(formData);
if (!uploadResponse) {
throw new Error("音频文件上传失败");
}
// 将音频转换为文本
const voiceTextResponse = await API.voiceTransText(uploadResponse);
isRecognizing.value = false;
isRecording.value = false;
if (!voiceTextResponse) {
throw new Error("语音转文字失败");
}
message.value = voiceTextResponse;
await handleSend();
} catch (error: any) {
isRecognizing.value = false;
isRecording.value = false;
console.error("语音处理失败:", isRecording.value, error);
ElMessage.error(error?.message || "语音处理失败");
} finally {
// 清理录音数据并重置状态
audioChunks = [];
}
};
// 发送消息处理
const handleSend = () => {
const trimmedMessage = message.value.trim();
if (!trimmedMessage || props.disabled) return;
if (trimmedMessage.length > 120) {
ElMessage.warning("消息长度不能超过120个字符");
return;
}
emit("send", trimmedMessage);
message.value = "";
if (isRecording.value) {
stopVoiceInput();
}
};
八、pc端展示和移动端适配
ini
//项目使用实现移动端适配:
/* - 使用rem适配
* - 支持响应式布局
* - 优化移动端交互体验
*/
import { debounce } from "lodash-es";
const doc: Document = window.document;
const docEl: HTMLElement = doc.documentElement;
// 设置根元素字体大小,用于rem适配
const setRem = debounce(() => {
// 获取视口宽度,并限制最大宽度为768px
const maxWidth = 768;
const clientWidth = Math.min(docEl.clientWidth, maxWidth);
// 基于750px设计稿,1rem = 100px
// 基于375px设计稿,1rem = 50px
const baseSize = 7.5;
docEl.style.fontSize = `${clientWidth / baseSize}px`;
}, 100);
// 初始化rem
window.addEventListener("load", setRem);
// 监听窗口变化和DOM加载完成事件
if (window.addEventListener) {
window.addEventListener("resize", setRem, false);
doc.addEventListener("DOMContentLoaded", setRem, false);
}
九、服务端接口
- 普通aixos接口
typescript
/**
* AI服务接口封装
* 用于处理与AI后端API的通信
*/
import axios from "axios";
import config from "@/config";
import type { AxiosInstance } from "axios";
import { ElMessage } from "element-plus";
class AIChatService {
private instance: AxiosInstance;
private baseURL = config.baseURL;
constructor() {
// 创建 axios 实例
this.instance = axios.create({
baseURL: this.baseURL,
timeout: 30000,
headers: {
"Content-Type": "application/json"
}
});
// 请求拦截器
this.instance.interceptors.request.use(
(config) => {
// 在这里可以添加 token 等认证信息
// const token = localStorage.getItem("token");
// if (token) {
// config.headers.Authorization = `Bearer ${token}`;
// }
return config;
},
(error) => {
let message = "请求配置错误";
if (error.code === "ECONNABORTED") {
message = "请求超时,正在重试...";
} else if (error.message?.includes("Network Error")) {
message = "网络连接失败,请检查网络设置";
} else if (error.config) {
message = `请求参数错误: ${error.config.url}`;
}
console.error("Request Config Error:", error);
ElMessage.warning(message);
return Promise.reject(error);
}
);
// 响应拦截器
this.instance.interceptors.response.use(
(response) => {
const { data } = response;
// 检查服务端返回的数据结构
if (data.code !== undefined) {
// 假设服务端返回格式为 { code: number, message: string, data: any }
if (data.code !== 200 && data.code !== "0") {
const errorMsg = data.message || "系统繁忙,请稍后重试";
ElMessage.error(errorMsg);
return Promise.reject(errorMsg);
}
return data.data; // 只返回实际数据部分
}
return data; // 保持原有返回格式
},
(error) => {
let message = "服务暂时不可用,请稍后重试";
if (error.response) {
// 服务器返回错误状态码
switch (error.response.status) {
case 400:
message = "请求参数格式不正确";
break;
case 401:
message = "登录已过期,请重新登录";
// 可以在这里处理登录过期逻辑
break;
case 403:
message = "没有权限访问该资源";
break;
case 404:
message = "请求的资源不存在";
break;
case 500:
message = "服务器内部错误,请稍后重试";
break;
default:
message = `请求失败 (${error.response.status})`;
}
} else if (error.request) {
// 请求发出但未收到响应
// message = "无法连接到服务器,请检查网络";
}
console.error("Request Error:", error);
// ElMessage.error(message || "信号弱,请稍后重试!!");
return Promise.reject(error);
}
);
}
// GET 请求方法
async get<T>(url: string, params?: any, config?: any): Promise<T> {
try {
// 合并请求配置
const requestConfig = { params, ...config };
// 确保headers被正确合并
if (config?.headers) {
requestConfig.headers = {
...this.instance.defaults.headers,
...config.headers
};
}
const response = await this.instance.get(url, requestConfig);
return response as T;
} catch (error: any) {
throw error;
console.error("POST Request Error:", error);
}
}
// POST 请求方法
async post<T>(url: string, data?: any, config?: any): Promise<T> {
try {
const requestConfig = { ...config };
// 确保headers被正确合并
if (config?.headers) {
requestConfig.headers = {
...this.instance.defaults.headers,
...config.headers
};
}
const response = await this.instance.post(url, data, requestConfig);
return response as T;
} catch (error) {
console.error("POST Request Error:", error);
throw error;
}
}
}
export const aiService = new AIChatService();
- 流式接口: (同上 七、主要功能模块实现>流式对话>a.流式接口)
十、后续功能:
- 模型切换
- 历史会话
- 个人信息
- 输入方式支持文件上传