You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

738 lines
25 KiB

10 months ago
<template>
<div ref="aiChatRef" class="ai-chat">
<el-scrollbar ref="scrollDiv" @scroll="handleScrollTop">
<div ref="dialogScrollbar" class="ai-chat__content p-24 chat-width" style="padding: 24px;">
<div class="item-content mb-16" style="margin-bottom: 16px">
<div class="avatar">
<img src="@/assets/logo/logo2.png" height="30px" />
</div>
<div class="content">
<el-card shadow="always" class="dialog-card">
<span style="font-size: 14px">您好我是 果知小助手您可以向我提出关于 果知的相关问题</span>
</el-card>
</div>
</div>
<template v-for="(item, index) in chatList" :key="index">
<!-- 问题 -->
<div v-if="item.type === 'question'" class="item-content mb-16 lighter" style="margin-bottom: 16px;font-weight: 400">
<div class="avatar">
<el-avatar style="width:30px;height: 30px;background: #3370FF">
<img src="@/assets/aichat/user-icon.svg" style="width: 30px" alt="" />
</el-avatar>
</div>
<div class="content">
<div class="text break-all pre-wrap" style="word-break: break-all;white-space: pre-wrap;">
{{ item.content }}
</div>
<el-tag type="success" style="margin-bottom: 5px;cursor: pointer" v-for="(fileName,index) in item.file" :disable-transitions="false" @click="downloadFile(fileName.file,fileName.bucket,item.sessionId)">{{fileName.file}}</el-tag>
</div>
</div>
<!-- 回答 -->
<div v-if="item.type === 'answer'" class="item-content mb-16 lighter" style="margin-bottom: 16px;font-weight: 400">
<div class="avatar">
<img src="@/assets/logo/logo2.png" height="30px" />
</div>
<div class="content">
<el-card shadow="always" class="dialog-card">
<MdRenderer :is_large="is_large" :chatIndex="index" :source="item.content" @fullscreenG6="fullscreen"></MdRenderer>
</el-card>
<div class="flex-between mt-8" style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px">
<div>
<el-button
type="primary"
v-if="item.isStop && !item.isEnd"
@click="startChat(index)"
link
9 months ago
>重新生成
10 months ago
</el-button>
<el-button type="primary" v-else-if="!item.isEnd" @click="stopChat(index)" link
>停止回答
</el-button>
</div>
</div>
<div v-if="item.isEnd" class="flex-between" style="display: flex; justify-content: space-between; align-items: center;">
<OperationButton :data="item" :index="index" @regeneration="regenerationChart(index)" @changeThumb="changeThumb" />
</div>
</div>
</div>
</template>
</div>
</el-scrollbar>
<div class="ai-chat__operate p-24" style="padding: 24px">
<!-- <slot name="operateBefore" />-->
<div class="chat-width">
<el-button type="primary" link class="new-chat-button mb-8" style="margin-bottom: 8px" @click="openUpload">
<el-icon><Upload /></el-icon><span class="ml-4"></span>
</el-button>
</div>
<div>
<el-tag type="success" style="margin-bottom: 5px" v-for="(tag,index) in currentFiles" closable :disable-transitions="false" @close="removeFile(index)">{{tag.file}}</el-tag>
</div>
<div>
<span v-if="currentMachine.length > 0"> </span>
<el-tag style="margin-bottom: 5px" size="large" v-for="tag in currentMachine" closable :disable-transitions="false" @close="removeMachine">
<span style="font-size: 16px;margin-left: 10px">{{tag}}</span>
</el-tag>
<span v-if="currentMachine.length > 0"> 对话</span>
</div>
<div class="operate-textarea flex chat-width">
<el-popover
:visible="popoverVisible"
placement="top"
:width="is_large?'400px':'860px'"
popper-style="max-width:860px;z-index:99999;margin-left:35px"
:show-arrow=false
:offset=1
>
<template #reference>
<el-input
ref="quickInputRef"
v-model="inputValue"
:placeholder="'@选择机器人,Ctrl+Enter 换行,Enter发送'"
:autosize="{ minRows: 1, maxRows: 4 }"
type="textarea"
:maxlength="100000"
@input="handleInput"
@keydown.enter="sendChatHandle($event)"
/>
</template>
<template #default>
<div style="width: 100%" v-click-outside="clickoutside">
<div style="align-items: center;display: flex">
<span style="font-size: 25px;font-weight: bold">选择智能体</span>
<el-link :underline="false" style="font-size: 20px;margin-left: auto" @click="closeMachinePop"><i class="ri-close-large-line"></i></el-link>
</div>
<el-scrollbar max-height="200px">
<div class="machineDiv" @click="chooseMachine('元数据专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">元数据专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据标准专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据标准专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据质量专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据质量专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据模型专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据模型专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据安全专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据安全专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据分析专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据分析专家</span>
</div>
<div class="machineDiv" @click="chooseMachine('数据治理管理专家','@/assets/aichat/智能体logo.jpg')" style="align-items: center;width:100%;display: flex">
<el-avatar style="width: 30px;height: 30px"><img src="@/assets/aichat/智能体logo.jpg"></el-avatar>
<span style="font-size: 16px;margin-left: 10px">数据治理管理专家</span>
</div>
</el-scrollbar>
</div>
</template>
</el-popover>
<div class="operate flex align-center">
<el-button
text
class="sent-button"
:disabled="isDisabledChart || loading"
@click="sendChatHandle"
>
<img v-show="isDisabledChart || loading" src="@/assets/aichat/icon_send.svg" alt="" />
<img v-show="!isDisabledChart && !loading" src="@/assets/aichat/icon_send_colorful.svg" alt="" />
</el-button>
</div>
</div>
</div>
<!-- 文件导入对话框 -->
9 months ago
<el-dialog z-index="9999" title="文件导入" v-model="upload.open" width="400px" append-to-body>
10 months ago
<el-upload v-if="upload.open"
ref="uploadRef"
:headers="upload.headers"
:action="upload.url"
:data = "upload.data"
:disabled="upload.isUploading"
:on-progress="handleFileUploadProgress"
:on-success="handleFileSuccess"
:auto-upload="false"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitFileForm"> </el-button>
<el-button @click="upload.open = false"> </el-button>
</div>
</template>
</el-dialog>
<el-dialog z-index="99999999999999999999" title="ER图全览" v-if="showFullscreenG6Data" :fullscreen="true" v-model="showFullscreenG6Data" append-to-body>
<fullscreenG6 :g6Data="fullscreenG6Data"></fullscreenG6>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick, computed, watch, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import antvg6 from './antv-g6.vue'
import OperationButton from './OperationButton.vue'
import MdRenderer from '@/views/aichat/MdRenderer.vue'
import fullscreenG6 from '@/views/aichat/fullscreenG6.vue'
import {getToken} from "@/utils/auth.js";
import {postChatMessage} from "@/api/aichat/aichat.js"
import Cookies from "js-cookie";
import {addChat} from "@/api/aichat/aichat";
defineOptions({ name: 'AiChat' })
const route = useRoute()
const fullscreenG6Data = ref(null)
const showFullscreenG6Data = ref(false)
const {
params: { accessToken, id },
query: { mode }
} = route
const props = defineProps({
is_large: Boolean,
cookieSessionId: String,
data: {
type: Object,
default: () => {}
},
record: {
type: Array,
default: () => []
},
chatId: {
type: String,
default: ''
}, // 历史记录Id
debug: {
type: Boolean,
default: false
}
})
const { proxy } = getCurrentInstance();
const aiChatRef = ref()
const quickInputRef = ref()
const scrollDiv = ref()
const dialogScrollbar = ref()
const loading = ref(false)
const inputValue = ref('')
const chartOpenId = ref('')
const chatList = ref([])
const answerList = ref([])
9 months ago
const controller = ref(null)
10 months ago
const popoverVisible = ref(false)
const currentMachine = ref([])
const currentFiles = ref([])
const upload = reactive({
// 是否显示弹出层(用户导入)
open: false,
// 弹出层标题(用户导入)
title: "",
// 是否禁用上传
isUploading: false,
// 设置上传的请求头部
headers: { Authorization: "Bearer " + getToken() },
// 上传的地址
url: import.meta.env.VITE_APP_BASE_API + "/aichat/upload",
data: {"sessionId":Cookies.get("chatSessionId")}
});
const isDisabledChart = computed(
() => !(inputValue.value.trim())
)
const emit = defineEmits(['scroll'])
function setScrollBottom() {
// 将滚动条滚动到最下面
scrollDiv.value.setScrollTop(getMaxHeight())
}
/**
* 滚动条距离最上面的高度
*/
const scrollTop = ref(0)
const getMaxHeight = () => {
return dialogScrollbar.value.scrollHeight
}
const handleScrollTop = ($event) => {
scrollTop.value = $event.scrollTop
emit('scroll', { ...$event, dialogScrollbar: dialogScrollbar.value, scrollDiv: scrollDiv.value })
}
const handleScroll = () => {
9 months ago
if (scrollDiv.value) {
10 months ago
// 内部高度小于外部高度 就需要出滚动条
if (scrollDiv.value.wrapRef.offsetHeight < dialogScrollbar.value.scrollHeight) {
// 如果当前滚动条距离最下面的距离在 规定距离 滚动条就跟随
9 months ago
scrollDiv.value.setScrollTop(getMaxHeight())
10 months ago
}
}
}
/**文件上传中处理 */
const handleFileUploadProgress = (event, file, fileList) => {
upload.isUploading = true;
};
const handleFileSuccess = (response, file, fileList) => {
currentFiles.value.push({file:response.data.file,bucket:response.data.bucket})
upload.open = false;
upload.isUploading = false;
9 months ago
proxy.$modal.msgSuccess(response.msg);
// proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true });
10 months ago
};
/** 提交上传文件 */
function submitFileForm() {
proxy.$refs["uploadRef"].submit();
}
watch(
() => props.chatId,
(val) => {
if (val && val !== 'new') {
chartOpenId.value = val
} else {
chartOpenId.value = ''
}
},
{ deep: true }
)
watch(
() => props.record,
(value) => {
chatList.value = value
},
{
immediate: true
}
)
watch(() => props.cookieSessionId, value => upload.data = {sessionId:value})
function regenerationChart(index){
let question = chatList.value[index - 1]
let chat = {
"type":"question",
"content":question.content,
"time": formatDate(new Date()),
"file": question.file}
chatList.value.push(chat)
let data = {
"query": chat.content,
"user_id": Cookies.get("username"),
"robot": currentMachine.value.length>0?currentMachine.value[0]:"",
"sessionId": props.sessionId,
"doc": chat.file,
"history": []
}
sendChatMessage(data)
}
function changeThumb(index,chat){
chatList.value[index].operate = chat.operate
chatList.value[index].thumbDownReason = chat.thumbDownReason
}
function openUpload(){
upload.open = true
}
function chooseMachine(val){
inputValue.value = inputValue.value.slice(0,-1)
currentMachine.value = [val]
popoverVisible.value = false
}
function closeMachinePop(){
if (inputValue.value.endsWith('@')){
inputValue.value = inputValue.value.slice(0,-1)
}
popoverVisible.value = false
}
function removeMachine(){
currentMachine.value = []
}
function fullscreen(data){
fullscreenG6Data.value = data
showFullscreenG6Data.value = true
}
function clickoutside() {
if (inputValue.value.endsWith('@')){
inputValue.value = inputValue.value.slice(0,-1)
}
popoverVisible.value = false
}
function removeFile(index){
currentFiles.value = currentFiles.value.slice(index,1)
}
function handleInput() {
popoverVisible.value = inputValue.value.endsWith("@");
}
function padZero(num) {
return num < 10 ? '0' + num : num;
}
function formatDate(date) {
const year = date.getFullYear();
const month = padZero(date.getMonth() + 1); // 月份从0开始,所以需要+1
const day = padZero(date.getDate());
const hours = padZero(date.getHours());
const minutes = padZero(date.getMinutes());
const seconds = padZero(date.getSeconds());
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function downloadFile(file,bucket,sessionId){
let data = {file,bucket,sessionId}
proxy.download("aichat/file/download", {
...data,
}, file);
}
async function sendChatHandle(event) {
if (!event.ctrlKey) {
// 如果没有按下组合键ctrl,则会阻止默认事件
event.preventDefault()
9 months ago
if (inputValue.value.trim() && (chatList.value.length === 0||
(chatList.value[chatList.value.length - 1].isStop ||
chatList.value[chatList.value.length - 1].isEnd))) {
10 months ago
chatList.value.push({
"type": "question",
"content": inputValue.value.trim(),
"time": formatDate(new Date()),
"sessionId": Cookies.get("chatSessionId"),
"sessionName": chatList.value.length > 0 ? chatList.value[0].content.substring(0, 20) : inputValue.value.trim().substring(0, 20),
"file": currentFiles.value
})
9 months ago
let question = JSON.parse(JSON.stringify(chatList.value[chatList.value.length - 1]))
question.file = JSON.stringify(question.file)
await addChat(question)
10 months ago
nextTick(() => {
// 将滚动条滚动到最下面
scrollDiv.value.setScrollTop(getMaxHeight())
})
inputValue.value = ''
let data = {
"query": inputValue.value.trim(),
"user_id": Cookies.get("username"),
"robot": currentMachine.value.length > 0 ? currentMachine.value[0] : "",
"sessionId": Cookies.get("chatSessionId"),
"doc": currentFiles.value,
"history": []
}
sendChatMessage(data)
}
} else {
// 如果同时按下ctrl+回车键,则会换行
inputValue.value += '\n'
}
}
function sendChatMessage(data){
9 months ago
controller.value = new AbortController()
postChatMessage(data,{signal:controller.value.signal}).then(res=>{
if (res.status !== 200){
chatList.value.push({"type":"answer","content":[{"type":"text","content":"服务异常,错误码:"+res.status}],"isEnd":true,"isStop":false,"sessionId":chatList.value[0].sessionId,"sessionName":chatList.value[0].sessionName,"operate":'',"thumbDownReason":''})
}else {
currentFiles.value = []
chatList.value.push({"type":"answer","content":[],"isEnd":false,"isStop":false,"sessionId":chatList.value[0].sessionId,"sessionName":chatList.value[0].sessionName, "operate":'',"thumbDownReason":''})
answerList.value.push({"index":chatList.value.length-1, "content":[], "isEnd":false})
const reader = res.body.getReader()
const write = getWrite(reader)
reader.read().then(write).then(()=> {
let answer = JSON.parse(JSON.stringify(chatList.value[chatList.value.length - 1]))
answer.content = JSON.stringify(answer.content)
addChat(answer)
})
}
10 months ago
}).catch((e) => {
9 months ago
chatList.value.push({"type":"answer","content":[{"type":"text","content":"服务异常"}],"isEnd":true,"isStop":false,"sessionId":chatList.value[0].sessionId,"sessionName":chatList.value[0].sessionName,"operate":"","thumbDownReason":""})
10 months ago
})
}
watch(
chatList,
() => {
9 months ago
nextTick(() => {
// 将滚动条滚动到最下面
scrollDiv.value.setScrollTop(getMaxHeight())
})
},{
deep:true, immediate:true
}
10 months ago
)
const getWrite = (reader) => {
let tempResult = '';
/**
* 处理流式数据
* @param {done, value} obj - 包含 done value 属性的对象
*/
const write_stream = ({ done,value }) => {
try {
if (done) {
return
}
const decoder = new TextDecoder('utf-8');
let str = decoder.decode(value, { stream: true });
// 解释:数据流返回的 chunk 可能不完整,因此我们需要累积并拆分它们。
tempResult += str;
const split = tempResult.match(/data:.*}\n\n/g);
if (split) {
str = split.join('');
tempResult = tempResult.replace(str, '');
} else {
return reader.read().then(write_stream);
}
// 如果 str 存在且以 "data:" 开头,则处理数据块。
if (str && str.startsWith('data:')) {
split.forEach((chunkStr) => {
const chunk = JSON.parse(chunkStr.replace('data:', ''));
if (chunk.docs && chunk.docs[0].length > 0){
//超链接
answerList.value[answerList.value.length - 1].content.push({"content":chunk.docs[0],"type":"docs"})
}else if (chunk.G6_ER && chunk.G6_ER.length > 0){
//说明是ER图
answerList.value[answerList.value.length - 1].content.push({"content":chunk.G6_ER,"type":"G6_ER"})
}else if (chunk.html_image && chunk.html_image.length > 0){
//说明是html的echarts图
answerList.value[answerList.value.length - 1].content.push({"content":chunk.html_image,"type":"html_image"})
}else if (chunk.table && chunk.table.length > 0){
//说明是 表格
answerList.value[answerList.value.length - 1].content.push({"content":chunk.table,"type":"table"})
}else {
// 纯文本
let last_answer = answerList.value[answerList.value.length - 1].content
chunk.choices[0].delta.content = chunk.choices[0].delta.content.replace("\n","\n\n")
if (last_answer.length > 0) {
if(last_answer[last_answer.length - 1].type === 'text'){
answerList.value[answerList.value.length-1].content[last_answer.length - 1].content += chunk.choices[0].delta.content
}else{
answerList.value[answerList.value.length - 1].content.push({"content":chunk.choices[0].delta.content,"type":"text"})
}
}else{
answerList.value[answerList.value.length - 1].content.push({"content":chunk.choices[0].delta.content,"type":"text"})
}
}
9 months ago
let isStop = chatList.value[chatList.value.length-1].isStop
if (!isStop){
chatList.value[chatList.value.length - 1].content = answerList.value[answerList.value.length - 1].content
10 months ago
}
nextTick(() => {
// 将滚动条滚动到最下面
scrollDiv.value.setScrollTop(getMaxHeight())
})
if (chunk.isEnd){
answerList.value[answerList.value.length - 1].isEnd = true
answerList.value[answerList.value.length - 1].time = formatDate(new Date())
9 months ago
if (!isStop){
10 months ago
chatList.value[chatList.value.length - 1].isEnd = true
chatList.value[chatList.value.length - 1].time = formatDate(new Date())
}
nextTick(() => {
// 将滚动条滚动到最下面
scrollDiv.value.setScrollTop(getMaxHeight())
})
return Promise.resolve();
}
});
}
} catch (e) {
return Promise.reject(e);
}
return reader.read().then(write_stream);
};
return write_stream
};
const stopChat = (index) => {
chatList.value[index].isStop = true
9 months ago
if (controller.value !== null){
controller.value.abort()
}
let answer = JSON.parse(JSON.stringify(chatList.value[index]))
answer.content = JSON.stringify(answer.content)
addChat(answer)
10 months ago
}
const startChat = (index) => {
9 months ago
regenerationChart(index)
10 months ago
}
defineExpose({
setScrollBottom
})
</script>
<style lang="scss" scoped>
.ai-chat {
--padding-left: 40px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
position: relative;
color: #1f2329;
&__content {
height: auto;
padding-top: 0;
box-sizing: border-box;
.avatar {
float: left;
}
.content {
padding-left: var(--padding-left);
:deep(ol) {
margin-left: 16px !important;
}
}
.text {
padding: 6px 0;
}
.problem-button {
width: 100%;
border: none;
border-radius: 8px;
background: #f5f6f7;
height: 46px;
padding: 0 12px;
line-height: 46px;
box-sizing: border-box;
color: #1f2329;
-webkit-line-clamp: 1;
word-break: break-all;
&:hover {
background: var(--el-color-primary-light-9);
}
&.disabled {
&:hover {
background: #f5f6f7;
}
}
:deep(.el-icon) {
color: var(--el-color-primary);
}
}
}
&__operate {
background: #f3f7f9;
position: relative;
width: 100%;
box-sizing: border-box;
z-index: 10;
&:before {
background: linear-gradient(0deg, #f3f7f9 0%, rgba(243, 247, 249, 0) 100%);
content: '';
position: absolute;
width: 100%;
top: -16px;
left: 0;
height: 16px;
}
.operate-textarea {
box-shadow: 0px 6px 24px 0px rgba(31, 35, 41, 0.08);
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #ffffff;
box-sizing: border-box;
&:has(.el-textarea__inner:focus) {
border: 1px solid var(--el-color-primary);
scrollbar-width: none;
}
:deep(.el-textarea__inner) {
border-radius: 8px !important;
box-shadow: none;
resize: none;
padding: 12px 16px;
box-sizing: border-box;
scrollbar-width: none;
}
.operate {
padding: 6px 10px;
.el-icon {
font-size: 20px;
}
.sent-button {
max-height: none;
.el-icon {
font-size: 24px;
}
}
:deep(.el-loading-spinner) {
margin-top: -15px;
.circular {
width: 31px;
height: 31px;
}
}
}
}
}
.dialog-card {
border: none;
border-radius: 8px;
box-sizing: border-box;
}
}
.chat-width {
max-width: 80%;
margin: 0 auto;
}
.machineDiv {
cursor: pointer;
padding-top: 3px;
padding-bottom: 3px;
}
.machineDiv:hover {
background-color: #ebf1ff;
font-weight: bold;
}
@media only screen and (max-width: 1000px) {
.chat-width {
max-width: 100% !important;
margin: 0 auto;
}
}
</style>