Files
gangyan/chat_web_front/src/components/ReadingCreate.vue

671 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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;
//如果是第一次创建对话先获取对话的idchatNumber
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 chatIdpromt是对话
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>