原生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.mzph.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. 控制信号量 信号量函数调用 运行结果展示 前言 上一篇文章介绍的共享内存有局限性,如:同步与互斥问题、内存管理复杂性问题、数据结构限制问题、可移植性差问题、调试困难问题。本篇博文介…