960 lines
54 KiB
HTML
Executable File
960 lines
54 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Gene Family MAS - 基因家族分析系统</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.css">
|
||
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.full.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||
<script src="https://unpkg.com/neovis.js@2.1.0/dist/neovis.js"></script>
|
||
<style>
|
||
body { margin: 0; font-family: 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; }
|
||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; }
|
||
.header h1 { margin: 0; font-size: 22px; }
|
||
.main-container { display: flex; padding: 15px; gap: 15px; min-height: calc(100vh - 80px); }
|
||
.left-panel { width: 280px; display: flex; flex-direction: column; gap: 10px; }
|
||
.btn-section { background: white; border-radius: 8px; padding: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||
.btn-section h4 { margin: 0 0 10px 0; color: #409EFF; font-size: 14px; }
|
||
.work-dir { background: #f0f0f0; border-radius: 4px; padding: 8px; font-size: 11px; color: #666; word-break: break-all; }
|
||
.work-dir-label { font-weight: bold; color: #409EFF; }
|
||
.work-dir .dir-item { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
|
||
.work-dir .dir-path { flex: 1; word-break: break-all; }
|
||
.middle-panel { flex: 1; display: flex; flex-direction: column; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 15px; min-width: 400px; }
|
||
.chat-area { flex: 1; overflow-y: auto; margin-bottom: 10px; border: 1px solid #e4e7ed; border-radius: 4px; padding: 10px; min-height: 200px; }
|
||
.chat-msg { margin-bottom: 12px; padding: 10px 14px; border-radius: 8px; max-width: 85%; }
|
||
.chat-msg.user { background: #ecf5ff; margin-left: auto; }
|
||
.chat-msg.assistant { background: #f0f9eb; }
|
||
.chat-msg .time { font-size: 11px; color: #999; margin-top: 5px; }
|
||
.right-panel { width: 420px; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 15px; overflow-y: auto; }
|
||
.right-panel h4 { margin: 0 0 10px 0; color: #67C23A; border-bottom: 2px solid #67C23A; padding-bottom: 8px; }
|
||
.status.pending { background: #909399; }
|
||
.status.running { background: #E6A23C; animation: pulse 1s infinite; }
|
||
.status.success { background: #67C23A; }
|
||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||
.file-list { font-size: 12px; }
|
||
.file-item { padding: 4px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
|
||
.file-item:hover { background: #f5f7fa; cursor: pointer; }
|
||
.preview-section { border: 1px solid #eee; border-radius: 6px; display: flex; flex-direction: column; min-height: 300px; margin-top: 10px; }
|
||
.preview-section .preview-header { padding: 10px 12px; border-bottom: 1px solid #eee; display: flex; align-items: center; justify-content: space-between; background: #fafafa; }
|
||
.preview-section .preview-title { font-size: 13px; font-weight: 500; }
|
||
.preview-section .preview-tools { display: flex; gap: 4px; }
|
||
.preview-section .preview-content { flex: 1; overflow: auto; background: #f5f7fa; display: flex; align-items: center; justify-content: center; min-height: 200px; }
|
||
.preview-section .preview-footer { padding: 8px 12px; border-top: 1px solid #eee; display: flex; align-items: center; justify-content: space-between; background: #fafafa; font-size: 12px; color: #666; }
|
||
.preview-placeholder { color: #999; text-align: center; }
|
||
.img-container { transform-origin: center center; transition: transform 0.2s ease; max-width: 100%; max-height: 100%; }
|
||
.img-container img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||
.result-file-section { max-height: 250px; overflow-y: auto; }
|
||
|
||
/* Markdown 样式 */
|
||
.markdown-content { line-height: 1.6; }
|
||
.markdown-content h1, .markdown-content h2, .markdown-content h3 { margin: 12px 0 8px 0; color: #333; }
|
||
.markdown-content h1 { font-size: 16px; border-bottom: 1px solid #ddd; padding-bottom: 4px; }
|
||
.markdown-content h2 { font-size: 14px; }
|
||
.markdown-content h3 { font-size: 13px; }
|
||
.markdown-content p { margin: 6px 0; }
|
||
.markdown-content ul, .markdown-content ol { margin: 6px 0; padding-left: 20px; }
|
||
.markdown-content li { margin: 3px 0; }
|
||
.markdown-content code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; font-size: 12px; }
|
||
.markdown-content pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; }
|
||
.markdown-content pre code { background: none; padding: 0; }
|
||
.markdown-content blockquote { border-left: 3px solid #409EFF; margin: 8px 0; padding-left: 10px; color: #666; }
|
||
.markdown-content strong { color: #409EFF; }
|
||
.markdown-content a { color: #409EFF; text-decoration: none; }
|
||
.markdown-content a:hover { text-decoration: underline; }
|
||
.markdown-content table { border-collapse: collapse; margin: 10px 0; width: 100%; font-size: 12px; border: 1px solid #ccc; }
|
||
.markdown-content th, .markdown-content td { border: 1px solid #ccc; padding: 8px 10px; text-align: left; }
|
||
.markdown-content th { background: #409EFF; color: white; font-weight: 600; }
|
||
.markdown-content tr:nth-child(even) { background: #f5f7fa; }
|
||
.markdown-content tr:hover { background: #e6f2ff; }
|
||
|
||
/* KG实体按钮样式 */
|
||
.kg-entity-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 12px;
|
||
margin: 4px 4px 4px 0;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 16px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease;
|
||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
||
}
|
||
.kg-entity-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.4);
|
||
background: linear-gradient(135deg, #5a6fd6 0%, #6a4190 100%);
|
||
}
|
||
.kg-entity-btn:active {
|
||
transform: translateY(0);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div class="header">
|
||
<h1>🧬 Gene Family MAS - 基因家族分析系统</h1>
|
||
</div>
|
||
<div class="main-container">
|
||
<div class="left-panel">
|
||
<div class="btn-section">
|
||
<h4>▶ 分析阶段</h4>
|
||
<el-select v-model="selectedStage" placeholder="选择分析阶段" style="width:100%">
|
||
<el-option label="Identify - 基因家族鉴定" value="identify"></el-option>
|
||
<el-option label="Phylogeny - 进化树分析" value="phylogeny"></el-option>
|
||
<el-option label="Motif - Motif分析" value="motif"></el-option>
|
||
<el-option label="Domain - 蛋白结构域" value="domain"></el-option>
|
||
<el-option label="Structure - 基因结构" value="structure"></el-option>
|
||
<el-option label="Full - 完整流程" value="full"></el-option>
|
||
</el-select>
|
||
<el-button type="primary" style="width:100%; margin-top:10px;" @click="startAnalysis" :disabled="!selectedStage">🚀 开始分析</el-button>
|
||
</div>
|
||
<div class="btn-section">
|
||
<h4>📚 知识搜索 (RAG)</h4>
|
||
<el-input v-model="ragQuery" placeholder="输入查询内容" clearable style="margin-bottom:8px;">
|
||
<template #prepend>🔍</template>
|
||
</el-input>
|
||
<el-button type="success" style="width:100%;" @click="searchRag" :loading="ragLoading" :disabled="!ragQuery.trim()">🔎 搜索知识库</el-button>
|
||
<div v-if="ragResults.length" style="margin-top:10px;max-height:200px;overflow-y:auto;">
|
||
<div v-for="(r, i) in ragResults" :key="i" style="background:#f5f7fa;padding:8px;margin-bottom:6px;border-radius:4px;font-size:11px;">
|
||
<div style="color:#409EFF;font-weight:bold;">📄 {{ r.doc_id }}</div>
|
||
<div style="color:#67C23A;margin:2px 0;">Score: {{ (r.score || 0).toFixed(3) }}</div>
|
||
<div style="color:#666;">{{ r.content?.substring(0, 150) }}...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="btn-section">
|
||
<h4>📁 上传文件</h4>
|
||
<el-upload action="/api/v1/upload/batch" name="files" :auto-upload="true" :on-success="handleUploadSuccess" :on-error="handleUploadError" :multiple="true" :show-file-list="false">
|
||
<el-button type="info" style="width:100%">点击上传文件</el-button>
|
||
</el-upload>
|
||
</div>
|
||
<div class="btn-section">
|
||
<h4>📂 工作目录</h4>
|
||
<div class="work-dir">
|
||
<div class="dir-item"><span class="work-dir-label">Job:</span> <span class="dir-path">{{ jobInfo.job_id }}</span></div>
|
||
<div class="dir-item"><span class="work-dir-label">目录:</span> <span class="dir-path">{{ jobInfo.workspace_dir }}</span></div>
|
||
</div>
|
||
<el-button size="small" type="warning" style="width:100%; margin-top:10px;" @click="createNewJob">➕ 新建任务</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="middle-panel">
|
||
<h4 style="margin:0 0 10px 0;">💬 对话与日志</h4>
|
||
<div class="chat-area" ref="chatArea">
|
||
<div v-for="(msg, idx) in messages" :key="idx" :class="'chat-msg ' + msg.role">
|
||
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
|
||
<div class="time">{{ msg.time }}</div>
|
||
</div>
|
||
</div>
|
||
<el-input v-model="nlInput" placeholder="用自然语言描述你想做什么" @keyup.enter="handleNlInput" :disabled="executing" style="margin-top:10px;">
|
||
<template #append><el-button @click="handleNlInput" :loading="nlLoading" :disabled="executing">💬 发送</el-button></template>
|
||
</el-input>
|
||
</div>
|
||
<div class="right-panel">
|
||
<el-tabs v-model="rightPanelTab" type="border-card" style="height: 100%;" class="right-panel-tabs">
|
||
<el-tab-pane label="📊 分析结果" name="results" style="height: calc(100vh - 200px); overflow-y: auto;">
|
||
<el-collapse v-model="activeStages">
|
||
<el-collapse-item v-for="stage in stages" :key="stage.name" :name="stage.name">
|
||
<template #title>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;padding-right:10px;">
|
||
<span><span :class="'status ' + stage.status" style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px;"></span>{{ stage.label }}</span>
|
||
<span style="color:#999;font-size:12px;">{{ stage.files.length }} 个文件</span>
|
||
</div>
|
||
</template>
|
||
<div v-if="stage.files.length" class="file-list">
|
||
<div v-for="f in stage.files" :key="f.name" class="file-item" @click="selectFile(stage.name, f)">
|
||
<span>{{ isImageFile(f.name) ? '🖼️' : '📄' }} {{ f.name }}</span>
|
||
<span style="color:#999;font-size:11px;">{{ formatSize(f.size) }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else style="color:#999;font-size:12px;padding:8px;">暂无文件</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
<div class="preview-section">
|
||
<div class="preview-header">
|
||
<span class="preview-title">{{ selectedImage || '请选择图片文件查看' }}</span>
|
||
<div class="preview-tools" v-if="selectedImage">
|
||
<el-button size="small" @click="zoomIn">➕</el-button>
|
||
<el-button size="small" @click="zoomOut">➖</el-button>
|
||
<el-button size="small" @click="resetZoom">⟲</el-button>
|
||
<el-button size="small" @click="downloadImage">⬇</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="preview-content">
|
||
<div v-if="selectedImage" class="img-container" :style="{ transform: 'scale(' + zoomLevel/100 + ')' }">
|
||
<img :src="selectedImageUrl" :alt="selectedImage">
|
||
</div>
|
||
<div v-else class="preview-placeholder">选择上方图片文件查看</div>
|
||
</div>
|
||
<div class="preview-footer" v-if="selectedImage">
|
||
<span>{{ selectedImage }}</span>
|
||
<span>{{ zoomLevel }}%</span>
|
||
</div>
|
||
</div>
|
||
<el-button size="small" @click="refreshResults" style="width:100%;margin-top:10px;">🔄 刷新结果</el-button>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="🧬 知识图谱" name="knowledge-graph" style="height: calc(100vh - 200px);">
|
||
<div style="display:flex;flex-direction:column;height:100%;">
|
||
<div style="margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;">
|
||
<div>
|
||
<el-input v-model="kgSearchQuery" placeholder="搜索基因或实体..." size="small" style="width:200px;">
|
||
<template #append><el-button size="small" @click="searchKgNode">🔍</el-button></template>
|
||
</el-input>
|
||
<el-button size="small" @click="reloadKnowledgeGraph" :loading="kgLoading" style="margin-left:10px;">🔄 重新加载</el-button>
|
||
<el-button size="small" @click="resetKgView" style="margin-left:5px;">⟲ 重置视图</el-button>
|
||
</div>
|
||
<el-button type="primary" size="small" @click="openKgFullscreen">⛶ 全屏查看</el-button>
|
||
</div>
|
||
<div id="kg-graph-container" style="flex:1;background:#f5f7fa;border-radius:4px;min-height:300px;cursor:pointer;" @dblclick="openKgFullscreen"></div>
|
||
<div style="margin-top:10px;font-size:11px;color:#666;">
|
||
<div>节点: {{ kgStats.nodes }} | 关系: {{ kgStats.edges }} | 类型: {{ kgStats.nodeTypes }}</div>
|
||
<div v-if="kgSelectedNode" style="margin-top:5px;padding:8px;background:#ecf5ff;border-radius:4px;">
|
||
<strong>{{ kgSelectedNode.name }}</strong> ({{ kgSelectedNode.category }})
|
||
<br>连接数: {{ kgSelectedNode.degree }}
|
||
</div>
|
||
<div style="margin-top:5px;color:#999;">💡 双击图谱或点击"全屏查看"按钮打开完整页面</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 知识图谱全屏对话框 -->
|
||
<el-dialog
|
||
v-model="kgFullscreenVisible"
|
||
title="🧬 知识图谱 - 完整视图"
|
||
width="95%"
|
||
height="95%"
|
||
:close-on-click-modal="false"
|
||
:fullscreen="true"
|
||
custom-class="kg-fullscreen-dialog"
|
||
>
|
||
<div style="display:flex;height:calc(100vh - 150px);gap:15px;">
|
||
<!-- 左侧控制面板 -->
|
||
<div style="width:280px;display:flex;flex-direction:column;gap:10px;overflow-y:auto;">
|
||
<el-card shadow="hover">
|
||
<template #header><span>📊 图谱统计</span></template>
|
||
<div style="font-size:13px;">
|
||
<div style="display:flex;justify-content:space-between;padding:5px 0;">
|
||
<span>节点总数:</span><strong>{{ kgStats.nodes }}</strong>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;padding:5px 0;">
|
||
<span>关系总数:</span><strong>{{ kgStats.edges }}</strong>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;padding:5px 0;">
|
||
<span>节点类型:</span><strong>{{ kgStats.nodeTypes }}</strong>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card shadow="hover">
|
||
<template #header><span>🔍 节点搜索</span></template>
|
||
<el-input v-model="kgFullscreenSearch" placeholder="输入基因名或实体..." clearable @keyup.enter="searchKgFullscreenNode">
|
||
<template #append><el-button @click="searchKgFullscreenNode">🔍</el-button></template>
|
||
</el-input>
|
||
</el-card>
|
||
|
||
<el-card shadow="hover" v-if="kgSelectedNode">
|
||
<template #header><span>📋 节点详情</span></template>
|
||
<div style="font-size:13px;">
|
||
<div style="margin-bottom:10px;">
|
||
<div style="color:#666;font-size:12px;">节点名称</div>
|
||
<div style="font-size:16px;color:#409EFF;font-weight:bold;">{{ kgSelectedNode.name }}</div>
|
||
</div>
|
||
<div style="margin-bottom:10px;">
|
||
<div style="color:#666;font-size:12px;">节点类型</div>
|
||
<el-tag size="small">{{ nodeTypeLabels[kgSelectedNode.category] || kgSelectedNode.category }}</el-tag>
|
||
</div>
|
||
<div>
|
||
<div style="color:#666;font-size:12px;">连接关系数</div>
|
||
<div>{{ kgSelectedNode.degree }} 个</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card shadow="hover">
|
||
<template #header><span>🎨 节点图例 (PC语义)</span></template>
|
||
<div style="font-size:11px;">
|
||
<div v-for="(color, type) in nodeColors" :key="type" style="display:flex;align-items:center;gap:5px;margin:3px 0;">
|
||
<div style="width:12px;height:12px;border-radius:2px;" :style="{ background: color }"></div>
|
||
<span>{{ nodeTypeLabels[type] || type }}</span>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 中间图谱区域 -->
|
||
<div style="flex:1;display:flex;flex-direction:column;">
|
||
<div id="kg-fullscreen-container" style="flex:1;background:#f5f7fa;border-radius:8px;"></div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="kgFullscreenVisible = false">关闭</el-button>
|
||
<el-button type="primary" @click="resetKgFullscreenView">重置视图</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, reactive, nextTick, onMounted, watch } = Vue;
|
||
const { ElMessage } = ElementPlus;
|
||
|
||
// 配置 marked
|
||
marked.setOptions({
|
||
gfm: true,
|
||
breaks: true,
|
||
tables: true,
|
||
headerIds: false,
|
||
mangle: false
|
||
});
|
||
|
||
createApp({
|
||
setup() {
|
||
const selectedStage = ref('');
|
||
const jobInfo = reactive({ job_id: '', workspace_dir: '' });
|
||
const messages = ref([]);
|
||
const chatArea = ref(null);
|
||
const nlInput = ref('');
|
||
const nlLoading = ref(false);
|
||
const executing = ref(false);
|
||
const ragQuery = ref('');
|
||
const ragLoading = ref(false);
|
||
const ragResults = ref([]);
|
||
const stages = reactive([
|
||
{ name: 'identify', label: '1️⃣ Identify', status: 'pending', files: [] },
|
||
{ name: 'phylogeny', label: '2️⃣ Phylogeny', status: 'pending', files: [] },
|
||
{ name: 'motif', label: '3️⃣ Motif', status: 'pending', files: [] },
|
||
{ name: 'domain', label: '4️⃣ Domain', status: 'pending', files: [] },
|
||
{ name: 'structure', label: '5️⃣ Structure', status: 'pending', files: [] }
|
||
]);
|
||
const activeStages = ref('identify');
|
||
const selectedFile = ref('');
|
||
const selectedImage = ref('');
|
||
const selectedImageUrl = ref('');
|
||
const zoomLevel = ref(100);
|
||
|
||
// 知识图谱相关
|
||
const rightPanelTab = ref('results');
|
||
const kgChart = ref(null);
|
||
const kgFullscreenChart = ref(null);
|
||
const kgData = ref({ nodes: [], edges: [] });
|
||
const kgLoading = ref(false);
|
||
const kgSearchQuery = ref('');
|
||
const kgFullscreenSearch = ref('');
|
||
const kgSelectedNode = ref(null);
|
||
const kgStats = ref({ nodes: 0, edges: 0, nodeTypes: 0 });
|
||
const kgFullscreenVisible = ref(false);
|
||
|
||
// PC语义颜色映射
|
||
const nodeColors = {
|
||
'gene': '#5DADE2', 'gene_id': '#85C1E9', 'gene_group': '#AED6F1', 'gene_family': '#5DADE2',
|
||
'phenotype': '#58D68D', 'attribute': '#82E0AA',
|
||
'tissue': '#F5B7B1', 'species': '#F1948A',
|
||
'process': '#AF7AC5', 'time': '#C39BD3', 'condition': '#D7BDE2', 'stress': '#E8DAEF',
|
||
'location': '#82E0AA',
|
||
'sra_dataset': '#E67E22', 'data_type': '#F39C12', 'count': '#EAEDED',
|
||
'unknown': '#95A5A6'
|
||
,
|
||
'article': '#2ecc71',
|
||
'paper_title': '#9b59b6'
|
||
};
|
||
|
||
const nodeTypeLabels = {
|
||
'gene': 'Gene/Protein', 'gene_id': 'Gene Identifier', 'gene_group': 'Gene Group', 'gene_family': 'Gene Family',
|
||
'phenotype': 'Phenotype', 'attribute': 'Attribute',
|
||
'tissue': 'Cell/Organ/Tissue', 'species': 'Organism',
|
||
'process': 'Biological Process', 'time': 'Time/Stage', 'condition': 'Condition', 'stress': 'Stress',
|
||
'location': 'Genomic Location',
|
||
'sra_dataset': 'Dataset', 'geo_dataset': 'GEO Dataset', 'data_type': 'Data Type', 'count': 'Count/Statistic',
|
||
'article': 'Article (PMID)', 'paper_title': 'Paper Title',
|
||
'unknown': 'Other'
|
||
};
|
||
|
||
// Markdown 渲染函数
|
||
const renderMarkdown = (content) => {
|
||
if (!content) return '';
|
||
return marked.parse(content);
|
||
};
|
||
|
||
const isImageFile = (filename) => ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.bmp', '.webp'].some(ext => filename.toLowerCase().endsWith(ext));
|
||
const selectFile = (stageName, file) => {
|
||
selectedFile.value = file.name;
|
||
if (isImageFile(file.name)) {
|
||
selectedImage.value = file.name;
|
||
selectedImageUrl.value = '/api/v1/files/' + file.path;
|
||
}
|
||
};
|
||
const zoomIn = () => { zoomLevel.value = Math.min(300, zoomLevel.value + 25); };
|
||
const zoomOut = () => { zoomLevel.value = Math.max(25, zoomLevel.value - 25); };
|
||
const resetZoom = () => { zoomLevel.value = 100; };
|
||
const downloadImage = () => {
|
||
if (selectedImageUrl.value) {
|
||
const link = document.createElement('a');
|
||
link.href = selectedImageUrl.value;
|
||
link.download = selectedImage.value;
|
||
link.click();
|
||
}
|
||
};
|
||
const formatSize = (bytes) => {
|
||
if (!bytes) return '0 B';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
|
||
return (bytes/1024/1024).toFixed(1) + ' MB';
|
||
};
|
||
const addMessage = (role, content) => {
|
||
const now = new Date();
|
||
messages.value.push({ role, content, time: now.toLocaleTimeString() });
|
||
nextTick(() => { if (chatArea.value) chatArea.value.scrollTop = chatArea.value.scrollHeight; });
|
||
};
|
||
const createNewJob = () => {
|
||
const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||
const random = Math.random().toString(36).substring(2, 6);
|
||
jobInfo.job_id = `job_${timestamp}_${random}`;
|
||
jobInfo.workspace_dir = `/home/xzq/gene-family-mas/workspace/${jobInfo.job_id}`;
|
||
stages.forEach(s => { s.files = []; s.status = 'pending'; });
|
||
messages.value = [];
|
||
ElMessage.success('已创建新任务: ' + jobInfo.job_id);
|
||
};
|
||
const handleUploadSuccess = (response) => { ElMessage.success('上传成功: ' + (response.files?.length || 0) + ' 个文件'); };
|
||
const handleUploadError = () => { ElMessage.error('上传失败'); };
|
||
// 存储当前RAG返回的KG实体
|
||
const currentKgEntities = ref([]);
|
||
|
||
// 打开KG页面并高亮指定实体
|
||
const openKgWithHighlight = (entityName) => {
|
||
if (!entityName) return;
|
||
const encodedName = encodeURIComponent(entityName);
|
||
window.open(`kg.html?highlight=${encodedName}`, '_blank');
|
||
};
|
||
|
||
const searchRag = async () => {
|
||
if (!ragQuery.value.trim()) return;
|
||
ragLoading.value = true;
|
||
try {
|
||
const response = await fetch('http://localhost:8000/api/v1/rag/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query: ragQuery.value.trim(), top_k: 5, include_sources: true, use_llm: true })
|
||
});
|
||
const data = await response.json();
|
||
if (data.response) {
|
||
// 构建回答内容(包含KG实体按钮)
|
||
let messageContent = '📚 ' + data.response;
|
||
|
||
// 添加知识图谱实体按钮
|
||
if (data.kg_entities && data.kg_entities.length > 0) {
|
||
currentKgEntities.value = data.kg_entities;
|
||
messageContent += '\n\n🔍 **相关实体:**\n';
|
||
data.kg_entities.forEach(entity => {
|
||
messageContent += `<button class="kg-entity-btn" onclick="window.openKgWithHighlight('${entity.name}')">🔗 ${entity.display_name || entity.name}</button>`;
|
||
});
|
||
}
|
||
|
||
addMessage('assistant', messageContent);
|
||
|
||
if (data.sources && data.sources.length > 0) {
|
||
ragResults.value = data.sources;
|
||
ElMessage.success('找到 ' + data.sources.length + ' 条相关文献');
|
||
}
|
||
// 显示关联的组学数据
|
||
if (data.datasets && data.datasets.length > 0) {
|
||
let dsText = '\n\n📊 可利用组学数据:\n';
|
||
data.datasets.forEach(d => {
|
||
dsText += `• ${d.dataset_id} (${d.dataset_type || 'N/A'}) - ${d.treatment || 'N/A'}\n`;
|
||
});
|
||
addMessage('assistant', dsText);
|
||
}
|
||
// 更新知识图谱
|
||
if (data.knowledge_graph && data.knowledge_graph.nodes.length > 0) {
|
||
updateKnowledgeGraph(data.knowledge_graph);
|
||
ElMessage.success('知识图谱已更新: ' + data.knowledge_graph.nodes.length + ' 个节点');
|
||
}
|
||
} else if (data.error) {
|
||
ElMessage.error('搜索失败: ' + data.error);
|
||
}
|
||
} catch (err) {
|
||
ElMessage.error('搜索失败: ' + err.message);
|
||
} finally {
|
||
ragLoading.value = false;
|
||
}
|
||
};
|
||
const handleNlInput = async () => {
|
||
if (!nlInput.value.trim()) return;
|
||
const userInput = nlInput.value.trim();
|
||
nlLoading.value = true;
|
||
executing.value = true;
|
||
try {
|
||
addMessage('user', userInput);
|
||
addMessage('assistant', '正在处理...');
|
||
const response = await fetch('http://localhost:8000/api/v1/rag/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ query: userInput, top_k: 10, include_sources: true, use_llm: true })
|
||
});
|
||
const data = await response.json();
|
||
if (data.error) {
|
||
addMessage('assistant', '❌ ' + data.error);
|
||
} else if (data.response) {
|
||
let text = data.response;
|
||
if (data.sources && data.sources.length > 0) {
|
||
text += '\n\n📚 参考文献:\n';
|
||
data.sources.forEach((s, i) => {
|
||
// V15.1: 将 pecan_v15 前缀替换为 PMID
|
||
let displayId = s.doc_id;
|
||
if (s.pmid) {
|
||
// 替换前缀: pecan_v15_rel_299 -> PMID_34966577_rel_299
|
||
displayId = s.doc_id.replace(/^[^_]+_[^_]+_/, `PMID_${s.pmid}_`);
|
||
}
|
||
text += `${i+1}. ${displayId} (相关度: ${(s.score * 100).toFixed(1)}%)\n`;
|
||
});
|
||
}
|
||
// 显示关联的组学数据
|
||
if (data.datasets && data.datasets.length > 0) {
|
||
text += '\n📊 可利用组学数据:\n';
|
||
data.datasets.forEach(d => {
|
||
text += `• ${d.dataset_id}: ${d.dataset_type || 'N/A'} | 处理: ${d.treatment || 'N/A'} | 样本: ${d.sample_count || 'N/A'}\n`;
|
||
});
|
||
}
|
||
addMessage('assistant', text);
|
||
// 更新知识图谱并切换标签页
|
||
if (data.knowledge_graph && data.knowledge_graph.nodes.length > 0) {
|
||
updateKnowledgeGraph(data.knowledge_graph);
|
||
rightPanelTab.value = 'knowledge-graph';
|
||
ElMessage.success('知识图谱已更新: ' + data.knowledge_graph.nodes.length + ' 个节点, ' + data.knowledge_graph.edges.length + ' 条关系');
|
||
}
|
||
}
|
||
await refreshResults();
|
||
} catch (err) {
|
||
addMessage('assistant', '❌ 请求失败: ' + err.message);
|
||
} finally {
|
||
nlInput.value = '';
|
||
nlLoading.value = false;
|
||
executing.value = false;
|
||
}
|
||
};
|
||
const startAnalysis = async () => {
|
||
if (!selectedStage.value) { ElMessage.warning('请选择分析阶段'); return; }
|
||
const map = { 'identify': '鉴定基因家族', 'phylogeny': '构建进化树', 'motif': '分析Motif', 'domain': '分析结构域', 'structure': '分析基因结构', 'full': '完整分析' };
|
||
nlInput.value = '请帮我' + map[selectedStage.value];
|
||
await handleNlInput();
|
||
};
|
||
const refreshResults = async () => {
|
||
if (!jobInfo.job_id) return;
|
||
try {
|
||
const response = await fetch('/api/v1/workspace/' + jobInfo.job_id + '/files');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
stages.forEach(stage => {
|
||
stage.files = data.files.filter(f => f.relative_path.startsWith(stage.name));
|
||
if (stage.files.length > 0) stage.status = 'success';
|
||
});
|
||
}
|
||
} catch (err) { console.error(err); }
|
||
};
|
||
|
||
// 知识图谱方法
|
||
// 从RAG结果更新知识图谱
|
||
const updateKnowledgeGraph = (ragKgData) => {
|
||
console.log('updateKnowledgeGraph called with:', ragKgData);
|
||
if (!ragKgData || !ragKgData.nodes) {
|
||
console.warn('No knowledge_graph data or nodes');
|
||
return;
|
||
}
|
||
|
||
console.log(`Processing ${ragKgData.nodes.length} nodes, ${ragKgData.edges?.length || 0} edges`);
|
||
|
||
// 计算节点度数
|
||
const nodeDegrees = {};
|
||
if (ragKgData.edges) {
|
||
ragKgData.edges.forEach(e => {
|
||
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
|
||
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
|
||
});
|
||
}
|
||
|
||
// 设置节点形状
|
||
const getNodeSymbol = (type) => {
|
||
const symbolMap = {
|
||
'gene': 'circle', 'gene_id': 'circle', 'gene_group': 'circle', 'gene_family': 'circle',
|
||
'phenotype': 'rect', 'attribute': 'rect',
|
||
'tissue': 'roundRect', 'species': 'roundRect',
|
||
'process': 'diamond', 'time': 'diamond', 'condition': 'diamond', 'stress': 'diamond',
|
||
'location': 'triangle',
|
||
'sra_dataset': 'pin', 'data_type': 'pin', 'count': 'pin'
|
||
};
|
||
return symbolMap[type] || 'circle';
|
||
};
|
||
|
||
// 更新全局 kgData ref
|
||
kgData.value = {
|
||
nodes: ragKgData.nodes.map(n => ({
|
||
id: n.id,
|
||
name: n.id,
|
||
category: n.type || 'unknown',
|
||
degree: nodeDegrees[n.id] || 0,
|
||
symbol: getNodeSymbol(n.type),
|
||
symbolSize: (n.type === 'gene' ? 20 : 12) + Math.min((nodeDegrees[n.id] || 0) * 0.3, 15),
|
||
itemStyle: {
|
||
color: nodeColors[n.type] || nodeColors.unknown,
|
||
borderColor: '#fff',
|
||
borderWidth: 1
|
||
}
|
||
})),
|
||
edges: ragKgData.edges.map((e, i) => ({
|
||
id: i,
|
||
source: e.source,
|
||
target: e.target,
|
||
name: e.relation,
|
||
lineStyle: { color: '#999', width: 1, curveness: 0.1 }
|
||
}))
|
||
};
|
||
|
||
kgStats.value = {
|
||
nodes: kgData.value.nodes.length,
|
||
edges: kgData.value.edges.length,
|
||
nodeTypes: Object.keys(nodeColors).length
|
||
};
|
||
|
||
// 如果图表已初始化,更新数据
|
||
if (kgChart.value) {
|
||
console.log('Updating existing chart');
|
||
kgChart.value.setOption({
|
||
series: [{
|
||
data: kgData.value.nodes,
|
||
links: kgData.value.edges
|
||
}]
|
||
});
|
||
} else {
|
||
console.log('Initializing new chart');
|
||
initKgChart();
|
||
}
|
||
|
||
// 如果全屏图表已初始化,也更新
|
||
if (kgFullscreenChart.value) {
|
||
kgFullscreenChart.value.setOption({
|
||
series: [{
|
||
data: kgData.value.nodes,
|
||
links: kgData.value.edges
|
||
}]
|
||
});
|
||
}
|
||
};
|
||
|
||
const loadKnowledgeGraph = async () => {
|
||
kgLoading.value = true;
|
||
try {
|
||
// 优先加载包含多组学数据的知识图谱
|
||
let response = await fetch('knowledge_graph_with_omics.json');
|
||
if (!response.ok) {
|
||
response = await fetch('knowledge_graph.json');
|
||
}
|
||
const data = await response.json();
|
||
|
||
// 计算节点度数
|
||
const nodeDegrees = {};
|
||
data.edges.forEach(e => {
|
||
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
|
||
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
|
||
});
|
||
|
||
// 设置节点形状
|
||
const getNodeSymbol = (type) => {
|
||
const symbolMap = {
|
||
'gene': 'circle', 'gene_id': 'circle', 'gene_group': 'circle', 'gene_family': 'circle',
|
||
'phenotype': 'rect', 'attribute': 'rect',
|
||
'tissue': 'roundRect', 'species': 'roundRect',
|
||
'process': 'diamond', 'time': 'diamond', 'condition': 'diamond', 'stress': 'diamond',
|
||
'location': 'triangle',
|
||
'sra_dataset': 'pin', 'data_type': 'pin', 'count': 'pin'
|
||
};
|
||
return symbolMap[type] || 'circle';
|
||
};
|
||
|
||
kgData.value = {
|
||
nodes: data.nodes.map(n => ({
|
||
id: n.id,
|
||
name: n.id,
|
||
category: n.type || 'unknown',
|
||
degree: nodeDegrees[n.id] || 0,
|
||
symbol: getNodeSymbol(n.type),
|
||
symbolSize: (n.type === 'gene' ? 20 : 12) + Math.min((nodeDegrees[n.id] || 0) * 0.3, 15),
|
||
itemStyle: {
|
||
color: nodeColors[n.type] || nodeColors.unknown,
|
||
borderColor: '#fff',
|
||
borderWidth: 1
|
||
}
|
||
})),
|
||
edges: data.edges.map((e, i) => ({
|
||
id: i,
|
||
source: e.source,
|
||
target: e.target,
|
||
name: e.relation,
|
||
lineStyle: { color: '#999', width: 1, curveness: 0.1 }
|
||
}))
|
||
};
|
||
|
||
kgStats.value = {
|
||
nodes: kgData.value.nodes.length,
|
||
edges: kgData.value.edges.length,
|
||
nodeTypes: Object.keys(nodeColors).length
|
||
};
|
||
|
||
initKgChart();
|
||
ElMessage.success('知识图谱加载完成');
|
||
} catch (error) {
|
||
ElMessage.error('加载图谱失败: ' + error.message);
|
||
} finally {
|
||
kgLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const initKgChart = () => {
|
||
const container = document.getElementById('kg-graph-container');
|
||
if (!container) {
|
||
console.error('kg-graph-container not found');
|
||
return;
|
||
}
|
||
|
||
console.log('initKgChart called, nodes:', kgData.value.nodes.length, 'edges:', kgData.value.edges.length);
|
||
|
||
if (kgChart.value) {
|
||
kgChart.value.dispose();
|
||
}
|
||
|
||
// 确保容器有尺寸
|
||
if (container.clientWidth === 0 || container.clientHeight === 0) {
|
||
console.warn('Container has no size, deferring initialization');
|
||
setTimeout(initKgChart, 100);
|
||
return;
|
||
}
|
||
|
||
kgChart.value = echarts.init(container);
|
||
|
||
const option = {
|
||
tooltip: {
|
||
formatter: params => {
|
||
if (params.dataType === 'node') {
|
||
const pcType = nodeTypeLabels[params.data.category] || params.data.category;
|
||
return `<b>${params.name}</b><br/>类型: ${pcType}<br/>关系数: ${params.data.degree}`;
|
||
}
|
||
return `<b>${params.data.source}</b> --${params.name}--> <b>${params.data.target}</b>`;
|
||
}
|
||
},
|
||
series: [{
|
||
type: 'graph',
|
||
layout: 'force',
|
||
data: kgData.value.nodes,
|
||
links: kgData.value.edges,
|
||
roam: true,
|
||
draggable: true,
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
formatter: '{b}',
|
||
fontSize: 10
|
||
},
|
||
force: {
|
||
repulsion: 300,
|
||
gravity: 0.1,
|
||
edgeLength: 100,
|
||
layoutAnimation: true
|
||
},
|
||
emphasis: {
|
||
focus: 'adjacency',
|
||
lineStyle: { width: 4 }
|
||
}
|
||
}]
|
||
};
|
||
|
||
kgChart.value.setOption(option);
|
||
|
||
kgChart.value.on('click', params => {
|
||
if (params.dataType === 'node') {
|
||
kgSelectedNode.value = params.data;
|
||
}
|
||
});
|
||
};
|
||
|
||
const searchKgNode = () => {
|
||
if (!kgSearchQuery.value || !kgChart.value) return;
|
||
|
||
const node = kgData.value.nodes.find(n =>
|
||
n.id.toLowerCase().includes(kgSearchQuery.value.toLowerCase())
|
||
);
|
||
|
||
if (node) {
|
||
kgChart.value.dispatchAction({
|
||
type: 'highlight',
|
||
seriesIndex: 0,
|
||
name: node.id
|
||
});
|
||
kgSelectedNode.value = node;
|
||
ElMessage.success(`找到节点: ${node.name}`);
|
||
} else {
|
||
ElMessage.warning('未找到匹配的节点');
|
||
}
|
||
};
|
||
|
||
const resetKgView = () => {
|
||
if (kgChart.value) {
|
||
// 取消任何高亮状态
|
||
kgChart.value.dispatchAction({
|
||
type: 'downplay',
|
||
seriesIndex: 0
|
||
});
|
||
// 重置缩放和位置 - 使用 setOption 而不是 dispatchAction
|
||
kgChart.value.setOption({
|
||
series: [{
|
||
center: null,
|
||
zoom: 1,
|
||
// 重新应用数据以确保样式正确
|
||
data: kgData.value.nodes,
|
||
links: kgData.value.edges
|
||
}]
|
||
});
|
||
// 触发 resize 以适应容器
|
||
kgChart.value.resize();
|
||
ElMessage.success('视图已重置');
|
||
}
|
||
};
|
||
|
||
// 重新加载知识图谱(从文件)
|
||
const reloadKnowledgeGraph = () => {
|
||
// 清空当前数据,强制重新加载
|
||
kgData.value = { nodes: [], edges: [] };
|
||
kgChart.value = null;
|
||
loadKnowledgeGraph();
|
||
};
|
||
|
||
// 全屏知识图谱方法
|
||
const openKgFullscreen = () => {
|
||
if (kgData.value.nodes.length === 0) {
|
||
ElMessage.warning('请先加载知识图谱');
|
||
return;
|
||
}
|
||
kgFullscreenVisible.value = true;
|
||
nextTick(() => {
|
||
initKgFullscreenChart();
|
||
});
|
||
};
|
||
|
||
const initKgFullscreenChart = () => {
|
||
const container = document.getElementById('kg-fullscreen-container');
|
||
if (!container) return;
|
||
|
||
if (kgFullscreenChart.value) {
|
||
kgFullscreenChart.value.dispose();
|
||
}
|
||
|
||
kgFullscreenChart.value = echarts.init(container);
|
||
|
||
const option = {
|
||
tooltip: {
|
||
formatter: params => {
|
||
if (params.dataType === 'node') {
|
||
const pcType = nodeTypeLabels[params.data.category] || params.data.category;
|
||
return `<b>${params.name}</b><br/>类型: ${pcType}<br/>关系数: ${params.data.degree}`;
|
||
}
|
||
return `<b>${params.data.source}</b> --${params.name}--> <b>${params.data.target}</b>`;
|
||
}
|
||
},
|
||
series: [{
|
||
type: 'graph',
|
||
layout: 'force',
|
||
data: kgData.value.nodes,
|
||
links: kgData.value.edges,
|
||
roam: true,
|
||
draggable: true,
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
formatter: '{b}',
|
||
fontSize: 11
|
||
},
|
||
force: {
|
||
repulsion: 500,
|
||
gravity: 0.1,
|
||
edgeLength: 150,
|
||
layoutAnimation: true
|
||
},
|
||
emphasis: {
|
||
focus: 'adjacency',
|
||
lineStyle: { width: 4 }
|
||
}
|
||
}]
|
||
};
|
||
|
||
kgFullscreenChart.value.setOption(option);
|
||
|
||
kgFullscreenChart.value.on('click', params => {
|
||
if (params.dataType === 'node') {
|
||
kgSelectedNode.value = params.data;
|
||
}
|
||
});
|
||
};
|
||
|
||
const searchKgFullscreenNode = () => {
|
||
if (!kgFullscreenSearch.value || !kgFullscreenChart.value) return;
|
||
|
||
const node = kgData.value.nodes.find(n =>
|
||
n.id.toLowerCase().includes(kgFullscreenSearch.value.toLowerCase())
|
||
);
|
||
|
||
if (node) {
|
||
kgFullscreenChart.value.dispatchAction({
|
||
type: 'highlight',
|
||
seriesIndex: 0,
|
||
name: node.id
|
||
});
|
||
kgSelectedNode.value = node;
|
||
ElMessage.success(`找到节点: ${node.name}`);
|
||
} else {
|
||
ElMessage.warning('未找到匹配的节点');
|
||
}
|
||
};
|
||
|
||
const resetKgFullscreenView = () => {
|
||
if (kgFullscreenChart.value) {
|
||
kgFullscreenChart.value.dispatchAction({ type: 'restore' });
|
||
}
|
||
};
|
||
|
||
// 监听标签页切换,切换到知识图谱时自动加载
|
||
watch(rightPanelTab, (newTab) => {
|
||
if (newTab === 'knowledge-graph' && kgData.value.nodes.length === 0) {
|
||
nextTick(() => loadKnowledgeGraph());
|
||
}
|
||
});
|
||
|
||
onMounted(() => {
|
||
createNewJob();
|
||
// 暴露openKgWithHighlight到window,供内联onclick使用
|
||
window.openKgWithHighlight = openKgWithHighlight;
|
||
});
|
||
return {
|
||
selectedStage, jobInfo, messages, chatArea, nlInput, nlLoading, executing,
|
||
ragQuery, ragLoading, ragResults, stages, activeStages, selectedFile, selectedImage, selectedImageUrl, zoomLevel,
|
||
rightPanelTab, kgData, kgChart, kgFullscreenChart, kgLoading, kgSearchQuery, kgFullscreenSearch, kgSelectedNode, kgStats, kgFullscreenVisible,
|
||
currentKgEntities,
|
||
nodeColors, nodeTypeLabels,
|
||
isImageFile, selectFile, zoomIn, zoomOut, resetZoom, downloadImage, formatSize, addMessage,
|
||
createNewJob, handleUploadSuccess, handleUploadError, searchRag, handleNlInput, startAnalysis, refreshResults,
|
||
loadKnowledgeGraph, reloadKnowledgeGraph, updateKnowledgeGraph, searchKgNode, resetKgView, openKgFullscreen, searchKgFullscreenNode, resetKgFullscreenView,
|
||
openKgWithHighlight, renderMarkdown
|
||
};
|
||
}
|
||
}).use(ElementPlus).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|