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.
 
 
 
 

908 lines
26 KiB

<template>
<div style="display: flex;
height: 100vh;">
<Menu
:initial-active="1"
/>
<div class="qwen-chat">
<!-- 聊天消息区域 -->
<div ref="messagesContainer" class="chat-messages">
<div
v-for="(msg, index) in messages"
:key="index"
:class="['message', msg.role]"
>
<div class="time">{{ msg.time }}</div>
<div class="word" v-if="msg.role === 'user'&& msg.hasFile" style="margin-bottom: 6px">
<img src="../assets/word.svg" style="width: 28px;margin-right: 5px; flex-shrink: 0; flex-grow: 0;">
<div style="flex: 1; overflow: auto;">
<div class="wordName" style="text-align: left;margin-bottom: 1px;font-size: 11px;line-height: 14px;">{{msg.file.name}}</div>
<div style=" align-items: center;height: 18px;font-size: 10px; line-height: 12px;display: flex;">{{msg.file.size}}</div>
</div>
<!-- <img-->
<!-- src="../assets/close.svg"-->
<!-- alt="close"-->
<!-- class="close-btn"-->
<!-- @click="removeFile"-->
<!-- />-->
</div>
<div v-if="msg.role === 'user'" style="display: flex;align-items: flex-start; justify-content: flex-end;">
<div class="bubble" v-if="msg.content">
{{ msg.content }}
</div>
</div>
<div v-else-if="msg.role === 'assistant'" style=" display: flex;align-items: flex-start; justify-content: flex-start;">
<img src="../assets/gpt.png" style="width: 45px;height: 45px;margin-right: 15px">
<div v-if="msg.isKG" class="kg-card">
<div v-if="msg.entities?.length" class="kg-section">
<h5 style="text-align: left">识别出的实体</h5>
<div class="entity-list">
<div
v-for="(ent, i) in msg.entities"
:key="i"
class="entity-item"
style="display: flex;margin-bottom: 10px"
>
<!-- 复选框放在外面 -->
<input
type="checkbox"
:id="'entity-' + i"
:value="ent"
v-model="ent.selected"
@change="handleEntitySelectionChange(msg,ent)"
/>
<!-- 使用 for 关联到 input 的 id -->
<label
:for="'entity-' + i"
class="entity-tag"
:class="'tag-' + ent.t"
>
{{ ent.n }}<small>({{ ent.t }})</small>
</label>
</div>
</div>
</div>
<div v-if="msg.relations?.length" class="kg-section">
<h5 style="text-align: left">识别出的关系</h5>
<ul class="relation-list">
<li v-for="(rel, i) in msg.relations" :key="i" class="relation-item">
<!-- 复选框 -->
<input
type="checkbox"
:id="'rel-' + i"
:value="rel"
v-model="rel.selected"
:disabled="!isRelationEnabled(msg,rel)"
/>
<!-- 标签区域(可点击) -->
<label :for="'rel-' + i" class="relation-label">
<span class="rel-subject">{{ rel.e1 }}</span>
<span class="rel-predicate">— {{ rel.r }} →</span>
<span class="rel-object">{{ rel.e2 }}</span>
</label>
</li>
</ul>
</div>
<div v-if="msg.entities?.length || msg.relations?.length" style="text-align: right; display: flex;
justify-content: flex-end;
margin-top: 12px;">
<button class="send-btn" @click="build(msg)" style="border-radius: 10px">
构建
</button>
</div>
<div v-if="!msg.entities?.length && !msg.relations?.length" style="font-size: 12px;
text-align: left;" >
未提取到有效医学实体或关系。</div>
</div>
<div v-else class="bubble assistant-text">
{{ msg.content }}
</div>
</div>
</div>
<div v-if="isSending" style="display: flex;margin-left: 5px;margin-bottom: 10px">
<div class="loading-indicator">
<div class="dot dot-1"></div>
<div class="dot dot-2"></div>
<div class="dot dot-3"></div>
<div class="dot dot-4"></div>
</div>
</div>
</div>
<!-- 输入区域(Qwen 风格) -->
<div class="input-area">
<!-- 下拉图标:绝对定位在 input-area 上方居中 -->
<div class="dropdown-icon" v-if="showScrollToBottom" @click="scrollToLatest">
<img src="../assets/下拉.png" style="width: 45px" />
</div>
<div class="word" v-if="showFile">
<img src="../assets/word.png" style="width: 28px;margin-right: 5px; flex-shrink: 0; flex-grow: 0;">
<div style="flex: 1; overflow: auto;">
<div class="wordName" style="text-align: left;margin-bottom: 1px;font-size: 11px;line-height: 14px;">{{file.fileName}}</div>
<div style=" color:#a2a5a7; align-items: center;height: 18px;font-size: 10px; line-height: 12px;display: flex;">{{file.size}}</div>
</div>
<img
src="../assets/close.svg"
alt="close"
class="close-btn"
@click="removeFile"
/>
</div>
<textarea
v-model="inputText"
placeholder="有什么可以帮助您?"
class="input-box"
@keyup.enter.exact.prevent="sendMessage"
:rows="rows"
></textarea>
<div class="icon-group">
<img
src="../assets/upload.png"
alt="用户头像"
class="avatar"
@click="upload"
style="margin-left: 10px;cursor:pointer;border-radius: 50%;width: 30px;box-shadow: rgb(0 0 0 / 18%) 0px 2px 8px; "
>
<div style="display: flex;align-items: center;">
<img v-if="inputStatus"
src="../assets/放大.png"
alt="用户头像"
class="avatar"
style="margin-left: 10px;cursor:pointer;width: 15px;margin-right: 26px "
@click="adjustHeight(false)"
>
<img v-if="!inputStatus"
src="../assets/缩小.png"
alt="用户头像"
class="avatar"
style="margin-left: 10px;cursor:pointer;width: 15px;margin-right: 26px "
@click="adjustHeight(true)"
>
<button class="send-btn" @click="sendMessage" title="发送" :disabled="isSending">
发送
</button>
<input
ref="fileInput"
type="file"
style="display: none;"
accept=".docx"
@change="handleFileChange"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Menu from "@/components/Menu.vue";
import axios from "axios";
import {analyze, build, getAIStatus} from "@/api/builder";
import {qaAnalyze} from "@/api/qa";
export default {
name: 'QwenChat',
components: {Menu},
data() {
return {
inputText: '',
messages: [
{ role: 'assistant', content: '你好!我是图谱构建助手,有什么可以帮你的吗?',isKG:false,hasFile:false }
],
status:"wait",
rows:3,
inputStatus:true,
fileName:"",
file:{},
showFile:false,
selectedEntities:[],
selectedRelations:[],
showScrollToBottom: false,
isSending: false,
};
},
methods: {
handleEntitySelectionChange(msg, ent) {
// 如果该实体被取消选中
if (!ent.selected) {
// 遍历所有的关系,找到与当前实体相关的关系,将它们的 selected 设置为 false
msg.relations.forEach(rel => {
if (rel.e1 === ent.n || rel.e2 === ent.n) {
rel.selected = false;
}
});
}
console.log(`实体 "${ent.n}" 的选中状态:`, ent.selected);
// 如果需要,你可以在这里做其他处理,比如更新状态等
},
// 为 entities 或 relations 添加 selected 标志
addSelectedFlag(items, isRelation = false) {
if (!items) return [];
return items.map(item => {
if (isRelation) {
return {
...item,
selected: true // 对于关系类型,添加 selected 属性,默认值为 false
};
}
return {
...item,
selected: true // 对于实体类型,添加 selected 属性,默认值为 false
};
});
},
handleScroll() {
const container = this.$refs.messagesContainer;
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 20;
this.showScrollToBottom = !isAtBottom;
},
scrollToBottom(behavior = 'smooth') {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
container.scrollTo({
top: container.scrollHeight,
behavior
});
this.showScrollToBottom = false; // 滚到底后隐藏按钮
});
},
// 当用户点击“下拉”图标时
scrollToLatest() {
this.scrollToBottom('smooth');
},
build(msg) {
// 获取选中的实体和关系
let selectedEntities = msg.entities.filter(ent => ent.selected); // 从 msg 中获取被选中的 entities
let selectedRelations = msg.relations.filter(rel => rel.selected); // 从 msg 中获取被选中的 relations
// 构建数据对象
let data = {
entities: selectedEntities, // 选中的实体
relations: selectedRelations // 选中的关系
};
// 调用后端的 build 方法
build(data);
},
handleRelationSelectionChange(msg, rel) {
// 处理关系的选中状态
console.log(`关系 "${rel.e1}${rel.r}${rel.e2}" 被选择了: `, rel.selected);
},
// 更新实体和关系的选中状态
isRelationEnabled(msg, rel) {
const selectedEntities = msg.entities.filter(ent => ent.selected);
// 确保 e1 和 e2 都在选中的实体中
const hasE1 = selectedEntities.some(ent => ent.n === rel.e1);
const hasE2 = selectedEntities.some(ent => ent.n === rel.e2);
return hasE1 && hasE2;
},
removeFile(){
this.file={}
this.showFile=false
},
upload(){
this.$refs.fileInput.value = '';
this.$refs.fileInput.click(); // 点击按钮时打开文件选择框
},
handleFileChange(event) {
console.log(event)
const file = event.target.files[0]; // 获取第一个选择的文件
console.log(file)
if (file) {
this.fileName = file.name; // 存储文件名
const sizeInBytes = file.size; // 获取文件的字节数
let fileSize;
if (sizeInBytes < 1024 * 1024) {
// 文件小于 1MB,显示为 KB
const sizeInKB = sizeInBytes / 1024; // 将字节转换为 KB
fileSize = `${sizeInKB.toFixed(2)} KB`;
} else {
// 文件大于等于 1MB,显示为 MB
const sizeInMB = sizeInBytes / (1024 * 1024); // 将字节转换为 MB
fileSize = `${sizeInMB.toFixed(2)} MB`;
}
this.file = {
fileName: file.name,
size: fileSize, // 自动加上单位 KB 或 MB
file:file
};
this.showFile = true;
console.log("文件信息:", this.file); // 打印文件信息
}
},
allSelect(msg){
if (msg.entities && msg.entities.length > 0) {
msg.entities.forEach(ent => {
ent.selected=true
this.selectedEntities.push(ent);
});
}
if (msg.relations && msg.relations.length > 0) {
msg.relations.forEach(rel => {
rel.selected=true
this.selectedRelations.push(rel);
});
}
},
adjustHeight(status){
if(status){
this.rows=3
this.inputStatus=true
}else{
this.rows=10
this.inputStatus=false
}
},
pollAIStatus() {
if (this.status !== "wait") return; // 如果已结束,不再继续
try {
const data = getAIStatus();
if (data.status === "ok") {
this.status = "ok";
} else if (data.status === "error") {
this.status = "error";
} else {
setTimeout(() => this.pollAIStatus(), 3000);
}
} catch (error) {
console.error("轮询出错:", error);
this.status = "error";
alert("查询状态失败,请重试");
}
},
getNowDate(){
const currentDate = new Date();
// 获取年份、月份、日期、小时、分钟和秒
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0'); // 月份从0开始,所以加1
const day = String(currentDate.getDate()).padStart(2, '0');
const hours = String(currentDate.getHours()).padStart(2, '0');
const minutes = String(currentDate.getMinutes()).padStart(2, '0');
const seconds = String(currentDate.getSeconds()).padStart(2, '0');
// 格式化时间为 YYYY-MM-DD HH:mm:ss
const formattedTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
return formattedTime;
},
fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// 移除 "data:...;base64," 前缀,只保留纯 base64 字符串
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
async sendMessage() {
this.isSending=true
const text = this.inputText.trim();
const hasFile = this.showFile && this.file.file;
// 如果既没文本也没文件,不发送
if (!text && !hasFile) {
this.isSending = false;
return;
}
let data={
"text":text
}
// this.pollAIStatus()
// 添加用户消息
const userMessage = {
role: 'user',
content: text,
time: this.getNowDate(),
file: hasFile ? { name: this.file.fileName, size: this.file.size,file:this.file.file } : null,
hasFile:hasFile
};
this.messages.push(userMessage);
this.inputText = '';
this.showFile=false
if (hasFile) {
// 📎 有文件:使用 FormData 上传
// 将 File 转为 Base64
const fileBase64 = await this.fileToBase64(this.file.file);
// 构造 JSON payload(不再是 FormData!)
const payload = {
file_base64: fileBase64,
filename: this.file.file.name,
text: text || ''
};
// 调用新的 analyzeJson 接口(注意:不是传 formData)
analyze(payload).then(res => {
let message = {
role: 'assistant',
content: res,
entities: this.addSelectedFlag(res.entities),
relations: this.addSelectedFlag(res.relations, true),
isKG: true,
time: this.getNowDate()
};
this.messages.push(message);
this.scrollToBottom('smooth');
this.isSending = false;
this.file={}
this.allSelect(message)
})
} else {
const payload = {
text: text || ''
};
analyze(payload).then(res => {
let message = {
role: 'assistant',
content: res,
entities: res.entities,
relations: res.relations,
isKG: true,
time: this.getNowDate()
};
this.messages.push(message);
this.scrollToBottom('smooth');
this.isSending = false;
this.file={}
this.allSelect(message)
})
}
this.scrollToBottom('smooth');
},
insertPrefix(prefix) {
this.inputText += prefix;
this.$nextTick(() => {
// 自动聚焦到 textarea 末尾(简单处理)
const el = this.$el.querySelector('.input-box');
el.focus();
});
},
// scrollToBottom() {
// this.$nextTick(() => {
// const container = this.$refs.messagesContainer;
// console.log(container)
// if (container) {
// // 使用 setTimeout 确保所有 DOM 更新完成后再执行滚动
// setTimeout(() => {
// container.scrollTop = container.scrollHeight;
// console.log(container.scrollTop)
// console.log(container.scrollHeight)
// }, 100); // 延迟 100ms
// }
// });
// },
saveMessage(message) {
const messages = JSON.parse(localStorage.getItem('messages')) || [];
messages.push(message); // 将新消息添加到消息数组中
localStorage.setItem('messages', JSON.stringify(messages));
},
loadMessages() {
const savedMessages = JSON.parse(localStorage.getItem('messages')) || [];
console.log(savedMessages)
this.messages = savedMessages; // 加载之前存储的消息
},
handleBeforeUnload() {
localStorage.removeItem('messages');
// 页面刷新或关闭前,保存所有消息
console.log("页面刷新或关闭前,保存消息...");
this.messages.forEach((message) => {
this.saveMessage(message);
});
},
},
watch: {
messages() {
this.scrollToBottom('smooth');
}
},
beforeUnmount() {
const container = this.$refs.messagesContainer;
container.removeEventListener('scroll', this.handleScroll);
},
beforeRouteLeave(to, from, next) {
console.log('【守卫触发】离开 /graph 路由');
// alert('即将离开,保存数据!'); // 临时测试
this.handleBeforeUnload();
next();
},
mounted() {
const container = this.$refs.messagesContainer;
container.addEventListener('scroll', this.handleScroll);
this.loadMessages();
this.scrollToBottom('smooth');
window.addEventListener('beforeunload', this.handleBeforeUnload);
},
};
</script>
<style scoped>
.qwen-chat {
flex: 1; overflow: auto;
display: flex;
flex-direction: column;
height: 100vh;
margin: 0 auto;
border: 1px solid #e0e0e0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #F3F3F3;
}
/* === 消息区域 === */
.chat-messages {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.message {
display: flex;
margin-bottom: 12px;
align-items: flex-start;
flex-direction: column; /* 垂直排列时间和内容 */
}
.message.user {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.bubble {
padding: 16px;
border-radius: 13px;
max-width: 55%;
word-break: break-word;
font-size: 12px;
text-align: left;
}
.message.user .bubble {
background-color: #e9e9e9;
color: #333;
}
.message.assistant .bubble {
background-color: #fff;
color: #333;
}
/* === 输入区域 === */
.input-area {
position: relative;
padding: 16px;
background: white;
border-top-left-radius: 24px;
border-top-right-radius: 24px;
box-shadow: 2px -1px 14px 0px rgb(159 160 161 / 22%);
}
.input-box::placeholder {
color: #AFAFAF; /* 设置字体颜色为红色 */
font-weight: 500;
}
.input-box {
width: 100%;
padding: 12px 16px;
border: none;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
resize: none;
outline: none;
transition: border-color 0.2s ease;
box-sizing: border-box;
}
.input-box:focus {
border-color: #007bff;
}
.action-buttons {
display: flex;
gap: 8px;
margin: 12px 0;
white-space: nowrap;
padding: 0 4px;
}
.btn {
display: flex; /* 使用flex布局 */
justify-content: center; /* 水平居中 */
align-items: center; /* 垂直居中 */
width: 20px; /* 设置按钮的宽度为20px */
height: 20px; /* 设置按钮的高度为20px */
border-radius: 50%; /* 圆形按钮 */
background-color: white; /* 背景色为白色 */
font-size: 18px; /* 设置加号的字体大小 */
line-height: 20px; /* 设置行高,确保加号垂直居中 */
color: #333; /* 加号的颜色 */
font-weight: bold; /* 加号字体加粗 */
cursor: pointer; /* 鼠标悬停时显示为点击效果 */
}
.btn:hover {
background: #e9e9e9;
}
.btn span {
font-size: 12px;
}
.more-btn {
background: transparent;
border: 1px dashed #ccc;
color: #666;
}
.icon-group {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.icon,
.send-btn span {
font-size: 16px;
cursor: pointer;
color: #999;
}
.icon:hover,
.send-btn:hover span {
color: #333;
}
.send-btn {
background: #B9CDFF;
border: none;
width: 60px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: background-color 0.2s;
font-size: 11px;
}
.send-btn:hover {
background: #155DFF;
}
/* === 自定义助手 KG 卡片 === */
.kg-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 16px;
padding: 16px;
width: 100%;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
font-size: 14px;
color: #333;
}
.kg-card h4 {
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
color: #1a73e8;
font-weight: 600;
}
.kg-section {
margin-bottom: 16px;
}
.kg-section h5 {
margin: 0 0 8px 0;
color: #555;
font-size: 14px;
font-weight: 600;
}
/* 实体标签 */
.entity-list {
flex-wrap: wrap;
gap: 8px;
}
.entity-tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 20px;
font-size: 13px;
background: #f0f8ff;
color: #1e88e5;
border: 1px solid #bbdefb;
}
.entity-tag small {
margin-left: 6px;
opacity: 0.8;
font-weight: normal;
}
/* 按类型着色(可扩展) */
/*
.tag-疾病 { background: #ffebee; color: #c62828; border-color: #ffcdd2; }
.tag-症状 { background: #e8f5e9; color: #2e7d32; border-color: #c8e6c9; }
.tag-检查 { background: #fff8e1; color: #ff8f00; border-color: #ffecb3; }
.tag-药物 { background: #f3e5f5; color: #7b1fa2; border-color: #ce93d8; }
*/
/* 关系列表 */
.relation-list {
list-style: none;
padding: 0;
margin: 0;
}
.relation-list li {
padding: 6px 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.relation-list li:last-child {
border-bottom: none;
}
/* 空状态 */
.empty-state {
color: #999;
font-style: italic;
padding: 12px 0;
}
/* 普通助手文本(非 KG) */
.assistant-text {
background-color: #e9ecef;
color: #333;
}
.message.assistant .time{
text-align: left;
color: #A5A5A5;
margin-bottom: 5px;
font-size: 12px;
margin-left: 60px;
}
.message.user .time{
text-align: right;
color: #A5A5A5;
margin-bottom: 5px;
font-size: 12px;
}
.word{
/* border: 1px solid rgba(17,17,51,.1);*/
border-radius: 8px;
justify-content: flex-start;
align-items: center;
width: 16%;
min-width: 149px;
max-width: 206px;
padding: 9px 6px;
display: flex;
margin-left: 10px;
position: relative;
background-color: #e9e9e9;
}
.wordName{
overflow: hidden; /* 隐藏超出部分 */
display: -webkit-box; /* 使用 webkit 的盒子模型 */
-webkit-box-orient: vertical; /* 设置为垂直方向 */
-webkit-line-clamp: 1; /* 限制为两行 */
text-overflow: ellipsis; /* 显示省略号 */
}
.word:hover .close-btn {
display: block; /* 悬浮时显示X按钮 */
}
.close-btn{
position: absolute;
width: 20px; /* 设置适合的图片大小 */
height: 20px;
cursor: pointer;
padding: 5px;
box-sizing: border-box;
top: -8px;
right: -7px;
border: 1px solid rgba(17,17,51,.1);
border-radius: 50%;
background-color: #fff;
display: none;
}
.dropdown-icon {
position: absolute;
top: -45px; /* 图标高度约45px,一半是22.5,所以 -22~ -25 可居中于顶部线上 */
left: 50%;
transform: translateX(-50%);
z-index: 10;
cursor: pointer;
}
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #ccc;
}
/* 加载指示器的整体样式 */
.loading-indicator {
display: inline-flex;
align-items: center;
gap: 10px; /* 圆点之间的间距 */
}
/* 单个圆点的基本样式 */
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
/* 给每个圆点设定特定的灰色 */
.dot-1 { background-color: #e0e0e0; } /* 浅灰色 */
.dot-2 { background-color: #b0b0b0; } /* 中浅灰色 */
.dot-3 { background-color: #808080; } /* 中深灰色 */
.dot-4 { background-color: #505050; } /* 深灰色 */
/* 给每个圆点设置不同的动画延迟以创造连续的效果 */
.dot:nth-child(1) { animation-delay: 0.1s; }
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.3s; }
.dot:nth-child(4) { animation-delay: 0.4s; }
.dot-1 { animation-delay: -0.4s; }
.dot-2 { animation-delay: -0.2s; }
.dot-3 { animation-delay: 0s; }
.dot-4 { animation-delay: 0.2s; }
/* 定义动画效果 */
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
} 40% {
transform: scale(1.0);
}
}
</style>