|
|
|
<template>
|
|
|
|
<div class="chat-container">
|
|
|
|
<div class="chat-layout">
|
|
|
|
<!-- 聊天消息区域 -->
|
|
|
|
<div class="chat-messages" ref="messagesContainer">
|
|
|
|
<div v-for="(message, index) in messages" :key="index" :class="['message', message.type]">
|
|
|
|
<div class="message-content">
|
|
|
|
<div class="message-text">{{ message.text }}</div>
|
|
|
|
<div class="message-text" v-html="message.text"></div>
|
|
|
|
<div v-if="message.type === 'assistant' && message.similarity" class="message-meta">
|
|
|
|
相似度: {{ (message.similarity * 100).toFixed(2) }}%
|
|
|
|
<a v-if="message.fileBase64" @click="showPreview(message)">预览文件</a>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
...
|
...
|
@@ -17,6 +19,30 @@ |
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- 文件预览区域 - 右侧 -->
|
|
|
|
<div v-if="previewContent" class="file-preview">
|
|
|
|
<div class="preview-header">
|
|
|
|
<span>文件预览</span>
|
|
|
|
<a-button type="text" @click="closePreview">
|
|
|
|
<template #icon><CloseOutlined /></template>
|
|
|
|
</a-button>
|
|
|
|
</div>
|
|
|
|
<div class="preview-content">
|
|
|
|
<div v-if="previewType === 'txt' || previewType === 'md'" class="text-preview">
|
|
|
|
<pre>{{ previewContent }}</pre>
|
|
|
|
</div>
|
|
|
|
<div v-else-if="previewType === 'doc' || previewType === 'docx'" class="doc-preview">
|
|
|
|
<!-- 使用Blob URL直接预览 -->
|
|
|
|
<iframe
|
|
|
|
:src="previewUrl"
|
|
|
|
frameborder="0"
|
|
|
|
style="width:100%; height:100%;"
|
|
|
|
></iframe>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<!-- 输入区域 -->
|
|
|
|
<div class="chat-input">
|
|
|
|
<a-input
|
|
...
|
...
|
@@ -39,9 +65,9 @@ |
|
|
|
|
|
|
|
<script lang="ts" name="zdy-rag-chat" setup>
|
|
|
|
import { ref, reactive, nextTick } from 'vue';
|
|
|
|
import { list, sendMessage as apiSendMessage } from './ZdyRag.api';
|
|
|
|
import { BasicTable, useTable } from '/@/components/Table';
|
|
|
|
import { sendMessage as apiSendMessage } from './ZdyRag.api';
|
|
|
|
import { useMessage } from '/@/hooks/web/useMessage';
|
|
|
|
import { CloseOutlined } from '@ant-design/icons-vue';
|
|
|
|
|
|
|
|
const { createMessage } = useMessage();
|
|
|
|
|
|
...
|
...
|
@@ -50,13 +76,17 @@ const messages = reactive([ |
|
|
|
{
|
|
|
|
type: 'assistant',
|
|
|
|
text: '您好!我是智能助手,请问有什么可以帮您?',
|
|
|
|
similarity: null
|
|
|
|
similarity: null,
|
|
|
|
fileBase64: null
|
|
|
|
}
|
|
|
|
]);
|
|
|
|
|
|
|
|
const inputMessage = ref('');
|
|
|
|
const loading = ref(false);
|
|
|
|
const messagesContainer = ref<HTMLElement>();
|
|
|
|
const previewContent = ref('');
|
|
|
|
const previewType = ref('');
|
|
|
|
const previewUrl = ref('');
|
|
|
|
|
|
|
|
// 发送消息
|
|
|
|
const sendMessage = async () => {
|
|
...
|
...
|
@@ -67,34 +97,102 @@ const sendMessage = async () => { |
|
|
|
messages.push({
|
|
|
|
type: 'user',
|
|
|
|
text: text,
|
|
|
|
similarity: null
|
|
|
|
similarity: null,
|
|
|
|
fileBase64: null
|
|
|
|
});
|
|
|
|
|
|
|
|
inputMessage.value = '';
|
|
|
|
loading.value = true;
|
|
|
|
closePreview();
|
|
|
|
|
|
|
|
try {
|
|
|
|
// 调用API发送消息
|
|
|
|
const res = await apiSendMessage({ questionText: text });
|
|
|
|
console.log("res....",res)
|
|
|
|
if (res?.answer) {
|
|
|
|
messages.push({
|
|
|
|
console.log('API响应:', res);
|
|
|
|
|
|
|
|
if (res) {
|
|
|
|
const newMessage = {
|
|
|
|
type: 'assistant',
|
|
|
|
text: res.answer,
|
|
|
|
similarity: res.similarity || null
|
|
|
|
});
|
|
|
|
text: formatAnswer(res.answer),
|
|
|
|
similarity: res.similarity || null,
|
|
|
|
fileBase64: res.fileBase64 || null,
|
|
|
|
fileName: res.fileName || ''
|
|
|
|
};
|
|
|
|
messages.push(newMessage);
|
|
|
|
|
|
|
|
// 如果有文件内容,自动显示预览
|
|
|
|
if (res.fileBase64 && res.fileName) {
|
|
|
|
showPreview(newMessage);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
createMessage.error('获取回答失败');
|
|
|
|
createMessage.error('获取回答失败: 返回数据为空');
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error('发送消息失败:', error);
|
|
|
|
createMessage.error('发送消息失败');
|
|
|
|
createMessage.error('发送消息失败: ' + (error as Error).message);
|
|
|
|
} finally {
|
|
|
|
loading.value = false;
|
|
|
|
scrollToBottom();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// 显示文件预览
|
|
|
|
const showPreview = (message) => {
|
|
|
|
if (!message.fileBase64 || !message.fileName) {
|
|
|
|
console.warn('无法预览: 缺少fileBase64或fileName');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
const fileExt = message.fileName.split('.').pop()?.toLowerCase() || '';
|
|
|
|
previewType.value = fileExt;
|
|
|
|
|
|
|
|
if (fileExt === 'txt' || fileExt === 'md') {
|
|
|
|
// 对于文本文件,直接解码显示
|
|
|
|
previewContent.value = atob(message.fileBase64);
|
|
|
|
} else if (fileExt === 'doc' || fileExt === 'docx') {
|
|
|
|
// 对于Word文档,创建Blob对象并生成URL
|
|
|
|
const byteCharacters = atob(message.fileBase64);
|
|
|
|
const byteNumbers = new Array(byteCharacters.length);
|
|
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
|
|
}
|
|
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
|
|
const blob = new Blob([byteArray], {
|
|
|
|
type: fileExt === 'docx' ?
|
|
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' :
|
|
|
|
'application/msword'
|
|
|
|
});
|
|
|
|
|
|
|
|
// 直接使用Blob URL
|
|
|
|
previewUrl.value = URL.createObjectURL(blob);
|
|
|
|
previewContent.value = 'doc-preview';
|
|
|
|
} else {
|
|
|
|
console.warn('不支持的文件类型:', fileExt);
|
|
|
|
createMessage.warning(`不支持预览 ${fileExt} 格式的文件`);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error('文件预览失败:', error);
|
|
|
|
createMessage.error('文件预览失败');
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// 关闭预览
|
|
|
|
const closePreview = () => {
|
|
|
|
previewContent.value = '';
|
|
|
|
previewType.value = '';
|
|
|
|
if (previewUrl.value) {
|
|
|
|
URL.revokeObjectURL(previewUrl.value);
|
|
|
|
previewUrl.value = '';
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// 格式化回答内容
|
|
|
|
const formatAnswer = (answer: string) => {
|
|
|
|
// 替换[n]为换行符,然后将换行符转换为<br>标签
|
|
|
|
return answer.replace(/\[n\]/g, '\n').replace(/\n/g, '<br>');
|
|
|
|
};
|
|
|
|
|
|
|
|
// 滚动到底部
|
|
|
|
const scrollToBottom = () => {
|
|
|
|
nextTick(() => {
|
|
...
|
...
|
@@ -110,23 +208,71 @@ const scrollToBottom = () => { |
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
height: calc(100vh - 120px);
|
|
|
|
max-width: 800px;
|
|
|
|
max-width: 1200px; /* 增加宽度以容纳两个区域 */
|
|
|
|
margin: 0 auto;
|
|
|
|
padding: 20px;
|
|
|
|
background-color: #f5f5f5;
|
|
|
|
border-radius: 8px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-messages {
|
|
|
|
.chat-layout {
|
|
|
|
display: flex;
|
|
|
|
flex: 1;
|
|
|
|
gap: 20px;
|
|
|
|
overflow: hidden;
|
|
|
|
margin-bottom: 20px;
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-messages {
|
|
|
|
flex: 2;
|
|
|
|
overflow-y: auto;
|
|
|
|
padding: 10px;
|
|
|
|
margin-bottom: 20px;
|
|
|
|
background-color: white;
|
|
|
|
border-radius: 8px;
|
|
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
|
|
}
|
|
|
|
|
|
|
|
.file-preview {
|
|
|
|
flex: 1;
|
|
|
|
min-width: 350px; /* 确保预览区域有最小宽度 */
|
|
|
|
background-color: white;
|
|
|
|
border-radius: 8px;
|
|
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
|
|
overflow: hidden;
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
.preview-header {
|
|
|
|
display: flex;
|
|
|
|
justify-content: space-between;
|
|
|
|
align-items: center;
|
|
|
|
padding: 10px 15px;
|
|
|
|
background-color: #f0f0f0;
|
|
|
|
border-bottom: 1px solid #e8e8e8;
|
|
|
|
}
|
|
|
|
|
|
|
|
.preview-content {
|
|
|
|
flex: 1;
|
|
|
|
overflow: auto;
|
|
|
|
|
|
|
|
.text-preview {
|
|
|
|
padding: 15px;
|
|
|
|
white-space: pre-wrap;
|
|
|
|
font-family: monospace;
|
|
|
|
}
|
|
|
|
|
|
|
|
.doc-preview {
|
|
|
|
height: 100%;
|
|
|
|
|
|
|
|
iframe {
|
|
|
|
width: 100%;
|
|
|
|
height: 100%;
|
|
|
|
border: none;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.message {
|
|
|
|
margin-bottom: 15px;
|
|
|
|
display: flex;
|
|
...
|
...
|
@@ -163,6 +309,16 @@ const scrollToBottom = () => { |
|
|
|
color: #666;
|
|
|
|
margin-top: 5px;
|
|
|
|
text-align: right;
|
|
|
|
|
|
|
|
a {
|
|
|
|
margin-left: 10px;
|
|
|
|
color: #1890ff;
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
text-decoration: underline;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.chat-input {
|
...
|
...
|
|