Files
2026-07-10 17:59:59 +08:00

960 lines
54 KiB
HTML
Executable File
Raw Permalink 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.
<!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>