原生SSE實現AI智能問答+Vue3前端打字機流效果

實現流程:
1.用戶點擊按鈕從右側展開抽屜(drawer),打開模擬對話框

2.用戶輸入問題,點擊提問按鈕,創建一個SSE實例請求后端數據,由于SSE是單向流,所以每提一個問題都需要先把之前的實例關掉,然后重新new個SSE實例

3.在SSE的onmessage里監聽返回的數據流,并拼接到前端對話框中(后端返回的是markdown語法的流,這里全局引入了marked.js插件用來解析markdown),我這里接的是deepseek,所以返回的數據流里會有推理信息,不過后端可以控制不返回推理信息,只返回結果

4.可以加一些細節處理,提升用戶體驗,比如:保存最近十條的聊天記錄(這里存到了localStorage里),允許用戶主動停止正在生成的內容,每次讀取流時頁面需要滾動到底部等

完整代碼如下:

<template><div><!-- AI對話框 --><a-drawerclass="ai-drawer"v-model:visible="status.showAI"placement="right"width="40%"><!-- 聊天面板 --><div ref="chatPanelRef" class="chat-panel"><div v-for="(item, index) in status.chatRecords" :key="index" class="chat-item"><template v-if="item.user==='AI'"><div class="avatar"><img src="@/assets/img/home/AI.svg" alt="智能問答" /></div><div class="cont"><div class="answer-cont"><template v-if="item.content.length <= 0"><loading-outlined /></template><template v-else><div v-html="item.content" class="answer-box"></div></template></div></div></template><template v-else><div class="cont user"><div class="answer-cont"><div v-html="item.content" class=""></div></div></div><div class="avatar user"><img src="@/assets/default-user.png" alt="用戶" /></div></template></div></div><!-- 輸入面板 --><div class="inp-panel"><div class="flex"><a-textareav-model:value="status.question":auto-size="{ minRows: 4, maxRows: 4 }"placeholder="說點什么吧...(shift + enter換行)"/><a-button type="primary" size="large" :title="status.isAsking ? '停止回答' : '提問'" class="search-btn" @click="onQuestion"><template #icon><send-outlined v-if="!status.isAsking" /><pause-circle-outlined v-else/></template></a-button></div></div></a-drawer><!-- AI按鈕 --><div id="aiBtn" class="ai-btn" @click.stop="handleShowPanel"><a-tooltip placement="top"overlayClassName="ai-popper"><img src="@/assets/img/home/AI.svg" alt="智能問答" /><template #title><p>我是AI小助手<br />可以試試問我一些問題</p></template></a-tooltip></div></div>
</template><script lang='ts' setup>
import { reactive, toRefs, onBeforeMount, onMounted, onBeforeUnmount, ref, watch, nextTick, computed } from "vue";
import { message } from "ant-design-vue";
import { companyAskUrl } from "@/http/company/index"const chatPanelRef = ref()
const isInThinkTag = ref()
let eventSource = null
const status = reactive({isMove: false, // 按鈕拖曳時不打開drawershowAI: false,isAsking: false, // 是否正在回答問題question: "", // 問題 請給我查詢中國對外翻譯有限公司的基本情況chatRecords: [], // 聊天記錄user: "", // 當前用戶_es: null,
})onMounted(() => {init()
})const init = () => {// 獲取當前用戶let userInfo = localStorage.getItem("userInfo")if (userInfo) {status.user = JSON.parse(userInfo) ? JSON.parse(userInfo).username : ""}// 默認讀取localStorage里的聊天歷史let chatRecords = localStorage.getItem("chatRecords")if (chatRecords) {status.chatRecords = JSON.parse(chatRecords)} else {status.chatRecords.push({user: "AI",content: "Hi,我是AI小助手,請問需要什么幫助嗎?"})}initAI()
}onBeforeUnmount(() => {closeConnect()
})// 初始化AI按鈕,允許拖曳
const initAI = () => {let aiBtn = document.getElementById("aiBtn");let offsetX = 0;let offsetY = 0;aiBtn.addEventListener("mousedown", function(event) {event.preventDefault(); // 阻止默認的拖動操作status.isMove = false;offsetX = event.clientX - aiBtn.offsetLeft; // 計算鼠標相對于按鈕左邊界的位移量offsetY = event.clientY - aiBtn.offsetTop;document.addEventListener("mousemove", mousemoveHandler); // 注冊鼠標移動事件處理函數document.addEventListener("mouseup", mouseupHandler); // 注冊鼠標松開事件處理函數function mousemoveHandler(e) {aiBtn.style.left = e.clientX - offsetX + "px"; // 更新按鈕的位置aiBtn.style.top = e.clientY - offsetY + "px";status.isMove = true;}function mouseupHandler() {document.removeEventListener("mousemove", mousemoveHandler); // 移除鼠標移動事件處理函數document.removeEventListener("mouseup", mouseupHandler); // 移除鼠標松開事件處理函數}});
}// 提問
const onQuestion = () => {if (status.question === "") {message.warning('提問內容不能為空', 0.7);return}// 停止之前的聊天if (status.isAsking) {status.chatRecords[status.chatRecords.length - 1].content += "已停止"closeConnect()return}// 開始新的聊天nextTick(() => {status.chatRecords.push({user: status.user,content: JSON.parse(JSON.stringify(status.question))})status.chatRecords.push({user: "AI",content: ""})// 滾動到底部srollToFt()onAnswer()})
}// 生成回答
const onAnswer = () => {initChat()
}// 初始化chat
const initChat = () => {status.isAsking = truetry {status._es = new EventSource(`${companyAskUrl}?prompt=${status.question}`)status._es.onmessage = (event) => {let data = event.dataif (data !== '') {const parsed = parseSSEData(event.data)if (parsed.content && parsed.content !== "") {console.log(parsed.content)if (!status.chatRecords[status.chatRecords.length - 1]._content) {status.chatRecords[status.chatRecords.length - 1]._content = ""}status.chatRecords[status.chatRecords.length - 1]._content += parsed.contentstatus.chatRecords[status.chatRecords.length - 1].content = (window as any).marked?.parse(status.chatRecords[status.chatRecords.length - 1]._content)// 保存聊天歷史saveChatHistory()}// 滾動到底部srollToFt()}}status._es.onerror = (error) => {console.error('SSE Error:', error)closeConnect()}} catch (error) {console.error('Connection Error:', error)closeConnect()}
}// 解析sse返回的數據
const parseSSEData = (data) => {try {const parsed = JSON.parse(data)// 檢查是否直接返回了 reasoning_contentconst directReasoning = parsed.choices?.[0]?.delta?.reasoning_contentif (directReasoning) {return {id: parsed.id,created: parsed.created,model: parsed.model,reasoning_content: directReasoning,content: parsed.choices?.[0]?.delta?.content || ''}}const content = parsed.choices?.[0]?.delta?.content || ''// 處理 think 標簽包裹的情況if (content.includes('<think>')) {isInThinkTag.value = trueconst startIndex = content.indexOf('<think>') + '<think>'.lengthreturn {id: parsed.id,created: parsed.created,model: parsed.model,reasoning_content: content.substring(startIndex),content: content.substring(0, content.indexOf('<think>'))}}if (content.includes('</think>')) {isInThinkTag.value = falseconst endIndex = content.indexOf('</think>')return {id: parsed.id,created: parsed.created,model: parsed.model,reasoning_content: content.substring(0, endIndex),content: content.substring(endIndex + '</think>'.length)}}// 根據狀態決定內容歸屬return {id: parsed.id,created: parsed.created,model: parsed.model,reasoning_content: isInThinkTag.value ? content : '',content: isInThinkTag.value ? '' : content}} catch (e) {console.error('解析JSON失敗:', e)return null}
}// 保存聊天記錄
const saveChatHistory = () => {let chatRecords = []// 只保留前200條記錄if (status.chatRecords.length > 20) {chatRecords = status.chatRecords.slice(1)} else {chatRecords = status.chatRecords}localStorage.setItem("chatRecords", JSON.stringify(chatRecords))
}// 關閉鏈接
const closeConnect = () => {status.isAsking = falseif (status._es) {status._es.close()status._es = null}saveChatHistory()
}// 展示彈窗
const handleShowPanel = () => {if (status.isMove) {return}status.showAI = true// 滾動到底部srollToFt()
}// 關閉彈框
const handleClose = () => {status.showAI = false;
}// 滾動到底部
const srollToFt = () => {nextTick(() => {chatPanelRef.value.scrollTo({top: chatPanelRef.value.scrollHeight})})
}// 跳轉頁面
const toPage = (item, citem) => {
}
</script><style lang="scss" scoped>
.ai-btn {position: fixed;right: 30px;bottom: 100px;cursor: pointer;z-index: 1000;display: flex;align-items: center;justify-content: center;width: 52px;height: 52px;background-color: #fff;border-radius: 50%;box-shadow: 0 0 4px #333;
}
.a-drawer__wrapper {::v-deep {.a-drawer__header {margin-bottom: 0;padding-top: 0;}.a-drawer__body {padding: 0 10px 20px;box-sizing: border-box;}}
}
.ai-drawer {.a-drawer__header {margin-bottom: 10px;}.chat-panel {position: relative;margin-bottom: 20px;width: 100%;height: calc(100% - 130px);overflow-y: auto;.chat-item {position: relative;display: flex;width: 100%;margin-bottom: 14px;.avatar {position: relative;display: flex;align-items: center;justify-content: center;margin: 0 10px;width: 40px;height: 40px;border-radius: 50%;box-sizing: border-box;box-shadow: 0px 1px 4px rgba(136, 136, 136, 1);overflow: hidden;&.user {img {max-width: 100%;max-height: 100%;}}img {max-width: 60%;max-height: 60%;}}.cont {position: relative;width: calc(100% - 120px);&.user {margin-left: 60px;.answer-cont {background-color: #ddd;}}.answer-cont {position: relative;width: 100%;min-height: 40px;line-height: 2;padding: 10px;box-sizing: border-box;border-radius: 10px;background-color: #ddd;}.answer-box {position: relative;line-height: 2;::v-deep {h1, h2, h3, h4 {line-height: 2;}p {line-height: 2;}span {// display: inline-block;line-height: 1.5;// color: rgb(5, 7, 59);}}}}}}.inp-panel {position: relative;width: 100%;height: auto;padding: 10px;box-sizing: border-box;border-radius: 10px;background-color: #eee;.flex {display: flex;// align-items: center;justify-content: center;.search-btn {margin-left: 4px;height: 50px;}}}@keyframes load {0%,80%,100% {box-shadow: 0 0 0 0 #dcdfe6;height: 3.6em;}40% {box-shadow: 0 -1em 0 0 #dcdfe6;height: 4.6em;}}@keyframes blink {from {opacity: 0;}to {opacity: 1;}}.aic-wapper {display: flex;.pointer::after {content: "|";animation: blink 1s infinite;color: #333;}}
}
</style>
<style lang="scss">
.ai-popper {// box-shadow: rgb(14 18 22 / 35%) 0px 10px 38px -10px,//   rgb(14 18 22 / 20%) 0px 10px 20px -15px;.ant-tooltip-arrow-content {background-color: #fff;}.ant-tooltip-inner {color: #333;background-color: #fff;}
}.content-ul {position: relative;list-style: circle;padding: 0 10px !important;box-sizing: border-box;li {list-style: circle;cursor: pointer;}
}
</style>

最終效果如下:

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/900723.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/900723.shtml
英文地址,請注明出處:http://en.pswp.cn/news/900723.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

CUDA 工具鏈將全面原生支持 Python

根據 NVIDIA 在 2025 年 GTC 大會上的官宣&#xff0c;CUDA 工具鏈將全面原生支持 Python 編程&#xff0c;這一重大更新旨在降低 GPU 編程門檻&#xff0c;吸引更廣泛的 Python 開發者進入 CUDA 生態。以下是核心信息整合&#xff1a; 1. 原生支持的意義與背景 無需 C/C 基礎…

jupyter notebook 顯示conda虛擬環境

使用 nb_conda_kernels 安裝 nb_conda_kernels&#xff1a;這個包可以自動從你的 Conda 環境中發現并列出內核。 conda activate base # 確保你在 base 環境或任何其他環境中安裝 conda install nb_conda_kernels顯示jupyternotebook當前所在的位置。

【AI】MCP概念

一文講透 MCP&#xff08;附 Apifox MCP Server 內測邀請&#xff09; 7分鐘講清楚MCP是什么&#xff1f;統一Function calling規范&#xff0c;工作量銳減至1/6&#xff0c;人人手搓Manus&#xff01;&#xff1f; | 一鍵鏈接千臺服務器&#xff0c;幾行代碼接入海量外部工具…

WSL1升級到WSL2注意事項

今天要在WSL上安裝docker&#xff0c;因為機器上安裝了wsl1&#xff0c;docker安裝后啟動不了&#xff0c;通過詢問deepseek發現docker只能在wsl2上安裝&#xff0c;因此就想著將本機的wsl1升級到wsl2。 確保你的 Windows 系統是 Windows 10&#xff08;版本 1903 及以上&…

Pycharm常用快捷鍵總結

主要是為了記錄windows下的PyCharm的快捷鍵&#xff0c;里面的操作都試過了功能描述會增加備注。 文件操作 快捷鍵功能描述Ctrl N新建文件Ctrl Shift N根據名稱查找文件Ctrl O打開文件Ctrl S保存當前文件Ctrl Shift S另存為Alt F12打開終端&#xff08;Terminal&…

電池分選機:新能源時代的品質守護者|深圳比斯特自動化

在這個新能源蓬勃發展的時代&#xff0c;電池作為能量的存儲與釋放單元&#xff0c;其性能與質量直接關系到整個系統的穩定運行與效率提升。而電池分選機&#xff0c;作為電池生產流程中的關鍵一環&#xff0c;正扮演著品質守護者的角色&#xff0c;為新能源產業的高質量發展保…

認識 Linux 內存構成:Linux 內存調優之虛擬內存與物理內存

寫在前面 博文內容涉及 Linux 內存構成基本認知包括虛擬內存和物理內存映射,多級頁表和MMU簡單認知理解不足小伙伴幫忙指正對每個人而言,真正的職責只有一個:找到自我。然后在心中堅守其一生,全心全意,永不停息。所有其它的路都是不完整的,是人的逃避方式,是對大眾理想的…

SCI科學論文的重要組成部分

科學論文的核心結構 科學論文通常遵循IMRAD結構&#xff0c;即&#xff1a; 引言(Introduction)方法(Methods)結果(Results)討論(Discussion) 除此之外&#xff0c;還包括其他幾個關鍵部分。讓我為您詳細介紹每個部分的作用和重要性&#xff1a; 1. 標題(Title) 標題是論文…

期權時間價值與隱含波動率怎么選?

期權隱含波動率與時間價值要怎么選&#xff1f;期權隱含波動率IV對期權價格有著巨大的影響。整體來看&#xff0c;期權隱波與期權價格呈正相關關系。當期權隱波從低水平上升時&#xff0c;期權價格也會相應上漲&#xff1b;反之&#xff0c;當隱波下降&#xff0c;期權價格則會…

STM32 HAL庫擴大USB CDC的輸入緩沖區

STM32 HAL庫,使用USB, 擴大輸入暫存區的方法 使用STM32的USB通訊CubeMX建立配置Serial Wire時鐘配置USB配置時鐘頻率設置代碼編寫運行效果總結使用STM32的USB通訊 STM32可以不用使用串口轉換直接和USB通訊。這給串口調試提供了極大的方便。編程,我使用了STM32CubeIDE編程。這…

ffmpeg函數簡介(封裝格式相關)

文章目錄 &#x1f31f; 前置說明&#xff1a;FFmpeg 中 AVFormatContext 是什么&#xff1f;&#x1f9e9; 1. avformat_alloc_context功能&#xff1a;場景&#xff1a; &#x1f9e9; 2. avformat_open_input功能&#xff1a;說明&#xff1a;返回值&#xff1a; &#x1f9…

費馬小定理

快速冪 理論 a n a a ? a a^n a a \cdots a anaa?a&#xff0c;暴力的計算需要 O(n) 的時間。 快速冪使用二進制拆分和倍增思想&#xff0c;僅需要 O(logn) 的時間。 對 n 做二進制拆分&#xff0c;例如&#xff0c; 3 13 3 ( 1101 ) 2 3 8 ? 3 4 ? 3 1 3^{13}…

ADGaussian:用于自動駕駛的多模態輸入泛化GS方法

25年4月來自香港中文大學和浙大的論文“ADGaussian: Generalizable Gaussian Splatting for Autonomous Driving with Multi-modal Inputs”。 提出 ADGaussian 方法&#xff0c;用于可泛化的街道場景重建。所提出的方法能夠從單視圖輸入實現高質量渲染。與之前主要關注幾何細…

js中this指向問題

在js中&#xff0c;this關鍵字的指向是一個比較重要的概念&#xff0c;它的值取決于函數的調用方式。 全局狀態下 //全局狀態下 this指向windowsconsole.log("this", this);console.log("thiswindows", this window); 在函數中 // 在函數中 this指向win…

我的NISP二級之路-03

目錄 一.ISMS 二.IP 三.http 四.防火墻 五.文件 解析 解析 六.攻擊 解析 解析 七.風險管理工程 八.信息系統安全保護等級 九.我國信息安全保障 一.ISMS 1.文檔體系建設是信息安全管理體系(ISMS)建設的直接體現&#xff0c;下列說法不正確的是&#xff1a; A&#…

HarmonyOS應用開發者高級-編程題-001

題目一&#xff1a;跨設備分布式數據同步 需求描述 開發一個分布式待辦事項應用&#xff0c;要求&#xff1a; 手機與平板登錄同一華為賬號時&#xff0c;自動同步任務列表任一設備修改任務狀態&#xff08;完成/刪除&#xff09;&#xff0c;另一設備實時更新任務數據在設備…

動態列表的數據渲染、新增、編輯等功能開發及數據處理

說一個比較繁瑣的功能吧&#xff0c;我使用的是 vue element UI vxe-table 來實現的這個動態列表&#xff0c;其實呢 vxe-table 這個表格插件里邊有動態表格 vxe-grid 只需要通過表頭數組里邊的 field: name, 與表體數組里的 name: Test1, 對應上就行了&#xff0c;很簡單吧…

Linux學習筆記——文件系統基礎與根文件系統詳解

文件系統基礎與根文件系統詳解 什么是文件系統&#xff1f;什么是根文件系統&#xff08;Root File System&#xff09;&#xff1f;一句話理解&#xff1a;更詳細地說&#xff1a; 根文件系統為什么重要&#xff1f;1. 啟動依賴2. 提供根目錄 /3. 支持掛載其他文件系統4. 提供…

R語言進行聚類分析

目錄 簡述6種系統聚類法 實驗實例和數據資料&#xff1a; 上機實驗步驟&#xff1a; 進行最短距離聚類&#xff1a; 進行最長距離聚類&#xff1a; 進行中間距離聚類&#xff1a; 進行類平均法聚類&#xff1a; 進行重心法聚類&#xff1a; 進行ward.D聚類&#xff1a;…

【回眸】Linux 內核 (十四)進程間通訊 之 信號量

前言 信號量概念 信號量常用API 1.創建/獲取一個信號量 2.改變信號量的值 3. 控制信號量 信號量函數調用 運行結果展示 前言 上一篇文章介紹的共享內存有局限性,如:同步與互斥問題、內存管理復雜性問題、數據結構限制問題、可移植性差問題、調試困難問題。本篇博文介…