引言
随着大语言模型技术的发展,基于AI的聊天问答系统成为了当前技术热点。本文将深入分析一个基于Vue 3的大模型聊天系统的技术实现,该项目集成了SSE流式响应、WebSocket语音识别、Markdown渲染、联网搜索等多种现代前端技术。
一、项目架构与技术栈
1.1 项目整体架构
采用了monorepo架构,使用pnpm + Turborepo管理多包项目,实现PC端和H5端代码共享:
```
xxx/
├── apps/ # 应用目录
│ ├── pc/ # PC端应用
│ └── h5/ # H5端应用
├── packages/ # 共享包目录
│ ├── api/ # API接口
│ ├── components/ # 共享组件
│ ├── stores/ # 状态管理
│ └── utils/ # 工具函数
├── turbo.json # Turborepo配置
└── pnpm-workspace.yaml # pnpm工作区配置
1.2 技术栈选型
项目采用了以下核心技术栈:
1. 框架选型:Vue 3 + Composition API
2. 状态管理:Pinia (Composition API风格)
3. UI框架:
- PC端:Element Plus
- H5端:Vant
4. 构建工具:Vite
5. 包管理:pnpm + Turborepo
6. TypeScript:全面类型支持
7. 网络请求:Axios + 自定义请求封装
8. 特色技术:
- SSE流式响应:fetchEventSource + TransformStream
- WebSocket语音识别:MediaRecorder API + WebSocket
- Markdown渲染:markdown-it + 自定义插件
- 代码高亮:Shiki
1.3 技术选型理由
1. Vue 3 + Composition API:提供更好的代码组织和复用能力,适合复杂业务逻辑
2. Pinia:轻量级状态管理,TypeScript支持良好,适合Vue 3项目
3. Monorepo架构:便于多端代码共享和统一版本管理
4. Element Plus/Vant:分别适配PC端和移动端,提供丰富的组件库
5. Vite:提供快速的开发体验和高效的构建过程
二、SSE流式响应与打字机效果实现
2.1 SSE流式响应原理
使用Server-Sent Events (SSE)技术实现大模型回复的流式响应,通过fetchEventSource库处理事件流:
```typescript
// 使用fetchEventSource库处理SSE连接
const { body } = await fetchEventSource(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
async onopen(response) {
if (response.ok) {
return; // 连接成功
}
throw new Error(`Failed to open SSE stream: ${response.status}`);
},
onmessage(msg) {
// 处理SSE消息
if (msg.data === '[DONE]') {
messageStore.updateMessageStatus(messageId, 'complete');
return;
}
try {
const data = JSON.parse(msg.data);
// 处理流式响应数据
messageStore.appendMessageContent(messageId, data.content);
} catch (e) {
console.error('解析SSE数据失败', e);
}
},
onerror(err) {
console.error('SSE错误', err);
messageStore.updateMessageStatus(messageId, 'error', err.message);
}
});
```
2.2 自定义TransformStream处理
项目实现了自定义TransformStream处理二进制流数据,将其转换为结构化事件:
```typescript
// 自定义TransformStream处理二进制流
const transformStream = new TransformStream({
transform(chunk, controller) {
const decoder = new TextDecoder();
const text = decoder.decode(chunk);
const lines = text.split('\n\n').filter(Boolean);
lines.forEach(line => {
if (line.startsWith('data:')) {
const data = line.slice(5).trim();
if (data === '[DONE]') {
controller.enqueue({ done: true });
} else {
try {
const parsed = JSON.parse(data);
controller.enqueue(parsed);
} catch (e) {
console.error('解析SSE数据失败', e);
}
}
}
});
}
});
```
2.3 打字机效果实现
打字机效果通过增量更新内容和CSS动画实现:
```typescript
// 状态管理中的增量更新
const appendMessageContent = (messageId, content) => {
const index = messages.value.findIndex(msg => msg.id === messageId);
if (index !== -1) {
// 增量更新内容
messages.value[index].content += content;
// 触发滚动到底部
nextTick(() => {
scrollToBottom();
});
}
};
```
```css
/* 打字机效果CSS */
.typing-effect {
display: inline-block;
border-right: 2px solid #000;
animation: blink-caret 0.75s step-end infinite;
white-space: pre-wrap;
}
@keyframes blink-caret {
from, to { border-color: transparent }
50% { border-color: #000 }
}
```
2.4 技术难点与解决方案
1. 二进制流解析:
- 难点:处理二进制流数据并解析为结构化事件
- 解决方案:使用TextDecoder和自定义TransformStream
2. 增量更新DOM:
- 难点:频繁DOM更新可能导致性能问题
- 解决方案:使用Vue的响应式系统和nextTick优化渲染
3. 错误处理与重连:
- 难点:网络波动导致连接中断
- 解决方案:实现重试机制和错误状态管理
三、WebSocket语音识别集成
3.1 语音识别架构
使用WebSocket和MediaRecorder API实现实时语音识别:
```
语音识别流程:
获取麦克风权限 => 建立WebSocket连接 => 初始化录音设备 => 发送会话参数 => 开始录音
=> 显示录音UI状态 => 显采集音频数据 => Base64编码 => WebSocket发送数据=> 解析识别结果
=> 更新输入框文本
```
3.2 WebSocket连接与音频处理
```typescript
// 建立WebSocket连接
const createWebSocket = () => {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket连接已建立');
wsStatus.value = 'open';
startRecording(); // 开始录音
};
ws.onmessage = (e) => {
try {
const response = JSON.parse(e.data);
// 处理WPGS动态文本修正
if (response.code === 0) {
const result = response.data.result;
const isEnd = response.data.status === 2;
// 处理增量更新和文本替换
if (response.data.voice_text_str) {
const latestText = response.data.voice_text_str;
// 替换或追加文本
voiceText.value = latestText;
}
if (isEnd) {
stopRecording();
}
}
} catch (error) {
console.error('处理WebSocket消息失败', error);
}
};
ws.onerror = (e) => {
console.error('WebSocket错误', e);
wsStatus.value = 'error';
handleReconnect();
};
ws.onclose = () => {
console.log('WebSocket连接已关闭');
wsStatus.value = 'closed';
};
};
```
3.3 MediaRecorder实现录音
```typescript
// 使用MediaRecorder API录制音频
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0 && ws && ws.readyState === WebSocket.OPEN) {
ws.send(e.data); // 发送音频数据到WebSocket
}
};
// 设置录音间隔,每100ms发送一次数据
mediaRecorder.start(100);
isRecording.value = true;
} catch (error) {
console.error('开始录音失败', error);
toastManager.show('无法访问麦克风');
}
};
// 停止录音
const stopRecording = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
// 停止所有音频轨道
mediaRecorder.stream.getTracks().forEach(track => track.stop());
isRecording.value = false;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
```
3.4 WPGS技术实现动态文本修正
使用WPGS (Word Piecewise Group Search) 技术实现语音识别过程中的动态文本修正:
```typescript
// 处理WPGS返回的动态修正文本
const handleWpgsResult = (result) => {
if (!result || !result.ws) return '';
let text = '';
// 处理每个词组
result.ws.forEach(ws => {
if (ws.cw && ws.cw.length > 0) {
// 取每个词组中置信度最高的结果
const bestResult = ws.cw.reduce((prev, current) =>
(current.sc > prev.sc) ? current : prev, ws.cw[0]);
text += bestResult.w;
}
});
return text;
};
```
3.5 技术难点与解决方案
1. 浏览器兼容性:
- 难点:不同浏览器对MediaRecorder的支持不同
- 解决方案:实现兼容性检测和降级处理
2. WebSocket连接稳定性:
- 难点:网络波动导致连接中断
- 解决方案:实现重连机制和状态恢复
3. WPGS动态文本修正:
- 难点:处理服务器返回的增量更新和文本替换
- 解决方案:设计专门的文本处理逻辑,处理不同类型的更新
四、Markdown渲染与代码高亮实现
4.1 Mrkdown渲染核心
使用markdown-it库实现Markdown渲染,并通过自定义插件扩展功能:
```typescript
// Markdown渲染配置
const md = markdownit({
html: true,
linkify: true,
typographer: true,
highlight: (code, lang) => {
// 使用Shiki进行代码高亮
try {
return shiki.codeToHtml(code, { lang, theme: 'github-dark' });
} catch (e) {
console.error('代码高亮失败', e);
return `<pre><code>${escape(code)}</code></pre>`;
}
}
});
// 注册自定义插件
md.use(markdownItAnchor)
.use(markdownItToc)
.use(customPlugin); // 处理项目特有的语法
```
4.2 Shiki代码高亮集成
使用Shiki实现高质量的代码语法高亮:
```typescript
// 初始化Shiki高亮器
import { getHighlighter } from 'shiki';
const initHighlighter = async () => {
try {
highlighter = await getHighlighter({
theme: 'github-dark',
langs: ['javascript', 'typescript', 'html', 'css', 'json', 'python', 'java']
});
} catch (e) {
console.error('初始化代码高亮失败', e);
}
};
// 代码高亮函数
const highlightCode = (code, lang) => {
if (!highlighter) return escape(code);
try {
// 如果语言不支持,使用纯文本
const language = highlighter.getLoadedLanguages().includes(lang) ? lang : 'text';
return highlighter.codeToHtml(code, { lang: language });
} catch (e) {
console.error('代码高亮失败', e);
return escape(code);
}
};
```
4.3 自定义Markdown组件
实现了自定义Markdown渲染组件,支持复制代码、行号显示等功能:
```vue
<!-- Markdown渲染组件 -->
<template>
<div class="markdown-container" ref="markdownRef">
<div v-html="renderedContent" @click="handleClick"></div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import markdownIt from 'markdown-it';
import { useShiki } from '@/hooks/useShiki';
const props = defineProps({
content: {
type: String,
default: ''
}
});
const markdownRef = ref(null);
const { highlight } = useShiki();
// 配置markdown-it
const md = markdownIt({
html: true,
linkify: true,
typographer: true,
highlight
});
// 渲染内容
const renderedContent = computed(() => {
return md.render(props.content || '');
});
// 处理点击事件,如复制代码
const handleClick = (e) => {
const target = e.target;
// 处理复制代码按钮点击
if (target.classList.contains('copy-code-button')) {
const codeBlock = target.closest('.code-block');
const code = codeBlock.querySelector('code').textContent;
navigator.clipboard.writeText(code)
.then(() => {
target.textContent = '已复制';
setTimeout(() => {
target.textContent = '复制';
}, 2000);
})
.catch(err => {
console.error('复制失败', err);
});
}
};
// 添加复制按钮到代码块
onMounted(() => {
if (!markdownRef.value) return;
const codeBlocks = markdownRef.value.querySelectorAll('pre code');
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
pre.classList.add('code-block');
// 创建复制按钮
const copyButton = document.createElement('button');
copyButton.className = 'copy-code-button';
copyButton.textContent = '复制';
pre.appendChild(copyButton);
});
});
</script>
```
4.4 XSS防护措施
实现了Markdown渲染的XSS防护:
```typescript
// XSS防护配置
const safeMarkdown = markdownit({
html: false, // 禁用HTML标签
linkify: true,
typographer: true
});
// 自定义规则过滤器
const customRender = (tokens, idx, options, env, self) => {
const token = tokens[idx];
// 过滤潜在危险属性
if (token.attrs) {
token.attrs = token.attrs.filter(([key]) => {
return !key.startsWith('on') && key !== 'style';
});
}
// 使用原始渲染器
return self.renderToken(tokens, idx, options);
};
// 替换默认渲染器
Object.keys(safeMarkdown.renderer.rules)
.forEach(key => {
const originalRule = safeMarkdown.renderer.rules[key];
safeMarkdown.renderer.rules[key] = (tokens, idx, options, env, self) => {
return customRender(tokens, idx, options, env, originalRule || self.renderToken);
};
});
```
4.5 技术难点与解决方案
1. 复杂Markdown渲染性能:
- 难点:大量Markdown内容渲染可能导致性能问题
- 解决方案:使用虚拟滚动和延迟渲染
2. 代码高亮主题适配:
- 难点:适配深色/浅色主题切换
- 解决方案:动态加载主题并缓存渲染结果
3. XSS安全防护:
- 难点:防止Markdown中的恶意代码执行
- 解决方案:禁用HTML标签和过滤危险属性
五、联网搜索与知识库集成
5.1 联网搜索实现
实现了联网搜索功能,支持实时获取搜索结果:
```typescript
// 联网搜索API调用
const searchWeb = async (query) => {
searchState.value = 'loading';
try {
const { data } = await webSearch({
query,
limit: 5
});
if (data.code === 200) {
searchResults.value = data.data.map(item => ({
id: item.id,
title: item.title,
content: item.snippet,
url: item.url,
source: 'web'
}));
searchState.value = 'success';
} else {
searchState.value = 'error';
searchError.value = data.message || '搜索失败';
}
} catch (error) {
console.error('联网搜索失败', error);
searchState.value = 'error';
searchError.value = error.message || '网络错误';
}
};
```
5.2 知识库集成
实现知识库集成功能,支持知识引用和来源追溯:
```typescript
// 知识库搜索
const searchKnowledgeBase = async (query) => {
kbState.value = 'loading';
try {
const { data } = await knowledgeSearch({
query,
limit: 10
});
if (data.code === 200) {
kbResults.value = data.data.map(item => ({
id: item.id,
title: item.title,
content: item.content,
source: item.source,
type: item.type,
confidence: item.confidence
}));
kbState.value = 'success';
} else {
kbState.value = 'error';
kbError.value = data.message || '搜索失败';
}
} catch (error) {
console.error('知识库搜索失败', error);
kbState.value = 'error';
kbError.value = error.message || '网络错误';
}
};
```
5.3 引用标注组件
实现引用标注组件,支持悬浮预览和来源跳转:
```vue
<!-- 引用标注组件 -->
<template>
<div class="reference-container">
<div
v-for="(ref, index) in references"
:key="index"
class="reference-item"
@mouseenter="showPreview(index)"
@mouseleave="hidePreview"
>
<span class="reference-number">[{{ index + 1 }}]</span>
<div v-if="activePreview === index" class="reference-preview">
<div class="preview-title">{{ ref.title }}</div>
<div class="preview-content">{{ ref.content }}</div>
<div class="preview-source">
<a :href="ref.url" target="_blank">{{ ref.source }}</a>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
references: {
type: Array,
default: () => []
}
});
const activePreview = ref(null);
const showPreview = (index) => {
activePreview.value = index;
};
const hidePreview = () => {
activePreview.value = null;
};
</script>
```
5.4 技术难点与解决方案
1. 搜索结果整合:
- 难点:整合多种来源的搜索结果并保持一致的用户体验
- 解决方案:设计统一的数据结构和展示组件
2. 引用标注实现:
- 难点:实现引用标注和来源追溯功能
- 解决方案:设计引用标注组件,支持悬浮预览和来源跳转
3. 搜索性能优化:
- 难点:提高搜索响应速度和结果相关性
- 解决方案:实现搜索缓存和结果排序优化
六、Pinia状态管理与消息处理
6.1 Composition API风格的Pinia实现
使用Composition API风格实现Pinia状态管理:
```typescript
// 消息状态管理
export const useMessageStore = defineStore('message', () => {
// 状态
const messages = ref([]);
const currentSession = ref(null);
const sessions = ref([]);
const loading = ref(false);
// Getters
const currentMessages = computed(() => {
if (!currentSession.value) return [];
return messages.value.filter(msg => msg.sessionId === currentSession.value);
});
// Actions
const addMessage = (message) => {
messages.value.push({
...message,
sessionId: currentSession.value,
timestamp: Date.now()
});
// 保存到本地存储
saveMessages();
};
const updateMessageStatus = (messageId, status, error = null) => {
const index = messages.value.findIndex(msg => msg.id === messageId);
if (index !== -1) {
messages.value[index] = {
...messages.value[index],
status,
error
};
// 保存到本地存储
saveMessages();
}
};
const appendMessageContent = (messageId, content) => {
const index = messages.value.findIndex(msg => msg.id === messageId);
if (index !== -1) {
messages.value[index].content += content;
// 延迟保存,避免频繁写入
debounce(saveMessages, 500)();
}
};
// 保存消息到本地存储
const saveMessages = () => {
try {
localStorage.setItem('chat_messages', JSON.stringify(messages.value));
} catch (e) {
console.error('保存消息失败', e);
}
};
// 加载消息
const loadMessages = () => {
try {
const savedMessages = localStorage.getItem('chat_messages');
if (savedMessages) {
messages.value = JSON.parse(savedMessages);
}
} catch (e) {
console.error('加载消息失败', e);
}
};
// 初始化加载
loadMessages();
return {
messages,
currentSession,
sessions,
loading,
currentMessages,
addMessage,
updateMessageStatus,
appendMessageContent
};
});
```
6.2 消息处理流程
实现完整的消息处理流程:
```typescript
// 发送消息流程
const sendMessage = async (content) => {
if (!content.trim()) return;
const messageId = generateUUID();
// 添加用户消息
messageStore.addMessage({
id: messageId,
role: 'user',
content,
status: 'sending'
});
// 添加AI回复占位
const aiMessageId = generateUUID();
messageStore.addMessage({
id: aiMessageId,
role: 'assistant',
content: '',
status: 'waiting'
});
try {
// 更新用户消息状态
messageStore.updateMessageStatus(messageId, 'sent');
// 更新AI消息状态
messageStore.updateMessageStatus(aiMessageId, 'receiving');
// 发送SSE请求
await fetchChatCompletion(content, aiMessageId);
// 请求完成后更新状态
messageStore.updateMessageStatus(aiMessageId, 'complete');
} catch (error) {
console.error('发送消息失败', error);
messageStore.updateMessageStatus(aiMessageId, 'error', error.message);
}
};
```
6.3 会话管理
实现完整的会话管理功能:
```typescript
// 会话管理
const createSession = () => {
const sessionId = generateUUID();
sessions.value.push({
id: sessionId,
title: '新对话',
createdAt: Date.now(),
updatedAt: Date.now()
});
currentSession.value = sessionId;
saveSessions();
return sessionId;
};
const switchSession = (sessionId) => {
currentSession.value = sessionId;
};
```
6.4 技术难点与解决方案
1. 状态持久化:
- 难点:刷新页面后如何恢复会话状态
- 解决方案:使用localStorage保存消息和会话数据
2. 状态同步:
- 难点:在多个组件之间同步状态
- 解决方案:使用Pinia集中管理状态,通过订阅实现同步
3. 性能优化:
- 难点:大量消息导致状态管理性能下降
- 解决方案:使用计算属性和防抖优化状态更新
七、文件上传与处理功能
7.1 队列管理与并发控制
使用队列管理控制并发上传数量:
```typescript
let queue = []; // 文件队列
let activeCount = 0; // 当前上传数量
const processQueue = () => {
while (queue.length > 0 && activeCount < 5) {
const file = queue.shift();
if (file) {
activeCount++;
uploadFile(file).finally(() => {
activeCount--;
processQueue();
});
}
}
};
```
7.2文件状态轮询
文件上传成功后,通过轮询检查服务器端解析状态:
```typescript
const pollStatus = async (docIds) => {
try {
const { data } = await uploadFileList({ docList: docIds });
// ...更新文件列表状态...
const needsPolling = fileList.value.some(file => file.status === 0 || file.status === -4);
if (needsPolling) {
setTimeout(() => pollStatus(docIds), 5000);
}
} catch (error) {
console.error('获取文件状态失败:', error);
}
};
```
7.3 技术难点与解决方案
1. 并发上传控制:
- 难点:避免同时上传过多文件导致性能问题
- 解决方案:使用队列管理机制限制并发数
2. 文件解析状态同步:
- 难点:客户端需要及时获取服务器端的文件解析状态
- 解决方案:实现轮询机制,定期检查文件状态
3. 多类型文件预览:
- 难点:支持图片、PDF等多种文件格式的预览
- 解决方案:根据文件类型动态选择预览组件
八、技术难点与亮点总结
8.1 核心技术难点
1. SSE流式响应与打字机效果:处理二进制流、事件分离和渲染优化
2. 多端适配与代码共享:共享核心逻辑、适配UI差异
3. WebSocket实时语音识别:处理浏览器兼容性、动态文本修正
4. 大量消息渲染性能优化:虚拟列表、DOM更新优化
5. 知识库集成与引用标注:整合多源数据、实现引用溯源
8.2 项目技术亮点
1. 自定义TransformStream处理SSE事件流:高效的二进制数据解析
2. 语音识别与文字输入的无缝集成:WPGS技术实现实时语音识别
3. Markdown渲染与代码高亮的精细实现:自定义插件、Shiki高亮
4. 完善的错误处理和状态恢复机制:全局错误处理、重试机制
5. 高效的文件上传与处理系统:队列管理、状态轮询
九、面试准备:问题与答案
1. 如何处理SSE流式响应中的打字机效果?
使用了自定义的TransformStream处理SSE事件流,将二进制数据解析为结构化事件。为实现平滑的打字机效果,我们采用了以下策略:
1. 使用TextDecoder解析二进制数据
2. 分割事件流并解析JSON数据
3. 使用状态管理将解析后的内容增量更新到UI
4. 实现防抖和节流机制,优化DOM更新频率
5. 使用CSS动画增强打字机视觉效果
2. 如何实现多端代码共享?
采用monorepo架构(pnpm + Turborepo)实现代码共享:
1. 将项目分为apps/和packages/两个主要目录
2. apps/目录下分别实现PC和H5端的UI层
3. packages/目录存放共享逻辑,如API请求、状态管理、工具函数等
4. 使用Pinia实现平台无关的状态管理
5. 通过依赖注入方式解决平台特定功能
6. 使用条件编译和动态导入处理平台差异
3. 如何优化大量消息渲染的性能?
采用多种策略优化大量消息渲染的性能:
1. 实现虚拟列表,只渲染可视区域的消息
2. 使用Web Worker分离复杂计算逻辑,如Markdown解析
3. 实现消息分页和懒加载机制
4. 对Markdown渲染和代码高亮进行缓存
5. 使用防抖和节流优化频繁DOM更新
6. 实现高效的DOM更新策略,减少重排重绘
7. 使用requestAnimationFrame优化动画和滚动效果
4. 项目中最大的技术挑战是什么?如何解决的?
最大的技术挑战是实现流式响应下的高性能渲染和状态管理。我们面临以下挑战:
1. 需要处理大量实时更新的消息内容
2. 复杂Markdown和代码高亮渲染导致性能问题
3. 在流式响应过程中保持良好的滚动体验
4. 处理网络波动和错误恢复
解决方案:
1. 设计高效的状态管理架构,将消息内容和UI状态分离
2. 实现增量渲染策略,只更新变化的部分
3. 使用Web Worker分离复杂计算
4. 实现虚拟滚动和分页加载
5. 设计完善的错误处理和重试机制
十、结语
通过对聊天系统的深入分析,不仅掌握了Vue 3、Pinia、Monorepo等现代前端技术的实践应用,还深入了解了SSE流式响应、WebSocket语音识别、Markdown渲染优化等高级技术的实现细节。这些经验对于构建高性能、可扩展的现代Web应用具有重要的参考价值