960 lines
54 KiB
HTML
960 lines
54 KiB
HTML
<!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>
|