原生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>
最终效果如下:

相关文章:
原生SSE实现AI智能问答+Vue3前端打字机流效果
实现流程: 1.用户点击按钮从右侧展开抽屉(drawer),打开模拟对话框 2.用户输入问题,点击提问按钮,创建一个SSE实例请求后端数据,由于SSE是单向流,所以每提一个问题都需要先把之前的实…...
LLC工作模态详解
1以半桥LLC谐振变换器为例,主开关Q1、Q2构成半桥结构,其驱动信号为固定占空比50%的互补信号,并且在上下桥臂之间应有死区时间。 谐振电感Ls、谐振电感Cs和变压器励磁电感Lm共同构成谐振槽路,具有两个谐振频率: 谐振电…...
线代第三课:n阶行列式
引言 行标取自然排列 不同行不同列的3个元素相乘 列标取排列的所有可能 列标排列的逆序数的奇偶性决定符号,- n阶行列式 第一种:按行展开 (1) 行标取自然排列 (2) 列标取排列的所有可能 (PS:可以理解为随意取) (3) 从…...
机器学习的一百个概念(10)假阳性率
前言 本文隶属于专栏《机器学习的一百个概念》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢! 本专栏目录结构和参考文献请见[《机器学习的一百个概念》 ima 知识库 知识库广场搜索: 知识库创建人机器学习@Shockang机器学习数学基础@Shocka…...
GitHub 克隆/下载失败的解决方案
🚀 GitHub 下载/克隆失败?一招搞定代理配置与回滚! 在国内使用 Git 操作 GitHub 时,经常会遇到以下问题: ❌ 下载失败、超时 ❌ Failed to connect to github.com port 443 ❌ SSL certificate problem 本文将详细讲解…...
pulsar proxy详解
什么是 Pulsar Proxy? Pulsar Proxy 是 Apache Pulsar 中的一个可选组件,作用是作为客户端与 Pulsar Brokers 之间的中间网关层。它并不是 Pulsar 核心功能必须的部分,但在特定场景下(如复杂的网络环境、安全性需求或动态集群管理…...
C++ Socket优化实战:提升网络应用的性能与效率
🧑 博主简介:CSDN博客专家、CSDN平台优质创作者,高级开发工程师,数学专业,拥有高级工程师证书;擅长C/C、C#等开发语言,熟悉Java常用开发技术,能熟练应用常用数据库SQL server,Oracle…...
STM32单片机入门学习——第30节: [9-6] FlyMcu串口下载STLINK Utility
写这个文章是用来学习的,记录一下我的学习过程。希望我能一直坚持下去,我只是一个小白,只是想好好学习,我知道这会很难,但我还是想去做! 本文写于:2025.04.09 STM32开发板学习——第30节: [9-6] FlyMcu串口下载&STLINK Utility 前言开发…...
Qt容器类在元对象系统中使用
解释 “QVector没有被注册到Qt的元对象系统中”这句话的意思是:QVector<double>这种数据类型没有被Qt的元对象系统(Meta-Object System)识别和管理。Qt的元对象系统是Qt框架的核心部分,它提供了信号与槽机制、动态属性系统…...
亮相CMEF,美的医疗全维度打造智慧医疗新生态
当下,医疗科技革命的浪潮正汹涌而来,AI技术在中国医疗器械领域迅猛发展,释放出巨大的潜力。 4月8日,在第91届中国国际医疗器械博览会(CMEF)上,2025美的医疗年度新品发布暨中国脊梁守护计划启动…...
数据库视图讲解(view)
一、为什么需要视图 二、视图的讲解 三、总结 一、为什么需要视图 视图一方面可以帮我们使用表的一部分而不是所有的表,另一方面也可以针对不同的用户制定不同的查询视图。 比如,针对一个公司的销售人员,我们只想给他看部分数据,…...
TQTT_KU5P开发板教程---文件的烧写与程序固化
文档功能介绍 本文档所描述的为文件的烧写固化,利用spi芯片将程序固化带芯片上,可以让开发板在重新上电时也可以跑程序。我们所使用的芯片型号为mt25qu256-spi-x1_x2_x4.本次实验采用的在led_shift项目的基础上将流水灯程序固化到flash芯片上,…...
进度管理__制订进度计划_资源平衡和资源平滑
本文讲解的资源平衡与资源平滑,是制订进度计划的工具与技术的第3项: 资源优化。 1. 资源平衡 资源平衡是为了在资源需求与资源供给之间取得平等, 根据资源制约因素对开始日期和完成日期进行调整的一种技术。 如果共享资源或关键资源只在特定…...
【ISP】ISP pipeline(AI)
ISP Pipeline 全流程概览 ISP(Image Signal Processing,图像信号处理)流程通常从原始 Bayer 数据出发,经过一系列模块处理,逐步完成图像校正和增强,最终生成用于显示或编码的标准图像。常见处理模块包括&a…...
C++ RAII 的用途及业务代码实现案例
C RAII 的用途及业务代码实现案例 RAII 的核心概念 RAII (Resource Acquisition Is Initialization,资源获取即初始化) 是 C 的核心编程范式,其核心思想是: 资源获取与对象构造绑定资源释放与对象析构绑定利用 C 对象生命周期自动管理资源…...
RVOS-2.基于NS16550a ,为os添加终端交互功能。
2.1 实验目的 为os添加uart功能,通过串口实现开发板与PC交互。 2.1 硬件信息 QEMU虚拟SoC含有 虚拟NS16550A设备 。 不同的地址线组合(A2、A1、A0)对应的读写模式和寄存器如下所示: 2.2 NS16550a 的初始化 线路控制寄存器&#…...
#SVA语法滴水穿石# (004)关于 ended 和 triggered 用法
在 SystemVerilog 断言(SVA, SystemVerilog Assertions)中,ended 是一个用于 序列(sequence) 的关键字,它表示某个序列(sequence)在特定时间点已经成功匹配(即“结束”)。 ended 主要用于 同步不同序列的时间关系,尤其是在多序列组合或属性(property)中需要对齐时…...
软件学报 区块链论文 截止2025年4月 录用汇总 附pdf下载
截止 2025年4月 软件学报 2024年 区块链论文 录用汇总 附pdf下载 1 Title: 基于多父链辅助工作量证明共识机制的后量子区块链系统 Authors: Key words: 区块链;后量子密码;共识机制;辅助工作量证明 Abstract: 随着量子计算机的发展,对于以传统椭圆曲线数字签名为基石的公…...
损失函数篇——针对YOLO-MIFIN模型
1. 总损失函数(公式9) L all λ conf L conf λ cls L cls λ loc L loc (9) L_{\text{all}} \lambda_{\text{conf}} L_{\text{conf}} \lambda_{\text{cls}} L_{\text{cls}} \lambda_{\text{loc}} L_{\text{loc}} \tag{9} LallλconfLconfλ…...
【MySQL 数据库】增删查改操作CRUD(上)
🔥博客主页🔥:【 坊钰_CSDN博客 】 欢迎各位点赞👍评论✍收藏⭐ 目录 1. CRUD 简介 2. Create -- 新增 2.1 语法 2.2 练习 3. Retrieve -- 检索 3.1 Select -- 查询 3.1.1 全列查询 3.1.2 指定列查询 3.1.3 表达式查询 3.…...
pycharm 有智能提示,但是没法自动导包,也就是alt+enter无效果
找到file->settings->editor->inspections 把python勾选上,原来不能用是因为只勾选了一部分。...
web前端: 什么是web?
web前端指的是利用HTML、CSS、JavaScript等各种web技术,做出能在浏览器上运行且用户可见的界面,比如网站网页、APP软件界面、游戏前端界面等。web前端主要包括web全局架构、web视觉表现和web交互效果这三部分。 WEB发展史 Web(World Wide We…...
Java 开发中主流安全框架的详细对比,涵盖 认证、授权、加密、安全策略 等核心功能,帮助开发者根据需求选择合适的方案
以下是 Java 开发中主流安全框架的详细对比,涵盖 认证、授权、加密、安全策略 等核心功能,帮助开发者根据需求选择合适的方案: 1. 主流安全框架对比表 框架名称类型核心功能适用场景优点缺点官网/文档Spring Security企业级安全框架认证、授…...
Linux网络编程——TCP协议格式、可靠性分析
目录 一、前言 二、TCP协议格式 三、TCP的可靠性 TCP协议的确认应答机制 总结 四、TCP协议的缓冲区及流量控制 五、 TCP流量控制 六、TCP报文类型 标记位 一、前言 在上一篇文章中,我们重点介绍了UDP协议格式的一些内容。在本文中介绍的便是TCP协议格式的…...
【深度学习】Downstream Model:预训练模型的下游应用与微调技术
Downstream Model:预训练模型的下游应用与微调技术 文章目录 Downstream Model:预训练模型的下游应用与微调技术1 什么是Downstream Model(下游模型)2 预训练模型与下游任务的关系3 微调技术与迁移学习微调的必要性高效迁移学习参…...
C# ref out关键字 理解学习记录
ref 在传参是可以以指针的方式传递,而不是传参数的值 举例,函数返回void ,局部变量要传参后得到结果: ref传参前要实例化赋值,而函数体内不一定要赋值 out 传参前不一定要赋值,而函数体内一定要赋值 ,与r…...
网络建设与运维神州数码DCN VRF虚拟路由转发 路由表隔离
作用: 通过在一台路由器或者三层交换机上创建多张路由表实现数据的隔离,常用与MPLS VPN、防火墙.... 如果发送的包在同一VRF中,则查表,查找到匹配的路由条目后,将指示的端口转发给下一跳 如果不在同一VRF中则丢弃。…...
Python中的AdaBoost分类器:集成方法与模型构建
引言 在机器学习领域,集成方法(Ensemble Methods)是一种通过结合多个基学习器来提高模型性能的技术。AdaBoost(Adaptive Boosting)是集成方法中的一种经典算法,它通过迭代训练多个弱分类器,并将…...
11:00开始面试,11:08就出来了,问的问题有点变态。。。
从小厂出来,没想到在另一家公司又寄了。 到这家公司开始上班,加班是每天必不可少的,看在钱给的比较多的份上,就不太计较了。没想到8月一纸通知,所有人不准加班,加班费不仅没有了,薪资还要降40%…...
大模型本地部署系列(1) Ollama的安装与配置
一. Ollama简介 Ollama 是一个 本地化的大模型运行工具,可以让你在自己的电脑(比如Mac、Windows、Linux)上直接下载和运行各种开源的大型语言模型(比如 LLaMA 3、Mistral、Gemma 等),而无需依赖互联网或云…...
