基于 Vue 的Deepseek流式加载对话Demo
目录
- 引言
- 组件概述
- 核心组件与功能实现
- 1. 消息显示组件(Message.vue)
- 2. 输入组件(Input.vue)
- 3. 流式请求处理(useDeepseek.ts)
- 4. 语音处理模块(Voice.vue)
- 总结
- Demo Github 地址
引言
在当今数字化时代,智能对话系统的应用越来越广泛,如客服聊天机器人、智能助手等。本文将详细介绍一个基于 Vue 框架开发的智能对话系统的实现过程,该系统支持文本输入、语音输入、流式响应等功能,让我们一步步揭开它的神秘面纱。
组件概述
整个组件主要由以下几个核心部分组成:
- 用户界面组件:负责与用户进行交互,包括文本输入框、语音输入按钮、消息显示区域等。
- 消息处理组件:将用户输入的消息进行处理,并显示在界面上。
- 流式请求处理:与后端进行流式通信,实时获取响应内容。
- 语音处理模块:支持语音输入功能,将语音转换为文本。
核心组件与功能实现
1. 消息显示组件(Message.vue)
该组件用于显示用户和系统的消息。在这个组件中,我们使用了 Markdown 解析器来处理消息内容,支持代码高亮和自定义标签。
<template>
<div
class="msg-item"
:class="{
'msg-item-system': role === 'system'
}"
>
<div
class="msg-content"
:class="{
'msg-content-user': role === 'user'
}"
>
<span class="msg-pop-container">
<span
class="msg-pop-default"
v-html="mkHtml"
ref="popRef"
:class="{
'msg-pop-primary': role === 'user'
}"
>
</span>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import MarkdownIt from "markdown-it";
import mk from "markdown-it-katex";
import hljs from "highlight.js";
import "highlight.js/styles/atom-one-dark-reasonable.css";
import { computed, nextTick, ref } from "vue";
interface Props {
role: string;
content: string;
streaming?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
content: "",
streaming: false
});
const md: MarkdownIt = MarkdownIt({
highlight: function (str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
}</code></div></div>`;
} catch (__) {
console.log(__, "error");
}
}
return `<div class="hl-code"><div class="hl-code-header"><span>${lang}</span></div><div class="hljs"><code>${md.utils.escapeHtml(
str
)}</code></div></div>`;
}
});
md.use(mk, {
throwOnError: false,
errorColor: " #cc0000"
});
// 自定义规则函数,用于解析 <think> 标签
function thinkTagRule(state, startLine, endLine, silent) {
let pos,
max,
nextLine,
token,
autoClosed = false;
let start = state.bMarks[startLine] + state.tShift[startLine];
let end = state.eMarks[startLine];
// 检查是否以 <think> 开头
if (start + 7 > end || state.src.slice(start, start + 7) !== "<think>") {
return false;
}
// 跳过 <think>
pos = start + 7;
// 查找 </think> 结束标签
for (nextLine = startLine; ; nextLine++) {
if (nextLine >= endLine) {
// 未找到结束标签
break;
}
max = state.bMarks[nextLine] + state.tShift[nextLine];
if (max < state.eMarks[nextLine] && state.src.slice(max, max + 8) === "</think>") {
// 找到结束标签
autoClosed = true;
break;
}
}
// 如果处于静默模式,只验证标签,不生成 token
if (silent) {
return autoClosed;
}
// 创建一个新的 token 表示开始标签
token = state.push("think_tag_open", "think", 1);
token.markup = "<think>";
token.map = [startLine, nextLine];
// 处理标签内部的内容
state.md.block.tokenize(state, startLine + 1, nextLine);
// 创建结束 token
token = state.push("think_tag_close", "think", -1);
token.markup = "</think>";
// 更新状态,跳过已处理的行
state.line = nextLine + 1;
return true;
}
// 将自定义规则添加到 MarkdownIt 实例中
md.block.ruler.before("paragraph", "think_tag", thinkTagRule);
// 自定义渲染规则,将 <think> 标签渲染为 <span class="think">
md.renderer.rules.think_tag_open = function () {
return '<span class="think">';
};
md.renderer.rules.think_tag_close = function () {
return "</span>";
};
function findLastElement(element: HTMLElement): HTMLElement {
if (!element.children.length) {
return element;
}
const lastChild = element.children[element.children.length - 1];
if (lastChild.nodeType === Node.ELEMENT_NODE) {
return findLastElement(lastChild as HTMLElement);
}
return element;
}
const popRef = ref();
const mkHtml = computed(() => {
if (props.role === "user") {
return props.content;
}
let html = md.render(props.content);
console.log(html); // 调试信息
nextTick(() => {
if (props.streaming) {
const parent = popRef.value;
if (!parent) return;
let lastChild = parent.lastElementChild || parent;
console.log(lastChild.tagName);
if (lastChild.tagName === "PRE") {
lastChild = lastChild.getElementsByClassName("hljs")[0] || lastChild;
}
if (lastChild.tagName === "OL") {
lastChild = findLastElement(lastChild as HTMLElement);
}
lastChild?.insertAdjacentHTML("beforeend", '<span class="input-cursor"></span>');
}
});
return html;
});
</script>
<style lang="scss" scoped>
.msg-item {
width: 100%;
display: flex;
margin-bottom: 10px;
padding: 0 10px;
border-radius: 4px;
.msg-content {
position: relative;
width: 100%;
flex: 1 1 auto;
.msg-pop-container {
position: relative;
display: inline-block;
max-width: 95%;
.msg-pop-default {
width: 100%;
display: inline-block;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
color: #252724;
:deep(p) {
margin-bottom: 0;
white-space: pre-line;
}
}
.msg-pop-primary {
background: #95ec69;
// white-space: pre-line;
}
}
}
}
.msg-content-user {
text-align: end;
}
.msg-item-system {
justify-content: flex-end;
}
</style>
<style lang="scss">
.think {
color: blue;
font-style: italic;
}
.hl-code {
margin-top: 1em;
}
.hl-code-header {
padding: 0 10px;
color: #abb2bf;
background: #1d2635;
border-radius: 4px 4px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.hljs {
padding: 10px;
overflow-x: auto;
border-radius: 0 0 4px 4px;
.input-cursor {
background: #fff;
/* fallback for old browsers */
}
}
.input-cursor {
position: relative;
display: inline-flex;
align-items: center;
width: 1px;
height: 1em;
background: #3b414b;
/* fallback for old browsers */
padding-left: 0.05em;
top: 0.1em;
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
0% {
visibility: visible;
}
50% {
visibility: hidden;
}
100% {
visibility: visible;
}
}
</style>
2. 输入组件(Input.vue)
该组件提供了文本输入框和语音输入按钮,支持用户输入问题并发送,同时可以开启新的对话。
<template>
<div class="msg-editor-container">
<!-- 文本输入框 -->
<div class="flex items-center">
<el-input
class="flex-1"
ref="inputDiv"
v-model="inputValue"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder="请输入你的问题"
@keydown.enter.exact="handleKeydown"
></el-input>
<!-- 语音 -->
<DeepseekVoiceVue @voiceTextChange="voiceTextChange"></DeepseekVoiceVue>
<!-- 新对话 -->
<el-tooltip :z-index="100000" effect="dark" content="新对话" placement="top">
<el-icon class="mr-1 focus:border-blue-400 hover:bg-[#f5f5f5] bg-white cursor-pointer" size="22px" @click="newSessionBtn">
<Plus />
</el-icon>
</el-tooltip>
<!-- 操作按钮 -->
<el-tooltip :z-index="100000" effect="dark" content="发送" placement="top">
<el-button type="primary" icon="Top" circle @click="handleKeydown"></el-button>
</el-tooltip>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import DeepseekVoiceVue from "./Voice.vue";
const emits = defineEmits(["submit", "newSession"]);
const inputValue = ref("");
const voiceTextChange = (text: string) => {
console.log(text);
inputValue.value = inputValue.value + text;
};
const handleKeydown = (e: Event) => {
e.stopPropagation();
e.returnValue = false;
if (inputValue.value === "") return;
emits("submit", inputValue.value);
inputValue.value = "";
};
const newSessionBtn = () => {
emits("newSession");
};
</script>
<style lang="scss" scoped>
.msg-editor-container {
border: 1px solid #dee0e3;
border-radius: 4px;
padding: 5px;
}
.operationBtn {
display: flex;
}
</style>
3. 流式请求处理(useDeepseek.ts)
通过useGpt钩子函数处理 GPT 流式请求,使用Typewriter类模拟打字效果,让响应内容逐字显示。
import { ref } from "vue";
import { StreamGpt, Typewriter, GptMsgs, RequestData } from "./fetchApi";
// useGpt 钩子函数,用于处理 GPT 流式请求
export const useGpt = (key: string) => {
const streamingText = ref("");
const streaming = ref(false);
const msgList = ref<GptMsgs>([]);
const sessionId = ref("");
// 初始化 Typewriter 实例
const typewriter = new Typewriter((str: string) => {
streamingText.value += str || "";
// console.log("str", str);
});
// 初始化 StreamGpt 实例
const gpt = new StreamGpt(key, {
onStart: (prompt: string) => {
streaming.value = true;
msgList.value.push({
role: "user",
content: prompt
});
},
onPatch: (text: string) => {
// console.log("onPatch", text);
typewriter.add(text);
},
onCreated: () => {
typewriter.start();
},
onDone: () => {
typewriter.done();
streaming.value = false;
msgList.value.push({
role: "system",
content: streamingText.value
});
streamingText.value = "";
}
});
// 发送流式请求
const stream = (requestData: RequestData) => {
if (sessionId.value === "") {
sessionId.value = generateUUID();
}
gpt.stream({ ...requestData, sessionId: sessionId.value });
};
// 新会话
const newSession = () => {
msgList.value = [];
streamingText.value = "";
sessionId.value = generateUUID();
};
// 生成 UUID
const generateUUID = () => {
let uuid = "";
for (let i = 0; i < 32; i++) {
const random = (Math.random() * 16) | 0;
if (i === 8 || i === 12 || i === 16 || i === 20) uuid += "-";
uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16);
}
return uuid;
};
return {
streamingText,
streaming,
msgList,
stream,
newSession
};
};
4. 语音处理模块(Voice.vue)
该模块支持语音录制和识别,将录制的语音转换为 WAV 文件或Base64,并发送到后端进行识别。
<template>
<!-- 语音输入按钮 -->
<button @click="toggleRecording">{{ isRecording ? '停止录音' : '开始录音' }}</button>
</template>
<script lang="ts" setup>
import { ref } from "vue";
const isRecording = ref(false);
const mediaRecorder: any = ref(null);
const chunks: any = ref([]);
const emits = defineEmits(["voiceTextChange"]);
// 开始录音
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.value = new MediaRecorder(stream);
mediaRecorder.value.ondataavailable = (event: any) => {
if (event.data.size > 0) {
chunks.value.push(event.data);
}
};
mediaRecorder.value.onstop = () => {
const wavBlob = encodeWAV(chunks.value);
wavTransformBase64(wavBlob);
chunks.value = [];
};
mediaRecorder.value.start();
isRecording.value = true;
} catch (error) {
console.error("录音失败:", error);
}
};
// 停止录音
const stopRecording = () => {
if (mediaRecorder.value) {
mediaRecorder.value.stop();
isRecording.value = false;
}
};
// 生成WAV文件
function encodeWAV(chunks) {
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
const header = generateWavHeader(totalLength);
const wavBuffer = new Uint8Array(header.byteLength + totalLength);
// 合并头和数据
wavBuffer.set(new Uint8Array(header.buffer), 0);
let offset = header.byteLength;
chunks.forEach(chunk => {
wavBuffer.set(new Uint8Array(chunk.buffer), offset);
offset += chunk.byteLength;
});
return new Blob([wavBuffer], { type: "audio/wav" });
}
// 生成WAV文件头
function generateWavHeader(dataLength) {
const header = new ArrayBuffer(44);
const view = new DataView(header);
// RIFF标识
writeString(view, 0, "RIFF");
// 文件长度(数据长度 + 36)
view.setUint32(4, dataLength + 36, true);
// WAVE格式
writeString(view, 8, "WAVE");
// fmt子块
writeString(view, 12, "fmt ");
// fmt块长度(16字节)
view.setUint32(16, 16, true);
// 格式类型(1=PCM)
view.setUint16(20, 1, true);
// 声道数
view.setUint16(22, WAV_CONFIG.channelCount, true);
// 采样率
view.setUint32(24, WAV_CONFIG.sampleRate, true);
// 字节率
view.setUint32(28, WAV_CONFIG.sampleRate * WAV_CONFIG.channelCount * (WAV_CONFIG.bitDepth / 8), true);
// 块对齐
view.setUint16(32, WAV_CONFIG.channelCount * (WAV_CONFIG.bitDepth / 8), true);
// 位深度
view.setUint16(34, WAV_CONFIG.bitDepth, true);
// data标识
writeString(view, 36, "data");
// 数据长度
view.setUint32(40, dataLength, true);
return view;
}
// 写入字符串到DataView
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
// 将WAV文件转换为Base64编码
function wavTransformBase64(wavBlob) {
const reader = new FileReader();
reader.onload = function (e) {
console.log("wav base64:", e.target.result);
getRecognition(e.target.result);
};
reader.readAsDataURL(wavBlob);
}
// 切换录音状态
const toggleRecording = () => {
if (isRecording.value) {
stopRecording();
} else {
startRecording();
}
};
const getRecognition = base64Str => {
isLoading.value = true;
Recognition(base64Str)
.then(res => {
emits("voiceTextChange", res.data);
})
.finally(() => {
console.log("voiceTextChange");
isLoading.value = false;
});
};
</script>
总结
通过以上步骤,我们实现了一个基于 Vue 的智能对话系统,支持文本输入、语音输入和流式响应。在开发过程中,我们使用了 Vue 的响应式原理和组件化开发思想,结合 Markdown 解析器、语音处理 API 和流式请求技术,为用户提供了一个流畅、智能的对话体验。未来,我们可以进一步优化系统性能,增加更多的功能,如多语言支持、情感分析等。
Demo Github 地址
stream-deepseek