fetchEventSource:传统axios请求是等接口将所有数据一次性响应回来后再渲染到页面上,当数据量较大时,响应速度较慢,且无法做到实时输出。而fetchEventSource允许客户端接收来自服务器的实时更新,前端可以实时的将流式数据展示到页面上,类似于打字机的效果。
fetchEventSource(url, {
method: "GET",
headers: {
"Content-type": "application/json",
Accept: "text/event-stream"
},
openWhenHidden: true,
onopen: (e) => {
//接口请求成功,但此时数据还未响应回来
},
onmessage: (event) => {
//响应数据持续数据
},
onclose: () => {
//请求关闭
},
onerror: () => {
//请求错误
}
})
MarkdownIt :SSE响应的数据格式是markdown,无法直接展示,需要使用MarkdownIt第三方库转换成html,然后通过v-model展示到页面上。
// 1、新建实例md:
const md = new MarkdownIt()
// 2.将markdown转化为html
const htmlStr= md.render(markdownStr)
Clipboard+html-to-text:复制时,需要使用html-to-text第三方库将html转化为text,然后借助Clipboard复制到粘贴板上。
//1.在html中设置“copy”类名,并绑定data-clipboard-text
//2.先将html转化成text,然后复制到粘贴板
const copyFn = (copyHtmlStr) => {
copyText.value=htmlToText(copyHtmlStr)
const clipboard = new Clipboard(".copy")
// 成功
clipboard.on("success", function (e) {
ElMessage.success("复制成功")
e.clearSelection()
// 释放内存
clipboard.destroy()
})
// 失败
clipboard.on("error", function (e) {
ElMessage.error("复制失败")
clipboard.destroy()
})
}
scrollEvent:由于数据流式输出,页面内容持续增加,可能会溢出屏幕,因此需要在fetchEventSource接收消息onmessage的过程中,通过设置scrollTop =scrollHeight让页面实现自动滚动。
fetchEventSource(url, {
...,
onmessage: (event) => {
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
},
...
})
1. 问题描述:当页面请求fetchEventSource已发出时,切换url到其他网站再切换回来到这个页面时,fetchEventSource会重复请求,导致这两次请求的内容重复。
解决方案:设置openWhenHidden为true,表示当页面退至后台时仍保持连接,默认值为false
2. 问题描述:前端调用AbortController的abort()方法取消请求时,只有第一次取消生效,当重新请求时,再次点击停止按钮不生效。
解决方案:每请求一次创建一个新的AbortController()实例,因为AbortController实例的abort()方法被设计为只能调用一次来取消请求,一旦调用了abort(),与AbortController相关的AbortSigal的aborted属性就会被设置成true,表示请求已取消,当再次调用abort()不会有任何效果。
3. 问题描述:当在fetchEventSource的onmessage中设置scrollTop =scrollHeight时,在生成问题的过程中无法向上滚动,但业务想要边生成边滚动查看。
解决方案:监听鼠标滚轮事件,在设置scrollTop =scrollHeight时添加判断,如果鼠标滚轮滑动且未到页面底部,则不自动滚动。
const isRolling = ref(false) //鼠标滚轮是否滚动
const isBottom = ref(false) //滚动参数
// 处理鼠标滚轮事件
const moveWheel1 = ref(true)
const moveWheel2 = ref(false)
const wheelClock = ref()
const stopWheel=()=> {
if (moveWheel2.value == true) {
moveWheel2.value = false
moveWheel1.value = true
}
}
const moveWheel=()=> {
if (moveWheel1.value == true) {
isRolling.value = true
moveWheel1.value = false
moveWheel2.value = true
//这里写开始滚动时调用的方法
wheelClock.value = setTimeout(stopWheel, 200)
} else {
clearTimeout(wheelClock.value)
wheelClock.value = setTimeout(stopWheel, 150)
}
}
const sendFn=()=>{
fetchEventSource(url, {
...,
onmessage: (event) => {
if (isRolling.value === false) {
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
isRolling.value = false
}
if (isBottom.value) {
isRolling.value = false
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
}
},
...
})
}
4. 问题描述:SSE响应回来的数据中表格样式未生效。
解决方案:MarkdownIt第三方库将markdown转换成html时,部分样式会丢失,需使用github-markdown-css添加样式。
npm i github-markdown-css
import "github-markdown-css"
index.vue:
<{{ item.answerIndex + 1 }} / {{ item.message.length
}}
重新生成
思考中…
停止生成
index.ts:
import "./index.scss"
import { defineComponent, ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"
import { fetchEventSource } from "@microsoft/fetch-event-source"
import { ElMessageBox, ElMessage } from "element-plus"
import MarkdownIt from "markdown-it"
import "github-markdown-css"
import Clipboard from "clipboard"
import { htmlToText } from "html-to-text"
import type { ChatItem } from "../../../types/chat"
export default defineComponent({
components: { },
setup() {
const inputValue = ref("")
const preInputValue = ref("") //上一次查询输入内容,用于重新生成使用
const isLoading = ref(false) //节流loading
const loadingStatus = ref(false) //加载状态显示loading
const chatList = ref([])
const contentItems = ref("") //当前正在输出的数据流
const chatContainerRef = ref()
const isRegenerate = ref(false) //是否重新生成
const controller = ref()
const signal = ref()
const copyText = ref("") //复制的文字
const copyIndex = ref()
const scrollTopShow = ref(false)
const scrollBottomShow = ref(false)
const isRolling = ref(false) //鼠标滚轮是否滚动
const isBottom = ref(false) //滚动参数
onMounted(() => {
initFn()
chatContainerRef.value.addEventListener("wheel", moveWheel)
window.addEventListener("message", function (event) {
// 处理接收到的消息
if (event.data && event.data.message) {
inputValue.value = event.data.message
sendFn()
}
})
})
// 处理鼠标滚轮事件
const moveWheel1 = ref(true)
const moveWheel2 = ref(false)
const wheelClock = ref()
function stopWheel() {
if (moveWheel2.value == true) {
// console.log("滚轮停止了")
// isRolling.value = false
moveWheel2.value = false
moveWheel1.value = true
}
}
function moveWheel() {
if (moveWheel1.value == true) {
// console.log("滚动了")
isRolling.value = true
moveWheel1.value = false
moveWheel2.value = true
//这里写开始滚动时调用的方法
wheelClock.value = setTimeout(stopWheel, 200)
} else {
clearTimeout(wheelClock.value)
wheelClock.value = setTimeout(stopWheel, 150)
}
}
//初始化
const initFn = () => {
chatList.value = []
}
//上一页
const preFn = (index: number, answerIndex: number) => {
if (isLoading.value) return ElMessage.error("正在生成内容,请勿切换。")
if (answerIndex === 0) {
chatList.value[index].answerIndex = chatList.value[index].message.length - 1
} else {
chatList.value[index].answerIndex = chatList.value[index].answerIndex - 1
}
}
//下一页
const nextFn = (index: number, answerIndex: number) => {
if (isLoading.value) return ElMessage.error("正在生成内容,请勿切换。")
if (answerIndex === chatList.value[index].message.length - 1) {
chatList.value[index].answerIndex = 0
} else {
chatList.value[index].answerIndex = chatList.value[index].answerIndex + 1
}
}
// 1、新建实例md:
const md = new MarkdownIt()
const currentHTML = computed(() => {
// 先判断存不存在,因为一开始currentPost有可能是undefined,在没有拿回数据的时候。
if (contentItems.value) {
if (contentItems.value.includes("")) {
const arr = contentItems.value.split("")
const thinkStr = `
师爷模型深度思考中...
${arr[0]}
`
return thinkStr + md.render(arr[1])
} else {
const thinkStr = `
师爷模型深度思考中...
${contentItems.value}
`
return thinkStr
}
}
})
//发送问题调用接口
const sendFn = () => {
showList.value = false
controller.value = new AbortController()
signal.value = controller.value.signal
//先判断inputStr有没有值,isRegenerate表示是否重新生成
const inputStr = isRegenerate.value ? preInputValue.value : inputValue.value
if (!inputStr) return ElMessage.error("请输入要查询的问题。")
if (isLoading.value) return ElMessage.error("正在生成内容,请稍后。")
isLoading.value = true
if (!isRegenerate.value) {
//第一次生成
chatList.value.push({ type: "user", message: [inputStr], answerIndex: 0, isLoading: false, reportFlag: null })
}
loadingStatus.value = true
const url = `/recheck-web/open/shiye/chat?message=${inputStr}`
fetchEventSource(url, {
method: "GET",
headers: {
"Content-type": "application/json",
Accept: "text/event-stream"
},
signal: signal.value,
openWhenHidden: true,
// params: JSON.stringify({ message: inputStr }),
onopen: (e) => {
if (e.status === 500) return ElMessage.error("服务器忙,请稍后再试。")
if (isRegenerate.value) {
//重新生成
chatList.value[chatList.value.length - 1].message.push("")
chatList.value[chatList.value.length - 1].answerIndex =
chatList.value[chatList.value.length - 1].message.length - 1
} else {
chatList.value.push({ type: "ai", message: [], answerIndex: 0, isLoading: true })
preInputValue.value = inputValue.value
}
chatList.value[chatList.value.length - 1].isLoading = true
inputValue.value = ""
isLoading.value = true
loadingStatus.value = false
},
onmessage: (event) => {
const data = JSON.parse(event.data)
const newItem = data ? data.content : ""
contentItems.value = contentItems.value + newItem
if (data.status !== "end") {
} else {
if (isRegenerate.value) {
//重新生成
chatList.value[chatList.value.length - 1].message[
chatList.value[chatList.value.length - 1].message.length - 1
] = currentHTML.value + ""
} else {
//第一次生成
chatList.value[chatList.value.length - 1].message.push(currentHTML.value + "")
}
if (data.type) {
chatList.value[chatList.value.length - 1].reportFlag = data.type
}
}
nextTick(() => {
if (isRolling.value === false) {
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
isRolling.value = false
}
if (isBottom.value) {
isRolling.value = false
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
}
})
},
onclose: () => {
isLoading.value = false
loadingStatus.value = false
chatList.value[chatList.value.length - 1].isLoading = false
contentItems.value = ""
},
onerror: () => {
isLoading.value = false
loadingStatus.value = false
chatList.value[chatList.value.length - 1].isLoading = false
contentItems.value = ""
}
})
}
//停止生成
const stopFn = () => {
isLoading.value = false
loadingStatus.value = false
controller.value.abort()
//chatList最后一项
const lastChatItem = chatList.value[chatList.value.length - 1]
if (isRegenerate.value) {
// lastChatItem.message[lastChatItem.message.length - 1] = md.render(contentItems.value + "\n" + "\n" + "停止生成")
lastChatItem.message[lastChatItem.message.length - 1] =
currentHTML.value + "停止生成"
} else {
// lastChatItem.message.push(md.render(contentItems.value + "\n" + "\n" + "停止生成"))
lastChatItem.message.push(currentHTML.value + "停止生成")
}
contentItems.value = ""
lastChatItem.isLoading = false
}
//重新生成
const regenerateFn = () => {
isRegenerate.value = true
sendFn()
}
//发送
const inputBlurFn = (event: any) => {
if (!event.ctrlKey) {
// 如果没有按下组合键ctrl,则会阻止默认事件
event.preventDefault()
isRegenerate.value = false
sendFn()
} else {
// 如果同时按下ctrl+回车键,则会换行
inputValue.value += "\n"
}
}
//复制功能
const copyFn = (index: number, answerIndex: number) => {
copyIndex.value = index
copyText.value = htmlToText(chatList.value[index].message[answerIndex])
const clipboard = new Clipboard(".copy")
// 成功
clipboard.on("success", function (e) {
ElMessage.success("复制成功")
e.clearSelection()
// 释放内存
clipboard.destroy()
})
// 失败
clipboard.on("error", function (e) {
ElMessage.error("复制失败")
clipboard.destroy()
})
}
//试问
const askFn = (question: string) => {
inputValue.value = question
isRegenerate.value = false
sendFn()
}
//滚动事件
const scrollEvent = (e: any) => {
//如果滚动到底部,显示向上滚动按钮
//如果滚动到顶部,显示向下滚动按钮
const scrollTop = e.target.scrollTop
const scrollHeight = e.target.scrollHeight
const offsetHeight = Math.ceil(e.target.getBoundingClientRect().height)
const currentHeight = scrollTop + offsetHeight
if (currentHeight >= scrollHeight) {
scrollTopShow.value = true
isBottom.value = true
} else {
isBottom.value = false
scrollTopShow.value = false
}
if (scrollHeight > offsetHeight) {
scrollBottomShow.value = true
} else {
scrollBottomShow.value = false
}
}
//向上滚动
const scrollTopFn = () => {
chatContainerRef.value.scrollTop = 0
}
//向下滚动
const scrollBottomFn = () => {
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight + 250
}
//下载尽调报告
const downloadReport = (index: number, answerIndex: number) => {
const downStr = chatList.value[index].message[answerIndex]
const arr = downStr.split("")
const blob = new Blob([arr[1]], { type: "text/plain" })
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = "尽调报告.docx"
link.click()
}
const generateReport = (question: string) => {
inputValue.value = question
isRegenerate.value = false
sendFn()
}
const viewDialogRef = ref()
const viewFn = () => {
viewDialogRef.value.dialogVisible = true
}
watch(
() => chatList.value,
() => {
if (chatList.value && chatList.value.length > 0) {
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight + 250
}
},
{ deep: true }
)
onUnmounted(() => {
if (isLoading.value) {
isLoading.value = false
loadingStatus.value = false
controller.value.abort()
}
})
return {
inputValue,
isLoading,
chatList,
preFn,
nextFn,
sendFn,
contentItems,
stopFn,
preInputValue,
currentHTML,
chatContainerRef,
regenerateFn,
inputBlurFn,
copyFn,
copyText,
askFn,
loadingStatus,
copyIndex,
downloadReport,
generateReport,
showList,
scrollEvent,
scrollTopFn,
scrollBottomFn,
scrollTopShow,
scrollBottomShow,
viewDialogRef,
viewFn
}
}
})
index.scss:
.report-page {
height: 100%;
width: 1201px;
margin: 20px calc((100% - 1201px) / 2 - 35px) 20px calc((100% - 1201px) / 2 + 35px);
.chat-container::-webkit-scrollbar {
width: 0; /* 对于垂直滚动条 */
}
.chat-container {
height: 85vh;
overflow-y: auto;
padding-bottom: 170px;
margin-top: 0px;
box-sizing: border-box;
.chat-list-container {
margin-top: 30px;
.chat-item {
display: flex;
margin-bottom: 20px;
.avatar {
width: 40px;
height: 40px;
}
.question {
display: flex;
margin-bottom: 30px;
margin-left: 70px;
margin-right: 10px;
.message {
padding: 12px 10px 10px;
border-radius: 14px 0px 14px 14px;
background: linear-gradient(128deg, #4672ff -1.27%, #7daafc 109.62%);
color: #fff;
}
.avatar {
margin-left: 20px;
}
}
.answer-container {
margin-right: 70px;
margin-bottom: 30px;
.answer {
display: flex;
.avatar-page {
width: 70px;
position: relative;
.avatar {
width: 60px;
height: 60px;
margin-right: 10px;
}
.page-container {
position: absolute;
top: 60px;
left: 3px;
color: #000;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
.pre-page {
margin-right: 1px;
cursor: pointer;
}
.next-page {
margin-left: 1px;
cursor: pointer;
}
}
}
.answer-message {
background-color: #fff;
padding: 20px;
border-radius: 0 14px 14px 14px;
min-width: 500px;
.download-btn {
color: #333;
text-align: center;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 14px;
background-color: #f2f3f8;
height: 34px;
margin-top: 20px;
border-radius: 6px;
border-color: transparent;
.icon-img {
width: 17px;
height: 17px;
margin-right: 5px;
}
&:hover {
background: linear-gradient(128deg, #4672ff -1.27%, #7daafc 109.62%);
color: #fff;
}
}
}
}
.btn-container {
margin-left: 80px;
margin-top: 18px;
text-align: left;
display: flex;
justify-content: space-between;
.opt-container {
.stop-btn,
.regenerate-btn {
cursor: pointer;
color: #57f;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
.tool-container {
background-color: #fff;
padding: 6px 10px 8px;
height: 40px;
border-radius: 20px;
min-width: 70px;
text-align: center;
.copy {
width: 28px;
height: 28px;
cursor: pointer;
}
.copy-acive {
color: #5577ff;
}
}
}
}
}
}
.user {
flex-direction: row-reverse;
}
.loading-status {
display: flex;
.avatar {
width: 60px;
height: 60px;
margin-right: 20px;
}
.think {
height: 52px;
line-height: 52px;
background-color: #fff;
text-align: center;
border-radius: 0 14px 14px 14px;
width: 100px;
color: #999;
}
.loading-img {
width: 40px;
height: 40px;
}
}
.scroll-container {
width: 38px;
height: 38px;
background-color: #fff;
border-radius: 19px;
display: flex;
justify-content: center;
align-items: center;
position: fixed;
left: calc((100% - 1201px) / 2 + 175px + 1061px);
}
.scroll-top {
bottom: 200px;
}
.scroll-bottom {
top: 53px;
}
}
.input-container {
position: fixed;
left: calc((100% - 1061px) / 2 + 35px);
bottom: 5%;
width: 1061px;
.stop-container {
cursor: pointer;
width: 104px;
height: 36px;
background-color: #fff;
color: #5863ff;
line-height: 36px;
text-align: center;
border-radius: 18px;
position: absolute;
top: -50px;
font-size: 14px;
font-style: normal;
font-weight: 400;
.stop-img {
width: 22px;
height: 22px;
vertical-align: middle;
margin-right: 3px;
}
}
.input {
.el-textarea__inner {
padding: 15px;
border-radius: 14px;
box-shadow: 14px 27px 45px 0px rgba(112, 144, 176, 0.2);
}
.el-textarea__inner::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.el-textarea__inner::-webkit-scrollbar-thumb {
border-radius: 3px;
-moz-border-radius: 3px;
-webkit-border-radius: 3px;
background-color: #c3c3c3;
}
.el-textarea__inner::-webkit-scrollbar-track {
background-color: transparent;
}
}
.icon-container {
position: absolute;
right: 10px;
bottom: 35px;
z-index: 999;
.loading-icon {
width: 40px;
height: 40px;
}
.send-icon {
width: 35px;
height: 35px;
}
}
}
}
.markdown-body {
box-sizing: border-box;
max-width: 1021px !important;
hr {
display: none !important;
}
}