实现流程:
1.用户点击按钮从右侧展开抽屉(drawer),打开模拟对话框
2.用户输入问题,点击提问按钮,创建一个SSE实例请求后端数据,由于SSE是单向流,所以每提一个问题都需要先把之前的实例关掉,然后重新new个SSE实例
3.在SSE的onmessage里监听返回的数据流,并拼接到前端对话框中(后端返回的是markdown语法的流,这里全局引入了marked.js插件用来解析markdown),我这里接的是deepseek,所以返回的数据流里会有推理信息,不过后端可以控制不返回推理信息,只返回结果
4.可以加一些细节处理,提升用户体验,比如:保存最近十条的聊天记录(这里存到了localStorage里),允许用户主动停止正在生成的内容,每次读取流时页面需要滚动到底部等
完整代码如下:
<template>
<div>
<!-- AI对话框 -->
<a-drawer
class="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-textarea
v-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, // 按钮拖曳时不打开drawer
showAI: 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 = true
try {
status._es = new EventSource(`${companyAskUrl}?prompt=${status.question}`)
status._es.onmessage = (event) => {
let data = event.data
if (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.content
status.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_content
const directReasoning = parsed.choices?.[0]?.delta?.reasoning_content
if (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 = true
const startIndex = content.indexOf('<think>') + '<think>'.length
return {
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 = false
const 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 = false
if (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>
最终效果如下: