<script lang="jsx" >
import {defineComponent, ref} from "vue";
import { CloseCircleOutlined } from '@ant-design/icons-vue';
import { useRouter } from "vue-router";
import { message } from 'ant-design-vue';
import { nextTick } from "vue";
import userAvatrImg from '@/assets/person.jpg'
import robotImg from './img/robot.png'
const AIChat = defineComponent({
setup() {
const router = useRouter()
const msgList = ref([])
// 头部render
const renderHeader = () => {
return <div class="header-nav">
<div class="back-icon" onClick={router.back}></div>
AI问答
</div>
}
const renderMsgItemSlots = {
'icon': () => {
// return <UserOutlined />
return <img src={userAvatrImg}></img>
}
}
const renderMsgItemSlotsRoot = {
'icon': () => {
return <img style="width:100%;height:100%" src={robotImg}></img>
}
}
const rendMsgItemUploading = (item) => {
switch(item.uploading) {
case 'loading':
return <span>文件上传解析中...</span>
case 'success':
return (<>
<span>{item.fileList.length}份文件已上传</span><a-progress strokeColor="#F6A622" format={(percent) => `${percent}%`} size="small" style="width: 100px;margin-left:12px" percent={100} />
</>
)
case 'error':
return <span style="color:red">文件解析异常,请尝试重新发送</span>
case 'stop':
return <span style="color:#F6A622">已停止</span>
}
}
const isAiStop = ref(false)
let controller = new AbortController();
const onStop = () => {
const msgVal = msgList.value
if (!msgVal[msgVal.length - 1].isAi) {
msgVal[msgVal.length - 1].uploading = 'stop'
}
isAiStop.value = true
sendLoading.value = false
controller.abort();
}
// 消息问答项render
const renderMsgItem = (item) => {
return <>
<div class="msg-item" id={item.domId}>
<div class="msg-item-left">
{
!item.isAi ?
<a-avatar size={40} v-slots={renderMsgItemSlots}>
</a-avatar>
:
<img style="width:100%;height:100%" src={robotImg}></img>
}
</div>
{
item.isAi ?
<div class="msg-item-right" v-html={item.content} >
</div> :
<div class="msg-item-right">
<div><span class="user-msg-title"> 文件附件:</span>
{
rendMsgItemUploading(item)
}
</div>
<div><span class="user-msg-title"> 筛选条件:</span>{item.sendMsg}</div>
</div>
}
</div>
</>
}
const renderMsgBodyNoData = () => {
return <>
<div class="msg-body-no-data">
<img src={robotImg}></img>
<div style="margin-top: 10px;color:#777;">请上传需要筛选的文件并输入筛选条件</div>
</div>
</>
}
// 消息问答体render
const renderMsgBody = () => {
return <>
<div class="msg-list-box">
<div class="msg-list-scroll-box">
{msgList.value.map((item) => {
return renderMsgItem(item)
})}
</div>
{
sendLoading.value ?
<div class="stop-btn" onClick={onStop}>停止</div> :
''
}
{
!msgList.value.length ?
renderMsgBodyNoData()
: ''
}
</div>
</>
}
// 上传的文件项目render
const renderFooterLeftFileItem = (item, index) => {
return <>
<div class="footer-pdf-item">
<div class="pdf-item-left">
<div class="file-name" title={item.name}>
{item.name}
</div>
<div class="file-size">
{(item.size/1000).toFixed(2)}kb
</div>
</div>
<div class="pdf-item-right">
<div class="pdf-icon"></div>
</div>
<div class="footer-pdf-item-delete-icon" title="移除" onClick={() => pdfFileList.value.splice(index, 1)}>
<CloseCircleOutlined />
</div>
</div>
</>
}
const pdfFileList = ref([])
// 点击文件上传
const onUpload = () => {
const dom = document.createElement('input')
dom.type = 'file'
dom.multiple = true
dom.accept = `application/msword,application/pdf`
dom.style.cssText = `
position: fixed;left: -999px;top: -200px;width: 0;height: 0;opcity: 0;
`
document.body.appendChild(dom)
dom.onchange = onUploadFileChange
dom.click()
setTimeout(() => {
document.body.removeChild(dom)
}, 2000)
}
const onUploadFileChange = (e) => {
const { target } = e
const { files } = target
pdfFileList.value.push(...files)
}
// 底部左侧render
const renderFooterLeft = () => {
return <>
<div class="footer-left">
<div class="footer-left-header">
<div style="display:flex;align-items:center;">
<div class="upload-icon"></div>
文件上传
</div>
<div class="upload-btn" onClick={onUpload}>
上传
</div>
</div>
<div class="file-pdf-box"> { pdfFileList.value.map((item, index) => renderFooterLeftFileItem(item, index)) } </div>
</div>
</>
}
const sendMsg = ref('')
let sendLoading = ref(false)
const httpPostSend = () => {
const form = new FormData()
form.append('prompt', sendMsg.value)
pdfFileList.value.forEach((item) => {
form.append('file', item)
})
let index = 0
controller = new AbortController();
// 发送请求获取流式数据
fetch('/xuehua/resume-genie', {
method: 'post',
responseType: 'stream',
body: form,
signal: controller.signal
})
.then(response => {
if (isAiStop.value) {
const msgVal = msgList.value
msgVal[msgVal.length - 1].uploading = 'stop'
sendLoading.value = false
return
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
const domId = `user-${new Date().getTime()}_ai`
let msgItemDom = null
const observe = new MutationObserver(function(mu, ob){
if (msgItemDom) {
msgItemDom.scrollIntoView()
// msgItemDom.scrollTop = msgItemDom.scrollHeight - msgItemDom.clientHeight;
} else {
msgItemDom = document.querySelector(`#${domId}`)
msgItemDom?.scrollIntoView()
}
})
return reader.read().then(function processText({ done, value }) {
if (done) {
console.log('Stream finished.');
if (!index) {
const msgVal = msgList.value
msgVal[msgVal.length - 1].uploading = 'error'
} else {
const msgVal = msgList.value
msgVal[msgVal.length - 2].uploading = 'success'
}
index = 0
sendLoading.value = false
return;
}
const val = decoder.decode(value)
if (index == 0) {
msgList.value.push({
isAi: true,
content: val,
domId
})
index++
nextTick(() => {
const dom = document.querySelector(`#${domId}`)
dom.scrollIntoView()
observe.observe(dom, { attributes: true, childList: true, subtree: true });
})
} else {
if (!isAiStop.value) {
const msgVal = msgList.value
msgVal[msgVal.length - 1].content += val
} else {
sendLoading.value = false
const msgVal = msgList.value
msgVal[msgVal.length - 2].uploading = 'stop'
}
}
// console.log(`Received data chunk: ${decoder.decode(value)}`);
// Continue reading
return reader.read().then(processText);
});
})
.catch(error => {
sendLoading.value = false
const msgVal = msgList.value
let uploading = 'error'
if (error.message == 'signal is aborted without reason' ||
error.message == 'BodyStreamBuffer was aborted'
) {
uploading = 'stop'
console.error('请求出错2:', error.message);
}
if (!msgVal[msgVal.length - 1].isAi) {
msgVal[msgVal.length - 1].uploading = uploading
} else {
msgVal[msgVal.length - 2].uploading = uploading
}
console.error('请求出错:', error);
});
}
const onSend = () => {
if (sendLoading.value) {
message.warning('请先等待AI完成解析');
return
}
let fileLen = pdfFileList.value.length
if (fileLen <= 0) {
message.warning('至少上传一份文件哦!');
return
}
if (!sendMsg.value.trim().length) {
message.warning('至少输入一项筛选维度哦!');
return
}
isAiStop.value = false
const domId = `user-${new Date().getTime()}`
msgList.value.push({
isAi: false,
fileList: pdfFileList.value,
sendMsg: sendMsg.value,
domId,
uploading: 'loading'
})
nextTick(() => {
document.querySelector(`#${domId}`).scrollIntoView()
})
sendLoading.value = true
httpPostSend()
}
// 底部右侧render
const renderFooterRight = () => {
return <>
<div class="footer-right">
<a-textarea
style="border: none;outline: none;resize: none;"
placeholder="请输入筛选维度,多个筛选条件,可用“,”隔开" v-model:value={sendMsg.value} />
<div style={{opacity: pdfFileList.value.length && sendMsg.value && !sendLoading.value ? `1` : `0.5` }} class="send-btn" onClick={onSend}>
<div class="send-icon"></div>
</div>
</div>
</>
}
// 底部render
const renderFooter = () => {
return <>
<div class="page-footer-box">
{renderFooterLeft()}
{renderFooterRight()}
</div>
</>
}
// 页面
return () => <div class="ai-chat-page">
{renderHeader()}
{renderMsgBody()}
{renderFooter()}
</div>
}
})
export default AIChat
</script>
<style lang="less" scoped>
.ai-chat-page {
height: 100%;
padding: 0 30px;
/*滚动条宽高 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #D8D8D8;;
border-radius: 6px;
}
/* 滚动条上的滚动滑块。样式 */
::-webkit-scrollbar-thumb {
border-radius: 6px;
background: #F6A622;
}
/* 鼠标悬停时,设置滑块的背景颜色为深灰色 */
::-webkit-scrollbar-thumb:hover {
background-color: #999999;
}
/* 鼠标按下时,设置滑块的背景颜色为灰色 */
::-webkit-scrollbar-thumb:active {
background-color: #F6A622;
}
}
.header-nav {
height: 80px;
display: flex;
align-items: center;
font-size: 20px;
color: #0B0E12;
.back-icon {
width: 24px;
height: 24px;
background-image: url(./img/back.svg);
margin-right: 8px;
cursor: pointer;
}
}
.msg-list-box {
height: calc(100vh - 80px - 170px - 30px);
border-radius: 10px;
padding: 30px;
box-sizing: border-box;
background-color: #fff;
position: relative;
.msg-body-no-data {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
text-align: center;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
img {
width: 50px;
height: 50px;
}
}
.stop-btn {
position: absolute;
bottom: 10px;
left: 50%;
transform: translate(-50%, 0);
background-image: url(./img/stop.svg);
background-position: 4px center;
background-repeat: no-repeat;
display: flex;
align-items: center;
padding: 8px;
padding-left: 24px;
background-color: #d7d1d1;
line-height: 1;
border-radius: 8px;
font-size: 10px;
cursor: pointer;
animation: focus 1.2s infinite;
}
@keyframes focus {
from {
opacity: 1;
}
to {
opacity: 0.5;
}
}
.msg-list-scroll-box {
height: calc(100vh - 80px - 170px - 30px - 60px);
padding-right: 24%;
overflow-y: auto;
}
}
.msg-item:not(:first-child) {
margin-top: 30px;
}
.msg-item {
display: flex;
align-items: flex-start;
max-height: 81%;
overflow-y: auto;
}
.msg-item-left {
flex-shrink: 0;
width: 40px;
height: 40px;
}
.msg-item-right {
background: #F1F1F1;
border-radius: 8px;
flex-grow: 1;
margin-left: 12px;
padding: 12px;
word-break: break-all;
font-weight: 400;
position: relative;
.stop-btn {
position: absolute;
}
.user-msg-title {
font-size: 14px;
color: #000000;
font-weight: 500;
}
:deep(.ant-progress-text) {
color: #F6A622;
margin-left: 4px;
}
}
.page-footer-box {
margin-top: 16px;
height: 170px;
display: flex;
.footer-left {
width: 344px;
height: 100%;
background: #FFFFFF;
border: 1px solid rgba(238,238,238,1);
border-radius: 10px;
flex-shrink: 0;
padding: 0 12px 10px 12px;
box-sizing: border-box;
.footer-left-header {
font-size: 14px;
color: #000000;
display: flex;
justify-content: space-between;
align-items: center;
height: 52px;
}
.file-pdf-box {
height: 108px;
overflow-y: auto;
}
.footer-pdf-item:not(:first-child) {
margin-top: 4px;
}
.footer-pdf-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 200px;
background: #FFFFFF;
border: 1px solid rgba(225,229,235,1);
padding: 8px 12px 9px 8px;
position: relative;
}
.footer-pdf-item-delete-icon {
position: absolute;
top: 50%;
left: calc(100% + 5px);
transform: translate(0, -50%);
opacity: 0.6;
cursor: pointer;
}
.pdf-item-left {
font-size: 12px;
color: #0B0E12;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.file-name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.file-size {
font-size: 10px;
color: #CDD0D3;
}
.pdf-item-right {
width: 22px;
flex-shrink: 0;
}
.pdf-icon {
width: 22px;
height: 20px;
background-image: url(./img/pdf.svg);
}
.upload-btn {
cursor: pointer;
font-size: 12px;
width: 78px;
line-height: 28px;
text-align: center;
border-radius: 40px;
color: #FFF;
background: #F6A622;
}
.upload-icon {
width: 28px;
height: 28px;
background-image: url(./img/upload.svg);
background-size: 70%;
background-position: center;
background-repeat: no-repeat;
margin-right: 8px;
}
}
.footer-right {
height: 100%;
background: #FFFFFF;
border: 1px solid rgba(238,238,238,1);
border-radius: 10px;
flex-grow: 1;
margin-left: 12px;
display: flex;
overflow: hidden;
.send-btn {
width: 58px;
opacity: 0.5;
background: #F6A622;
border-radius: 0px 4px 4px 0px;
flex-shrink: 0;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.send-icon {
width: 18px;
height: 18px;
background-image: url(./img//send.svg);
}
}
}
:deep(p:last-child) {
margin-bottom: 0;
}
</style>
记录一个AI对话VUE页面,使用了MutationObserver处理数据滚动问题
最新推荐文章于 2024-09-08 09:17:59 发布