一.项目功能:
- 智能问答(实时谈天+流通打字机效果+自动滚动)
- 克制天生(取消接口调用)、重新天生
- 复制功能、问答分页
二.效果展示:
三.技术分析:
- 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
- <el-icon class="copy" @click="copyFn(copyHtmlStr)" :data-clipboard-text="copyText"> </el-icon>
- //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:
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<ChatItem[]>([])
- const contentItems = ref("") //当前正在输出的数据流
- const chatContainerRef = ref()
- const isRegenerate = ref(false) //是否重新生成
- const controller = ref()
- const signal = ref()
- const copyText = ref("") //复制的文字
- const copyIndex = ref<number>()
- 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("</sy_think>")) {
- const arr = contentItems.value.split("</sy_think>")
- const thinkStr = `
- <h4> 师爷模型深度思考中...</h4>
- <div style="color: gray;font-size:14px;padding-left:10px;margin-bottom:10px;line-height:25px;border-left:1px solid #e5e5e5">${arr[0]}</div>
- <div><div>
- `
- return thinkStr + md.render(arr[1])
- } else {
- const thinkStr = `
- <h4> 师爷模型深度思考中...</h4>
- <div style="color: gray;font-size:14px;padding-left:10px;margin-bottom:10px;line-height:25px;border-left:1px solid #e5e5e5">${contentItems.value}</div>
- <div><div>
- `
- 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 + "<div style='font-size:16px;margin-top:10px'>停止生成</div>"
- } else {
- // lastChatItem.message.push(md.render(contentItems.value + "\n" + "\n" + "停止生成"))
- lastChatItem.message.push(currentHTML.value + "<div style='font-size:16px;margin-top:10px'>停止生成</div>")
- }
- 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("<div><div>")
- 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:
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |