671 lines
18 KiB
Vue
671 lines
18 KiB
Vue
<template>
|
||
<div style="height: 100%">
|
||
<!-- 对话内容-->
|
||
<div class="message-content" ref="messageContent">
|
||
<ReadingCreateMessage id="readingMessageContent" v-for="(item, index) in chatInfo" :key="index" :chatInfo="item" :index="index"
|
||
@removeIndex="removeIndex(index)" @refresh="refresh"/>
|
||
</div>
|
||
|
||
<!-- 文字窗口-->
|
||
<div>
|
||
<div class="tool-bar">
|
||
<div class="label"></div>
|
||
<div class="clean" @click="cleanChat">
|
||
<img src="../assets/images/writing/brush.png">
|
||
<div>清除对话</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="search-scope" v-if="selectedFile">
|
||
<div class="scope-label">检索范围:</div>
|
||
<div class="scope-option" :class="{ active: searchScope === 'file' }" @click="searchScope = 'file'"
|
||
:title="'仅在「' + selectedFile.fileName + '」中检索'">
|
||
<span class="scope-icon">📄</span>
|
||
<span class="scope-name">{{ selectedFile.fileName }}</span>
|
||
</div>
|
||
<div class="scope-option" :class="{ active: searchScope === 'kb' }" @click="searchScope = 'kb'"
|
||
:title="'在「' + selectedFile.folderName + '」知识库的所有文件中检索'">
|
||
<span class="scope-icon">📁</span>
|
||
<span class="scope-name">{{ selectedFile.folderName }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-box">
|
||
<div class="quote-box" v-if="quoteMsg">
|
||
<div class="vertical-line"></div>
|
||
<div class="quote-info" :title="quoteMsg">{{ quoteMsg }}</div>
|
||
<div class="quote-close" @click="clearQuote"><img src="@/assets/images/close.png" alt=""></div>
|
||
</div>
|
||
<textarea
|
||
v-model.trim="textarea"
|
||
class="box-textarea"
|
||
:class="quoteMsg ? 'quote-textarea' : ''"
|
||
type="textarea"
|
||
maxlength="1000"
|
||
@input="handleInput"
|
||
@keydown.enter="keyDown"
|
||
placeholder="请输入你想提的问题,字数不能超过1000字"/>
|
||
<div class="text-box-bottom">
|
||
<div class="web-search-toggle" :class="{ active: webSearchEnabled }" @click="webSearchEnabled = !webSearchEnabled"
|
||
:title="webSearchEnabled ? '联网搜索已开启,点击关闭' : '开启联网搜索,从互联网获取最新信息'">
|
||
<span class="ws-icon">🌐</span>
|
||
<span class="ws-text">联网搜索</span>
|
||
</div>
|
||
<div class="send-btn">
|
||
<img v-if="textarea&&!sendStatus" style="width: 38px" src="../assets/images/writing/send-blue.png" @click="send('','0')">
|
||
<img v-if="!textarea&&!sendStatus" src="../assets/images/writing/send-gray.png">
|
||
<img v-if="sendStatus" src="../assets/images/chat/stopChat.png" @click="handleStop">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bottom">以上内容均由AI生成, 仅供参考和借鉴。</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang='ts'>
|
||
import {inject, onMounted, reactive, ref, watch, type Ref} from "vue";
|
||
import {
|
||
deleteChatMessageById,
|
||
delHistoryChatById,
|
||
fetchChatAPI,
|
||
fetchConversationId,
|
||
interrupt,
|
||
listChatMessage,
|
||
listFileChatHistory
|
||
} from "@/api";
|
||
import {generateAuthInfo} from "@/utils/request/headerUtils";
|
||
import ReadingCreateMessage from "@/components/ReadingCreateMessage.vue";
|
||
import {withLoading} from "@/utils/loading";
|
||
import {ElMessage} from "element-plus";
|
||
import {debounce} from "@/utils";
|
||
|
||
const selectedFile = inject('selectedFile') as Ref<any>;
|
||
|
||
// 引用内容
|
||
const quoteMsg = inject("quoteMsg");
|
||
const updateQuoteMsg = inject("updateQuoteMsg");
|
||
/**
|
||
* 清除引用信息
|
||
*/
|
||
const clearQuote = () => {
|
||
updateQuoteMsg('');
|
||
}
|
||
|
||
//const title = inject('aiboxTitle');
|
||
const searchScope = ref<'file' | 'kb'>('file');
|
||
const webSearchEnabled = ref(false);
|
||
const textarea = ref("");
|
||
const firstChat = ref(true);
|
||
const sendStatus = ref(false);
|
||
const chatStatus = ref(false);
|
||
const chatNumber = ref(0);
|
||
const chatId = ref('');
|
||
const conversationId = ref('');
|
||
const chatInfo = reactive([]);
|
||
let controller = null;
|
||
const baseURL = import.meta.env.VITE_GLOB_API_CTX;
|
||
|
||
const cleanChat = async () => {
|
||
if(chatId.value){
|
||
try {
|
||
withLoading(await delHistoryChatById(chatId.value));
|
||
chatInfo.length = 0;
|
||
chatId.value = '';
|
||
firstChat.value = true;
|
||
} catch (error: any) {
|
||
ElMessage.error(error && error.message ? error.message : '未知错误');
|
||
}
|
||
}
|
||
}
|
||
|
||
const removeIndex = async (index) => {
|
||
try {
|
||
await deleteChatMessageById(chatInfo[index].user.conversationId);
|
||
chatInfo.splice(index, 1);
|
||
} catch (error: any) {
|
||
ElMessage.error(error && error.message ? error.message : '未知错误');
|
||
}
|
||
};
|
||
|
||
const readingBoxSend = (quote: string, type: string) => {
|
||
send(quote, type);
|
||
}
|
||
defineExpose({
|
||
readingBoxSend
|
||
})
|
||
|
||
let send = async (quote: string, type: string) => {
|
||
if (quote && quote.trim()) {
|
||
textarea.value = quote;
|
||
}
|
||
if (textarea.value === "" || sendStatus.value) {
|
||
return;
|
||
}
|
||
|
||
sendStatus.value = true;
|
||
|
||
//如果是第一次创建对话,先获取对话的id,chatNumber
|
||
if (firstChat.value) {
|
||
await getFetchChatAPI(textarea.value);
|
||
}
|
||
//获取conversationId
|
||
await getFetchConversationId(textarea.value);
|
||
chatStatus.value = true;
|
||
|
||
//流式会话
|
||
await getFetchChatAPIProcess(type);
|
||
firstChat.value ? (firstChat.value = false) : "";
|
||
sendStatus.value = false;
|
||
};
|
||
|
||
// 获取对话 chatNumber chatId,promt是对话
|
||
const getFetchChatAPI = async (prompt) => {
|
||
const chatData = {
|
||
prompt,
|
||
type: 99,
|
||
fileId: selectedFile.value?.fileId
|
||
};
|
||
try {
|
||
const fetchChatAPIData = await fetchChatAPI(chatData);
|
||
chatNumber.value = fetchChatAPIData.data.chatNumber;
|
||
chatId.value = fetchChatAPIData.data.id;
|
||
} catch (error: any) {
|
||
ElMessage.error(error && error.message ? error.message : '未知错误');
|
||
}
|
||
};
|
||
|
||
//根据所写的问题获取conversationId
|
||
const getFetchConversationId = async (prompt) => {
|
||
const messageData = {
|
||
chatNumber: chatNumber.value,
|
||
prompt,
|
||
model: "QIANWEN",
|
||
promptName: "default",
|
||
promptTitle: "default",
|
||
promptPrompt: '请输入你想提的问题,字数不能超过1000字',
|
||
chatId: chatId.value,
|
||
};
|
||
try {
|
||
const fetchConversationIdData = await fetchConversationId(messageData);
|
||
conversationId.value = fetchConversationIdData.data;
|
||
} catch (error: any) {
|
||
ElMessage.error(error && error.message ? error.message : '未知错误');
|
||
}
|
||
};
|
||
|
||
// 对话,流式对话
|
||
const getFetchChatAPIProcess = async (type: string) => {
|
||
const info = reactive({
|
||
user: {content: textarea.value, conversationId: "", quote: quoteMsg.value},
|
||
ai: {content: "", question: [], contentStatus: false},
|
||
});
|
||
textarea.value = "";
|
||
chatInfo.push(info);
|
||
autoScroll();
|
||
|
||
let aiQuestion = ``;
|
||
if (!controller) {
|
||
controller = new AbortController();
|
||
}
|
||
let headers = generateAuthInfo();
|
||
headers["Content-Type"] = "application/json";
|
||
const response = await fetch(
|
||
`${baseURL}/knowledgeChat/chatSelf?conversationId=${conversationId.value}&promptName=default?type=` + type,
|
||
{
|
||
method: "POST",
|
||
headers: headers,
|
||
signal: controller.signal,
|
||
body: JSON.stringify({
|
||
fileNames: searchScope.value === 'file' ? [selectedFile.value?.embeddingId] : [],
|
||
conversationId: conversationId.value,
|
||
promptName: "default",
|
||
knowledgeBaseIdList: [selectedFile.value?.folderId],
|
||
chatType: type,
|
||
quote: quoteMsg.value,
|
||
webSearch: webSearchEnabled.value
|
||
}),
|
||
}
|
||
);
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let done = false;
|
||
let result = "";
|
||
let picTemp = false;
|
||
// 逐块读取流数据
|
||
while (!done) {
|
||
const {value, done: doneReading} = await reader.read();
|
||
done = doneReading;
|
||
result += decoder.decode(value, {stream: true});
|
||
|
||
// 分割数据并逐条展示
|
||
const lines = result.split("\n");
|
||
for (let i = 0; i < lines.length - 1; i++) {
|
||
let lineObj = JSON.parse(lines[i]);
|
||
if (
|
||
!info.user.conversationId ||
|
||
info.user.conversationId != lineObj.conversationId
|
||
) {
|
||
info.user.conversationId = lineObj.conversationId;
|
||
}
|
||
if (lineObj.text) {
|
||
let index = info.ai.content.indexOf("思考中...");
|
||
if (index != -1) {
|
||
let text = info.ai.content.substring(0, index);
|
||
info.ai.content = text;
|
||
}
|
||
if (info.ai.content.indexOf(".png)") != -1 && info.ai.content.indexOf("http") != -1) {
|
||
info.ai.content = info.ai.content.replace("(http", "http");
|
||
info.ai.content = info.ai.content.replace(".png)", ".png");
|
||
|
||
picTemp = true;
|
||
}
|
||
info.ai.content += lineObj.text;
|
||
|
||
} else if (lineObj.docs) {
|
||
info.ai.content += `\n**参考文献:**`;
|
||
for(let i=0;i<lineObj.docs.length;i++){
|
||
lineObj.docs[i]=lineObj.docs[i].replaceAll(/\(\.files.*?\)/g, "");
|
||
lineObj.docs[i]=lineObj.docs[i].replaceAll(/\(\.\/files.*?\)/g, "");
|
||
}
|
||
if(lineObj.docs.length>0){
|
||
let lineObjDocs=lineObj.docs.join("\n\n\n");
|
||
info.ai.content += '<div class="docs">'+lineObjDocs+'</div>';
|
||
}else{
|
||
info.ai.content += `<div class="docs">${lineObj.docs}</div>`;
|
||
}
|
||
}
|
||
}
|
||
// 保留最后一块数据,等待下一次读取
|
||
result = lines[lines.length - 1];
|
||
// 自动滚动到最新位置
|
||
autoPositionToBottom();
|
||
}
|
||
};
|
||
const refresh=async (content:string )=>{
|
||
//获取conversationId
|
||
await getFetchConversationId(content);
|
||
sendStatus.value = true;
|
||
chatStatus.value = true;
|
||
|
||
textarea.value=content;
|
||
//流式会话
|
||
await getFetchChatAPIProcess('0');
|
||
firstChat.value ? (firstChat.value = false) : "";
|
||
sendStatus.value = false;
|
||
}
|
||
|
||
const messageContent=ref(null);
|
||
|
||
const autoScroll=()=>{
|
||
if(messageContent.value){
|
||
setTimeout(()=>{
|
||
messageContent.value.scrollTop = messageContent.value.scrollHeight;
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 监听滚动条
|
||
*/
|
||
// 标志位:用户是否手动滚动
|
||
const isUserScrolled = ref(false);
|
||
const handleReadingMessageContentScroll = () => {
|
||
const element = document.getElementById("readingMessageContent");
|
||
if (!element) return;
|
||
|
||
element.addEventListener("scroll", () => {
|
||
const scrollTop = element.scrollTop;
|
||
const scrollHeight = element.scrollHeight;
|
||
const clientHeight = element.clientHeight;
|
||
|
||
if (scrollTop + clientHeight < scrollHeight - 55) {
|
||
isUserScrolled.value = true; // 用户手动滚动
|
||
} else {
|
||
isUserScrolled.value = false; // 用户滚动到最下方
|
||
}
|
||
});
|
||
}
|
||
// 滚动条自定位
|
||
const autoPositionToBottom = () => {
|
||
const element = document.getElementById("readingMessageContent");
|
||
if (!element || isUserScrolled.value) return;
|
||
|
||
element.scrollTop = element.scrollHeight + 55;
|
||
}
|
||
|
||
const loadChatHistory = async () => {
|
||
// 重置对话状态
|
||
chatInfo.length = 0;
|
||
chatId.value = '';
|
||
firstChat.value = true;
|
||
chatNumber.value = 0;
|
||
conversationId.value = '';
|
||
|
||
if (!selectedFile.value?.fileId) return;
|
||
|
||
try {
|
||
let hisResponse = await listFileChatHistory(selectedFile.value.fileId);
|
||
if (hisResponse.code == 200 && hisResponse.data) {
|
||
chatNumber.value = hisResponse.data;
|
||
firstChat.value = false;
|
||
let historyList = await listChatMessage({
|
||
chatNumber: chatNumber.value,
|
||
fileType: 1
|
||
});
|
||
let chatInfoBack = historyList.data;
|
||
chatId.value = chatInfoBack[0].chatId;
|
||
for (let i = 0; i < chatInfoBack.length;) {
|
||
let index = chatInfoBack[i + 1].content.indexOf("**参考文献:**");
|
||
let aiStrDoc = "";
|
||
let aisStr = "";
|
||
if (
|
||
chatInfoBack[i + 1].content &&
|
||
chatInfoBack[i + 1].content.indexOf("**参考文献:**") != -1
|
||
) {
|
||
aiStrDoc = chatInfoBack[i + 1].content.substring(index + 9);
|
||
aiStrDoc=aiStrDoc.replaceAll(/\(\.files.*?\)/g, "");
|
||
aiStrDoc=aiStrDoc.replaceAll(/\(\.\/files.*?\)/g, "");
|
||
|
||
aisStr =
|
||
chatInfoBack[i + 1].content.substring(0, index + 9) +
|
||
`<div class="docs">\n${aiStrDoc}</div>`;
|
||
} else {
|
||
aisStr = chatInfoBack[i + 1].content;
|
||
}
|
||
let info = {
|
||
user: {
|
||
content: chatInfoBack[i].content,
|
||
conversationId: chatInfoBack[i].messageId,
|
||
},
|
||
ai: {content: aisStr},
|
||
};
|
||
chatInfo.push(info);
|
||
i = i + 2;
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
// 新文件没有历史记录,不报错
|
||
}
|
||
};
|
||
|
||
// 监听文件切换,重新加载对话历史
|
||
watch(() => selectedFile.value?.fileId, () => {
|
||
searchScope.value = 'file';
|
||
loadChatHistory();
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await loadChatHistory();
|
||
handleReadingMessageContentScroll();
|
||
})
|
||
let messageInstance = null;
|
||
|
||
// 自定义的错误消息显示函数
|
||
const customErrorMessage = (options) => {
|
||
if (messageInstance) {
|
||
messageInstance.close(); // 如果已有消息实例,则关闭它
|
||
}
|
||
messageInstance = ElMessage.error(options); // 创建新的错误消息实例
|
||
};
|
||
|
||
const handleInput = (event) => {
|
||
if (textarea.value.length >= 1000 ) {
|
||
// 显示警告框
|
||
customErrorMessage('您已达到最大字数限制,无法继续输入更多内容。');
|
||
|
||
// 强制设置textarea的内容为截断后的值
|
||
textarea.value = textarea.value.substring(0, 1000);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 输入框的回车事件
|
||
* @param e
|
||
*/
|
||
const keyDown = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
if (!sendStatus.value) {
|
||
debounce(() => {
|
||
send('','0')
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleStop = async () => {
|
||
controller.abort();
|
||
controller = null;
|
||
let messageData = {
|
||
messageId: conversationId.value
|
||
};
|
||
await interrupt(messageData);
|
||
if (
|
||
chatInfo[chatInfo.length - 1].ai.content == "" ||
|
||
chatInfo[chatInfo.length - 1].ai.content == "思考中..."
|
||
) {
|
||
chatInfo[chatInfo.length - 1].ai.content = "已停止响应";
|
||
}
|
||
|
||
sendStatus.value = false;
|
||
};
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.message-content {
|
||
height: calc(100% - 320px);
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 0;
|
||
}
|
||
}
|
||
|
||
.background-color {
|
||
width: 100%;
|
||
height: 30px;
|
||
margin-left: 20px;
|
||
background: linear-gradient(360deg, #C0D4FD 0%, rgba(199, 219, 255, 0) 100%);
|
||
border-radius: 0px 0px 0px 0px;
|
||
position: fixed;
|
||
bottom: 300px;
|
||
}
|
||
|
||
.tool-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
padding-top: 8px;
|
||
height: 40px;
|
||
.label {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
margin-left: 20px;
|
||
|
||
img {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
div {
|
||
font-family: PingFang SC, PingFang SC;
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
color: #000000;
|
||
line-height: 16px;
|
||
margin-left: 4px;
|
||
}
|
||
}
|
||
|
||
.clean {
|
||
width: 84px;
|
||
height: 24px;
|
||
background: #004EA0;
|
||
border-radius: 4px 4px 4px 4px;
|
||
display: flex;
|
||
justify-content: space-around;
|
||
cursor: pointer !important;
|
||
margin-right: 20px;
|
||
|
||
img {
|
||
width: 12px;
|
||
height: 12px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
div {
|
||
font-family: Microsoft YaHei, Microsoft YaHei;
|
||
font-weight: 400;
|
||
font-size: 12px;
|
||
color: #FAFBFF;
|
||
line-height: 24px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.search-scope {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 4px 12px;
|
||
gap: 6px;
|
||
|
||
.scope-label {
|
||
font-size: 13px;
|
||
color: #333;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.scope-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 6px 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid #E0E0E0;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: #666;
|
||
transition: all 0.2s;
|
||
max-width: 45%;
|
||
overflow: hidden;
|
||
|
||
&:hover { border-color: #004EA0; color: #004EA0; }
|
||
&.active { border-color: #004EA0; color: #fff; background: #004EA0; }
|
||
|
||
.scope-icon { font-size: 14px; flex-shrink: 0; }
|
||
.scope-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
}
|
||
}
|
||
|
||
.text-box {
|
||
height: 190px;
|
||
background: #FFFFFF;
|
||
border-radius: 8px;
|
||
border: 1px solid #D5DDFF;
|
||
margin: 12px 20px 12px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.box-textarea {
|
||
outline: none;
|
||
border: none;
|
||
resize: none;
|
||
width: 100%;
|
||
flex: 1;
|
||
padding: 16px;
|
||
line-height: 24px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.text-box-bottom {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 12px;
|
||
|
||
.web-search-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 6px 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid #E0E0E0;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: #999;
|
||
transition: all 0.2s;
|
||
user-select: none;
|
||
|
||
&:hover { border-color: #10a37f; color: #10a37f; }
|
||
&.active { border-color: #10a37f; color: #fff; background: #10a37f; }
|
||
|
||
.ws-icon { font-size: 14px; }
|
||
.ws-text { font-size: 13px; }
|
||
}
|
||
|
||
.send-btn {
|
||
img { width: 38px; cursor: pointer; }
|
||
}
|
||
}
|
||
|
||
img {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.quote-box {
|
||
display: flex;
|
||
padding: 16px;
|
||
align-items: center;
|
||
|
||
.vertical-line {
|
||
width: 2px;
|
||
height: 16px;
|
||
background: #004EA0;
|
||
border-radius: 1px 1px 1px 1px;
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.quote-info {
|
||
width: calc(100% - 14px);
|
||
height: 20px;
|
||
font-size: 14px;
|
||
color: #004EA0;
|
||
line-height: 20px;
|
||
margin-left: 12px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.quote-close {
|
||
height: 16px;
|
||
|
||
img {
|
||
width: 14px;
|
||
margin: auto;
|
||
float: none;
|
||
cursor: pointer;
|
||
}
|
||
}
|
||
}
|
||
|
||
.quote-textarea {
|
||
padding: 0 16px 16px 16px;
|
||
height: calc(100% - 106px);
|
||
}
|
||
}
|
||
|
||
.bottom {
|
||
font-family: PingFang SC, PingFang SC;
|
||
font-weight: 400;
|
||
font-size: 14px;
|
||
color: #858A94;
|
||
line-height: 30px;
|
||
float: right;
|
||
margin-right: 16px;
|
||
}
|
||
</style> |