一.项目功能:
- 智能问答(实时聊天+流畅打字机效果+自动滚动)
- 停止生成(取消接口调用)、重新生成
- 复制功能、问答分页
二.效果展示:

三.技术分析:
-
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 = `文章来源于互联网:人工智能之web前端开发(deepSeek与文心一言结合版)师爷模型深度思考中...
${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; } }$(function() {
setTimeout(function () {
var mathcodeList = document.querySelectorAll('.htmledit_views img.mathcode');
if (mathcodeList.length > 0) {
for (let i = 0; i < mathcodeList.length; i++) {
if (mathcodeList[i].complete) {
if (mathcodeList[i].naturalWidth === 0 || mathcodeList[i].naturalHeight === 0) {
var alt = mathcodeList[i].alt;
alt = '(' + alt + ')';
var curSpan = $('');
curSpan.text(alt);
$(mathcodeList[i]).before(curSpan);
$(mathcodeList[i]).remove();
}
} else {
mathcodeList[i].onerror = function() {
var alt = mathcodeList[i].alt;
alt = '(' + alt + ')';
var curSpan = $('');
curSpan.text(alt);
$(mathcodeList[i]).before(curSpan);
$(mathcodeList[i]).remove();
};
}
}
MathJax.Hub.Queue(["Typeset",MathJax.Hub]);
}
}, 500)
});参与评论
您还未登录,请先
登录
后发表或查看评论02-23
1617
02-25
1918
window.csdn.csdnFooter.options = {
el: '.blog-footer-bottom',
type: 2
}$("a.flexible-btn").click(function(){
$(this).parents('div.aside-box').removeClass('flexible-box');
$(this).parents("p.text-center").remove();
})var timert = setInterval(function() {
sideToolbar = $(".csdn-side-toolbar");
if (sideToolbar.length > 0) {
sideToolbar.css('cssText', 'bottom:64px !important;')
clearInterval(timert);
}
}, 200);
实付元点击重新获取
扫码支付
钱包余额
0抵扣说明:
1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。文心一言插件开发全流程 文心一言插件开发全流程ERNIE-Bot-SDK可以调用文心一言的能力 项目地址: https://gitcode.com/Resource-Bundle-Collection/69ac8 简介 本资源文件详细介绍了如何开发和注册文心一…
5bei.cn大模型教程网













扫一扫






















为什么被折叠?
请填写红包祝福语或标题
红包个数最小为10个
红包金额最低5元
前往充值 >