人工智能之web前端开发(deepSeek与文心一言结合版)
一.项目功能:[*]智能问答(实时谈天+流通打字机效果+自动滚动)
[*]克制天生(取消接口调用)、重新天生
[*]复制功能、问答分页
二.效果展示:
https://i-blog.csdnimg.cn/direct/9e9aff542886421ba87a801bfee76af5.png
三.技术分析:
[*] 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()不会有任何效果。
https://i-blog.csdnimg.cn/direct/8fbbdc8b5b0e44e6b22d69b579c7d60a.png
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:
<template>
<div class="report-page">
<div class="chat-container" ref="chatContainerRef" @scroll="scrollEvent">
<div v-for="(item, index) in chatList" :key="index" class="chat-list-container">
<div class="chat-item" :class="item.type === 'user' ? 'user' : 'ai'">
<div v-if="item.type === 'user'" class="question">
<div class="message" v-if="item.message && item.message.length > 0">{{ item.message }}</div>
<img class="avatar" src="../../assets/chat/userAvatar.png" alt="" />
</div>
<div v-else-if="item.type === 'ai'">
<div v-if="item.message.length > 0 || item.isLoading" class="answer-container">
<div class="answer">
<div class="avatar-page">
<div><img class="avatar" src="../../assets/chat/aiAvatar.png" alt="" /></div>
<div class="page-container" v-if="item.message.length > 1">
<span class="pre-page" @click="preFn(index, item.answerIndex)"><</span
>{{ item.answerIndex + 1 }} / {{ item.message.length
}}<span class="next-page" @click="nextFn(index, item.answerIndex)">></span>
</div>
</div>
<div class="answer-message">
<div v-if="item.isLoading">
<div v-html="currentHTML" class="markdown-body" />
</div>
<div v-else>
<div v-html="item.message" class="markdown-body" />
<!-- <div v-if="item.reportFlag === 1">
<el-button @click="downloadReport(index, item.answerIndex)" class="download-btn">
<SvgIcon class="icon-img" name="download" />
下载尽调报告</el-button
>
</div> -->
</div>
</div>
</div>
<div class="btn-container">
<div class="opt-container">
<div v-if="index === chatList.length - 1">
<!-- <span v-if="item.isLoading" class="stop-btn" @click="stopFn">停止生成</span> -->
<span v-if="!item.isLoading && preInputValue" class="regenerate-btn" @click="regenerateFn"
>重新生成</span
>
</div>
</div>
<div class="tool-container" v-if="!item.isLoading">
<el-icon
class="copy"
:class="copyIndex === index ? 'copy-acive' : ''"
@click="copyFn(index, item.answerIndex)"
:data-clipboard-text="copyText"
>
<CopyDocument />
</el-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="loading-status" v-if="loadingStatus">
<div><img class="avatar" src="../../assets/chat/aiAvatar.png" alt="" /></div>
<div class="think">思考中…</div>
<div><img class="loading-img" src="../../assets/chat/loading.gif" alt="" /></div>
</div>
<div v-if="!isLoading">
<div class="scroll-container scroll-top" v-if="scrollTopShow">
<el-icon style="vertical-align: middle" @click="scrollTopFn">
<ArrowUp />
</el-icon>
</div>
<div class="scroll-container scroll-bottom" v-else-if="scrollBottomShow">
<el-icon style="vertical-align: middle" @click="scrollBottomFn">
<ArrowDown />
</el-icon>
</div>
</div>
</div>
<div class="input-container">
<div class="stop-container" @click="stopFn" v-if="isLoading">
<img class="stop-img" src="../../assets/chat/stop.png" alt="" />停止生成
</div>
<el-input
class="input"
type="textarea"
v-model="inputValue"
placeholder="你可以这样问:请写一份江苏省xxx有限公司的尽调报告"
:autosize="{ minRows: 1, maxRows: 11 }"
@keydown.enter.native="inputBlurFn($event)"
/>
<div class="icon-container" :style="{ cursor: isLoading ? '' : 'pointer' }" @click="inputBlurFn">
<img v-if="isLoading" class="loading-icon" src="../../assets/chat/loading.gif" alt="" />
<img v-else-if="!isLoading && inputValue" src="../../assets/chat/send.svg" class="send-icon" alt="" />
<img v-else src="../../assets/chat/unsend.svg" class="send-icon" alt="" />
</div>
<Agreement />
</div>
<ViewDialog ref="viewDialogRef" />
</div>
</template>
<script lang="ts" src="./index.ts"></script>
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.answerIndex = chatList.value.message.length - 1
} else {
chatList.value.answerIndex = chatList.value.answerIndex - 1
}
}
//下一页
const nextFn = (index: number, answerIndex: number) => {
if (isLoading.value) return ElMessage.error("正在生成内容,请勿切换。")
if (answerIndex === chatList.value.message.length - 1) {
chatList.value.answerIndex = 0
} else {
chatList.value.answerIndex = chatList.value.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}</div>
<div><div>
`
return thinkStr + md.render(arr)
} 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: , 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.message.push("")
chatList.value.answerIndex =
chatList.value.message.length - 1
} else {
chatList.value.push({ type: "ai", message: [], answerIndex: 0, isLoading: true })
preInputValue.value = inputValue.value
}
chatList.value.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.message[
chatList.value.message.length - 1
] = currentHTML.value + ""
} else {
//第一次生成
chatList.value.message.push(currentHTML.value + "")
}
if (data.type) {
chatList.value.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.isLoading = false
contentItems.value = ""
},
onerror: () => {
isLoading.value = false
loadingStatus.value = false
chatList.value.isLoading = false
contentItems.value = ""
}
})
}
//停止生成
const stopFn = () => {
isLoading.value = false
loadingStatus.value = false
controller.value.abort()
//chatList最后一项
const lastChatItem = chatList.value
if (isRegenerate.value) {
// lastChatItem.message = md.render(contentItems.value + "\n" + "\n" + "停止生成")
lastChatItem.message =
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.message)
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.message
const arr = downStr.split("<div><div>")
const blob = new Blob(], { 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;
}
}
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页:
[1]