目錄
- 引言
- 組件概述
- 核心組件與功能實現
- 1. 消息顯示組件(Message.vue)
- 2. 輸入組件(Input.vue)
- 3. 流式請求處理(useDeepseek.ts)
- 4. 語音處理模塊(Voice.vue)
- 總結
- Demo Github 地址
引言
在當今數字化時代,智能對話系統的應用越來越廣泛,如客服聊天機器人、智能助手等。本文將詳細介紹一個基于 Vue 框架開發的智能對話系統的實現過程,該系統支持文本輸入、語音輸入、流式響應等功能,讓我們一步步揭開它的神秘面紗。
組件概述
整個組件主要由以下幾個核心部分組成:
- 用戶界面組件:負責與用戶進行交互,包括文本輸入框、語音輸入按鈕、消息顯示區域等。
- 消息處理組件:將用戶輸入的消息進行處理,并顯示在界面上。
- 流式請求處理:與后端進行流式通信,實時獲取響應內容。
- 語音處理模塊:支持語音輸入功能,將語音轉換為文本。
核心組件與功能實現
1. 消息顯示組件(Message.vue)
該組件用于顯示用戶和系統的消息。在這個組件中,我們使用了 Markdown 解析器來處理消息內容,支持代碼高亮和自定義標簽。
<template><divclass="msg-item":class="{'msg-item-system': role === 'system'}"><divclass="msg-content":class="{'msg-content-user': role === 'user'}"><span class="msg-pop-container"><spanclass="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;}}// 如果處于靜默模式,只驗證標簽,不生成 tokenif (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);// 創建結束 tokentoken = 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-inputclass="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();};// 生成 UUIDconst 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