6499 lines
327 KiB
Plaintext
6499 lines
327 KiB
Plaintext
<!DOCTYPE html>
|
||||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>PlantBioAgents - Plant Bioinformation Analysis Platform</title>
|
|||
|
|
<!-- Element Plus CSS -->
|
|||
|
|
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.3.14/dist/index.css">
|
|||
|
|
<link rel="stylesheet" href="/static/conversation-sidebar.css">
|
|||
|
|
|
|||
|
|
<!-- Vue 3 -->
|
|||
|
|
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js"></script>
|
|||
|
|
|
|||
|
|
<!-- Element Plus -->
|
|||
|
|
<script src="https://unpkg.com/element-plus@2.3.14/dist/index.full.js"></script>
|
|||
|
|
|
|||
|
|
<!-- Element Plus Icons -->
|
|||
|
|
<script src="https://unpkg.com/@element-plus/icons-vue@2.1.0/dist/index.iife.js"></script>
|
|||
|
|
|
|||
|
|
<!-- Conversation Sidebar -->
|
|||
|
|
<script src="/static/conversation-sidebar.js"></script>
|
|||
|
|
<style>
|
|||
|
|
/* ========== Global Border Radius Override - Square Style ========== */
|
|||
|
|
/* Override Element Plus default border radius */
|
|||
|
|
:root {
|
|||
|
|
--el-border-radius-base: 0px;
|
|||
|
|
--el-border-radius-small: 0px;
|
|||
|
|
--el-border-radius-round: 0px;
|
|||
|
|
}
|
|||
|
|
.el-button { border-radius: 0px !important; }
|
|||
|
|
.el-input__wrapper { border-radius: 0px !important; }
|
|||
|
|
.el-input__inner { border-radius: 0px !important; }
|
|||
|
|
.el-select .el-input__wrapper { border-radius: 0px !important; }
|
|||
|
|
.el-dialog { border-radius: 0px !important; }
|
|||
|
|
.el-message-box { border-radius: 0px !important; }
|
|||
|
|
.el-card { border-radius: 0px !important; }
|
|||
|
|
.el-tag { border-radius: 0px !important; }
|
|||
|
|
.el-badge__content { border-radius: 0px !important; }
|
|||
|
|
.el-dropdown-menu { border-radius: 0px !important; }
|
|||
|
|
.el-popover { border-radius: 0px !important; }
|
|||
|
|
.el-tooltip__popper { border-radius: 0px !important; }
|
|||
|
|
.el-collapse-item__header { border-radius: 0px !important; }
|
|||
|
|
.el-collapse-item__wrap { border-radius: 0px !important; }
|
|||
|
|
.el-tabs--border-card { border-radius: 0px !important; }
|
|||
|
|
.el-tabs__nav { border-radius: 0px !important; }
|
|||
|
|
.el-switch__core { border-radius: 0px !important; }
|
|||
|
|
.el-slider__runway { border-radius: 0px !important; }
|
|||
|
|
.el-slider__bar { border-radius: 0px !important; }
|
|||
|
|
.el-slider__button { border-radius: 0px !important; }
|
|||
|
|
.el-checkbox__inner { border-radius: 0px !important; }
|
|||
|
|
.el-radio__inner { border-radius: 0px !important; }
|
|||
|
|
.el-textarea__inner { border-radius: 0px !important; }
|
|||
|
|
.el-table { border-radius: 0px !important; }
|
|||
|
|
.el-table__header-wrapper { border-radius: 0px !important; }
|
|||
|
|
.el-pagination .btn-prev, .el-pagination .btn-next { border-radius: 0px !important; }
|
|||
|
|
.el-pagination .el-pager li { border-radius: 0px !important; }
|
|||
|
|
.el-upload--picture-card { border-radius: 0px !important; }
|
|||
|
|
.el-upload-list__item { border-radius: 0px !important; }
|
|||
|
|
|
|||
|
|
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: 10px; gap: 10px; height: calc(100vh - 70px); overflow: hidden; box-sizing: border-box; }
|
|||
|
|
|
|||
|
|
/* 会话侧边栏 */
|
|||
|
|
.conversation-sidebar-container {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 左侧:控制区 - 固定无滚动 */
|
|||
|
|
.left-panel {
|
|||
|
|
width: 240px;
|
|||
|
|
min-width: 200px;
|
|||
|
|
max-width: 280px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 8px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
.left-panel .btn-section {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
max-height: fit-content;
|
|||
|
|
}
|
|||
|
|
.btn-section { background: white; border-radius: 0px; padding: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); flex-shrink: 0; }
|
|||
|
|
.btn-section h4 { margin: 0 0 8px 0; color: #409EFF; font-size: 13px; }
|
|||
|
|
|
|||
|
|
/* Analysis StageSelect */
|
|||
|
|
.stage-select { margin-bottom: 10px; }
|
|||
|
|
|
|||
|
|
/* Working Directory显示 */
|
|||
|
|
.work-dir { background: #f0f0f0; border-radius: 0px; 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: 0px;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|||
|
|
padding: 10px;
|
|||
|
|
min-width: 320px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
height: 100%;
|
|||
|
|
max-height: 100%;
|
|||
|
|
}
|
|||
|
|
/* 可拖拽区域样式 */
|
|||
|
|
.resizable { resize: both; overflow: auto; }
|
|||
|
|
.resizable-v { resize: vertical; overflow: auto; }
|
|||
|
|
.resizable:hover, .resizable-v:hover { cursor: ns-resize; }
|
|||
|
|
|
|||
|
|
/* 执行日志区域 */
|
|||
|
|
.chat-area { flex: 1; overflow-y: auto; margin-bottom: 10px; border: 1px solid #e4e7ed; border-radius: 0px; padding: 10px; min-height: 120px; resize: vertical; max-height: 400px; }
|
|||
|
|
.chat-msg { margin-bottom: 12px; padding: 10px 14px; border-radius: 0px; max-width: 85%; }
|
|||
|
|
.chat-msg.user { background: #ecf5ff; margin-left: auto; text-align: right; }
|
|||
|
|
.chat-msg.assistant { background: #f0f9eb; }
|
|||
|
|
.chat-msg .time { font-size: 11px; color: #999; margin-top: 5px; }
|
|||
|
|
|
|||
|
|
/* 右侧:结果展示区 - 自适应可滚动 */
|
|||
|
|
.right-panel {
|
|||
|
|
width: 380px;
|
|||
|
|
min-width: 320px;
|
|||
|
|
max-width: 420px;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|||
|
|
padding: 10px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
.right-panel h4 { margin: 0 0 10px 0; color: #67C23A; border-bottom: 2px solid #67C23A; padding-bottom: 8px; }
|
|||
|
|
|
|||
|
|
.stage-section { margin-bottom: 15px; padding: 10px; background: #f9f9f9; border-radius: 0px; }
|
|||
|
|
.stage-title { font-weight: bold; color: #606266; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
|
|||
|
|
.stage-title .status { width: 8px; height: 8px; border-radius: 50%; }
|
|||
|
|
.status.pending { background: #909399; }
|
|||
|
|
.status.running { background: #E6A23C; animation: pulse 1s infinite; }
|
|||
|
|
.status.success { background: #67C23A; }
|
|||
|
|
.status.failed { background: #F56C6C; }
|
|||
|
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|||
|
|
|
|||
|
|
/* 图片网格 */
|
|||
|
|
.img-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; max-height: 300px; overflow-y: auto; resize: vertical; }
|
|||
|
|
.img-item { position: relative; cursor: pointer; }
|
|||
|
|
.img-item img { width: 100%; height: 100px; object-fit: cover; border-radius: 0px; border: 1px solid #e4e7ed; }
|
|||
|
|
.img-item:hover { opacity: 0.9; }
|
|||
|
|
|
|||
|
|
/* 文件列表 */
|
|||
|
|
.file-list { font-size: 12px; }
|
|||
|
|
.file-item { padding: 4px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
|
|||
|
|
|
|||
|
|
/* 参数对话框 */
|
|||
|
|
.param-group { margin-bottom: 15px; }
|
|||
|
|
.param-group h5 { margin: 0 0 8px 0; color: #409EFF; font-size: 13px; }
|
|||
|
|
.param-row { display: flex; gap: 10px; margin-bottom: 8px; }
|
|||
|
|
.param-row .el-input, .param-row .el-input-number { flex: 1; }
|
|||
|
|
|
|||
|
|
/* 图片预览对话框 */
|
|||
|
|
.preview-dialog .el-dialog__body { padding: 10px; text-align: center; }
|
|||
|
|
.preview-img { max-width: 100%; max-height: 70vh; }
|
|||
|
|
.param-adjust { margin-top: 15px; padding: 15px; background: #f5f7fa; border-radius: 0px; }
|
|||
|
|
|
|||
|
|
/* 新版结果区域样式 */
|
|||
|
|
.result-file-section { max-height: 250px; overflow-y: auto; margin-bottom: 10px; border: 1px solid #eee; border-radius: 0px; }
|
|||
|
|
.result-file-section .el-collapse { border: none; }
|
|||
|
|
.result-file-section .el-collapse-item__header { font-size: 13px; }
|
|||
|
|
.result-file-section .el-collapse-item__content { padding: 0; }
|
|||
|
|
|
|||
|
|
.file-list { font-size: 12px; }
|
|||
|
|
.file-item { padding: 8px 16px; cursor: pointer; display: flex; justify-content: space-between; border-bottom: 1px solid #f5f5f5; transition: background 0.2s; }
|
|||
|
|
.file-item:hover { background: #f5f7fa; }
|
|||
|
|
.file-item.active { background: #ecf5ff; color: #409EFF; font-weight: 500; }
|
|||
|
|
|
|||
|
|
.preview-section { border: 1px solid #eee; border-radius: 0px; display: flex; flex-direction: column; min-height: 200px; max-height: 400px; resize: vertical; overflow: hidden; }
|
|||
|
|
.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-section .zoom-controls { display: flex; align-items: center; gap: 8px; }
|
|||
|
|
|
|||
|
|
.preview-placeholder { color: #999; text-align: center; }
|
|||
|
|
.preview-placeholder .el-icon { font-size: 48px; margin-bottom: 8px; display: block; }
|
|||
|
|
.preview-error { color: #F56C6C; text-align: center; }
|
|||
|
|
.preview-error .el-icon { font-size: 48px; margin-bottom: 8px; display: block; }
|
|||
|
|
|
|||
|
|
.img-container {
|
|||
|
|
transform-origin: center center;
|
|||
|
|
transition: transform 0.2s ease;
|
|||
|
|
max-width: 100%;
|
|||
|
|
max-height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
.img-container img {
|
|||
|
|
max-width: 100%;
|
|||
|
|
max-height: 100%;
|
|||
|
|
object-fit: contain;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ========== Agent Mode Enhanced Styles ========== */
|
|||
|
|
.agent-header {
|
|||
|
|
animation: slideDown 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 模式切换按钮白色文字 */
|
|||
|
|
.agent-header .el-switch__label {
|
|||
|
|
color: white !important;
|
|||
|
|
}
|
|||
|
|
.agent-header .el-switch__label.is-active {
|
|||
|
|
color: white !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ========== Agent Status Component Styles ========== */
|
|||
|
|
.agent-status-panel {
|
|||
|
|
border: 1px solid #ddd;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
padding: 16px;
|
|||
|
|
background: #f9f9f9;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
padding-bottom: 8px;
|
|||
|
|
border-bottom: 1px solid #eee;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-header h3 {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-mode {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #666;
|
|||
|
|
background: #e0e0e0;
|
|||
|
|
padding: 2px 8px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-messages {
|
|||
|
|
flex: 1;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
max-height: 400px;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message {
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
background: white;
|
|||
|
|
border-left: 3px solid #ddd;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message.user {
|
|||
|
|
border-left-color: #4CAF50;
|
|||
|
|
background: #E8F5E9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message.assistant {
|
|||
|
|
border-left-color: #2196F3;
|
|||
|
|
background: #E3F2FD;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message.error {
|
|||
|
|
border-left-color: #f44336;
|
|||
|
|
background: #FFEBEE;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message.system {
|
|||
|
|
border-left-color: #9E9E9E;
|
|||
|
|
background: #F5F5F5;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message-role {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #666;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ========== RAG Agent Status Styles ========== */
|
|||
|
|
.agent-status-panel .status-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .status-badge {
|
|||
|
|
padding: 4px 12px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .status-badge.idle { background: #909399; color: white; }
|
|||
|
|
.agent-status-panel .status-badge.analyzing { background: #409EFF; color: white; }
|
|||
|
|
.agent-status-panel .status-badge.retrieving { background: #E6A23C; color: white; }
|
|||
|
|
.agent-status-panel .status-badge.reflecting { background: #67C23A; color: white; }
|
|||
|
|
.agent-status-panel .status-badge.generating { background: #8E44AD; color: white; }
|
|||
|
|
.agent-status-panel .status-badge.completed { background: #67C23A; color: white; }
|
|||
|
|
.agent-status-panel .status-badge.error { background: #F56C6C; color: white; }
|
|||
|
|
|
|||
|
|
.agent-status-panel .rag-status {
|
|||
|
|
background: white;
|
|||
|
|
padding: 15px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
margin-bottom: 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .query-text {
|
|||
|
|
font-weight: bold;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
color: #303133;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .retrieval-steps {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 10px;
|
|||
|
|
margin: 15px 0;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .step {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 5px;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
background: #f0f0f0;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .step.pending { opacity: 0.5; }
|
|||
|
|
.agent-status-panel .step.running { background: #ecf5ff; color: #409EFF; }
|
|||
|
|
.agent-status-panel .step.completed { background: #f0f9eb; color: #67C23A; }
|
|||
|
|
|
|||
|
|
.agent-status-panel .reflection-panel,
|
|||
|
|
.agent-status-panel .multihop-panel {
|
|||
|
|
margin-top: 15px;
|
|||
|
|
padding: 10px;
|
|||
|
|
background: #fdf6ec;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .reflection-title,
|
|||
|
|
.agent-status-panel .multihop-title {
|
|||
|
|
font-weight: bold;
|
|||
|
|
margin-bottom: 5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .kg-visualization {
|
|||
|
|
background: white;
|
|||
|
|
padding: 15px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
margin-bottom: 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .kg-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .kg-chart {
|
|||
|
|
height: 300px;
|
|||
|
|
background: #f5f7fa;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .kg-stats {
|
|||
|
|
margin-top: 10px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #666;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .results-panel {
|
|||
|
|
background: white;
|
|||
|
|
padding: 15px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
margin-bottom: 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .result-item {
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
padding: 10px;
|
|||
|
|
background: #f5f7fa;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .result-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
margin-bottom: 5px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .result-doc { color: #409EFF; }
|
|||
|
|
.agent-status-panel .result-score { color: #67C23A; }
|
|||
|
|
|
|||
|
|
.agent-status-panel .result-content {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #666;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message-content {
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message-time {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: #999;
|
|||
|
|
text-align: right;
|
|||
|
|
margin-top: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-input {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-input textarea {
|
|||
|
|
flex: 1;
|
|||
|
|
padding: 8px;
|
|||
|
|
border: 1px solid #ddd;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
resize: none;
|
|||
|
|
font-family: inherit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-input button {
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
background: #2196F3;
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-input button:disabled {
|
|||
|
|
background: #ccc;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ========== 动态消息内容样式系统 ========== */
|
|||
|
|
/* 基础消息气泡 - 自适应padding */
|
|||
|
|
.agent-status-panel .message-bubble {
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 短内容:紧凑模式 */
|
|||
|
|
.agent-status-panel .message-bubble.short-content {
|
|||
|
|
padding: 6px 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message-bubble.short-content .message-text {
|
|||
|
|
font-size: 13px;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 中等内容:标准模式 */
|
|||
|
|
.agent-status-panel .message-bubble.medium-content {
|
|||
|
|
padding: 10px 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message-bubble.medium-content .message-text {
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 结构化内容(RAG结果):紧凑分段 */
|
|||
|
|
.agent-status-panel .message-bubble.structured-content {
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message-bubble.structured-content .message-text {
|
|||
|
|
font-size: 13px;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* RAG结构化内容专用样式 */
|
|||
|
|
.rag-content-wrapper {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-header {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #67C23A;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-section {
|
|||
|
|
background: #f9f9f9;
|
|||
|
|
border-left: 3px solid #4CAF50;
|
|||
|
|
border-radius: 0 0px 0px 0;
|
|||
|
|
padding: 8px 10px;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-section-title {
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #333;
|
|||
|
|
font-size: 13px;
|
|||
|
|
margin: 0 0 4px 0;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-section-title::before {
|
|||
|
|
content: '';
|
|||
|
|
display: inline-block;
|
|||
|
|
width: 6px;
|
|||
|
|
height: 6px;
|
|||
|
|
background: #4CAF50;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-section-content {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #555;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-section-content p {
|
|||
|
|
margin: 0 0 4px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-section-content p:last-child {
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 标签区域紧凑布局 */
|
|||
|
|
.rag-tags-row {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 4px;
|
|||
|
|
margin-top: 6px;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-tag {
|
|||
|
|
font-size: 10px;
|
|||
|
|
padding: 2px 6px;
|
|||
|
|
background: #e8f5e9;
|
|||
|
|
color: #2e7d32;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
border: 1px solid #c8e6c9;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-source {
|
|||
|
|
font-size: 10px;
|
|||
|
|
color: #909399;
|
|||
|
|
margin-left: auto;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 引用来源链接 */
|
|||
|
|
.rag-source-link {
|
|||
|
|
color: #409EFF;
|
|||
|
|
text-decoration: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-source-link:hover {
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 底部提示信息 */
|
|||
|
|
.rag-footer {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: #909399;
|
|||
|
|
margin-top: 6px;
|
|||
|
|
padding-top: 6px;
|
|||
|
|
border-top: 1px dashed #e4e7ed;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 长内容:展开/折叠支持 */
|
|||
|
|
.agent-status-panel .message-bubble.long-content {
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .message-bubble.long-content .message-text {
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 消息间距动态调整 */
|
|||
|
|
.agent-message {
|
|||
|
|
transition: margin 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-message.compact {
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-message.normal {
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-message.spacious {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ========== RAG历史消息专用样式 ========== */
|
|||
|
|
.rag-history-panel {
|
|||
|
|
margin-top: 10px;
|
|||
|
|
padding: 10px;
|
|||
|
|
background: #f5f7fa;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
max-height: 400px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-message {
|
|||
|
|
border-radius: 0px;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-message.user {
|
|||
|
|
background: #ecf5ff;
|
|||
|
|
text-align: right;
|
|||
|
|
padding: 6px 10px;
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-message.assistant {
|
|||
|
|
background: white;
|
|||
|
|
border: 1px solid #e4e7ed;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-message.assistant.compact {
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-message.assistant.normal {
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-message.assistant.spacious {
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ========== 执行日志/RAG消息专用样式 ========== */
|
|||
|
|
.execution-log-item.assistant {
|
|||
|
|
background: white;
|
|||
|
|
border: 1px solid #e4e7ed;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
padding: 8px;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item.assistant.compact {
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
padding: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item.assistant.normal {
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
padding: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item.assistant.spacious {
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
padding: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item.assistant .log-content.structured-content {
|
|||
|
|
/* 结构化内容使用rag样式 */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item.assistant .log-content.short-content {
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item.assistant .log-content.medium-content {
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item.assistant .log-content.long-content {
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-message-time {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: #999;
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-user-content {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #303133;
|
|||
|
|
padding: 0 8px 6px 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-ai-content {
|
|||
|
|
padding: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-ai-content.short-content {
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-ai-content.medium-content {
|
|||
|
|
padding: 8px 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-ai-content.structured-content {
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.rag-ai-content.long-content {
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-tasks {
|
|||
|
|
border-top: 1px solid #eee;
|
|||
|
|
padding-top: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .agent-tasks h4 {
|
|||
|
|
margin: 0 0 8px 0;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
background: white;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-item:hover {
|
|||
|
|
background: #f0f0f0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-item.pending {
|
|||
|
|
border-left: 3px solid #FFC107;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-item.running {
|
|||
|
|
border-left: 3px solid #2196F3;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-item.completed {
|
|||
|
|
border-left: 3px solid #4CAF50;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-item.failed {
|
|||
|
|
border-left: 3px solid #f44336;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-status {
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-message {
|
|||
|
|
flex: 1;
|
|||
|
|
font-size: 12px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-status-panel .task-progress {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: #666;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes slideDown {
|
|||
|
|
from { opacity: 0; transform: translateY(-10px); }
|
|||
|
|
to { opacity: 1; transform: translateY(0); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes fadeIn {
|
|||
|
|
from { opacity: 0; transform: translateY(10px); }
|
|||
|
|
to { opacity: 1; transform: translateY(0); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-chat-history {
|
|||
|
|
scroll-behavior: smooth;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.intent-card {
|
|||
|
|
animation: fadeIn 0.4s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-message.user {
|
|||
|
|
animation: fadeIn 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-message.assistant {
|
|||
|
|
animation: fadeIn 0.3s ease 0.1s both;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 合并后的Agent执行日志区域 */
|
|||
|
|
.agent-execution-panel {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
height: 100%;
|
|||
|
|
background: linear-gradient(180deg, #fafbfc 0%, #f0f4f8 100%);
|
|||
|
|
border-radius: 0px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 执行日志条目 - Zoom Out宽度 */
|
|||
|
|
.execution-log-item {
|
|||
|
|
padding: 6px 10px;
|
|||
|
|
margin-bottom: 6px;
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
border-left: 3px solid #409EFF;
|
|||
|
|
font-size: 12px;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
max-width: 50%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* RAG模式下执行日志更宽 */
|
|||
|
|
.execution-log-item.assistant {
|
|||
|
|
max-width: 95%;
|
|||
|
|
border-left: 3px solid #67C23A;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item .log-time {
|
|||
|
|
color: #909399;
|
|||
|
|
font-size: 11px;
|
|||
|
|
margin-bottom: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.execution-log-item .log-content {
|
|||
|
|
color: #303133;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 错误信息框 - 放在右侧底部 */
|
|||
|
|
.error-log-panel {
|
|||
|
|
margin-top: auto;
|
|||
|
|
padding: 12px;
|
|||
|
|
background: #fef0f0;
|
|||
|
|
border: 1px solid #fde2e2;
|
|||
|
|
border-radius: 0px;
|
|||
|
|
max-height: 200px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-log-panel pre {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 11px;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
color: #f56c6c;
|
|||
|
|
white-space: pre-wrap;
|
|||
|
|
word-break: break-all;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<!-- Vue未加载时的提示 -->
|
|||
|
|
<div id="loading-msg" style="padding: 50px; text-align: center; font-size: 18px; color: #666;">
|
|||
|
|
正在加载页面...
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="app" style="display: none;">
|
|||
|
|
<!-- Header -->
|
|||
|
|
<div class="header">
|
|||
|
|
<div style="display: flex; align-items: baseline; gap: 12px;">
|
|||
|
|
<h1 style="margin: 0; font-size: 28px; font-weight: 600;">🌱 PlantBioAgents</h1>
|
|||
|
|
<span style="font-size: 15px; opacity: 0.85; font-weight: 400;">A Multi-Agent Plant Bioinformation Analysis Platform integrating Agentic RetrievalAugmented Generation (RAG)</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="main-container">
|
|||
|
|
<!-- 左侧:控制区 -->
|
|||
|
|
<div class="left-panel">
|
|||
|
|
<!-- Analysis StageSelect -->
|
|||
|
|
<div class="btn-section">
|
|||
|
|
<h4>▶ Analysis Stage</h4>
|
|||
|
|
<el-select v-model="selectedStage" placeholder="SelectAnalysis Stage" style="width:100%" @change="onStageChange">
|
|||
|
|
<el-option label="===== 数据准备 =====" value="prepare_header" disabled></el-option>
|
|||
|
|
<el-option label="Genome Prepare - Genome数据预处理" value="genome_prepare"></el-option>
|
|||
|
|
<el-option label="===== 核心分析 =====" value="core_header" disabled></el-option>
|
|||
|
|
<el-option label="Identify - Gene Family Identification" value="identify"></el-option>
|
|||
|
|
<el-option label="Phylogeny - Phylogenetic Analysis" value="phylogeny"></el-option>
|
|||
|
|
<el-option label="PEP - Protein Property Analysis" value="pep"></el-option>
|
|||
|
|
<el-option label="Motif - Motif Analysis" value="motif"></el-option>
|
|||
|
|
<el-option label="Domain - Protein Domain" value="domain"></el-option>
|
|||
|
|
<el-option label="Structure - Gene Structure" value="structure"></el-option>
|
|||
|
|
<el-option label="Promoter - Cis-acting Elements" value="promoter"></el-option>
|
|||
|
|
<el-option label="Synteny - Synteny Analysis(Circos)" value="synteny"></el-option>
|
|||
|
|
<el-option label="Karyotype - Karyotype Analysis" value="karyotype"></el-option>
|
|||
|
|
<el-option label="Interspecies Synteny - 种间Synteny Analysis" value="interspecies_synteny"></el-option>
|
|||
|
|
<el-option label="PPI - Protein Interaction" value="ppi"></el-option>
|
|||
|
|
<el-option label="KaKs - KaKs Analysis" value="kaks"></el-option>
|
|||
|
|
<el-option label="Expression (使用FPKM) - Differential Expression" value="expression"></el-option>
|
|||
|
|
<el-option label="RNA-seq (Raw Data) - Complete Pipeline from FASTQ" value="rnaseq"></el-option>
|
|||
|
|
<el-option label="Combo - Combined Plot(Tree+Motif+Structure+Domain)" value="combo"></el-option>
|
|||
|
|
<el-option label="Run All - Complete Pipeline" value="all"></el-option>
|
|||
|
|
<!-- RNA-seq拆分选项 -->
|
|||
|
|
<el-option label="===== RNA-Seq 分析 =====" value="rnaseq_header" disabled></el-option>
|
|||
|
|
<el-option label="RNA-Seq QC - Quality Control" value="rnaseq_qc"></el-option>
|
|||
|
|
<el-option label="RNA-Seq Align - Alignment" value="rnaseq_align"></el-option>
|
|||
|
|
<el-option label="RNA-Seq Quant - Quantification" value="rnaseq_quant"></el-option>
|
|||
|
|
<el-option label="RNA-Seq DE - 差异表达" value="rnaseq_de"></el-option>
|
|||
|
|
<el-option label="Annotation - 背景注释(生成gene2go/gene2ko)" value="annotation"></el-option>
|
|||
|
|
<el-option label="RNA-Seq GO Enrich - GO富集分析" value="rnaseq_go_enrich"></el-option>
|
|||
|
|
<el-option label="RNA-Seq KEGG Enrich - KEGG富集分析" value="rnaseq_kegg_enrich"></el-option>
|
|||
|
|
<el-option label="RNA-Seq Enrich - Enrichment Analysis" value="rnaseq_enrich"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
|
|||
|
|
<el-button
|
|||
|
|
type="primary"
|
|||
|
|
style="width:100%; margin-top:8px;"
|
|||
|
|
@click="showConfirm"
|
|||
|
|
:disabled="!selectedStage || isExecuting"
|
|||
|
|
:loading="isExecuting">
|
|||
|
|
{{ isExecuting ? '⏳ Executing...' : '🚀 Execute Analysis' }}
|
|||
|
|
</el-button>
|
|||
|
|
|
|||
|
|
<!-- Stop Execution按钮 -->
|
|||
|
|
<el-button
|
|||
|
|
v-if="isExecuting"
|
|||
|
|
type="danger"
|
|||
|
|
style="width:100%; margin-top:8px;"
|
|||
|
|
@click="stopExecution"
|
|||
|
|
:disabled="isStopping">
|
|||
|
|
{{ isStopping ? '⏹ Stopping...' : '⏹ Stop Execution' }}
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Naming Convention示例 -->
|
|||
|
|
<div class="btn-section" style="background: #f0f9ff; border-left: 3px solid #409EFF;">
|
|||
|
|
<h4 style="color: #409EFF;">📋 File Naming Convention</h4>
|
|||
|
|
<div style="font-size: 11px; color: #606266; line-height: 1.8;">
|
|||
|
|
<div style="margin-bottom: 6px;"><b>Genome:</b> <code style="background: #e6f7ff; padding: 2px 6px; border-radius: 3px;">At.genome.fasta</code></div>
|
|||
|
|
<div style="margin-bottom: 6px;"><b>GFF:</b> <code style="background: #e6f7ff; padding: 2px 6px; border-radius: 3px;">At.gff3</code></div>
|
|||
|
|
<div style="margin-bottom: 6px;"><b>CDS:</b> <code style="background: #e6f7ff; padding: 2px 6px; border-radius: 3px;">At.cds.fasta</code></div>
|
|||
|
|
<div style="margin-bottom: 6px;"><b>PEP:</b> <code style="background: #e6f7ff; padding: 2px 6px; border-radius: 3px;">At.pep.fasta</code></div>
|
|||
|
|
<div style="margin-bottom: 6px;"><b>HMM:</b> <code style="background: #fff7e6; padding: 2px 6px; border-radius: 3px;">WRKY.hmm</code></div>
|
|||
|
|
<div><b>Subfamily:</b> <code style="background: #f6ffed; padding: 2px 6px; border-radius: 3px;">At_WRKY.subfamily</code></div>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 8px; font-size: 10px; color: #999;">
|
|||
|
|
💡 <i>At=Arabidopsis, WRKY=Gene Family</i>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 上传区域 -->
|
|||
|
|
<div class="btn-section">
|
|||
|
|
<h4>📁 Upload Files <span v-if="uploadedFiles.length > 0" style="font-size: 11px; color: #909399;">({{ uploadedFiles.length }})</span></h4>
|
|||
|
|
<el-upload
|
|||
|
|
class="upload-btn"
|
|||
|
|
action="/api/upload"
|
|||
|
|
name="files"
|
|||
|
|
:auto-upload="true"
|
|||
|
|
:on-success="handleUploadSuccess"
|
|||
|
|
:on-error="handleUploadError"
|
|||
|
|
:multiple="true"
|
|||
|
|
:file-list="uploadedFiles"
|
|||
|
|
show-file-list="true"
|
|||
|
|
style="max-height: 105px; overflow-y: auto;">
|
|||
|
|
<el-button type="info" style="width:100%">
|
|||
|
|
<el-icon><upload-filled /></el-icon> Click to Upload
|
|||
|
|
</el-button>
|
|||
|
|
</el-upload>
|
|||
|
|
|
|||
|
|
<el-divider style="margin: 10px 0;"></el-divider>
|
|||
|
|
|
|||
|
|
<!-- InputFile Path -->
|
|||
|
|
<el-input
|
|||
|
|
v-model="inputPath"
|
|||
|
|
placeholder="RNA-seq file path (e.g., /data/rnaseq/samples.yaml)"
|
|||
|
|
@keyup.enter="loadFromPath">
|
|||
|
|
<template #append>
|
|||
|
|
<el-button @click="loadFromPath">Load</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-input>
|
|||
|
|
|
|||
|
|
<!-- Download Examples -->
|
|||
|
|
<el-dropdown @command="handleDownloadExample" style="width:100%; margin-top:8px;">
|
|||
|
|
<el-button type="warning" size="small" style="width:100%">
|
|||
|
|
📥 Download Examples
|
|||
|
|
</el-button>
|
|||
|
|
<template #dropdown>
|
|||
|
|
<el-dropdown-menu>
|
|||
|
|
<el-dropdown-item command="samples">samples.yaml (RNA-seq完整配置-新格式)</el-dropdown-item>
|
|||
|
|
<el-dropdown-item command="geneID">geneID (基因ID列表)</el-dropdown-item>
|
|||
|
|
<el-dropdown-item command="sample">sample.txt (表达分析样本-旧格式)</el-dropdown-item>
|
|||
|
|
<el-dropdown-item command="fpkm">expression.fpkm.txt (表达量矩阵)</el-dropdown-item>
|
|||
|
|
<el-dropdown-item command="annotation_config">annotation_config.yaml (功能注释配置)</el-dropdown-item>
|
|||
|
|
</el-dropdown-menu>
|
|||
|
|
</template>
|
|||
|
|
</el-dropdown>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Basic Parameters -->
|
|||
|
|
<div class="btn-section">
|
|||
|
|
<div style="display: flex; gap: 10px; align-items: center;">
|
|||
|
|
<!-- Prefix?????config.xlsx???????????? -->
|
|||
|
|
<el-input v-model="params.cpu" placeholder="CPU" size="small" style="width: 60px;"></el-input>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Working Directory -->
|
|||
|
|
<div class="btn-section" style="flex: 1; min-height: 0; overflow: hidden;">
|
|||
|
|
<h4>📂 Working Directory</h4>
|
|||
|
|
<div class="work-dir" style="font-size: 10px;">
|
|||
|
|
<div class="dir-item">
|
|||
|
|
<span class="work-dir-label">Job:</span>
|
|||
|
|
<span class="dir-path">{{ jobInfo.job_id }}</span>
|
|||
|
|
<el-button size="small" type="text" @click="copyPath(jobInfo.job_id)">
|
|||
|
|
<el-icon><document-copy /></el-icon>
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
<div class="dir-item">
|
|||
|
|
<span class="work-dir-label">Input:</span>
|
|||
|
|
<span class="dir-path">{{ jobInfo.input_dir }}</span>
|
|||
|
|
<el-button size="small" type="text" @click="copyPath(jobInfo.input_dir)">
|
|||
|
|
<el-icon><document-copy /></el-icon>
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
<div class="dir-item">
|
|||
|
|
<span class="work-dir-label">Output:</span>
|
|||
|
|
<span class="dir-path">{{ jobInfo.output_dir }}</span>
|
|||
|
|
<el-button size="small" type="text" @click="copyPath(jobInfo.output_dir)">
|
|||
|
|
<el-icon><document-copy /></el-icon>
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
|||
|
|
<el-button size="small" type="warning" style="flex: 1;" @click="createNewJob">
|
|||
|
|
➕ New
|
|||
|
|
</el-button>
|
|||
|
|
<el-button size="small" type="primary" style="flex: 1;" @click="showJobSelector">
|
|||
|
|
📂 Select
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 中间:合并后的Agent执行日志区 -->
|
|||
|
|
<div class="middle-panel">
|
|||
|
|
<!-- 合并后的Agent智能助手头部 -->
|
|||
|
|
<div class="agent-header" style="margin-bottom: 10px; padding: 10px 12px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 0px; color: white; flex-shrink: 0;">
|
|||
|
|
<!-- 标题行 -->
|
|||
|
|
<div style="font-size: 18px; font-weight: 700; margin-bottom: 8px; display: flex; align-items: center;">
|
|||
|
|
<el-icon style="margin-right: 8px; font-size: 20px;"><chat-dot-round /></el-icon>
|
|||
|
|
Agent Assistant / Execution Log
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 模型选择和模式切换行 -->
|
|||
|
|
<div style="font-size: 13px; opacity: 0.95; display: flex; gap: 15px; align-items: center; justify-content: space-between;">
|
|||
|
|
<div style="display: flex; gap: 12px; align-items: center;">
|
|||
|
|
<!-- 模型Select器 -->
|
|||
|
|
<el-select v-model="selectedModel" size="small" style="width: 140px;" popper-class="model-select-dropdown" @change="onModelChange">
|
|||
|
|
<el-option key="kimi" label="🧠 Kimi (Default)" value="kimi"></el-option>
|
|||
|
|
<el-option key="glm" label="🔬 GLM-4" value="glm"></el-option>
|
|||
|
|
<el-option key="minimax" label="⚡ MiniMax" value="minimax"></el-option>
|
|||
|
|
<el-option key="llama3" label="🦙 Llama3 (Local)" value="llama3"></el-option>
|
|||
|
|
<el-option key="qwen2" label="🤖 Qwen2 (Local)" value="qwen2"></el-option>
|
|||
|
|
<el-option key="ollama" label="📍 Ollama (Local)" value="ollama"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
<span v-if="agentSession.messages.length > 0">Rounds: {{ Math.floor(agentSession.messages.length / 2) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div style="display: flex; gap: 10px; align-items: center;">
|
|||
|
|
<!-- 偏好模式切换按钮 -->
|
|||
|
|
<span style="font-size: 11px; opacity: 0.8; margin-right: 5px;">Preference:</span>
|
|||
|
|
<el-switch
|
|||
|
|
v-model="preferenceMode"
|
|||
|
|
active-text="📚 Literature"
|
|||
|
|
inactive-text="🔧 Skill"
|
|||
|
|
@change="togglePreferenceMode"
|
|||
|
|
style="--el-switch-on-color: #67C23A; --el-switch-off-color: #409EFF;">
|
|||
|
|
</el-switch>
|
|||
|
|
<el-button v-if="agentSession.messages.length > 0" size="small" type="info" plain @click="clearAgentSession">
|
|||
|
|
<el-icon><delete /></el-icon> Clear
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 会话选择器 - 红框区域 -->
|
|||
|
|
<div style="background: white; border: 1px solid #e4e7ed; border-radius: 0px; padding: 10px 15px; margin-bottom: 10px; display: flex; align-items: center; gap: 15px; flex-shrink: 0;">
|
|||
|
|
<span style="font-size: 13px; color: #606266; white-space: nowrap;">💬 当前会话:</span>
|
|||
|
|
|
|||
|
|
<!-- 最近会话快捷按钮 -->
|
|||
|
|
<div style="display: flex; gap: 8px; flex: 1; overflow-x: auto;">
|
|||
|
|
<el-button
|
|||
|
|
v-for="conv in recentConversations"
|
|||
|
|
:key="conv.conversation_id"
|
|||
|
|
:type="currentConversationId === conv.conversation_id ? 'primary' : 'default'"
|
|||
|
|
size="small"
|
|||
|
|
@click="selectConversation(conv)"
|
|||
|
|
style="white-space: nowrap;">
|
|||
|
|
{{ conv.title }}
|
|||
|
|
<el-tag v-if="conv.gene_family" size="small" type="success" style="margin-left: 5px;">{{ conv.gene_family }}</el-tag>
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 更多会话下拉框 -->
|
|||
|
|
<el-dropdown @command="selectConversation" trigger="click">
|
|||
|
|
<el-button type="default" size="small">
|
|||
|
|
更多会话 <el-icon><arrow-down /></el-icon>
|
|||
|
|
</el-button>
|
|||
|
|
<template #dropdown>
|
|||
|
|
<el-dropdown-menu>
|
|||
|
|
<el-dropdown-item
|
|||
|
|
v-for="conv in moreConversations"
|
|||
|
|
:key="conv.conversation_id"
|
|||
|
|
:command="conv">
|
|||
|
|
<div style="display: flex; align-items: center; justify-content: space-between; width: 200px;">
|
|||
|
|
<span>{{ conv.title }}</span>
|
|||
|
|
<el-tag v-if="conv.gene_family" size="small" type="success">{{ conv.gene_family }}</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</el-dropdown-item>
|
|||
|
|
<el-dropdown-item divided @click="createNewConversation">
|
|||
|
|
<el-icon><plus /></el-icon> 新建会话
|
|||
|
|
</el-dropdown-item>
|
|||
|
|
</el-dropdown-menu>
|
|||
|
|
</template>
|
|||
|
|
</el-dropdown>
|
|||
|
|
|
|||
|
|
<!-- 新建会话按钮 -->
|
|||
|
|
<el-button type="primary" size="small" @click="createNewConversation">
|
|||
|
|
<el-icon><plus /></el-icon> 新建
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 合并后的对话和执行日志区域 -->
|
|||
|
|
<div class="agent-chat-history" ref="agentChatArea" style="flex: 1; overflow-y: auto; margin-bottom: 8px; border: 1px solid #e4e7ed; border-radius: 0px; padding: 10px; background: #fafbfc; min-height: 150px;">
|
|||
|
|
<!-- 欢迎消息 -->
|
|||
|
|
<div v-if="agentSession.messages.length === 0 && messages.length === 0" style="text-align: center; padding: 20px 15px; color: #909399;">
|
|||
|
|
<el-icon style="font-size: 36px; margin-bottom: 10px;"><chat-square /></el-icon>
|
|||
|
|
<div style="font-size: 14px; margin-bottom: 8px;">欢迎使用 BotanicalBaseAgent</div>
|
|||
|
|
<div style="font-size: 12px; line-height: 1.5;">
|
|||
|
|
基因鉴定、进化树、Motif Analysis、结构域预测<br>
|
|||
|
|
<el-tag size="small" type="info" style="margin-top:8px;">💡 试试说:"帮我分析 Ft SPL 基因家族"</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Agent对话消息 - 动态间距 -->
|
|||
|
|
<div v-for="(msg, idx) in agentSession.messages" :key="'agent_'+idx"
|
|||
|
|
:class="['agent-message', msg.role, getMessageSpacingClass(msg.content)]"
|
|||
|
|
style="animation: fadeIn 0.3s ease;">
|
|||
|
|
<!-- 用户消息 - 动态样式 -->
|
|||
|
|
<div v-if="msg.role === 'user'" :class="['agent-message', getMessageSpacingClass(msg.content)]" style="display: flex; justify-content: flex-end;">
|
|||
|
|
<div :class="['message-bubble', getContentClass(msg.content)]" style="max-width: 80%; background: #409EFF; color: white; border-radius: 0px 12px 2px 12px; box-shadow: 0 2px 8px rgba(64,158,255,0.3);">
|
|||
|
|
<div class="message-text">{{ msg.content }}</div>
|
|||
|
|
<div style="font-size: 11px; opacity: 0.8; margin-top: 4px; text-align: right;">{{ msg.time }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="width: 36px; height: 36px; border-radius: 50%; background: #409EFF; margin-left: 10px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold;">我</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- AI消息 - 动态样式 -->
|
|||
|
|
<div v-else style="display: flex;">
|
|||
|
|
<div style="width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, #67C23A 0%, #85ce61 100%); margin-right: 10px; display: flex; align-items: center; justify-content: center; color: white;">
|
|||
|
|
<el-icon><magic-stick /></el-icon>
|
|||
|
|
</div>
|
|||
|
|
<div style="max-width: 80%;">
|
|||
|
|
<div :class="['message-bubble', getContentClass(msg.content)]"
|
|||
|
|
style="background: white; border-radius: 0px 12px 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border: 1px solid #e4e7ed;">
|
|||
|
|
<div class="message-text" style="color: #303133;" v-html="msg.isHtml ? msg.content : formatMessage(msg.content, true)"></div>
|
|||
|
|
<div style="font-size: 11px; color: #909399; margin-top: 6px;">{{ msg.time }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="msg.intent" class="intent-card" style="margin-top: 6px; padding: 8px 10px; background: #f0f9eb; border-radius: 0px; border-left: 3px solid #67C23A;">
|
|||
|
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
|
|||
|
|
<el-icon style="color: #67C23A;"><check /></el-icon>
|
|||
|
|
<span style="font-weight: 600; color: #67C23A; font-size: 12px;">意图识别成功</span>
|
|||
|
|
<el-tag size="small" :type="getTaskTypeColor(msg.intent.taskType)" style="margin-left: auto;">{{ msg.intent.taskType || '未知任务' }}</el-tag>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="msg.intent.stage" style="font-size: 11px; color: #606266;">
|
|||
|
|
<b>Analysis Stage:</b> {{ getStageLabel(msg.intent.stage) }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 执行日志/RAG消息 - 动态样式 -->
|
|||
|
|
<div v-for="(msg, idx) in messages" :key="'log_'+idx"
|
|||
|
|
:class="['execution-log-item', msg.role, getMessageSpacingClass(msg.content)]">
|
|||
|
|
<div class="log-time">{{ msg.time }}</div>
|
|||
|
|
<!-- RAG AI消息使用动态格式化 -->
|
|||
|
|
<div v-if="ragMode && msg.role === 'assistant'"
|
|||
|
|
:class="['log-content', getContentClass(msg.content)]"
|
|||
|
|
v-html="formatMessage(msg.content, true)"></div>
|
|||
|
|
<!-- 其他消息保持原样 -->
|
|||
|
|
<div v-else class="log-content" v-html="msg.content"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 加载指示器 -->
|
|||
|
|
<div v-if="nlLoading" style="display: flex; align-items: center; justify-content: center; padding: 20px;">
|
|||
|
|
<el-icon class="is-loading" style="font-size: 24px; color: #409EFF;"><loading /></el-icon>
|
|||
|
|
<span style="margin-left: 10px; color: #606266;">AI 正在思考中...</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- V2: RAG对话历史显示 - 动态样式 -->
|
|||
|
|
<div v-if="ragMode && ragHistory.length > 0" class="rag-history-panel">
|
|||
|
|
<div v-for="(msg, idx) in ragHistory.slice(-5)" :key="idx"
|
|||
|
|
:class="['rag-message', msg.role, getMessageSpacingClass(msg.content)]">
|
|||
|
|
<div class="rag-message-time">{{ msg.time }}</div>
|
|||
|
|
<!-- 用户消息:简单显示 -->
|
|||
|
|
<div v-if="msg.role === 'user'" class="rag-user-content">{{ msg.content }}</div>
|
|||
|
|
<!-- AI消息:使用动态格式化 -->
|
|||
|
|
<div v-else :class="['rag-ai-content', getContentClass(msg.content)]" v-html="formatMessage(msg.content, true)"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 自然语言Input框 -->
|
|||
|
|
<div class="nl-input-area" style="flex-shrink: 0; padding-top: 5px;">
|
|||
|
|
<el-input
|
|||
|
|
v-model="nlInput"
|
|||
|
|
:placeholder="ragMode ? 'Input问题,如:SPL基因家族功能' : '描述需求,如:帮我分析Ft SPL基因家族'"
|
|||
|
|
@keyup.enter="handleNlInput"
|
|||
|
|
:disabled="executing"
|
|||
|
|
type="textarea"
|
|||
|
|
:rows="2"
|
|||
|
|
style="width:100%;">
|
|||
|
|
</el-input>
|
|||
|
|
<el-button @click="handleNlInput" :loading="nlLoading" :disabled="executing" style="margin-top: 5px; width:100%; height: 32px;" :type="ragMode ? 'success' : 'primary'">
|
|||
|
|
{{ ragMode ? '📚 Ask' : '💬 Send' }}
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 右侧:结果展示区(新版:上部分文件列表 + 下部分图片展示) -->
|
|||
|
|
<div class="right-panel">
|
|||
|
|
<h4 style="margin: 0 0 8px 0; font-size: 14px;">📊 Analysis Results</h4>
|
|||
|
|
|
|||
|
|
<!-- Running Async Task -->
|
|||
|
|
<div v-if="currentTask && currentTask.stage" style="margin-bottom: 8px; padding: 8px; background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 0px;">
|
|||
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|||
|
|
<div style="font-size: 16px; color: #1890ff; animation: rotating 2s linear infinite;">⏳</div>
|
|||
|
|
<div style="flex: 1;">
|
|||
|
|
<div style="font-weight: 500; color: #096dd9; font-size: 12px;">
|
|||
|
|
{{ currentTask.stage.toUpperCase() }} Executing
|
|||
|
|
</div>
|
|||
|
|
<div style="font-size: 11px; color: #666;">
|
|||
|
|
⏱️ {{ executeElapsed }}s | Refresh every 5s
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="display: flex; gap: 6px;">
|
|||
|
|
<el-button
|
|||
|
|
type="danger"
|
|||
|
|
size="small"
|
|||
|
|
@click="stopExecution"
|
|||
|
|
:loading="isStopping"
|
|||
|
|
:disabled="isStopping">
|
|||
|
|
⏹ Stop
|
|||
|
|
</el-button>
|
|||
|
|
<el-tag type="primary" size="small">Polling</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
@keyframes rotating {
|
|||
|
|
from { transform: rotate(0deg); }
|
|||
|
|
to { transform: rotate(360deg); }
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
|
|||
|
|
<!-- Result Files Section -->
|
|||
|
|
<div class="result-file-section" style="flex: 1; overflow-y: auto; min-height: 0;">
|
|||
|
|
<el-collapse v-model="activeStages" accordion style="font-size: 12px;">
|
|||
|
|
<!-- MCScanX HTML结果(独立项,可展开) -->
|
|||
|
|
<el-collapse-item name="mcscanx_html" v-if="mcscanxHtmlFiles && mcscanxHtmlFiles.length > 0">
|
|||
|
|
<template #title>
|
|||
|
|
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;padding-right:10px;">
|
|||
|
|
<span>
|
|||
|
|
<span class="status success" style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px;"></span>
|
|||
|
|
🔗 MCScanX HTML Results
|
|||
|
|
</span>
|
|||
|
|
<span style="color:#999;font-size:12px;">{{ mcscanxHtmlFiles.length }} files</span>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
<div class="file-list">
|
|||
|
|
<div
|
|||
|
|
v-for="f in mcscanxHtmlFiles"
|
|||
|
|
:key="f.name"
|
|||
|
|
class="file-item"
|
|||
|
|
@click="openMcscanxHtml(f)">
|
|||
|
|
<span>🌐 {{ f.name }}</span>
|
|||
|
|
<span style="color:#999;font-size:11px;">{{ formatSize(f.size) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</el-collapse-item>
|
|||
|
|
|
|||
|
|
<el-collapse-item
|
|||
|
|
v-for="stage in stages"
|
|||
|
|
:key="stage.name"
|
|||
|
|
:name="stage.name"
|
|||
|
|
:title="stage.label + ' (' + (stage.files ? stage.files.length : 0) + ')'">
|
|||
|
|
|
|||
|
|
<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 v-if="stage.elapsed_time > 0" style="color:#67c23a;font-size:11px;margin-left:6px;">(Time: {{ stage.elapsed_time }}s)</span>
|
|||
|
|
</span>
|
|||
|
|
<span style="color:#999;font-size:12px;">{{ (stage.files ? stage.files.length : 0) + (stage.images ? stage.images.length : 0) }} files</span>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<!-- 图片文件列表 -->
|
|||
|
|
<div v-if="stage.images && stage.images.length" class="file-list">
|
|||
|
|
<div
|
|||
|
|
v-for="(img, idx) in stage.images"
|
|||
|
|
:key="'img_'+idx"
|
|||
|
|
class="file-item"
|
|||
|
|
:class="{ active: selectedFile === getFileName(img) && selectedResultStage === stage.name }"
|
|||
|
|
@click="selectImageFile(stage.name, img)">
|
|||
|
|
<span>🖼️ {{ getFileName(img) }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 普通文件列表 -->
|
|||
|
|
<div v-if="stage.files && stage.files.length" class="file-list">
|
|||
|
|
<div
|
|||
|
|
v-for="f in stage.files"
|
|||
|
|
:key="f.name"
|
|||
|
|
class="file-item"
|
|||
|
|
:class="{ active: selectedFile === f.name && selectedResultStage === stage.name }"
|
|||
|
|
@click="selectFile(stage.name, f)">
|
|||
|
|
<span>
|
|||
|
|
{{ isImageFile(f.name) ? '🖼️' : (f.name.endsWith('.ppi_url.txt') ? '🔗' : '📄') }}
|
|||
|
|
{{ f.name }}
|
|||
|
|
<span v-if="f.name.endsWith('.ppi_url.txt')" style="color:#409EFF;font-size:11px;">(点击打开STRING)</span>
|
|||
|
|
</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;">No files</div>
|
|||
|
|
</el-collapse-item>
|
|||
|
|
</el-collapse>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Agent Tasks列表 -->
|
|||
|
|
<div v-if="agentTasks.length > 0" class="agent-tasks-section" style="margin-bottom: 10px; border: 1px solid #e4e7ed; border-radius: 0px; padding: 8px; background: #f5f7fa;">
|
|||
|
|
<div style="font-weight: bold; color: #409EFF; margin-bottom: 6px; font-size: 12px;">
|
|||
|
|
<el-icon><list /></el-icon> Agent Tasks ({{ agentTasks.length }})
|
|||
|
|
</div>
|
|||
|
|
<div v-for="task in agentTasks.slice(-5)" :key="task.task_id"
|
|||
|
|
style="padding: 6px; margin-bottom: 4px; border-radius: 0px; font-size: 11px; cursor: pointer;"
|
|||
|
|
:style="task.status === 'completed' ? 'background: #f0f9eb; border-left: 3px solid #67C23A;' :
|
|||
|
|
task.status === 'failed' ? 'background: #fef0f0; border-left: 3px solid #f56c6c;' :
|
|||
|
|
task.status === 'running' ? 'background: #fdf6ec; border-left: 3px solid #e6a23c;' :
|
|||
|
|
'background: white; border-left: 3px solid #909399;'"
|
|||
|
|
@click="showTaskDetail(task)">
|
|||
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|||
|
|
<span style="font-weight: 500;">{{ task.message.substring(0, 20) }}{{ task.message.length > 20 ? '...' : '' }}</span>
|
|||
|
|
<el-tag size="small" :type="task.status === 'completed' ? 'success' : task.status === 'failed' ? 'danger' : task.status === 'running' ? 'warning' : 'info'" style="font-size: 10px;">
|
|||
|
|
{{ task.status === 'completed' ? '✓' : task.status === 'failed' ? '✗' : task.status === 'running' ? '⟳' : '⏸' }} {{ task.progress }}%
|
|||
|
|
</el-tag>
|
|||
|
|
</div>
|
|||
|
|
<div style="color: #909399; font-size: 10px; margin-top: 2px;">
|
|||
|
|
{{ task.current_stage || 'Pending' }} | {{ formatTime(task.start_time) }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 下部分:Image Preview(Zoom In版)-->
|
|||
|
|
<div class="preview-section" style="flex-shrink: 0; min-height: 280px; max-height: 400px;">
|
|||
|
|
<div class="preview-header" style="padding: 8px 12px; display: flex; align-items: center; gap: 10px;">
|
|||
|
|
<!-- 固定名称位置,多出来隐藏 -->
|
|||
|
|
<span class="preview-title" style="font-size: 13px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="selectedImage">
|
|||
|
|
<span v-if="selectedImage">{{ selectedImage }}</span>
|
|||
|
|
<span v-else style="color:#999;">Select Image</span>
|
|||
|
|
</span>
|
|||
|
|
<!-- 功能按钮 -->
|
|||
|
|
<div class="preview-tools" v-if="selectedImage" style="gap: 6px; display: flex; flex-shrink: 0;">
|
|||
|
|
<el-button size="small" @click="zoomIn" title="Zoom In" style="padding: 6px 12px; min-width: 36px;">➕</el-button>
|
|||
|
|
<el-button size="small" @click="zoomOut" title="Zoom Out" style="padding: 6px 12px; min-width: 36px;">➖</el-button>
|
|||
|
|
<el-button size="small" @click="resetZoom" title="Reset" style="padding: 6px 12px; min-width: 36px;">⟲</el-button>
|
|||
|
|
<el-button size="small" @click="downloadImage" title="Download" style="padding: 6px 12px; min-width: 36px;">⬇</el-button>
|
|||
|
|
<!-- 新增全屏按钮 -->
|
|||
|
|
<el-button size="small" type="primary" @click="toggleFullscreen" title="Fullscreen" style="padding: 6px 12px; min-width: 60px;">
|
|||
|
|
<el-icon><full-screen /></el-icon> 全屏
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- Zoom In预览区域 -->
|
|||
|
|
<div class="preview-content" ref="previewContentRef" style="height: 220px; min-height: 200px;">
|
|||
|
|
<div v-if="selectedImage" class="img-container" :style="{ transform: 'scale(' + zoomLevel/100 + ')' }">
|
|||
|
|
<img :src="selectedImageUrl" :alt="selectedImage" @load="onImageLoad" @error="onImageError" style="max-height: 200px; min-height: 180px;">
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="preview-placeholder" style="font-size: 14px;">
|
|||
|
|
<el-icon style="font-size: 48px; margin-bottom: 10px;"><picture-filled /></el-icon>
|
|||
|
|
<span>Select图片查看</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
|||
|
|
<el-button size="small" @click="refreshResults" style="flex: 1;">
|
|||
|
|
<el-icon><refresh /></el-icon> Refresh
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 执行日志显示框 - 成功和失败都显示 -->
|
|||
|
|
<div v-if="logContent" class="execution-log-panel" style="margin-top: 10px; margin-left: -10px; margin-right: -10px; border: 2px solid #dcdfe6; border-radius: 0px; padding: 10px; background: #f5f7fa; max-height: 300px; overflow: hidden;"
|
|||
|
|
:style="logContent.includes('Error') || logContent.includes('Traceback') || logContent.includes('失败') ? 'border-color: #f56c6c;' : 'border-color: #67c23a;'">
|
|||
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|||
|
|
<span style="font-weight: bold; font-size: 12px;" :style="logContent.includes('Error') || logContent.includes('Traceback') || logContent.includes('失败') ? 'color: #f56c6c;' : 'color: #67c23a;'">
|
|||
|
|
<el-icon style="margin-right: 4px;"><warning /></el-icon>
|
|||
|
|
{{ logContent.includes('Error') || logContent.includes('Traceback') || logContent.includes('失败') ? 'Execution Error' : 'Execution Log' }}
|
|||
|
|
</span>
|
|||
|
|
<el-button size="small" type="info" link @click="clearErrorLog" style="font-size: 11px;">Clear</el-button>
|
|||
|
|
</div>
|
|||
|
|
<pre style="font-size: 11px; max-height: 150px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; background: #fff; padding: 8px; border-radius: 0px; border: 1px solid #e4e7ed;">{{ logContent }}</pre>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 参数确认对话框 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="confirmDialogVisible"
|
|||
|
|
title="⚙️ 参数确认与调整"
|
|||
|
|
width="600px"
|
|||
|
|
:close-on-click-modal="false">
|
|||
|
|
|
|||
|
|
<div class="param-dialog">
|
|||
|
|
<div class="param-group">
|
|||
|
|
<h5>Basic Parameters</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<!-- Prefix?????config.xlsx???????????? -->
|
|||
|
|
<el-input-number v-model="confirmParams.cpu" :min="1" :max="32">
|
|||
|
|
<template #prepend>CPU</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Genome Prepare 参数 -->
|
|||
|
|
<div v-if="confirmParams.stage === 'genome_prepare'" class="param-group">
|
|||
|
|
<h5>Genome Prepare 参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="SelectGenome数据来源格式,auto会自动检测" placement="top">
|
|||
|
|
<el-select v-model="confirmParams.genomePrepareType" placeholder="Select数据库类型" style="width:100%">
|
|||
|
|
<el-option label="自动检测 (auto)" value="auto" />
|
|||
|
|
<el-option label="JGI 格式" value="jgi" />
|
|||
|
|
<el-option label="Ensembl 格式" value="ensembl" />
|
|||
|
|
<el-option label="NCBI 格式" value="ncbi" />
|
|||
|
|
</el-select>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #f6f8fa; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>📦 Input文件要求(支持.gz压缩):</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
<div><b>JGI:</b> *protein_primaryTranscriptOnly.fa + *gene_exons.gff3 (+ *genome.fa)</div>
|
|||
|
|
<div><b>Ensembl:</b> *.pep.all.fa + *.gff3 (+ *.cds.all.fa)</div>
|
|||
|
|
<div><b>NCBI:</b> *_protein.faa + *_genomic.gff (+ *_cds_from_genomic.fna)</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="color: #409EFF; margin-top: 8px;">
|
|||
|
|
💡 <b>智能提取:</b>如果没有蛋白文件,但有 genome.fa + GFF,系统会自动用 gffread 提取蛋白序列
|
|||
|
|
</div>
|
|||
|
|
<div style="color: #67C23A; margin-top: 8px;">
|
|||
|
|
✅ Output文件将保存到 input/{job_id}/ 目录供后续分析使用
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="confirmParams.stage === 'identify'" class="param-group">
|
|||
|
|
<h5>Identify 参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="Pfam Domain 显著性阈值,越小越严格" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.idEvalue" :step="0.001" :min="0" style="width:100%">
|
|||
|
|
<template #prepend>Domain E-value</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
<el-tooltip content="HMM 模型覆盖度阈值,用于过滤低覆盖度匹配" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.idCoverage" :step="0.05" :min="0" :max="1" style="width:100%">
|
|||
|
|
<template #prepend>HMM Coverage</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="BLAST 显著性阈值,用于初步筛选候选基因" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.blastEvalue" :step="0.0001" :min="0" style="width:100%">
|
|||
|
|
<template #prepend>Blast E-value</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="必需包含的 Pfam 蛋白家族ID,多个用逗号分隔" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.requiredPfams" placeholder="如 SBP,LRR" style="width:100%">
|
|||
|
|
<template #prepend>Required Pfams</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="用于辅助鉴定的参考物种,多个用逗号分隔" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.refSpecies" placeholder="如 Arabidopsis_thaliana" style="width:100%">
|
|||
|
|
<template #prepend>参考物种</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="confirmParams.stage === 'phylogeny'" class="param-group">
|
|||
|
|
<h5>Phylogeny 参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="MAFFT 多序列Alignment参数,如 --auto --thread -1" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.mafftParams">
|
|||
|
|
<template #prepend>MAFFT</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="IQ-TREE 建树参数,如 -m MFP -bb 1000" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.iqtreeParams">
|
|||
|
|
<template #prepend>IQ-TREE</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="confirmParams.stage === 'motif'" class="param-group">
|
|||
|
|
<h5>Motif 参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="MEME 挖掘的保守 Motif 数量" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.nmotifs" :min="1" :max="50" style="width:100%">
|
|||
|
|
<template #prepend>Motif数量</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="Motif 最小氨基酸长度" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.minw" :min="3" :max="20" style="width:100%">
|
|||
|
|
<template #prepend>最小宽度</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
<el-tooltip content="Motif 最大氨基酸长度" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.maxw" :min="10" :max="300" style="width:100%">
|
|||
|
|
<template #prepend>最大宽度</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="confirmParams.stage === 'domain'" class="param-group">
|
|||
|
|
<h5>Domain 参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="蛋白质结构域检索的 E-value 阈值" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.domEvalue" :step="0.001" :min="0" style="width:100%">
|
|||
|
|
<template #prepend>E-value</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
<el-tooltip content="蛋白质结构域覆盖度阈值" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.domCoverage" :step="0.05" :min="0" :max="1" style="width:100%">
|
|||
|
|
<template #prepend>Coverage</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Expression 参数 -->
|
|||
|
|
<div v-if="confirmParams.stage === 'expression'" class="param-group">
|
|||
|
|
<h5>Expression 参数 (FPKM/TPM矩阵)</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="从NCBI GEODownload表达量数据,留空则使用已Upload Files" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.geoId" placeholder="InputGEO编号 (如 GSE111937)" style="width:100%">
|
|||
|
|
<template #prepend>GEO编号</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="margin-top:10px">
|
|||
|
|
<el-tooltip content="基因ID转换方式:去除.T01后缀或添加.T01后缀" placement="top">
|
|||
|
|
<el-select v-model="confirmParams.idTransform" placeholder="SelectID转换方式" style="width:100%">
|
|||
|
|
<el-option label="不转换 (none)" value="none" />
|
|||
|
|
<el-option label="去除.T01后缀 (remove_T01)" value="remove_T01" />
|
|||
|
|
<el-option label="添加.T01后缀 (add_T01)" value="add_T01" />
|
|||
|
|
</el-select>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #f6f8fa; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>📊 表达量数据来源(Select一种):</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
<div>✅ <b>方式1:</b>直接上传 - 上传表达量矩阵(*.fpkm.txt/*.tpm.txt)+基因ID+sample.txt到Input目录</div>
|
|||
|
|
<div>✅ <b>方式2:</b>GEO编号 - InputGEO编号(如 GSE111937)自动Download</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RNA-seq 参数 -->
|
|||
|
|
<div v-if="confirmParams.stage === 'rnaseq'" class="param-group">
|
|||
|
|
<h5>RNA-seq 参数 (原始FASTQ数据Complete Pipeline)</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="SelectInput模式:上传模式需要手动上传参考Genome和GTF,Download模式可自动检索物种数据" placement="top">
|
|||
|
|
<el-select v-model="confirmParams.rnaseqMode" placeholder="SelectInput模式" style="width:100%">
|
|||
|
|
<el-option label="上传模式 (upload) - 上传参考Genome+GTF" value="upload" />
|
|||
|
|
<el-option label="检索Download模式 (download) - 自动检索物种数据" value="download" />
|
|||
|
|
</el-select>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="margin-top:10px" v-if="confirmParams.rnaseqMode === 'download'">
|
|||
|
|
<el-tooltip content="Input物种拉丁名,如 Arabidopsis thaliana" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.speciesName" placeholder="Input物种拉丁名 (如 Arabidopsis thaliana)" style="width:100%">
|
|||
|
|
<template #prepend>物种名</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #f6f8fa; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>🔬 RNA-seq 数据来源:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
<div v-if="confirmParams.rnaseqMode === 'upload'">
|
|||
|
|
✅ <b>上传模式:</b>需要上传以下文件到Input目录:<br>
|
|||
|
|
• 参考Genome (*.fa/*.fasta)<br>
|
|||
|
|
• 基因注释 (*.gtf/*.gff)<br>
|
|||
|
|
• 样本配置文件 (samples.yaml)<br>
|
|||
|
|
• 原始测序文件 (*_R1.fastq.gz, *_R2.fastq.gz)
|
|||
|
|
</div>
|
|||
|
|
<div v-else>
|
|||
|
|
🔍 <b>检索Download模式:</b>Input物种拉丁名后,系统会自动搜索并返回参考Genome和GTF的FTPDownload链接
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RNA-seq QC 参数 -->
|
|||
|
|
<div v-if="confirmParams.stage === 'rnaseq_qc'" class="param-group">
|
|||
|
|
<h5>RNA-seq QC 参数 (Quality Control - Step 1)</h5>
|
|||
|
|
<div class="param-row" style="margin-bottom:10px">
|
|||
|
|
<el-tooltip content="Select数据来源方式:Upload Files或指定已有数据路径" placement="top">
|
|||
|
|
<el-select v-model="confirmParams.rnaseqDataMode" placeholder="Select数据来源" style="width:100%">
|
|||
|
|
<el-option label="📤 Upload Files到input目录" value="upload" />
|
|||
|
|
<el-option label="🔗 使用已有数据路径(自动创建软链接)" value="symlink" />
|
|||
|
|
</el-select>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="confirmParams.rnaseqDataMode === 'symlink'" class="param-row" style="margin-bottom:10px">
|
|||
|
|
<el-tooltip content="填写Raw Data所在目录的绝对路径,多个路径用逗号分隔,如 /data/SRR1,/data/SRR2" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.rnaseqDataPath" placeholder="填写数据目录绝对路径,多个路径用逗号分隔" style="width:100%">
|
|||
|
|
<template #prepend>数据路径</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #f6f8fa; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>📋 所需文件:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;" v-if="confirmParams.rnaseqDataMode === 'upload'">
|
|||
|
|
• 样本配置文件 <b>samples.yaml</b><br>
|
|||
|
|
• 原始测序文件 <b>*_R1.fastq.gz, *_R2.fastq.gz</b><br>
|
|||
|
|
• 参考Genome <b>*.fa/*.fasta</b><br>
|
|||
|
|
• 基因注释 <b>*.gtf/*.gff</b><br>
|
|||
|
|
<br>
|
|||
|
|
<div style="color: #E6A23C;">💡 可通过上方"Download Examples文件"获取samples.yaml模板</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;" v-else>
|
|||
|
|
• 数据路径下需包含:<b>samples.yaml</b> + <b>FASTQ文件</b><br>
|
|||
|
|
• input目录仍需上传:<b>参考Genome</b> + <b>基因注释</b><br>
|
|||
|
|
• 支持多个路径(逗号分隔),自动合并所有FASTQ文件<br>
|
|||
|
|
• 系统将自动创建FASTQ文件的软链接<br>
|
|||
|
|
<br>
|
|||
|
|
<div style="color: #E6A23C;">💡 适合大数据量/多组织/多时期,避免重复上传</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RNA-seq Align 参数 -->
|
|||
|
|
<div v-if="confirmParams.stage === 'rnaseq_align'" class="param-group">
|
|||
|
|
<h5>RNA-seq Align 参数 (Alignment - Step 2)</h5>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #e6f7ff; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>✅ 自动使用上一步Output:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• 使用 QC Output的 clean reads<br>
|
|||
|
|
• 使用上一步的参考Genome和GTF<br>
|
|||
|
|
<br>
|
|||
|
|
<b>无需再次Upload Files</b>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RNA-seq Quant 参数 -->
|
|||
|
|
<div v-if="confirmParams.stage === 'rnaseq_quant'" class="param-group">
|
|||
|
|
<h5>RNA-seq Quant 参数 (Quantification - Step 3)</h5>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #e6f7ff; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>✅ 自动使用上一步Output:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• 使用 Align Output的 BAM 文件<br>
|
|||
|
|
• 使用上一步的 GTF 注释<br>
|
|||
|
|
<br>
|
|||
|
|
<b>无需再次Upload Files</b>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RNA-seq DE 参数 -->
|
|||
|
|
<div v-if="confirmParams.stage === 'rnaseq_de'" class="param-group">
|
|||
|
|
<h5>RNA-seq DE 参数 (差异表达 - Step 4)</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.rnaseqForegroundMode" style="width:100%">
|
|||
|
|
<template #prepend>前景基因</template>
|
|||
|
|
<el-option label="自动判定(推荐):优先基因家族,缺失时回退全基因组" value="auto"></el-option>
|
|||
|
|
<el-option label="基因家族 foreground:仅分析 final_members.id 中的家族成员" value="family"></el-option>
|
|||
|
|
<el-option label="全基因组 foreground:使用所有差异表达基因" value="global"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #e6f7ff; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>✅ 自动使用上一步Output:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• 使用 Quant Output的表达量矩阵<br>
|
|||
|
|
• 使用 samples.yaml 中的分组信息<br>
|
|||
|
|
<br>
|
|||
|
|
<b>无需再次Upload Files</b>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RNA-seq Enrich 参数 -->
|
|||
|
|
<div v-if="['rnaseq_enrich', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich'].includes(confirmParams.stage)" class="param-group">
|
|||
|
|
<h5>RNA-seq Enrich 参数 (Enrichment Analysis - Step 5)</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.enrichType" style="width:100%">
|
|||
|
|
<template #prepend>富集类型</template>
|
|||
|
|
<el-option label="GO 富集" value="go"></el-option>
|
|||
|
|
<el-option label="KEGG 富集" value="kegg"></el-option>
|
|||
|
|
<el-option label="GO + KEGG" value="both"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.rnaseqForegroundMode" style="width:100%">
|
|||
|
|
<template #prepend>前景基因</template>
|
|||
|
|
<el-option label="自动判定(推荐):优先 family_DE_genes,缺失时回退 DE_genes" value="auto"></el-option>
|
|||
|
|
<el-option label="基因家族 foreground:使用 family_DE_genes" value="family"></el-option>
|
|||
|
|
<el-option label="全基因组 foreground:使用全局 DE_genes" value="global"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="margin-top:10px">
|
|||
|
|
<el-select v-model="confirmParams.enrichBackgroundSource" style="width:100%">
|
|||
|
|
<template #prepend>背景来源</template>
|
|||
|
|
<el-option label="自动选择:优先 gene2go,否则内置OrgDb" value="auto"></el-option>
|
|||
|
|
<el-option label="使用已上传 gene2go 文件" value="gene2go"></el-option>
|
|||
|
|
<el-option label="使用内置模式物种 OrgDb" value="orgdb"></el-option>
|
|||
|
|
<el-option label="内置 eggNOG 自动注释 + 富集(后台长任务,数小时到半天)" value="annotation"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="margin-top:10px" v-if="confirmParams.enrichBackgroundSource !== 'gene2go' && confirmParams.enrichBackgroundSource !== 'annotation'">
|
|||
|
|
<el-select v-model="confirmParams.enrichSpecies" placeholder="请选择模式物种" style="width:100%">
|
|||
|
|
<template #prepend>OrgDb物种</template>
|
|||
|
|
<el-option label="拟南芥 Arabidopsis (ath)" value="ath"></el-option>
|
|||
|
|
<el-option label="人 Human (hsa)" value="hsa"></el-option>
|
|||
|
|
<el-option label="小鼠 Mouse (mmu)" value="mmu"></el-option>
|
|||
|
|
<el-option label="大鼠 Rat (rno)" value="rno"></el-option>
|
|||
|
|
<el-option label="果蝇 Fly (dme)" value="dme"></el-option>
|
|||
|
|
<el-option label="酵母 Yeast (sce)" value="sce"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="margin-top:10px" v-if="confirmParams.enrichBackgroundSource !== 'gene2go' && confirmParams.enrichBackgroundSource !== 'annotation'">
|
|||
|
|
<el-input v-model="confirmParams.enrichKeyType" placeholder="auto" style="width:100%">
|
|||
|
|
<template #prepend>keyType</template>
|
|||
|
|
</el-input>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="margin-top:10px" v-if="confirmParams.enrichType === 'kegg' || confirmParams.enrichType === 'both'">
|
|||
|
|
<el-input v-model="confirmParams.keggOrganism" placeholder="如 ath / hsa / osa" style="width:100%">
|
|||
|
|
<template #prepend>KEGG organism</template>
|
|||
|
|
</el-input>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #fdf6ec; border-radius: 0px; font-size: 12px;" v-if="confirmParams.enrichBackgroundSource === 'annotation'">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>⚠️ 自动注释说明:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• 系统会先使用目标物种蛋白序列生成 gene2go 背景文件<br>
|
|||
|
|
• 小型/中型蛋白组通常需要数小时;几百 MB 级别数据可能需要半天左右<br>
|
|||
|
|
• gene2go 生成后会继续执行 RNA-seq 富集分析
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #e6f7ff; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>✅ 自动使用上一步Output:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• 基因家族模式使用 family_DE_genes;全基因组模式使用 DE_genes<br>
|
|||
|
|
<br>
|
|||
|
|
<b>无需再次Upload Files</b>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="confirmParams.stage === 'ppi'" class="param-group">
|
|||
|
|
<h5>PPI 参数 (Protein Interaction)</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.ppiSpecies" style="width:100%">
|
|||
|
|
<template #prepend>物种</template>
|
|||
|
|
<el-option label="拟南芥 (ath)" value="ath"></el-option>
|
|||
|
|
<el-option label="水稻 (osa)" value="osa"></el-option>
|
|||
|
|
<el-option label="玉米 (zma)" value="zma"></el-option>
|
|||
|
|
<el-option label="大豆 (gma)" value="gma"></el-option>
|
|||
|
|
<el-option label="番茄 (sly)" value="sly"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-input-number v-model="confirmParams.ppiScore" :min="100" :max="1000" :step="50" style="width:100%">
|
|||
|
|
<template #prepend>置信度阈值</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #e6f7ff; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>✅ 自动使用 Identify Output:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• 使用基因家族 ID 列表查询 STRING 数据库<br>
|
|||
|
|
• 置信度越高,互作关系越可靠<br>
|
|||
|
|
<b>需要先运行 Identify 阶段</b>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #fff7e6; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>📤 同源映射文件(可选):</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• 如需查询非模式植物,请上传同源映射文件到 input 目录<br>
|
|||
|
|
• 文件名:<code>ortholog_pairs.txt</code> 或 <code>Ft_Ath_orthologs.txt</code><br>
|
|||
|
|
• 格式:<code>FtGeneID\tAthGeneID</code>(制表符分隔)<br>
|
|||
|
|
• 系统会自动搜索 workspace/kaks_analysis/ 目录下的映射文件
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="confirmParams.stage === 'kaks'" class="param-group">
|
|||
|
|
<h5>Ka/Ks 参数 (KaKs Analysis)</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-input-number v-model="confirmParams.kaksNeutralRate" :min="1e-10" :max="1e-7" :step="1e-9" style="width:100%">
|
|||
|
|
<template #prepend>中性替换率</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #e6f7ff; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>📤 需要Upload Files:</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
• <b>cds1.fasta</b> - 物种1 CDS序列 (如 Ft.cds.fasta)<br>
|
|||
|
|
• <b>cds2.fasta</b> - 物种2 CDS序列 (如 Ath.cds.fasta)<br>
|
|||
|
|
• <b>pep1.fasta</b> - 物种1蛋白序列 (如 Ft.pep.fasta)<br>
|
|||
|
|
• <b>pep2.fasta</b> - 物种2蛋白序列 (如 Ath.pep.fasta)<br>
|
|||
|
|
• <b>ortholog_pairs.txt</b> - 同源基因对<br>
|
|||
|
|
<b>系统会自动搜索以下位置的文件:</b><br>
|
|||
|
|
• input/ 目录<br>
|
|||
|
|
• workspace/<job>/kaks_analysis/ 目录<br>
|
|||
|
|
• 支持格式:FtGeneID\tAthGeneID(制表符分隔)
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="confirmParams.stage === 'interspecies_synteny'" class="param-group">
|
|||
|
|
<h5>Interspecies species selection</h5>
|
|||
|
|
<div v-if="interspeciesSpeciesLoading" style="color:#909399; font-size:12px; padding: 8px 0;">Loading species from config.xlsx...</div>
|
|||
|
|
<div v-else>
|
|||
|
|
<div style="margin-bottom: 8px; color:#606266; font-size:12px;">Targets are selected by default. References prefer At/Ath; otherwise the first Ref=True species in config is selected.</div>
|
|||
|
|
<div class="param-row" style="display:block; margin-bottom: 10px;">
|
|||
|
|
<div style="font-weight:600; margin-bottom:6px;">Target species (Ref=False)</div>
|
|||
|
|
<el-checkbox-group v-model="confirmParams.targetSpecies">
|
|||
|
|
<el-checkbox v-for="sp in interspeciesSpeciesOptions.targets" :key="'target-' + sp" :label="sp">{{ sp }}</el-checkbox>
|
|||
|
|
</el-checkbox-group>
|
|||
|
|
<div v-if="!interspeciesSpeciesOptions.targets.length" style="color:#F56C6C; font-size:12px;">No target species found</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="display:block;">
|
|||
|
|
<div style="font-weight:600; margin-bottom:6px;">Reference species (Ref=True)</div>
|
|||
|
|
<el-checkbox-group v-model="confirmParams.refSpeciesList">
|
|||
|
|
<el-checkbox v-for="sp in interspeciesSpeciesOptions.refs" :key="'ref-' + sp" :label="sp">{{ sp }}</el-checkbox>
|
|||
|
|
</el-checkbox-group>
|
|||
|
|
<div v-if="!interspeciesSpeciesOptions.refs.length" style="color:#F56C6C; font-size:12px;">No reference species found</div>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top:10px; padding:8px; background:#f6f8fa; color:#606266; font-size:12px;">
|
|||
|
|
Current selection: targets {{ confirmParams.targetSpecies.join(', ') || 'none' }}; references {{ confirmParams.refSpeciesList.join(', ') || 'none' }}. Auto-run uses this selection after the countdown.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style="margin-top: 15px; padding: 10px; background: #f0f9eb; border-radius: 0px; font-size: 12px; color: #67C23A;">
|
|||
|
|
💡 <b>提示:</b>以上参数可根据具体数据进行调整。如不调整,则使用默认参数。
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template #footer>
|
|||
|
|
<span style="margin-right: auto; color: #909399; font-size: 12px;">
|
|||
|
|
⏱️ 已耗时: {{ executeElapsed }}s
|
|||
|
|
<span v-if="autoExecute" style="color: #E6A23C; margin-left: 10px;">
|
|||
|
|
⚡ {{ autoExecuteCountdown }}秒后自动执行
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
<el-button @click="confirmDialogVisible = false; clearAutoExecuteTimer()">❌ 取消</el-button>
|
|||
|
|
<el-button type="primary" @click="doExecute" :loading="executing">✅ 确认执行</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 批量执行确认对话框 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="batchExecuteDialogVisible"
|
|||
|
|
title="🚀 批量执行确认"
|
|||
|
|
width="600px"
|
|||
|
|
:close-on-click-modal="false">
|
|||
|
|
|
|||
|
|
<div class="batch-execute-dialog">
|
|||
|
|
<div style="margin-bottom: 15px;">
|
|||
|
|
<h4>📋 待执行阶段列表 ({{ batchExecuteStages.length }}个)</h4>
|
|||
|
|
<div style="max-height: 300px; overflow-y: auto; background: #f5f7fa; padding: 10px; border-radius: 0px;">
|
|||
|
|
<div v-for="(stage, idx) in batchExecuteStages" :key="typeof stage === 'object' ? stage.id : stage"
|
|||
|
|
:style="{padding: '6px 10px', margin: '4px 0', borderRadius: '4px', background: idx < batchExecuteIndex ? '#67C23A20' : idx === batchExecuteIndex ? '#409EFF20' : '#fff', borderLeft: '3px solid ' + (idx < batchExecuteIndex ? '#67C23A' : idx === batchExecuteIndex ? '#409EFF' : '#dcdfe6')}">
|
|||
|
|
<span style="color: #909399; margin-right: 8px;">{{ idx + 1 }}.</span>
|
|||
|
|
<span :style="{fontWeight: idx === batchExecuteIndex ? 'bold' : 'normal', color: idx < batchExecuteIndex ? '#67C23A' : idx === batchExecuteIndex ? '#409EFF' : '#606266'}">
|
|||
|
|
{{ typeof stage === 'object' ? (stage.name || getStageLabel(stage.id)) : getStageLabel(stage) }}
|
|||
|
|
</span>
|
|||
|
|
<el-tag v-if="idx < batchExecuteIndex" size="small" type="success" style="margin-left: 8px;">✓</el-tag>
|
|||
|
|
<el-tag v-if="idx === batchExecuteIndex && isBatchExecuting" size="small" type="primary" style="margin-left: 8px;">执行中...</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="isBatchExecuting" style="padding: 10px; background: #f0f9eb; border-radius: 0px; text-align: center;">
|
|||
|
|
<el-progress :percentage="Math.round((batchExecuteIndex / batchExecuteStages.length) * 100)" :status="batchExecuteIndex === batchExecuteStages.length ? 'success' : ''"></el-progress>
|
|||
|
|
<div style="margin-top: 8px; color: #67C23A;">
|
|||
|
|
正在执行: {{ typeof batchExecuteStages[batchExecuteIndex] === 'object' ? (batchExecuteStages[batchExecuteIndex].name || getStageLabel(batchExecuteStages[batchExecuteIndex].id)) : getStageLabel(batchExecuteStages[batchExecuteIndex]) }} ({{ batchExecuteIndex + 1 }}/{{ batchExecuteStages.length }})
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template #footer>
|
|||
|
|
<span style="margin-right: auto; color: #909399; font-size: 12px;" v-if="!isBatchExecuting">
|
|||
|
|
⏱️ {{ batchExecuteCountdown }}秒后自动开始执行
|
|||
|
|
</span>
|
|||
|
|
<span style="margin-right: auto; color: #67C23A; font-size: 12px;" v-else>
|
|||
|
|
批量执行进行中...
|
|||
|
|
</span>
|
|||
|
|
<el-button v-if="!isBatchExecuting" @click="cancelBatchExecute">❌ 取消</el-button>
|
|||
|
|
<el-button v-if="!isBatchExecuting" type="primary" @click="startBatchExecute" :loading="isBatchExecuting">🚀 开始执行</el-button>
|
|||
|
|
<el-button v-else type="primary" disabled>执行中...</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 共线性类型选择对话框 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="syntenyChoiceDialogVisible"
|
|||
|
|
title="请选择共线性分析类型"
|
|||
|
|
width="560px"
|
|||
|
|
:close-on-click-modal="false">
|
|||
|
|
|
|||
|
|
<div style="display: grid; gap: 12px;">
|
|||
|
|
<div
|
|||
|
|
v-for="option in syntenyChoiceOptions"
|
|||
|
|
:key="option.id"
|
|||
|
|
style="padding: 14px; border: 1px solid #dcdfe6; border-radius: 6px; background: #fff; cursor: pointer;"
|
|||
|
|
@click="confirmSyntenyChoice(option)">
|
|||
|
|
<div style="font-weight: 600; color: #303133; margin-bottom: 6px;">{{ option.label }}</div>
|
|||
|
|
<div style="font-size: 13px; color: #606266; line-height: 1.6;">{{ option.description }}</div>
|
|||
|
|
<div style="margin-top: 8px;">
|
|||
|
|
<el-tag
|
|||
|
|
v-for="stage in option.stages"
|
|||
|
|
:key="stage"
|
|||
|
|
size="small"
|
|||
|
|
style="margin-right: 6px;">
|
|||
|
|
{{ getStageLabel(stage) }}
|
|||
|
|
</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template #footer>
|
|||
|
|
<el-button @click="cancelSyntenyChoice">取消</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 诊断修复确认对话框 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="diagnosisConfirmDialogVisible"
|
|||
|
|
title="🔧 参数修复确认"
|
|||
|
|
width="600px"
|
|||
|
|
:close-on-click-modal="false">
|
|||
|
|
|
|||
|
|
<div class="param-dialog">
|
|||
|
|
<div class="param-group">
|
|||
|
|
<h5>⚠️ 检测到问题</h5>
|
|||
|
|
<div style="padding: 10px; background: #fdf6ec; border-radius: 0px; margin-bottom: 15px;">
|
|||
|
|
<div style="color: #E6A23C; font-weight: bold; margin-bottom: 8px;">
|
|||
|
|
{{ diagnosisFixData.errorType }}
|
|||
|
|
</div>
|
|||
|
|
<div style="color: #606266; font-size: 14px;">
|
|||
|
|
{{ diagnosisFixData.description }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="param-group">
|
|||
|
|
<h5>💡 建议修复方案</h5>
|
|||
|
|
<div style="padding: 10px; background: #f0f9ff; border-radius: 0px; margin-bottom: 15px;">
|
|||
|
|
<div style="color: #409EFF; font-size: 14px; margin-bottom: 10px;">
|
|||
|
|
{{ diagnosisFixData.reason }}
|
|||
|
|
</div>
|
|||
|
|
<div style="display: flex; gap: 10px; align-items: center;">
|
|||
|
|
<div style="flex: 1;">
|
|||
|
|
<div style="font-size: 12px; color: #909399; margin-bottom: 4px;">当前参数</div>
|
|||
|
|
<el-tag type="info" size="small">{{ diagnosisFixData.currentParamsDisplay }}</el-tag>
|
|||
|
|
</div>
|
|||
|
|
<div style="font-size: 20px; color: #409EFF;">→</div>
|
|||
|
|
<div style="flex: 1;">
|
|||
|
|
<div style="font-size: 12px; color: #909399; margin-bottom: 4px;">建议参数</div>
|
|||
|
|
<el-tag type="success" size="small">{{ diagnosisFixData.suggestedParamsDisplay }}</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style="text-align: center; padding: 10px; background: #f5f7fa; border-radius: 0px;">
|
|||
|
|
<span style="color: #606266; font-size: 14px;">
|
|||
|
|
⏱️ {{ diagnosisConfirmCountdown }}秒后自动应用修复方案
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template #footer>
|
|||
|
|
<span class="dialog-footer">
|
|||
|
|
<span v-if="diagnosisConfirmCountdown > 0" style="color: #E6A23C; margin-right: 10px;">
|
|||
|
|
⚡ {{ diagnosisConfirmCountdown }}秒后自动执行
|
|||
|
|
</span>
|
|||
|
|
<el-button @click="rejectDiagnosisFix">
|
|||
|
|
❌ 跳过修复
|
|||
|
|
</el-button>
|
|||
|
|
<el-button type="primary" @click="confirmDiagnosisFix">
|
|||
|
|
✅ 应用修复
|
|||
|
|
</el-button>
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 图片预览对话框 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="previewDialogVisible"
|
|||
|
|
title="🖼️ 图片预览"
|
|||
|
|
width="80%"
|
|||
|
|
class="preview-dialog">
|
|||
|
|
|
|||
|
|
<el-image :src="previewImg" class="preview-img" :preview-src-list="[previewImg]" fit="contain"></el-image>
|
|||
|
|
|
|||
|
|
<div class="param-adjust" v-if="previewImg">
|
|||
|
|
<h5>调整参数重新绘图</h5>
|
|||
|
|
<el-row :gutter="10">
|
|||
|
|
<el-col :span="12">
|
|||
|
|
<el-input-number v-model="previewParams.width" :min="400" :max="2000" style="width:100%">
|
|||
|
|
<template #prepend>宽度</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-col>
|
|||
|
|
<el-col :span="12">
|
|||
|
|
<el-input-number v-model="previewParams.height" :min="300" :max="1500" style="width:100%">
|
|||
|
|
<template #prepend>高度</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-col>
|
|||
|
|
</el-row>
|
|||
|
|
<el-button type="warning" size="small" style="margin-top:10px" @click="redrawImage">
|
|||
|
|
🔄 重新绘图
|
|||
|
|
</el-button>
|
|||
|
|
</div>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 阶段确认对话框(批量执行用) -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="stageConfirmDialogVisible"
|
|||
|
|
:title="`⚙️ 阶段 ${stageConfirmCurrent}/${stageConfirmTotal}: ${getStageLabel(selectedStage)}`"
|
|||
|
|
width="600px"
|
|||
|
|
:close-on-click-modal="false"
|
|||
|
|
:show-close="false">
|
|||
|
|
|
|||
|
|
<div class="stage-confirm-dialog">
|
|||
|
|
<div style="margin-bottom: 15px; padding: 10px; background: #f0f9eb; border-radius: 0px;">
|
|||
|
|
<div style="color: #67C23A; font-weight: bold;">
|
|||
|
|
📋 当前阶段: {{ getStageLabel(selectedStage) }}
|
|||
|
|
</div>
|
|||
|
|
<div style="color: #606266; font-size: 12px; margin-top: 5px;">
|
|||
|
|
进度: {{ stageConfirmCurrent }} / {{ stageConfirmTotal }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 阶段特定参数 -->
|
|||
|
|
<div class="param-group">
|
|||
|
|
<h5>基本参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<!-- Prefix?????config.xlsx???????????? -->
|
|||
|
|
<el-input-number v-model="confirmParams.cpu" :min="1" :max="32">
|
|||
|
|
<template #prepend>CPU</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- PPI 参数 -->
|
|||
|
|
<div v-if="selectedStage === 'ppi'" class="param-group">
|
|||
|
|
<h5>PPI 参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="参考物种,拟南芥用ath" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.ppiSpecies" placeholder="ath" style="width:100%">
|
|||
|
|
<template #prepend>参考物种</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
<el-tooltip content="STRING score阈值,默认700" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.ppiScore" :min="100" :max="1000" style="width:100%">
|
|||
|
|
<template #prepend>Score阈值</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 8px; background: #f6f8fa; border-radius: 0px; font-size: 12px; color: #606266;">
|
|||
|
|
💡 基因ID将自动从 final/{{ confirmParams.prefix || "Multi.<GeneFamily>" }}.final_members.id 读取
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- KaKs 参数 -->
|
|||
|
|
<div v-if="selectedStage === 'kaks'" class="param-group">
|
|||
|
|
<h5>KaKs 参数</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="中性进化速率,默认7.08e-9" placement="top">
|
|||
|
|
<el-input-number v-model="confirmParams.kaksNeutralRate" :step="0.01" :min="0" style="width:100%">
|
|||
|
|
<template #prepend>中性速率</template>
|
|||
|
|
</el-input-number>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Expression 参数 -->
|
|||
|
|
<div v-if="selectedStage === 'expression'" class="param-group">
|
|||
|
|
<h5>Expression 参数 (FPKM/TPM矩阵)</h5>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-tooltip content="从NCBI GEODownload表达量数据,留空则使用已Upload Files" placement="top">
|
|||
|
|
<el-input v-model="confirmParams.geoId" placeholder="InputGEO编号 (如 GSE111937)" style="width:100%">
|
|||
|
|
<template #prepend>GEO编号</template>
|
|||
|
|
</el-input>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" style="margin-top:10px">
|
|||
|
|
<el-tooltip content="基因ID转换方式:去除.T01后缀或添加.T01后缀" placement="top">
|
|||
|
|
<el-select v-model="confirmParams.idTransform" placeholder="SelectID转换方式" style="width:100%">
|
|||
|
|
<el-option label="不转换 (none)" value="none" />
|
|||
|
|
<el-option label="去除.T01后缀 (remove_T01)" value="remove_T01" />
|
|||
|
|
<el-option label="添加.T01后缀 (add_T01)" value="add_T01" />
|
|||
|
|
</el-select>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
<div style="margin-top: 10px; padding: 10px; background: #f6f8fa; border-radius: 0px; font-size: 12px;">
|
|||
|
|
<div style="color: #303133; margin-bottom: 8px;"><b>📊 表达量数据来源(Select一种):</b></div>
|
|||
|
|
<div style="color: #606266; line-height: 1.8;">
|
|||
|
|
<div>✅ <b>方式1:</b>直接上传 - 上传表达量矩阵(*.fpkm.txt/*.tpm.txt)+基因ID+sample.txt到Input目录</div>
|
|||
|
|
<div>✅ <b>方式2:</b>GEO编号 - InputGEO编号(如 GSE111937)自动Download</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RNA-seq 参数 -->
|
|||
|
|
<div v-if="selectedStage && selectedStage.startsWith('rnaseq')" class="param-group">
|
|||
|
|
<h5>RNA-seq 参数</h5>
|
|||
|
|
<div v-if="selectedStage === 'rnaseq_de'" style="display: grid; gap: 10px;">
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.rnaseqForegroundMode" style="width:100%">
|
|||
|
|
<template #prepend>差异表达范围</template>
|
|||
|
|
<el-option label="自动判定:有 final_members.id 时做基因家族,否则做全基因组" value="auto"></el-option>
|
|||
|
|
<el-option label="基因家族差异表达:只输出 family_DE_genes" value="family"></el-option>
|
|||
|
|
<el-option label="全基因组差异表达:输出全局 DE_genes" value="global"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div style="padding: 10px; background: #e6f7ff; border-radius: 0px; color: #606266; font-size: 12px; line-height: 1.8;">
|
|||
|
|
当前阶段会复用 RNA-seq 定量输出和 samples.yaml。若选择基因家族差异表达,需要已有 final_members.id;若选择全基因组差异表达,不依赖基因家族成员列表。
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else-if="['rnaseq_enrich', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich'].includes(selectedStage)" style="display: grid; gap: 10px;">
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.enrichType" style="width:100%">
|
|||
|
|
<template #prepend>富集类型</template>
|
|||
|
|
<el-option label="GO 富集" value="go"></el-option>
|
|||
|
|
<el-option label="KEGG 富集" value="kegg"></el-option>
|
|||
|
|
<el-option label="GO + KEGG" value="both"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.rnaseqForegroundMode" style="width:100%">
|
|||
|
|
<template #prepend>前景基因</template>
|
|||
|
|
<el-option label="自动判定:优先 family_DE_genes,缺失时回退全局 DE_genes" value="auto"></el-option>
|
|||
|
|
<el-option label="基因家族 foreground:使用 family_DE_genes" value="family"></el-option>
|
|||
|
|
<el-option label="全基因组 foreground:使用全局 DE_genes" value="global"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row">
|
|||
|
|
<el-select v-model="confirmParams.enrichBackgroundSource" style="width:100%">
|
|||
|
|
<template #prepend>背景来源</template>
|
|||
|
|
<el-option label="自动选择:优先已上传背景,其次内置模式物种" value="auto"></el-option>
|
|||
|
|
<el-option label="使用已上传 gene2go/gene2ko 背景文件" value="gene2go"></el-option>
|
|||
|
|
<el-option label="使用内置模式物种 OrgDb(仅 GO)" value="orgdb"></el-option>
|
|||
|
|
<el-option label="内置 eggNOG 自动注释 + 继续富集(长任务)" value="annotation"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" v-if="confirmParams.enrichBackgroundSource !== 'gene2go' && confirmParams.enrichBackgroundSource !== 'annotation'">
|
|||
|
|
<el-select v-model="confirmParams.enrichSpecies" placeholder="内置物种代码" style="width:100%">
|
|||
|
|
<template #prepend>OrgDb物种</template>
|
|||
|
|
<el-option label="拟南芥 Arabidopsis (ath)" value="ath"></el-option>
|
|||
|
|
<el-option label="人 Human (hsa)" value="hsa"></el-option>
|
|||
|
|
<el-option label="小鼠 Mouse (mmu)" value="mmu"></el-option>
|
|||
|
|
<el-option label="大鼠 Rat (rno)" value="rno"></el-option>
|
|||
|
|
<el-option label="果蝇 Fly (dme)" value="dme"></el-option>
|
|||
|
|
<el-option label="酵母 Yeast (sce)" value="sce"></el-option>
|
|||
|
|
</el-select>
|
|||
|
|
</div>
|
|||
|
|
<div class="param-row" v-if="confirmParams.enrichType === 'kegg' || confirmParams.enrichType === 'both'">
|
|||
|
|
<el-input v-model="confirmParams.keggOrganism" placeholder="如 ath / hsa / osa">
|
|||
|
|
<template #prepend>KEGG organism</template>
|
|||
|
|
</el-input>
|
|||
|
|
</div>
|
|||
|
|
<div style="padding: 10px; background: #fdf6ec; border-radius: 0px; color: #606266; font-size: 12px; line-height: 1.8;" v-if="confirmParams.enrichBackgroundSource === 'annotation'">
|
|||
|
|
自动注释会先用目标物种蛋白序列生成 gene2go/gene2ko 背景文件,再继续执行富集。小型/中型蛋白组通常需要数小时,几百 MB 级数据可能需要半天左右。
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else style="padding: 10px; background: #fdf6ec; border-radius: 0px; color: #E6A23C; font-size: 12px;">
|
|||
|
|
⚠️ RNA-seq分析需要预先上传样本数据。如未上传,此阶段将跳过。
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div style="margin-top: 15px; padding: 10px; background: #f0f9eb; border-radius: 0px; font-size: 12px; color: #67C23A;">
|
|||
|
|
💡 <b>提示:</b>15秒内不操作将使用默认参数自动执行
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<template #footer>
|
|||
|
|
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
|||
|
|
<div style="color: #E6A23C; font-size: 14px;">
|
|||
|
|
⏱️ {{ stageConfirmCountdown }}秒后自动执行
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<el-button type="danger" @click="stopBatchExecute" style="margin-right: 10px;">⏹️ 停止全部</el-button>
|
|||
|
|
<el-button @click="skipStageExecute">⏭️ 跳过</el-button>
|
|||
|
|
<el-button type="primary" @click="confirmStageExecute">✅ 确认执行</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 执行结果对话框 - 简化版 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="resultDialogVisible"
|
|||
|
|
title="✅ 执行完成"
|
|||
|
|
width="400px"
|
|||
|
|
:show-close="false"
|
|||
|
|
:center="true">
|
|||
|
|
<div v-if="resultDialogParams.stage" style="text-align: center; padding: 20px 0;">
|
|||
|
|
<div style="font-size: 48px; color: #67c23a; margin-bottom: 16px;">✓</div>
|
|||
|
|
<div style="font-size: 18px; font-weight: bold; margin-bottom: 8px;">
|
|||
|
|
{{ resultDialogParams.stage }} 执行完成
|
|||
|
|
</div>
|
|||
|
|
<div style="font-size: 14px; color: #909399;">
|
|||
|
|
⏱️ 执行时间: {{ resultDialogParams.elapsed }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<template #footer>
|
|||
|
|
<el-button type="primary" @click="resultDialogVisible = false" style="width: 100%;">确定</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- Working DirectorySelect对话框 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="jobSelectorVisible"
|
|||
|
|
title="Select已有Working Directory"
|
|||
|
|
width="700px"
|
|||
|
|
:close-on-click-modal="false"
|
|||
|
|
>
|
|||
|
|
<div v-if="existingJobs.length > 0" style="border: 1px solid #e4e7ed; border-radius: 0px;">
|
|||
|
|
<div style="display: flex; background: #f5f7fa; padding: 10px; font-weight: bold; border-bottom: 1px solid #e4e7ed;">
|
|||
|
|
<div style="width: 220px;">Job ID</div>
|
|||
|
|
<div style="flex: 1;">已完成阶段</div>
|
|||
|
|
<div style="width: 80px; text-align: center;">操作</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-for="(job, index) in existingJobs" :key="job.job_id"
|
|||
|
|
style="display: flex; padding: 10px; border-bottom: 1px solid #ebeef5; align-items: center;"
|
|||
|
|
:style="{ background: index % 2 === 1 ? '#fafafa' : '#fff' }">
|
|||
|
|
<div style="width: 220px; font-size: 13px; word-break: break-all;">{{ job.job_id }}</div>
|
|||
|
|
<div style="flex: 1;">
|
|||
|
|
<template v-if="job.completed_stages && job.completed_stages.length > 0">
|
|||
|
|
<el-tag v-for="stage in job.completed_stages" :key="stage"
|
|||
|
|
size="small" type="success" style="margin-right: 5px; margin-bottom: 3px;">
|
|||
|
|
{{ stage }}
|
|||
|
|
</el-tag>
|
|||
|
|
</template>
|
|||
|
|
<span v-else style="color: #999;">无</span>
|
|||
|
|
</div>
|
|||
|
|
<div style="width: 80px; text-align: center;">
|
|||
|
|
<el-button type="primary" size="small" @click="selectJob(job.job_id)">Select</el-button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else style="text-align: center; padding: 40px; color: #999;">
|
|||
|
|
暂无已有Working Directory
|
|||
|
|
</div>
|
|||
|
|
<template #footer>
|
|||
|
|
<el-button @click="jobSelectorVisible = false">取消</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
|
|||
|
|
<!-- 深度解读弹窗 -->
|
|||
|
|
<el-dialog
|
|||
|
|
v-model="interpretationDialogVisible"
|
|||
|
|
title=" "
|
|||
|
|
width="80%"
|
|||
|
|
:close-on-click-modal="false"
|
|||
|
|
class="interpretation-dialog"
|
|||
|
|
>
|
|||
|
|
<div v-if="interpretationReport" class="report-container">
|
|||
|
|
<!-- 报告头部 -->
|
|||
|
|
<div class="report-header">
|
|||
|
|
<div class="report-title">🔬 {{ interpretationReport.metadata.title }}</div>
|
|||
|
|
<div class="report-meta">
|
|||
|
|
分析流程: {{ interpretationReport.metadata.included_stages.join(' → ') }} |
|
|||
|
|
生成时间: {{ formatTime(interpretationReport.metadata.generated_at) }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 流程可视化 -->
|
|||
|
|
<div class="section-title">📈 阶段性进展</div>
|
|||
|
|
<div class="flow-visualization">
|
|||
|
|
<template v-for="(stage, index) in interpretationReport.stage_flow.stages" :key="stage.id">
|
|||
|
|
<div class="flow-stage">
|
|||
|
|
<div class="flow-stage-icon">{{ index === interpretationReport.stage_flow.stages.length - 1 ? '🎯' : '✅' }}</div>
|
|||
|
|
<div class="flow-stage-name">{{ stage.name }}</div>
|
|||
|
|
<div class="flow-stage-result">{{ stage.key_result }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="index < interpretationReport.stage_flow.stages.length - 1" class="flow-arrow">→</div>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 阶段关联分析 -->
|
|||
|
|
<div class="section-title" v-if="interpretationReport.stage_connections.length > 0">🔗 阶段间关联分析</div>
|
|||
|
|
<div v-for="(conn, idx) in interpretationReport.stage_connections" :key="idx" class="connection-card">
|
|||
|
|
<div class="connection-header">{{ conn.from }} → {{ conn.to }}</div>
|
|||
|
|
<div>{{ conn.description }}</div>
|
|||
|
|
<div style="margin-top: 8px; color: #409eff; font-size: 13px;">
|
|||
|
|
💡 {{ conn.insight }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 关键发现 -->
|
|||
|
|
<div class="section-title">💡 阶段性科学发现</div>
|
|||
|
|
<div v-for="finding in interpretationReport.key_findings" :key="finding.id" class="finding-item">
|
|||
|
|
<div class="finding-title">{{ finding.id }}. {{ finding.title }}</div>
|
|||
|
|
<div class="finding-desc">{{ finding.description }}</div>
|
|||
|
|
<div class="finding-evidence">📋 证据: {{ finding.evidence }}</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 生物学故事 -->
|
|||
|
|
<div class="section-title">🧬 生物学故事</div>
|
|||
|
|
<div class="story-box">
|
|||
|
|
{{ interpretationReport.biological_story }}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 下一步建议 -->
|
|||
|
|
<div class="section-title">📋 下一步建议</div>
|
|||
|
|
<div v-for="(step, idx) in interpretationReport.next_steps" :key="idx" class="next-step-item">
|
|||
|
|
<div :class="['next-step-priority', 'priority-' + step.priority]">
|
|||
|
|
{{ step.priority === 'high' ? '高优先级' : '中优先级' }}
|
|||
|
|
</div>
|
|||
|
|
<div class="next-step-content">
|
|||
|
|
<div class="next-step-title">{{ step.stage }}: {{ step.description }}</div>
|
|||
|
|
<div class="next-step-desc">{{ step.rationale }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 弹窗底部按钮 -->
|
|||
|
|
<template #footer>
|
|||
|
|
<el-button @click="downloadInterpretationReport('markdown')">
|
|||
|
|
📄 Markdown
|
|||
|
|
</el-button>
|
|||
|
|
<el-button @click="downloadInterpretationReport('json')">
|
|||
|
|
📊 JSON
|
|||
|
|
</el-button>
|
|||
|
|
<el-button type="primary" @click="downloadInterpretationReport('pdf')">
|
|||
|
|
📥 下载PDF报告
|
|||
|
|
</el-button>
|
|||
|
|
<el-button type="success" @click="copyInterpretationToClipboard">
|
|||
|
|
📋 复制到剪贴板
|
|||
|
|
</el-button>
|
|||
|
|
<el-button @click="interpretationDialogVisible = false">
|
|||
|
|
关闭
|
|||
|
|
</el-button>
|
|||
|
|
</template>
|
|||
|
|
</el-dialog>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Agent状态显示组件模板 - 集成gene-family-mas-v2版本 -->
|
|||
|
|
<template id="agent-status-component-template">
|
|||
|
|
<div class="agent-status-panel">
|
|||
|
|
<!-- Agent状态显示 -->
|
|||
|
|
<div class="status-header">
|
|||
|
|
<h4>🤖 Agent状态</h4>
|
|||
|
|
<span :class="['status-badge', status]">{{ statusText }}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- RAG查询状态 -->
|
|||
|
|
<div class="rag-status" v-if="ragQuery">
|
|||
|
|
<div class="query-info">
|
|||
|
|
<div class="query-text">{{ ragQuery }}</div>
|
|||
|
|
<div class="query-progress">
|
|||
|
|
<el-progress :percentage="progress" :status="progressStatus"></el-progress>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 检索步骤 -->
|
|||
|
|
<div class="retrieval-steps">
|
|||
|
|
<div v-for="(step, idx) in steps" :key="idx"
|
|||
|
|
:class="['step', step.status]">
|
|||
|
|
<span class="step-icon">{{ step.icon }}</span>
|
|||
|
|
<span class="step-name">{{ step.name }}</span>
|
|||
|
|
<span class="step-time" v-if="step.time">{{ step.time }}ms</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Self-RAG反思状态 -->
|
|||
|
|
<div class="reflection-panel" v-if="reflection">
|
|||
|
|
<div class="reflection-title">📝 Self-RAG反思</div>
|
|||
|
|
<div class="reflection-content">
|
|||
|
|
<div>迭代: {{ reflection.iteration }}/{{ reflection.maxIterations }}</div>
|
|||
|
|
<div>原因: {{ reflection.reason }}</div>
|
|||
|
|
<div v-if="reflection.rewrittenQuery">
|
|||
|
|
重写查询: {{ reflection.rewrittenQuery }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- MultiHopRAG状态 -->
|
|||
|
|
<div class="multihop-panel" v-if="multiHop">
|
|||
|
|
<div class="multihop-title">🔀 MultiHopRAG</div>
|
|||
|
|
<div class="multihop-content">
|
|||
|
|
<div>跳数: {{ multiHop.hops }}</div>
|
|||
|
|
<div>来源: {{ multiHop.sources.join(', ') }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 知识图谱可视化 -->
|
|||
|
|
<div class="kg-visualization" v-if="showKG">
|
|||
|
|
<div class="kg-header">
|
|||
|
|
<h5>🧬 知识图谱</h5>
|
|||
|
|
<el-button size="small" @click="refreshKG">刷新</el-button>
|
|||
|
|
</div>
|
|||
|
|
<div id="kg-chart" class="kg-chart"></div>
|
|||
|
|
<div class="kg-stats">
|
|||
|
|
节点: {{ kgStats.nodes }} | 关系: {{ kgStats.edges }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 结果展示 -->
|
|||
|
|
<div class="results-panel" v-if="results.length > 0">
|
|||
|
|
<h5>📄 检索结果</h5>
|
|||
|
|
<div v-for="(result, idx) in results" :key="idx" class="result-item">
|
|||
|
|
<div class="result-header">
|
|||
|
|
<span class="result-doc">{{ result.doc_id }}</span>
|
|||
|
|
<span class="result-score">Score: {{ result.score.toFixed(3) }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="result-content">{{ result.content }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 原有Agent消息显示 - 动态样式 -->
|
|||
|
|
<div class="agent-messages" ref="messageContainer" v-if="messages.length > 0">
|
|||
|
|
<div v-for="(msg, index) in messages" :key="index"
|
|||
|
|
:class="['message', msg.role, getMessageSpacingClass(msg.content)]">
|
|||
|
|
<div class="message-role">{{ getRoleLabel(msg.role) }}</div>
|
|||
|
|
<div :class="['message-content', getContentClass(msg.content)]" v-html="formatMessage(msg.content, msg.role === 'assistant')"></div>
|
|||
|
|
<div class="message-time">{{ formatTime(msg.timestamp) }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="agent-input" v-if="showInput">
|
|||
|
|
<textarea v-model="userInput"
|
|||
|
|
placeholder="Input您的问题,例如:分析SPL基因家族"
|
|||
|
|
@keydown.enter.prevent="sendMessage"
|
|||
|
|
rows="3"></textarea>
|
|||
|
|
<button @click="sendMessage" :disabled="isProcessing">
|
|||
|
|
{{ isProcessing ? '处理中...' : '发送' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="agent-tasks" v-if="tasks.length > 0">
|
|||
|
|
<h4>📋 任务列表</h4>
|
|||
|
|
<div v-for="task in tasks" :key="task.task_id"
|
|||
|
|
:class="['task-item', task.status]"
|
|||
|
|
@click="selectTask(task)">
|
|||
|
|
<span class="task-status">{{ getStatusIcon(task.status) }}</span>
|
|||
|
|
<span class="task-message">{{ task.message }}</span>
|
|||
|
|
<span class="task-progress">{{ task.progress }}%</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const { createApp, ref, reactive, computed, nextTick, onMounted } = Vue;
|
|||
|
|
const { ElMessage, ElMessageBox } = ElementPlus;
|
|||
|
|
|
|||
|
|
// ========== Agent Status Component - 集成RAG Agent状态 ==========
|
|||
|
|
const AgentStatusComponent = {
|
|||
|
|
template: '#agent-status-component-template',
|
|||
|
|
|
|||
|
|
data() {
|
|||
|
|
return {
|
|||
|
|
messages: [],
|
|||
|
|
userInput: '',
|
|||
|
|
isProcessing: false,
|
|||
|
|
currentMode: '待机',
|
|||
|
|
sessionId: '',
|
|||
|
|
tasks: [],
|
|||
|
|
currentTask: null,
|
|||
|
|
pollingInterval: null,
|
|||
|
|
// RAG Agent状态
|
|||
|
|
status: 'idle',
|
|||
|
|
ragQuery: '',
|
|||
|
|
progress: 0,
|
|||
|
|
steps: [
|
|||
|
|
{ name: '查询分析', icon: '🔍', status: 'pending', time: null },
|
|||
|
|
{ name: '文档检索', icon: '📚', status: 'pending', time: null },
|
|||
|
|
{ name: '结果评估', icon: '✓', status: 'pending', time: null },
|
|||
|
|
{ name: '生成回答', icon: '✍', status: 'pending', time: null }
|
|||
|
|
],
|
|||
|
|
reflection: null,
|
|||
|
|
multiHop: null,
|
|||
|
|
results: [],
|
|||
|
|
showKG: false,
|
|||
|
|
showInput: true,
|
|||
|
|
kgStats: { nodes: 0, edges: 0 },
|
|||
|
|
kgChart: null
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
computed: {
|
|||
|
|
statusText() {
|
|||
|
|
const statusMap = {
|
|||
|
|
'idle': '待机',
|
|||
|
|
'analyzing': '分析中',
|
|||
|
|
'retrieving': '检索中',
|
|||
|
|
'reflecting': '反思中',
|
|||
|
|
'generating': '生成中',
|
|||
|
|
'completed': '完成',
|
|||
|
|
'error': '错误'
|
|||
|
|
};
|
|||
|
|
return statusMap[this.status] || this.status;
|
|||
|
|
},
|
|||
|
|
progressStatus() {
|
|||
|
|
if (this.status === 'error') return 'exception';
|
|||
|
|
if (this.status === 'completed') return 'success';
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
mounted() {
|
|||
|
|
this.sessionId = 'session_' + Date.now();
|
|||
|
|
this.loadTasks();
|
|||
|
|
this.startPolling();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
beforeUnmount() {
|
|||
|
|
this.stopPolling();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
methods: {
|
|||
|
|
async sendMessage() {
|
|||
|
|
if (!this.userInput.trim() || this.isProcessing) return;
|
|||
|
|
|
|||
|
|
const message = this.userInput.trim();
|
|||
|
|
this.userInput = '';
|
|||
|
|
this.isProcessing = true;
|
|||
|
|
this.currentMode = '处理中';
|
|||
|
|
this.status = 'analyzing';
|
|||
|
|
this.progress = 10;
|
|||
|
|
this.updateStep(0, 'running');
|
|||
|
|
|
|||
|
|
// 添加用户消息
|
|||
|
|
this.addMessage('user', message);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 步骤1: 查询分析
|
|||
|
|
await this.simulateStep(0, 500);
|
|||
|
|
this.status = 'retrieving';
|
|||
|
|
this.progress = 30;
|
|||
|
|
|
|||
|
|
// 调用Agent执行API
|
|||
|
|
const response = await fetch('/api/agent/execute', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
message: message,
|
|||
|
|
session_id: this.sessionId,
|
|||
|
|
job_id: window.parentJobInfo ? window.parentJobInfo.job_id : 'default_job',
|
|||
|
|
prefix: window.parentParams ? window.parentParams.prefix : '',
|
|||
|
|
model: 'kimi'
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.success) {
|
|||
|
|
this.currentTask = data.task_id;
|
|||
|
|
this.addMessage('system', `任务已提交: ${data.task_id}`);
|
|||
|
|
this.loadTasks();
|
|||
|
|
} else {
|
|||
|
|
this.addMessage('error', data.message || '提交失败');
|
|||
|
|
this.currentMode = '错误';
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
this.addMessage('error', `请求失败: ${error.message}`);
|
|||
|
|
this.currentMode = '错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.isProcessing = false;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadTasks() {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/agent/tasks');
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.success) {
|
|||
|
|
this.tasks = data.tasks;
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载任务失败:', error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async checkTaskStatus(taskId) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/agent/status/${taskId}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.success && data.task) {
|
|||
|
|
const task = data.task;
|
|||
|
|
|
|||
|
|
// 更新任务状态
|
|||
|
|
const taskIndex = this.tasks.findIndex(t => t.task_id === taskId);
|
|||
|
|
if (taskIndex >= 0) {
|
|||
|
|
this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...task };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新当前模式
|
|||
|
|
if (task.status === 'running') {
|
|||
|
|
this.currentMode = 'Executing';
|
|||
|
|
} else if (task.status === 'completed') {
|
|||
|
|
this.currentMode = '已完成';
|
|||
|
|
} else if (task.status === 'failed') {
|
|||
|
|
this.currentMode = '失败';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果任务完成,显示结果
|
|||
|
|
if (task.status === 'completed' && task.result) {
|
|||
|
|
this.addMessage('assistant', task.result.response || '任务完成');
|
|||
|
|
|
|||
|
|
// 显示执行结果摘要
|
|||
|
|
if (task.result.execution_result) {
|
|||
|
|
const execResult = task.result.execution_result;
|
|||
|
|
if (execResult.status === 'success') {
|
|||
|
|
const manifest = execResult.manifest || {};
|
|||
|
|
const metrics = manifest.metrics || {};
|
|||
|
|
this.addMessage('system',
|
|||
|
|
`✅ 分析完成! 找到 ${metrics.final_members || 0} 个基因成员`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (task.status === 'failed') {
|
|||
|
|
this.addMessage('error', task.result?.response || '任务失败');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('检查任务状态失败:', error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
selectTask(task) {
|
|||
|
|
this.currentTask = task.task_id;
|
|||
|
|
this.checkTaskStatus(task.task_id);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
startPolling() {
|
|||
|
|
this.pollingInterval = setInterval(() => {
|
|||
|
|
if (this.currentTask) {
|
|||
|
|
this.checkTaskStatus(this.currentTask);
|
|||
|
|
}
|
|||
|
|
this.loadTasks();
|
|||
|
|
}, 3000); // 每3s轮询一次
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
stopPolling() {
|
|||
|
|
if (this.pollingInterval) {
|
|||
|
|
clearInterval(this.pollingInterval);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// RAG Agent方法
|
|||
|
|
async simulateStep(stepIndex, duration) {
|
|||
|
|
await new Promise(resolve => setTimeout(resolve, duration));
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
updateStep(index, status, time = null) {
|
|||
|
|
this.steps[index].status = status;
|
|||
|
|
if (time !== null) {
|
|||
|
|
this.steps[index].time = time;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async searchDocuments() {
|
|||
|
|
const startTime = Date.now();
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/rag/advanced/search', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
query: this.ragQuery,
|
|||
|
|
use_agentic: true,
|
|||
|
|
enable_self_rag: true,
|
|||
|
|
enable_multi_hop: true
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
const data = await response.json();
|
|||
|
|
return {
|
|||
|
|
results: data.sources || [],
|
|||
|
|
time: Date.now() - startTime
|
|||
|
|
};
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('检索失败:', error);
|
|||
|
|
return { results: [], time: Date.now() - startTime };
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async evaluateResults() {
|
|||
|
|
const startTime = Date.now();
|
|||
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|||
|
|
return {
|
|||
|
|
iteration: 1,
|
|||
|
|
maxIterations: 3,
|
|||
|
|
reason: '检索结果充分',
|
|||
|
|
needsRewrite: false,
|
|||
|
|
rewrittenQuery: null,
|
|||
|
|
time: Date.now() - startTime
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async multiHopSearch(query) {
|
|||
|
|
const startTime = Date.now();
|
|||
|
|
await new Promise(resolve => setTimeout(resolve, 600));
|
|||
|
|
return {
|
|||
|
|
hops: 2,
|
|||
|
|
sources: ['Doc1', 'Doc2', 'Doc3'],
|
|||
|
|
results: [],
|
|||
|
|
time: Date.now() - startTime
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
initKGChart() {
|
|||
|
|
if (typeof echarts !== 'undefined') {
|
|||
|
|
const chartDom = document.getElementById('kg-chart');
|
|||
|
|
if (chartDom) {
|
|||
|
|
this.kgChart = echarts.init(chartDom);
|
|||
|
|
this.loadKGData();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
async loadKGData() {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/v1/kg/graph');
|
|||
|
|
const data = await response.json();
|
|||
|
|
this.kgStats = {
|
|||
|
|
nodes: data.nodes?.length || 0,
|
|||
|
|
edges: data.edges?.length || 0
|
|||
|
|
};
|
|||
|
|
this.renderKG(data.nodes || [], data.edges || []);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('加载知识图谱失败:', error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
renderKG(nodes, edges) {
|
|||
|
|
if (!this.kgChart) return;
|
|||
|
|
const option = {
|
|||
|
|
tooltip: {},
|
|||
|
|
series: [{
|
|||
|
|
type: 'graph',
|
|||
|
|
layout: 'force',
|
|||
|
|
data: nodes.map(n => ({
|
|||
|
|
id: n.id,
|
|||
|
|
name: n.name || n.id,
|
|||
|
|
symbolSize: n.degree || 20,
|
|||
|
|
category: n.category || 'default'
|
|||
|
|
})),
|
|||
|
|
links: edges.map(e => ({
|
|||
|
|
source: e.source,
|
|||
|
|
target: e.target,
|
|||
|
|
label: { show: true, formatter: e.relation }
|
|||
|
|
})),
|
|||
|
|
roam: true,
|
|||
|
|
label: { show: true },
|
|||
|
|
force: { repulsion: 100 }
|
|||
|
|
}]
|
|||
|
|
};
|
|||
|
|
this.kgChart.setOption(option);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
refreshKG() {
|
|||
|
|
this.loadKGData();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
updateKG() {
|
|||
|
|
this.loadKGData();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
addMessage(role, content) {
|
|||
|
|
this.messages.push({
|
|||
|
|
role: role,
|
|||
|
|
content: content,
|
|||
|
|
timestamp: new Date().toISOString()
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 滚动到底部
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const container = this.$refs.messageContainer;
|
|||
|
|
if (container) {
|
|||
|
|
container.scrollTop = container.scrollHeight;
|
|||
|
|
}
|
|||
|
|
}, 100);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getRoleLabel(role) {
|
|||
|
|
const labels = {
|
|||
|
|
'user': '👤 用户',
|
|||
|
|
'assistant': '🤖 Agent',
|
|||
|
|
'system': '💬 系统',
|
|||
|
|
'error': '❌ 错误',
|
|||
|
|
'execution': '⚙️ 执行',
|
|||
|
|
'planning': '📋 规划',
|
|||
|
|
'diagnosis': '🔍 诊断'
|
|||
|
|
};
|
|||
|
|
return labels[role] || role;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getStatusIcon(status) {
|
|||
|
|
const icons = {
|
|||
|
|
'pending': '⏳',
|
|||
|
|
'running': '🔄',
|
|||
|
|
'completed': '✅',
|
|||
|
|
'failed': '❌',
|
|||
|
|
'cancelled': '🚫'
|
|||
|
|
};
|
|||
|
|
return icons[status] || '❓';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
formatMessage(content, isAI = false) {
|
|||
|
|
if (!content) return '';
|
|||
|
|
|
|||
|
|
// 检测是否是结构化内容
|
|||
|
|
const isStructured = isAI && this.detectStructuredContent(content);
|
|||
|
|
|
|||
|
|
let processed = content
|
|||
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|||
|
|
|
|||
|
|
if (isStructured) {
|
|||
|
|
// 结构化内容使用智能格式化
|
|||
|
|
processed = this.formatStructuredContent(processed);
|
|||
|
|
} else {
|
|||
|
|
processed = processed.replace(/\n/g, '<br>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return processed;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
detectStructuredContent(content) {
|
|||
|
|
const patterns = [
|
|||
|
|
/研究总结|基于.*文献|基于.*篇文献/i,
|
|||
|
|
/^\d+\.\s+.+$/m,
|
|||
|
|
/转录因子|研究发现|研究结论/i,
|
|||
|
|
/DNA BINDING|TRANSCRIPTION|GENE FAMILY/i,
|
|||
|
|
/Arabidopsis|SPL.*BMC|doi\.org|PMID/i
|
|||
|
|
];
|
|||
|
|
return patterns.some(p => p.test(content));
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
formatStructuredContent(content) {
|
|||
|
|
let html = content;
|
|||
|
|
|
|||
|
|
// 1. 提取头部信息
|
|||
|
|
html = html.replace(/^(基于\s*\d+\s*篇文献[^\n]*)/i,
|
|||
|
|
'<div class="rag-header">📚 $1</div>');
|
|||
|
|
|
|||
|
|
// 2. 处理编号段落
|
|||
|
|
html = html.replace(/(\d+)\.\s*([^\n<]+)([\n<]|$)/g,
|
|||
|
|
'<div class="rag-section">' +
|
|||
|
|
'<div class="rag-section-title">$1. $2</div>');
|
|||
|
|
|
|||
|
|
// 3. 处理段落内容
|
|||
|
|
html = html.replace(/<div class="rag-section">([\s\S]*?)(?=<div class="rag-section">|<div class="rag-tags|$)/g,
|
|||
|
|
(match, content) => {
|
|||
|
|
const tagPattern = /([A-Z][A-Z\s]*[A-Z])(?=,|$|<)/g;
|
|||
|
|
const tags = [];
|
|||
|
|
let cleanContent = content.replace(tagPattern, (match) => {
|
|||
|
|
tags.push(match.trim());
|
|||
|
|
return '';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const sourcePattern = /(Arabidopsis[^,]*|doi[^\s]*|PMID[^\s]*)/i;
|
|||
|
|
const sourceMatch = cleanContent.match(sourcePattern);
|
|||
|
|
const source = sourceMatch ? sourceMatch[1] : '';
|
|||
|
|
cleanContent = cleanContent.replace(sourcePattern, '');
|
|||
|
|
cleanContent = cleanContent.replace(/[,,]\s*$/, '').trim();
|
|||
|
|
|
|||
|
|
let tagsHtml = '';
|
|||
|
|
if (tags.length > 0) {
|
|||
|
|
tagsHtml = '<div class="rag-tags-row">' +
|
|||
|
|
tags.map(t => `<span class="rag-tag">${t}</span>`).join('') +
|
|||
|
|
(source ? `<span class="rag-source">📄 ${source}</span>` : '') +
|
|||
|
|
'</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return `<div class="rag-section">` +
|
|||
|
|
(cleanContent ? `<div class="rag-section-content">${cleanContent}</div>` : '') +
|
|||
|
|
tagsHtml +
|
|||
|
|
`</div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 4. 处理底部提示
|
|||
|
|
html = html.replace(/([💡📝].*知识图谱.*关联.*)/i,
|
|||
|
|
'<div class="rag-footer">$1</div>');
|
|||
|
|
|
|||
|
|
// 5. 包裹整个内容
|
|||
|
|
if (!html.includes('rag-content-wrapper')) {
|
|||
|
|
html = `<div class="rag-content-wrapper">${html}</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return html;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getMessageSpacingClass(content) {
|
|||
|
|
if (!content) return 'compact';
|
|||
|
|
const length = content.length;
|
|||
|
|
const lines = content.split('\n').length;
|
|||
|
|
if (length < 100 && lines <= 2) return 'compact';
|
|||
|
|
if (length > 1000 || lines > 20) return 'spacious';
|
|||
|
|
return 'normal';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getContentClass(content) {
|
|||
|
|
if (!content) return 'medium-content';
|
|||
|
|
const length = content.length;
|
|||
|
|
const lines = content.split('\n').length;
|
|||
|
|
const isStructured = this.detectStructuredContent(content);
|
|||
|
|
if (isStructured) return 'structured-content';
|
|||
|
|
if (length < 150 && lines <= 3) return 'short-content';
|
|||
|
|
if (length > 800 || lines > 15) return 'long-content';
|
|||
|
|
return 'medium-content';
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
formatTime(timestamp) {
|
|||
|
|
if (!timestamp) return '';
|
|||
|
|
const date = new Date(timestamp);
|
|||
|
|
return date.toLocaleTimeString('zh-CN', {
|
|||
|
|
hour: '2-digit',
|
|||
|
|
minute: '2-digit',
|
|||
|
|
second: '2-digit'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 调试:确认Vue加载成功
|
|||
|
|
console.log('Vue loaded:', typeof Vue);
|
|||
|
|
console.log('ElementPlus loaded:', typeof ElementPlus);
|
|||
|
|
|
|||
|
|
const app = createApp({
|
|||
|
|
setup() {
|
|||
|
|
// ========== 会话管理 ==========
|
|||
|
|
const conversations = ref([]); // 从API加载,不再硬编码
|
|||
|
|
const currentConversationId = ref('');
|
|||
|
|
|
|||
|
|
// 加载会话列表
|
|||
|
|
const loadConversations = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/conversations/list/default');
|
|||
|
|
const data = await response.json();
|
|||
|
|
conversations.value = data.conversations || [];
|
|||
|
|
if (conversations.value.length > 0 && !currentConversationId.value) {
|
|||
|
|
currentConversationId.value = conversations.value[0].conversation_id;
|
|||
|
|
}
|
|||
|
|
console.log('[会话] 加载了', conversations.value.length, '个会话');
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[会话] 加载失败:', e);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 最近会话(显示为快捷按钮)
|
|||
|
|
const recentConversations = computed(() => {
|
|||
|
|
return conversations.value.slice(0, 3);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 更多会话(显示在下拉框中)
|
|||
|
|
const moreConversations = computed(() => {
|
|||
|
|
return conversations.value.slice(3);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 创建新会话(调用API)
|
|||
|
|
const createNewConversation = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/conversations/create', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
user_id: 'default',
|
|||
|
|
title: '新会话 ' + new Date().toLocaleTimeString(),
|
|||
|
|
job_id: null, // 新建会话不绑定 job
|
|||
|
|
input_dir: null,
|
|||
|
|
output_dir: null,
|
|||
|
|
working_dir: null
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.conversation_id) {
|
|||
|
|
currentConversationId.value = data.conversation_id;
|
|||
|
|
// 清空当前会话消息
|
|||
|
|
agentSession.messages = [];
|
|||
|
|
console.log('[会话] 清空消息,新建会话:', data.conversation_id);
|
|||
|
|
|
|||
|
|
// 清空 jobInfo
|
|||
|
|
jobInfo.job_id = null;
|
|||
|
|
jobInfo.input_dir = '';
|
|||
|
|
jobInfo.output_dir = '';
|
|||
|
|
jobInfo.working_dir = '';
|
|||
|
|
console.log('[会话] 清空 jobInfo');
|
|||
|
|
|
|||
|
|
// 清空分析结果区域
|
|||
|
|
stages.forEach(stage => {
|
|||
|
|
stage.images = [];
|
|||
|
|
stage.files = [];
|
|||
|
|
stage.status = 'pending';
|
|||
|
|
stage.elapsed_time = 0;
|
|||
|
|
});
|
|||
|
|
console.log('[会话] 清空 stages');
|
|||
|
|
|
|||
|
|
await loadConversations(); // 刷新列表
|
|||
|
|
ElementPlus.ElMessage.success('新会话已创建,请选择或创建 Working Directory');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[会话] 创建失败:', e);
|
|||
|
|
ElementPlus.ElMessage.error('创建会话失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const selectConversation = async (conv) => {
|
|||
|
|
currentConversationId.value = conv.conversation_id;
|
|||
|
|
ElementPlus.ElMessage.info('切换到: ' + conv.title);
|
|||
|
|
|
|||
|
|
// 恢复关联的 job_id
|
|||
|
|
if (conv.job_id) {
|
|||
|
|
jobInfo.job_id = conv.job_id;
|
|||
|
|
jobInfo.input_dir = conv.input_dir || '';
|
|||
|
|
jobInfo.output_dir = conv.output_dir || '';
|
|||
|
|
jobInfo.working_dir = conv.working_dir || '';
|
|||
|
|
console.log('[会话] 恢复 job:', conv.job_id);
|
|||
|
|
|
|||
|
|
// 先清空 stages 状态
|
|||
|
|
stages.forEach(stage => {
|
|||
|
|
stage.images = [];
|
|||
|
|
stage.files = [];
|
|||
|
|
stage.status = 'pending';
|
|||
|
|
stage.elapsed_time = 0;
|
|||
|
|
});
|
|||
|
|
console.log('[会话] 已清空 stages 状态');
|
|||
|
|
|
|||
|
|
// 再刷新该 job 的分析结果
|
|||
|
|
await refreshResults(conv.job_id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载会话历史消息
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/conversations/${conv.conversation_id}/messages`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.messages) {
|
|||
|
|
// 清空当前Agent会话消息
|
|||
|
|
agentSession.messages = [];
|
|||
|
|
// 加载历史消息
|
|||
|
|
data.messages.forEach(msg => {
|
|||
|
|
agentSession.messages.push({
|
|||
|
|
role: msg.role,
|
|||
|
|
content: msg.content,
|
|||
|
|
time: new Date(msg.timestamp).toLocaleTimeString(),
|
|||
|
|
intent: msg.metadata?.intent || null
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
console.log('[会话] 加载了', data.messages.length, '条历史消息');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[会话] 加载历史消息失败:', e);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 会话列表过滤(用于侧边栏)
|
|||
|
|
const filteredConversations = computed(() => {
|
|||
|
|
return conversations.value;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Analysis StageSelect
|
|||
|
|
const selectedStage = ref('');
|
|||
|
|
|
|||
|
|
// Basic Parameters
|
|||
|
|
const params = reactive({
|
|||
|
|
prefix: '',
|
|||
|
|
cpu: 8
|
|||
|
|
});
|
|||
|
|
window.parentParams = params;
|
|||
|
|
|
|||
|
|
// Input路径
|
|||
|
|
const inputPath = ref('');
|
|||
|
|
|
|||
|
|
// 任务信息
|
|||
|
|
const jobInfo = reactive({
|
|||
|
|
job_id: 'default_job',
|
|||
|
|
input_dir: '',
|
|||
|
|
working_dir: '',
|
|||
|
|
output_dir: ''
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 对话消息
|
|||
|
|
const messages = ref([]);
|
|||
|
|
const logContent = ref('');
|
|||
|
|
const chatArea = ref(null);
|
|||
|
|
|
|||
|
|
// 自然语言Input
|
|||
|
|
const nlInput = ref('');
|
|||
|
|
const nlLoading = ref(false);
|
|||
|
|
|
|||
|
|
// V2: RAG对话记忆
|
|||
|
|
const ragMode = ref(false); // 是否RAG模式
|
|||
|
|
const preferenceMode = ref(false); // 偏好模式: false=Skill(默认), true=Literature
|
|||
|
|
const autoDetectedMode = ref('skill'); // 自动检测的模式: 'skill' 或 'faq'
|
|||
|
|
|
|||
|
|
// Agent Mode - Model Selection
|
|||
|
|
const selectedModel = ref('kimi');
|
|||
|
|
const modelDisplayName = computed(() => {
|
|||
|
|
const names = {
|
|||
|
|
kimi: '🧠 Kimi',
|
|||
|
|
glm: '🔬 GLM-4',
|
|||
|
|
minimax: '⚡ MiniMax',
|
|||
|
|
llama3: '🦙 Llama3',
|
|||
|
|
qwen2: '🤖 Qwen2',
|
|||
|
|
ollama: '📍 Ollama'
|
|||
|
|
};
|
|||
|
|
return names[selectedModel.value] || '🧠 Kimi';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Agent Session State
|
|||
|
|
const agentSession = reactive({
|
|||
|
|
messages: [],
|
|||
|
|
currentTask: null,
|
|||
|
|
tasks: []
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 深度解读功能状态
|
|||
|
|
const interpretationDialogVisible = ref(false);
|
|||
|
|
const interpretationReport = ref(null);
|
|||
|
|
const currentInterpretStage = ref({ id: '', name: '' });
|
|||
|
|
|
|||
|
|
// Agent Tasks列表
|
|||
|
|
const agentTasks = ref([]);
|
|||
|
|
|
|||
|
|
// Agent意图历史
|
|||
|
|
const intentHistory = ref([]);
|
|||
|
|
|
|||
|
|
// Upload Files列表
|
|||
|
|
const uploadedFiles = ref([]);
|
|||
|
|
|
|||
|
|
const userId = ref('xia-lab'); // 用户ID
|
|||
|
|
const sessionId = ref(''); // 会话ID
|
|||
|
|
const ragHistory = ref([]); // RAG对话历史
|
|||
|
|
const apiBaseUrl = ref('http://localhost:7868/api'); // API基础URL - 前端服务同时提供API
|
|||
|
|
|
|||
|
|
// V2: 生成唯一ID
|
|||
|
|
const generateId = () => {
|
|||
|
|
return 'user_' + Math.random().toString(36).substr(2, 9);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// V2: 初始化用户ID
|
|||
|
|
const initUserId = () => {
|
|||
|
|
let uid = localStorage.getItem('bbav_user_id');
|
|||
|
|
if (!uid) {
|
|||
|
|
uid = generateId();
|
|||
|
|
localStorage.setItem('bbav_user_id', uid);
|
|||
|
|
}
|
|||
|
|
userId.value = uid;
|
|||
|
|
|
|||
|
|
let sid = localStorage.getItem('bbav_session_id');
|
|||
|
|
if (!sid) {
|
|||
|
|
sid = 'session_' + Date.now();
|
|||
|
|
localStorage.setItem('bbav_session_id', sid);
|
|||
|
|
}
|
|||
|
|
sessionId.value = sid;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// V2: 切换偏好模式
|
|||
|
|
const togglePreferenceMode = (val) => {
|
|||
|
|
// val 是 switch 组件传入的新值
|
|||
|
|
// false = Skill (默认), true = Literature
|
|||
|
|
if (val) {
|
|||
|
|
ElMessage.success('Preference set to: 📚 Literature (will use RAG for ambiguous queries)');
|
|||
|
|
} else {
|
|||
|
|
ElMessage.info('Preference set to: 🔧 Skill (will execute analysis for ambiguous queries)');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// V2: 切换RAG模式 (保留用于兼容性)
|
|||
|
|
const toggleRagMode = (val) => {
|
|||
|
|
if (val) {
|
|||
|
|
initUserId();
|
|||
|
|
ElMessage.success('Switched to Literature Q&A mode');
|
|||
|
|
} else {
|
|||
|
|
ElMessage.info('Switched to Skill Execution mode');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// V2: ClearRAG会话
|
|||
|
|
const clearRagSession = () => {
|
|||
|
|
sessionId.value = 'session_' + Date.now();
|
|||
|
|
localStorage.setItem('bbav_session_id', sessionId.value);
|
|||
|
|
ragHistory.value = [];
|
|||
|
|
ElMessage.success('已Clear当前会话');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 参数确认对话框
|
|||
|
|
const confirmDialogVisible = ref(false);
|
|||
|
|
const autoExecute = ref(false); // 自动执行模式
|
|||
|
|
const autoExecuteCountdown = ref(15); // 15秒倒计时
|
|||
|
|
let autoExecuteTimer = null; // 倒计时定时器
|
|||
|
|
|
|||
|
|
// 诊断修复确认对话框
|
|||
|
|
const diagnosisConfirmDialogVisible = ref(false);
|
|||
|
|
const diagnosisConfirmCountdown = ref(30); // 30秒倒计时
|
|||
|
|
let diagnosisConfirmTimer = null;
|
|||
|
|
const diagnosisFixData = ref({
|
|||
|
|
errorType: '',
|
|||
|
|
description: '',
|
|||
|
|
currentParams: {},
|
|||
|
|
suggestedParams: {},
|
|||
|
|
currentParamsDisplay: '',
|
|||
|
|
suggestedParamsDisplay: '',
|
|||
|
|
reason: '',
|
|||
|
|
sessionId: '',
|
|||
|
|
jobId: ''
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 批量执行相关
|
|||
|
|
const batchExecuteDialogVisible = ref(false);
|
|||
|
|
const batchExecuteStages = ref([]);
|
|||
|
|
const batchExecuteIndex = ref(0);
|
|||
|
|
const batchExecuteParams = ref({});
|
|||
|
|
const batchExecuteCountdown = ref(15);
|
|||
|
|
let batchExecuteTimer = null;
|
|||
|
|
const isBatchExecuting = ref(false);
|
|||
|
|
const shouldStopBatchExecute = ref(false);
|
|||
|
|
const currentBatchStage = ref('');
|
|||
|
|
|
|||
|
|
// 共线性模糊意图选择
|
|||
|
|
const syntenyChoiceDialogVisible = ref(false);
|
|||
|
|
const syntenyChoiceQuery = ref('');
|
|||
|
|
const syntenyChoiceBaseIntent = ref({});
|
|||
|
|
const syntenyChoiceOptions = ref([]);
|
|||
|
|
|
|||
|
|
// 阶段确认对话框
|
|||
|
|
const stageConfirmDialogVisible = ref(false);
|
|||
|
|
const stageConfirmCountdown = ref(15);
|
|||
|
|
let stageConfirmTimer = ref(null);
|
|||
|
|
let stageConfirmResolve = ref(null);
|
|||
|
|
const stageConfirmCurrent = ref(0);
|
|||
|
|
const stageConfirmTotal = ref(0);
|
|||
|
|
const stageConfirmQueue = ref([]); // 确认对话框队列
|
|||
|
|
let stageConfirmProcessing = false; // 是否正在处理确认对话框
|
|||
|
|
|
|||
|
|
// 依赖驱动的并行执行架构
|
|||
|
|
const stageDependencies = {
|
|||
|
|
// 第一阶段:基因家族鉴定(必须最先完成)
|
|||
|
|
'identify': [],
|
|||
|
|
|
|||
|
|
// 第二阶段:依赖鉴定完成,可并行
|
|||
|
|
'phylogeny': ['identify'],
|
|||
|
|
'pep': ['identify'],
|
|||
|
|
'synteny': ['identify'],
|
|||
|
|
|
|||
|
|
// 第三阶段:依赖identify + phylogeny
|
|||
|
|
'structure': ['identify', 'phylogeny'],
|
|||
|
|
'motif': ['identify', 'phylogeny'],
|
|||
|
|
'domain': ['identify', 'phylogeny'],
|
|||
|
|
'promoter': ['identify', 'phylogeny'],
|
|||
|
|
'expression': ['identify', 'phylogeny'],
|
|||
|
|
'enrichment': ['identify', 'phylogeny'],
|
|||
|
|
'pep': ['identify'],
|
|||
|
|
'msa': ['identify'],
|
|||
|
|
|
|||
|
|
// 共线性相关
|
|||
|
|
'synteny': ['identify'],
|
|||
|
|
'karyotype': ['identify', 'synteny'],
|
|||
|
|
'interspecies_synteny': ['identify', 'phylogeny'],
|
|||
|
|
|
|||
|
|
// 同源基因和PPI
|
|||
|
|
'ortholog': ['identify', 'phylogeny'],
|
|||
|
|
'ppi': ['identify', 'phylogeny', 'ortholog'],
|
|||
|
|
|
|||
|
|
// Ka/Ks
|
|||
|
|
'kaks': ['identify', 'phylogeny', 'interspecies_synteny'],
|
|||
|
|
|
|||
|
|
// 组合图
|
|||
|
|
'combo': ['identify', 'phylogeny', 'motif', 'structure', 'domain'],
|
|||
|
|
|
|||
|
|
// RNA-seq 独立流程(严格按顺序执行)
|
|||
|
|
'rnaseq_qc': [],
|
|||
|
|
'rnaseq_align': ['rnaseq_qc'],
|
|||
|
|
'rnaseq_quant': ['rnaseq_align'],
|
|||
|
|
'rnaseq_de': ['rnaseq_quant'],
|
|||
|
|
'annotation': [],
|
|||
|
|
'rnaseq_go_enrich': ['rnaseq_de'],
|
|||
|
|
'rnaseq_kegg_enrich': ['rnaseq_de'],
|
|||
|
|
'rnaseq_enrich': ['rnaseq_de']
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 阶段执行状态跟踪
|
|||
|
|
const stageExecutionStatus = ref({}); // { stageName: 'pending'|'running'|'completed'|'failed' }
|
|||
|
|
const runningStages = ref(new Set()); // 当前正在执行的阶段
|
|||
|
|
const maxParallelStages = 4; // 最大并行数
|
|||
|
|
|
|||
|
|
const confirmParams = reactive({
|
|||
|
|
stage: '',
|
|||
|
|
prefix: '',
|
|||
|
|
cpu: 8,
|
|||
|
|
// Identify 参数
|
|||
|
|
idEvalue: 0.001,
|
|||
|
|
idCoverage: 0.3,
|
|||
|
|
blastEvalue: 0.00001,
|
|||
|
|
requiredPfams: '',
|
|||
|
|
refSpecies: '',
|
|||
|
|
// Phylogeny 参数
|
|||
|
|
mafftParams: '--auto --thread -1',
|
|||
|
|
iqtreeParams: '-m MFP -bb 1000 -nt AUTO',
|
|||
|
|
// Motif 参数
|
|||
|
|
nmotifs: 10,
|
|||
|
|
minw: 6,
|
|||
|
|
maxw: 200,
|
|||
|
|
// Domain 参数
|
|||
|
|
domEvalue: 0.00001,
|
|||
|
|
domCoverage: 0.3,
|
|||
|
|
// Expression 参数
|
|||
|
|
idTransform: 'remove_T01',
|
|||
|
|
geoId: '',
|
|||
|
|
// RNA-seq 参数
|
|||
|
|
rnaseqMode: 'upload',
|
|||
|
|
speciesName: '',
|
|||
|
|
rnaseqForegroundMode: 'auto',
|
|||
|
|
enrichBackgroundSource: 'auto',
|
|||
|
|
enrichSpecies: '',
|
|||
|
|
enrichKeyType: 'auto',
|
|||
|
|
enrichType: 'go',
|
|||
|
|
keggOrganism: '',
|
|||
|
|
// RNA-seq QC 参数
|
|||
|
|
rnaseqDataMode: 'upload',
|
|||
|
|
rnaseqDataPath: '',
|
|||
|
|
// PPI 参数
|
|||
|
|
ppiSpecies: 'ath',
|
|||
|
|
ppiScore: 700,
|
|||
|
|
// KaKs 参数
|
|||
|
|
kaksNeutralRate: 7.08e-9,
|
|||
|
|
// Genome Prepare 参数
|
|||
|
|
genomePrepareType: 'auto',
|
|||
|
|
targetSpecies: [],
|
|||
|
|
refSpeciesList: []
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 执行状态
|
|||
|
|
geoId: ''
|
|||
|
|
|
|||
|
|
// 执行状态
|
|||
|
|
const interspeciesSpeciesOptions = reactive({
|
|||
|
|
targets: [],
|
|||
|
|
refs: [],
|
|||
|
|
default_targets: [],
|
|||
|
|
default_refs: []
|
|||
|
|
});
|
|||
|
|
const interspeciesSpeciesLoading = ref(false);
|
|||
|
|
|
|||
|
|
const executing = ref(false);
|
|||
|
|
|
|||
|
|
const stages = reactive([
|
|||
|
|
{ name: 'genome_prepare', label: '0️⃣ Genome Prepare', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'identify', label: '1️⃣ Identify', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'phylogeny', label: '2️⃣ Phylogeny', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'motif', label: '3️⃣ Motif', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'domain', label: '4️⃣ Domain', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'structure', label: '5️⃣ Structure', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'promoter', label: '6️⃣ Promoter', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'synteny', label: '7️⃣ 种内Synteny', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'karyotype', label: '8️⃣ Karyotype', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'interspecies_synteny', label: '9️⃣ 种间Synteny', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'ppi', label: '🔟 PPI', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'kaks', label: '1️⃣1️⃣ KaKs', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'pep', label: '1️⃣2️⃣ PEP', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'expression', label: '1️⃣3️⃣ Expression', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'combo', label: '1️⃣4️⃣ Combo', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
// RNA-seq stages
|
|||
|
|
{ name: 'rnaseq_qc', label: '1️⃣5️⃣ RNA-seq QC', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'rnaseq_align', label: '1️⃣6️⃣ RNA-seq Align', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'rnaseq_quant', label: '1️⃣7️⃣ RNA-seq Quant', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'rnaseq_de', label: '1️⃣8️⃣ RNA-seq DE', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'annotation', label: '1️⃣9️⃣ Annotation', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'rnaseq_go_enrich', label: '2️⃣0️⃣ RNA-seq GO Enrich', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'rnaseq_kegg_enrich', label: '2️⃣1️⃣ RNA-seq KEGG Enrich', status: 'pending', images: [], files: [], elapsed_time: 0 },
|
|||
|
|
{ name: 'rnaseq_enrich', label: '2️⃣2️⃣ RNA-seq Enrich', status: 'pending', images: [], files: [], elapsed_time: 0 }
|
|||
|
|
]);
|
|||
|
|
// 新版结果区域 - 展开的阶段
|
|||
|
|
const activeStages = ref('phylogeny');
|
|||
|
|
|
|||
|
|
// 当前选中的文件
|
|||
|
|
const selectedResultStage = ref('');
|
|||
|
|
const selectedFile = ref('');
|
|||
|
|
const selectedImage = ref('');
|
|||
|
|
const selectedImageUrl = ref('');
|
|||
|
|
const selectedImageSize = ref('');
|
|||
|
|
const zoomLevel = ref(100);
|
|||
|
|
const previewContentRef = ref(null);
|
|||
|
|
|
|||
|
|
// 判断是否为图片文件
|
|||
|
|
const isImageFile = (filename) => {
|
|||
|
|
const imgExts = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.bmp', '.webp'];
|
|||
|
|
return imgExts.some(ext => filename.toLowerCase().endsWith(ext));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Select文件
|
|||
|
|
const selectFile = (stageName, file) => {
|
|||
|
|
selectedResultStage.value = stageName;
|
|||
|
|
selectedFile.value = file.name;
|
|||
|
|
|
|||
|
|
// PPI URL 文件:点击后在新窗口打开 STRING 链接
|
|||
|
|
if (file.name.endsWith('.ppi_url.txt')) {
|
|||
|
|
fetch(file.path)
|
|||
|
|
.then(r => r.text())
|
|||
|
|
.then(text => {
|
|||
|
|
// 提取 URL:查找 http 开头的行或包含 http 的行
|
|||
|
|
const lines = text.split('\n');
|
|||
|
|
for (const line of lines) {
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
// 直接以 http 开头的行
|
|||
|
|
if (trimmed.startsWith('http')) {
|
|||
|
|
window.open(trimmed, '_blank');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// 包含 http 的行(如 "View online: https://...")
|
|||
|
|
const httpMatch = trimmed.match(/(https?:\/\/[^\s]+)/);
|
|||
|
|
if (httpMatch) {
|
|||
|
|
window.open(httpMatch[1], '_blank');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 如果没找到 URL,显示文件内容
|
|||
|
|
ElMessage.info('STRING 链接文件内容:' + text.substring(0, 200));
|
|||
|
|
})
|
|||
|
|
.catch(err => {
|
|||
|
|
ElMessage.error('读取文件失败: ' + err.message);
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TSV/CSV 文件:点击后显示内容预览
|
|||
|
|
if (file.name.endsWith('.tsv') || file.name.endsWith('.csv')) {
|
|||
|
|
fetch(file.path)
|
|||
|
|
.then(r => r.text())
|
|||
|
|
.then(text => {
|
|||
|
|
// 显示前 500 字符的预览
|
|||
|
|
const preview = text.substring(0, 500) + (text.length > 500 ? '...' : '');
|
|||
|
|
ElMessage.info({
|
|||
|
|
message: '文件预览(前500字符):\n' + preview,
|
|||
|
|
duration: 5000,
|
|||
|
|
showClose: true
|
|||
|
|
});
|
|||
|
|
})
|
|||
|
|
.catch(err => {
|
|||
|
|
ElMessage.error('读取文件失败: ' + err.message);
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isImageFile(file.name)) {
|
|||
|
|
selectedImage.value = file.name;
|
|||
|
|
selectedImageUrl.value = '/api/file/' + stageName + '/' + file.name;
|
|||
|
|
selectedImageSize.value = formatSize(file.size);
|
|||
|
|
zoomLevel.value = 100;
|
|||
|
|
} else {
|
|||
|
|
selectedImage.value = '';
|
|||
|
|
selectedImageUrl.value = '';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 缩放图片 - Zoom In
|
|||
|
|
const zoomIn = () => {
|
|||
|
|
zoomLevel.value = Math.min(300, zoomLevel.value + 25);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 缩放图片 - Zoom Out
|
|||
|
|
const zoomOut = () => {
|
|||
|
|
zoomLevel.value = Math.max(25, zoomLevel.value - 25);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Reset缩放
|
|||
|
|
const resetZoom = () => {
|
|||
|
|
zoomLevel.value = 100;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 全屏切换
|
|||
|
|
const toggleFullscreen = () => {
|
|||
|
|
if (!document.fullscreenElement) {
|
|||
|
|
previewContentRef.value?.requestFullscreen();
|
|||
|
|
} else {
|
|||
|
|
document.exitFullscreen();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Download图片
|
|||
|
|
const downloadImage = () => {
|
|||
|
|
if (selectedImageUrl.value) {
|
|||
|
|
const link = document.createElement('a');
|
|||
|
|
link.href = selectedImageUrl.value;
|
|||
|
|
link.download = selectedImage.value;
|
|||
|
|
link.click();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 图片加载回调
|
|||
|
|
const onImageLoad = () => {
|
|||
|
|
zoomLevel.value = 100;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 图片加载错误
|
|||
|
|
const onImageError = () => {
|
|||
|
|
console.error('图片加载失败:', selectedImageUrl.value);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 缩放变化
|
|||
|
|
const onZoomChange = (val) => {
|
|||
|
|
zoomLevel.value = val;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 从路径中提取文件名
|
|||
|
|
const getFileName = (path) => {
|
|||
|
|
return path.split('/').pop();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Select图片文件
|
|||
|
|
const selectImageFile = (stageName, imgPath) => {
|
|||
|
|
const fileName = getFileName(imgPath);
|
|||
|
|
selectedResultStage.value = stageName;
|
|||
|
|
selectedFile.value = fileName;
|
|||
|
|
selectedImage.value = fileName;
|
|||
|
|
// 直接使用完整路径访问图片
|
|||
|
|
selectedImageUrl.value = imgPath;
|
|||
|
|
selectedImageSize.value = '';
|
|||
|
|
zoomLevel.value = 100;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 图片预览
|
|||
|
|
const previewDialogVisible = ref(false);
|
|||
|
|
const previewImg = ref('');
|
|||
|
|
const previewParams = reactive({ width: 800, height: 600 });
|
|||
|
|
|
|||
|
|
// 格式化文件大小
|
|||
|
|
const formatSize = (bytes) => {
|
|||
|
|
if (bytes < 1024) return bytes + ' B';
|
|||
|
|
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
|
|||
|
|
return (bytes/1024/1024).toFixed(1) + ' MB';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 格式化时间
|
|||
|
|
const formatTime = (timestamp) => {
|
|||
|
|
if (!timestamp) return '';
|
|||
|
|
const date = new Date(timestamp);
|
|||
|
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取Agent Tasks列表
|
|||
|
|
const fetchAgentTasks = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/agent/tasks');
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
agentTasks.value = data.tasks || [];
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('获取Agent Tasks失败:', err);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 显示任务详情
|
|||
|
|
const showTaskDetail = (task) => {
|
|||
|
|
ElMessage.info(`任务: ${task.message}\n状态: ${task.status}\n进度: ${task.progress}%`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 定时RefreshAgent Tasks
|
|||
|
|
let agentTaskTimer = null;
|
|||
|
|
const startAgentTaskPolling = () => {
|
|||
|
|
agentTaskTimer = setInterval(fetchAgentTasks, 5000);
|
|||
|
|
// 同时启动诊断确认轮询
|
|||
|
|
startDiagnosisConfirmPolling();
|
|||
|
|
};
|
|||
|
|
const stopAgentTaskPolling = () => {
|
|||
|
|
if (agentTaskTimer) clearInterval(agentTaskTimer);
|
|||
|
|
// 同时停止诊断确认轮询
|
|||
|
|
stopDiagnosisConfirmPolling();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 诊断确认状态轮询
|
|||
|
|
const startDiagnosisConfirmPolling = () => {
|
|||
|
|
if (diagnosisConfirmTimer) clearInterval(diagnosisConfirmTimer);
|
|||
|
|
diagnosisConfirmTimer = setInterval(checkDiagnosisConfirmation, 2000);
|
|||
|
|
};
|
|||
|
|
const stopDiagnosisConfirmPolling = () => {
|
|||
|
|
if (diagnosisConfirmTimer) clearInterval(diagnosisConfirmTimer);
|
|||
|
|
diagnosisConfirmTimer = null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 检查是否有等待确认的修复方案
|
|||
|
|
const checkDiagnosisConfirmation = async () => {
|
|||
|
|
if (!sessionId.value) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`${apiBaseUrl.value}/agent/pending_confirmation/${sessionId.value}?job_id=${jobInfo.job_id || ''}`);
|
|||
|
|
if (!response.ok) return;
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.status === "waiting" && data.confirmation) {
|
|||
|
|
// 有等待确认的修复方案,显示确认对话框
|
|||
|
|
if (!diagnosisConfirmDialogVisible.value) {
|
|||
|
|
showDiagnosisConfirm(data.confirmation);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
// 静默失败,不影响正常轮询
|
|||
|
|
console.debug('[Diagnosis Polling] Error:', err);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// MCScanX HTML文件列表
|
|||
|
|
const mcscanxHtmlFiles = ref([]);
|
|||
|
|
|
|||
|
|
// 获取MCScanX HTML文件列表
|
|||
|
|
const refreshMcscanxHtml = async () => {
|
|||
|
|
if (!jobInfo.job_id || jobInfo.job_id === 'default_job') return;
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/mcscanx_html/${jobInfo.job_id}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.exists) {
|
|||
|
|
mcscanxHtmlFiles.value = data.files || [];
|
|||
|
|
} else {
|
|||
|
|
mcscanxHtmlFiles.value = [];
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('获取MCScanX HTML失败:', err);
|
|||
|
|
mcscanxHtmlFiles.value = [];
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 打开MCScanX HTML文件
|
|||
|
|
const openMcscanxHtml = (file) => {
|
|||
|
|
window.open(file.path, '_blank');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 添加消息
|
|||
|
|
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 copyPath = async (path) => {
|
|||
|
|
try {
|
|||
|
|
await navigator.clipboard.writeText(path);
|
|||
|
|
ElMessage.success('路径已复制');
|
|||
|
|
} catch (err) {
|
|||
|
|
ElMessage.error('复制失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Refresh任务信息
|
|||
|
|
const refreshInfo = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/info');
|
|||
|
|
const data = await response.json();
|
|||
|
|
jobInfo.job_id = data.job_id || 'default_job';
|
|||
|
|
jobInfo.working_dir = data.working_dir || '';
|
|||
|
|
jobInfo.input_dir = data.input_dir || '';
|
|||
|
|
jobInfo.output_dir = data.output_dir || '';
|
|||
|
|
// 更新全局变量供 Agent 组件使用
|
|||
|
|
window.parentJobInfo = { ...jobInfo };
|
|||
|
|
} catch (err) { console.error(err); }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// NewWorking Directory
|
|||
|
|
const createNewJob = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/new_job', { method: 'POST' });
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
jobInfo.job_id = data.job_id;
|
|||
|
|
jobInfo.input_dir = data.input_dir;
|
|||
|
|
jobInfo.working_dir = data.workspace_dir;
|
|||
|
|
jobInfo.output_dir = data.workspace_dir + '/final';
|
|||
|
|
// 更新全局变量供 Agent 组件使用
|
|||
|
|
window.parentJobInfo = { ...jobInfo };
|
|||
|
|
// Clear结果
|
|||
|
|
stages.forEach(s => { s.images = []; s.files = []; s.status = 'pending'; });
|
|||
|
|
messages.value = [];
|
|||
|
|
logContent.value = '';
|
|||
|
|
|
|||
|
|
// 更新当前会话的 job_id
|
|||
|
|
if (currentConversationId.value) {
|
|||
|
|
try {
|
|||
|
|
await fetch('/api/conversations/link-to-job', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
conversation_id: currentConversationId.value,
|
|||
|
|
job_id: data.job_id
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
console.log('[会话] 已绑定 job:', data.job_id);
|
|||
|
|
await loadConversations(); // 刷新列表
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[会话] 绑定 job 失败:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ElMessage.success('已创建新Working Directory: ' + data.job_id);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
ElMessage.error('创建失败: ' + err.message);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 已有Working Directory列表
|
|||
|
|
const existingJobs = ref([]);
|
|||
|
|
const jobSelectorVisible = ref(false);
|
|||
|
|
const selectedJobId = ref('');
|
|||
|
|
|
|||
|
|
// 显示Working DirectorySelect器
|
|||
|
|
const showJobSelector = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/list_jobs');
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
existingJobs.value = data.jobs;
|
|||
|
|
jobSelectorVisible.value = true;
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
ElMessage.error('获取Working Directory列表失败: ' + err.message);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// SelectWorking Directory
|
|||
|
|
const selectJob = async (jobId) => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/select_job', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ job_id: jobId })
|
|||
|
|
});
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
jobInfo.job_id = data.job_id;
|
|||
|
|
jobInfo.input_dir = data.input_dir;
|
|||
|
|
jobInfo.working_dir = data.workspace_dir;
|
|||
|
|
jobInfo.output_dir = data.workspace_dir + '/final';
|
|||
|
|
// 更新全局变量供 Agent 组件使用
|
|||
|
|
window.parentJobInfo = { ...jobInfo };
|
|||
|
|
jobSelectorVisible.value = false;
|
|||
|
|
|
|||
|
|
// 更新当前会话的 job_id
|
|||
|
|
if (currentConversationId.value) {
|
|||
|
|
try {
|
|||
|
|
await fetch('/api/conversations/link-to-job', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
conversation_id: currentConversationId.value,
|
|||
|
|
job_id: data.job_id
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
console.log('[会话] 已绑定 job:', data.job_id);
|
|||
|
|
await loadConversations();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[会话] 绑定 job 失败:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ElMessage.success('已切换到Working Directory: ' + data.job_id);
|
|||
|
|
// Refresh结果展示
|
|||
|
|
await refreshResults();
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error(data.error || '切换失败');
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
ElMessage.error('切换失败: ' + err.message);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 阶段Select变化
|
|||
|
|
const onStageChange = (val) => {
|
|||
|
|
console.log('Select阶段:', val);
|
|||
|
|
if (val && val.startsWith('rnaseq')) {
|
|||
|
|
// RNA-seq阶段特殊处理
|
|||
|
|
loadRnaseqData();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ========== 会话管理方法 ==========
|
|||
|
|
const onSelectConversation = (conv) => {
|
|||
|
|
console.log('Select会话:', conv);
|
|||
|
|
currentConversation.value = conv;
|
|||
|
|
if (conv) {
|
|||
|
|
// 加载会话消息
|
|||
|
|
loadConversationMessages(conv.conversation_id);
|
|||
|
|
// 更新标题
|
|||
|
|
document.title = conv.title + ' - BotanicalBaseAgent';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onNewConversation = (conv) => {
|
|||
|
|
console.log('新建会话:', conv);
|
|||
|
|
currentConversation.value = conv;
|
|||
|
|
// 清空当前消息
|
|||
|
|
messages.value = [];
|
|||
|
|
agentSession.messages = [];
|
|||
|
|
// 添加欢迎消息
|
|||
|
|
const welcomeMsg = {
|
|||
|
|
role: 'assistant',
|
|||
|
|
content: '您好!我是BotanicalBaseAgent,专注于基因家族分析。请告诉我您想分析哪个基因家族?',
|
|||
|
|
timestamp: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
messages.value.push(welcomeMsg);
|
|||
|
|
agentSession.messages.push(welcomeMsg);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onSidebarToggle = (collapsed) => {
|
|||
|
|
sidebarCollapsed.value = collapsed;
|
|||
|
|
console.log('侧边栏状态:', collapsed ? '折叠' : '展开');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 加载会话消息
|
|||
|
|
const loadConversationMessages = async (conversationId) => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`${apiBaseUrl.value}/conversations/${conversationId}/messages`);
|
|||
|
|
if (response.ok) {
|
|||
|
|
const data = await response.json();
|
|||
|
|
messages.value = data.messages || [];
|
|||
|
|
agentSession.messages = data.messages || [];
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('加载会话消息失败:', e);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 加载RNA-seq数据
|
|||
|
|
const loadRnaseqData = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/rnaseq/samples');
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
rnaseqSamples.value = data.samples;
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('加载RNA-seq样本失败:', e);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const rnaseqSamples = ref([]);
|
|||
|
|
|
|||
|
|
// 显示确认对话框
|
|||
|
|
const loadInterspeciesSpeciesOptions = async () => {
|
|||
|
|
interspeciesSpeciesLoading.value = true;
|
|||
|
|
try {
|
|||
|
|
const jobId = jobInfo.job_id || '';
|
|||
|
|
const response = await fetch(`/api/interspecies/species-options?job_id=${encodeURIComponent(jobId)}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (!data.success) throw new Error(data.message || 'Failed to load species options');
|
|||
|
|
interspeciesSpeciesOptions.targets = data.targets || [];
|
|||
|
|
interspeciesSpeciesOptions.refs = data.refs || [];
|
|||
|
|
interspeciesSpeciesOptions.default_targets = data.default_targets || [];
|
|||
|
|
interspeciesSpeciesOptions.default_refs = data.default_refs || [];
|
|||
|
|
confirmParams.targetSpecies = [...interspeciesSpeciesOptions.default_targets];
|
|||
|
|
confirmParams.refSpeciesList = [...interspeciesSpeciesOptions.default_refs];
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[DEBUG] Failed to load interspecies species options:', err);
|
|||
|
|
ElMessage.error(`Failed to load species options: ${err.message || err}`);
|
|||
|
|
interspeciesSpeciesOptions.targets = [];
|
|||
|
|
interspeciesSpeciesOptions.refs = [];
|
|||
|
|
confirmParams.targetSpecies = [];
|
|||
|
|
confirmParams.refSpeciesList = [];
|
|||
|
|
} finally {
|
|||
|
|
interspeciesSpeciesLoading.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const showConfirm = async () => {
|
|||
|
|
console.log('[DEBUG] showConfirm called, selectedStage:', selectedStage.value);
|
|||
|
|
if (!selectedStage.value) {
|
|||
|
|
ElMessage.warning('请先SelectAnalysis Stage');
|
|||
|
|
console.log('[DEBUG] showConfirm: no stage selected, returning');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
confirmParams.stage = selectedStage.value;
|
|||
|
|
confirmParams.prefix = "";
|
|||
|
|
confirmParams.cpu = params.cpu;
|
|||
|
|
if (selectedStage.value === 'rnaseq_go_enrich') {
|
|||
|
|
confirmParams.enrichType = 'go';
|
|||
|
|
} else if (selectedStage.value === 'rnaseq_kegg_enrich') {
|
|||
|
|
confirmParams.enrichType = 'kegg';
|
|||
|
|
}
|
|||
|
|
if (['rnaseq_enrich', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich'].includes(selectedStage.value)) {
|
|||
|
|
confirmParams.rnaseqForegroundMode = 'auto';
|
|||
|
|
}
|
|||
|
|
if (selectedStage.value === 'interspecies_synteny') {
|
|||
|
|
await loadInterspeciesSpeciesOptions();
|
|||
|
|
} else {
|
|||
|
|
confirmParams.targetSpecies = [];
|
|||
|
|
confirmParams.refSpeciesList = [];
|
|||
|
|
}
|
|||
|
|
console.log('[DEBUG] showConfirm: setting confirmDialogVisible = true');
|
|||
|
|
confirmDialogVisible.value = true;
|
|||
|
|
console.log('[DEBUG] showConfirm: confirmDialogVisible =', confirmDialogVisible.value);
|
|||
|
|
addMessage('user', `▶ 准备运行: ${selectedStage.value.toUpperCase()}`);
|
|||
|
|
console.log('[DEBUG] showConfirm: message added');
|
|||
|
|
|
|||
|
|
// 启动15秒自动执行倒计时
|
|||
|
|
startAutoExecuteTimer();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 启动自动执行倒计时
|
|||
|
|
const startAutoExecuteTimer = () => {
|
|||
|
|
autoExecute.value = true;
|
|||
|
|
autoExecuteCountdown.value = confirmParams.stage === 'interspecies_synteny' ? 20 : 15;
|
|||
|
|
clearAutoExecuteTimer();
|
|||
|
|
autoExecuteTimer = setInterval(() => {
|
|||
|
|
autoExecuteCountdown.value--;
|
|||
|
|
if (autoExecuteCountdown.value <= 0) {
|
|||
|
|
clearAutoExecuteTimer();
|
|||
|
|
if (confirmDialogVisible.value && !executing.value) {
|
|||
|
|
console.log(`[DEBUG] Auto executing after ${confirmParams.stage === 'interspecies_synteny' ? 20 : 15}s countdown`);
|
|||
|
|
doExecute();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 1000);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 清除自动执行倒计时
|
|||
|
|
const clearAutoExecuteTimer = () => {
|
|||
|
|
if (autoExecuteTimer) {
|
|||
|
|
clearInterval(autoExecuteTimer);
|
|||
|
|
autoExecuteTimer = null;
|
|||
|
|
}
|
|||
|
|
autoExecute.value = false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 诊断修复确认相关函数
|
|||
|
|
const startDiagnosisConfirmTimer = () => {
|
|||
|
|
diagnosisConfirmCountdown.value = 30;
|
|||
|
|
clearDiagnosisConfirmTimer();
|
|||
|
|
|
|||
|
|
diagnosisConfirmTimer = setInterval(() => {
|
|||
|
|
diagnosisConfirmCountdown.value--;
|
|||
|
|
if (diagnosisConfirmCountdown.value <= 0) {
|
|||
|
|
clearDiagnosisConfirmTimer();
|
|||
|
|
if (diagnosisConfirmDialogVisible.value) {
|
|||
|
|
console.log('[DEBUG] Auto applying diagnosis fix after 30s');
|
|||
|
|
ElMessage.warning('⏱️ 倒计时结束,自动应用修复方案...');
|
|||
|
|
confirmDiagnosisFix();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 1000);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const clearDiagnosisConfirmTimer = () => {
|
|||
|
|
if (diagnosisConfirmTimer) {
|
|||
|
|
clearInterval(diagnosisConfirmTimer);
|
|||
|
|
diagnosisConfirmTimer = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const showDiagnosisConfirm = (fixData) => {
|
|||
|
|
diagnosisFixData.value = {
|
|||
|
|
errorType: fixData.error_type || '执行错误',
|
|||
|
|
description: fixData.description || '检测到执行问题',
|
|||
|
|
currentParams: fixData.current_params || {},
|
|||
|
|
suggestedParams: fixData.suggested_params || {},
|
|||
|
|
currentParamsDisplay: fixData.current_params_display || JSON.stringify(fixData.current_params || {}),
|
|||
|
|
suggestedParamsDisplay: fixData.suggested_params_display || JSON.stringify(fixData.suggested_params || {}),
|
|||
|
|
reason: fixData.reason || '建议调整参数以解决问题',
|
|||
|
|
sessionId: fixData.session_id || sessionId.value,
|
|||
|
|
jobId: fixData.job_id || currentJobId.value
|
|||
|
|
};
|
|||
|
|
diagnosisConfirmDialogVisible.value = true;
|
|||
|
|
startDiagnosisConfirmTimer();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const confirmDiagnosisFix = async () => {
|
|||
|
|
clearDiagnosisConfirmTimer();
|
|||
|
|
diagnosisConfirmDialogVisible.value = false;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`${apiBaseUrl.value}/agent/confirm_fix`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
session_id: diagnosisFixData.value.sessionId,
|
|||
|
|
job_id: diagnosisFixData.value.jobId,
|
|||
|
|
confirmed: true,
|
|||
|
|
fix_data: diagnosisFixData.value.suggestedParams
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
ElMessage.success('✅ 已应用修复方案,继续执行...');
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error('❌ 应用修复失败');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[Diagnosis Confirm] Error:', error);
|
|||
|
|
ElMessage.error('❌ 网络错误,请重试');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const rejectDiagnosisFix = async () => {
|
|||
|
|
clearDiagnosisConfirmTimer();
|
|||
|
|
diagnosisConfirmDialogVisible.value = false;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`${apiBaseUrl.value}/agent/confirm_fix`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
session_id: diagnosisFixData.value.sessionId,
|
|||
|
|
job_id: diagnosisFixData.value.jobId,
|
|||
|
|
confirmed: false
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
ElMessage.info('⏭️ 已跳过修复,继续下一阶段...');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[Diagnosis Reject] Error:', error);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 批量执行相关函数
|
|||
|
|
const startBatchExecuteTimer = () => {
|
|||
|
|
batchExecuteCountdown.value = 30; // 增加到30秒,给用户更多时间确认
|
|||
|
|
clearBatchExecuteTimer();
|
|||
|
|
|
|||
|
|
// 添加明显的提示消息
|
|||
|
|
ElMessage.info(`📋 批量执行确认对话框已打开,${batchExecuteCountdown.value}秒后自动开始执行,请点击确认或取消`);
|
|||
|
|
|
|||
|
|
batchExecuteTimer = setInterval(() => {
|
|||
|
|
batchExecuteCountdown.value--;
|
|||
|
|
if (batchExecuteCountdown.value <= 0) {
|
|||
|
|
clearBatchExecuteTimer();
|
|||
|
|
if (batchExecuteDialogVisible.value && !isBatchExecuting.value) {
|
|||
|
|
console.log('[DEBUG] Auto starting batch execute after 30s');
|
|||
|
|
ElMessage.warning('⏱️ 倒计时结束,自动开始执行...');
|
|||
|
|
startBatchExecute();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 1000);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const clearBatchExecuteTimer = () => {
|
|||
|
|
if (batchExecuteTimer) {
|
|||
|
|
clearInterval(batchExecuteTimer);
|
|||
|
|
batchExecuteTimer = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const startBatchExecute = async () => {
|
|||
|
|
clearBatchExecuteTimer();
|
|||
|
|
batchExecuteDialogVisible.value = false;
|
|||
|
|
isBatchExecuting.value = true;
|
|||
|
|
shouldStopBatchExecute.value = false;
|
|||
|
|
|
|||
|
|
const stages = batchExecuteStages.value;
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', `🚀 开始依赖驱动的并行执行,共 ${stages.length} 个阶段...`);
|
|||
|
|
|
|||
|
|
// 初始化阶段状态
|
|||
|
|
stageExecutionStatus.value = {};
|
|||
|
|
|
|||
|
|
// 检查已完成的阶段(通过manifest文件)
|
|||
|
|
const checkCompletedStages = async () => {
|
|||
|
|
const allStages = ['identify', 'phylogeny', 'motif', 'domain', 'structure', 'promoter',
|
|||
|
|
'synteny', 'karyotype', 'interspecies_synteny', 'ppi', 'kaks', 'pep',
|
|||
|
|
'expression', 'rnaseq_qc', 'rnaseq_align', 'rnaseq_quant', 'rnaseq_de',
|
|||
|
|
'annotation', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich', 'rnaseq_enrich'];
|
|||
|
|
const jobId = jobInfo.job_id;
|
|||
|
|
const prefix = encodeURIComponent(params.prefix || '');
|
|||
|
|
|
|||
|
|
for (const stage of allStages) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/stage-status?job_id=${jobId}&stage=${stage}&prefix=${prefix}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.completed) {
|
|||
|
|
stageExecutionStatus.value[stage] = 'completed';
|
|||
|
|
console.log(`[DEBUG] Stage ${stage} already completed`);
|
|||
|
|
} else {
|
|||
|
|
// 明确设置为未完成状态,避免undefined
|
|||
|
|
stageExecutionStatus.value[stage] = 'pending';
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// 出错时设置为pending
|
|||
|
|
stageExecutionStatus.value[stage] = 'pending';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await checkCompletedStages();
|
|||
|
|
|
|||
|
|
// 设置待执行阶段为pending
|
|||
|
|
stages.forEach(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
if (!stageExecutionStatus.value[stageId]) {
|
|||
|
|
stageExecutionStatus.value[stageId] = 'pending';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
runningStages.value.clear();
|
|||
|
|
|
|||
|
|
// 启动依赖驱动的并行执行
|
|||
|
|
await executeStagesWithDependencies(stages);
|
|||
|
|
|
|||
|
|
isBatchExecuting.value = false;
|
|||
|
|
currentBatchStage.value = '';
|
|||
|
|
|
|||
|
|
if (!shouldStopBatchExecute.value) {
|
|||
|
|
// 只统计当前批次 stages 中的 completed 数量(避免累积历史状态)
|
|||
|
|
const completedCount = stages.filter(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
return stageExecutionStatus.value[stageId] === 'completed';
|
|||
|
|
}).length;
|
|||
|
|
const failedCount = stages.filter(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
return stageExecutionStatus.value[stageId] === 'failed';
|
|||
|
|
}).length;
|
|||
|
|
const totalCount = stages.length;
|
|||
|
|
|
|||
|
|
if (failedCount > 0) {
|
|||
|
|
addAgentMessage('assistant', `⚠️ 批量执行完成:成功 ${completedCount}/${totalCount},失败 ${failedCount}/${totalCount}`);
|
|||
|
|
} else {
|
|||
|
|
addAgentMessage('assistant', `🎉 批量执行完成!成功: ${completedCount}/${totalCount}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 依赖驱动的并行执行核心逻辑
|
|||
|
|
const executeStagesWithDependencies = async (stages) => {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const checkAndExecute = async () => {
|
|||
|
|
// 检查是否停止
|
|||
|
|
if (shouldStopBatchExecute.value) {
|
|||
|
|
resolve();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 【调试日志】打印当前状态
|
|||
|
|
console.log('[DEBUG-STATE] checkAndExecute called');
|
|||
|
|
console.log('[DEBUG-STATE] stageExecutionStatus:', JSON.parse(JSON.stringify(stageExecutionStatus.value)));
|
|||
|
|
console.log('[DEBUG-STATE] runningStages:', Array.from(runningStages.value));
|
|||
|
|
|
|||
|
|
// 获取可执行的阶段(依赖已完成,且未在执行/完成)
|
|||
|
|
const executableStages = stages.filter(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
const status = stageExecutionStatus.value[stageId];
|
|||
|
|
const deps = stageDependencies[stageId] || [];
|
|||
|
|
const depsCompleted = deps.every(dep => stageExecutionStatus.value[dep] === 'completed');
|
|||
|
|
|
|||
|
|
console.log(`[DEBUG-CHECK] Stage ${stageId}: status=${status}, deps=${JSON.stringify(deps)}, depsCompleted=${depsCompleted}`);
|
|||
|
|
|
|||
|
|
if (status !== 'pending') return false;
|
|||
|
|
return depsCompleted;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log('[DEBUG-STATE] executableStages:', executableStages.map(s => typeof s === 'object' ? s.id : s));
|
|||
|
|
|
|||
|
|
// 检查是否全部完成
|
|||
|
|
const allCompleted = stages.every(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
return stageExecutionStatus.value[stageId] === 'completed' ||
|
|||
|
|
stageExecutionStatus.value[stageId] === 'failed';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (allCompleted) {
|
|||
|
|
console.log('[DEBUG-STATE] All stages completed, resolving');
|
|||
|
|
resolve();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 并行执行可执行的阶段(限制最大并行数)
|
|||
|
|
const availableSlots = maxParallelStages - runningStages.value.size;
|
|||
|
|
const stagesToExecute = executableStages.slice(0, availableSlots);
|
|||
|
|
|
|||
|
|
console.log('[DEBUG-STATE] availableSlots:', availableSlots, 'stagesToExecute:', stagesToExecute.map(s => typeof s === 'object' ? s.id : s));
|
|||
|
|
|
|||
|
|
if (stagesToExecute.length > 0) {
|
|||
|
|
console.log('[DEBUG] Executing stages:', stagesToExecute);
|
|||
|
|
|
|||
|
|
// 并行启动所有可执行阶段
|
|||
|
|
const executePromises = stagesToExecute.map(stage =>
|
|||
|
|
executeStageWithConfirmation(stage, stages.findIndex(s => {
|
|||
|
|
const sid = typeof s === 'object' ? s.id : s;
|
|||
|
|
const tid = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
return sid === tid;
|
|||
|
|
}) + 1, stages.length)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await Promise.all(executePromises);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 继续检查(如果有阶段还在运行,稍后再检查)
|
|||
|
|
if (runningStages.value.size > 0 || executableStages.length > availableSlots) {
|
|||
|
|
console.log('[DEBUG] Stages still running or waiting for slots, rechecking in 2s...');
|
|||
|
|
setTimeout(checkAndExecute, 2000);
|
|||
|
|
} else if (!allCompleted) {
|
|||
|
|
// 还有阶段未完成,检查是否有依赖失败或等待依赖完成
|
|||
|
|
const failedDeps = stages.filter(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
const status = stageExecutionStatus.value[stageId];
|
|||
|
|
if (status === 'pending') {
|
|||
|
|
const deps = stageDependencies[stageId] || [];
|
|||
|
|
return deps.some(dep => stageExecutionStatus.value[dep] === 'failed');
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (failedDeps.length > 0) {
|
|||
|
|
const failedNames = failedDeps.map(s => typeof s === 'object' ? s.name : s).join(', ');
|
|||
|
|
addAgentMessage('assistant', `⚠️ 以下阶段因依赖失败被跳过: ${failedNames}`);
|
|||
|
|
failedDeps.forEach(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
stageExecutionStatus.value[stageId] = 'failed';
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重要:继续检查,等待依赖完成或新阶段可执行
|
|||
|
|
console.log('[DEBUG] Waiting for dependencies, rechecking in 2s...');
|
|||
|
|
setTimeout(checkAndExecute, 2000);
|
|||
|
|
} else {
|
|||
|
|
console.log('[DEBUG] All stages completed or failed, resolving.');
|
|||
|
|
resolve();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 开始执行
|
|||
|
|
checkAndExecute();
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 执行单个阶段(带确认对话框)
|
|||
|
|
const executeStageWithConfirmation = async (stage, currentIndex, totalStages) => {
|
|||
|
|
// stage可能是对象或字符串,统一处理
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
const stageName = typeof stage === 'object' ? stage.name : stage;
|
|||
|
|
|
|||
|
|
console.log(`[DEBUG-STAGE] executeStageWithConfirmation START: ${stageId}`);
|
|||
|
|
|
|||
|
|
runningStages.value.add(stageId);
|
|||
|
|
stageExecutionStatus.value[stageId] = 'running';
|
|||
|
|
currentBatchStage.value = stageId;
|
|||
|
|
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: added to runningStages, status set to running`);
|
|||
|
|
console.log(`[DEBUG-STAGE] runningStages:`, Array.from(runningStages.value));
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 显示阶段确认对话框(带15秒倒计时)
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: showing confirm dialog`);
|
|||
|
|
const shouldExecute = await showStageConfirmDialog(stage, currentIndex, totalStages);
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: confirm dialog result: ${shouldExecute}`);
|
|||
|
|
|
|||
|
|
if (!shouldExecute) {
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: user skipped`);
|
|||
|
|
addAgentMessage('assistant', `⏭️ 跳过阶段: ${stageName}`);
|
|||
|
|
stageExecutionStatus.value[stageId] = 'failed'; // 标记为失败以便下游知道
|
|||
|
|
runningStages.value.delete(stageId);
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: status set to failed, removed from runningStages`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (shouldStopBatchExecute.value) {
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: batch stopped`);
|
|||
|
|
stageExecutionStatus.value[stageId] = 'failed';
|
|||
|
|
runningStages.value.delete(stageId);
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: status set to failed, removed from runningStages`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', `⚙️ 执行阶段: ${stageName}...`);
|
|||
|
|
|
|||
|
|
// 执行阶段
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: calling executeSingleStageWithParams`);
|
|||
|
|
await executeSingleStageWithParams(stageId);
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: executeSingleStageWithParams returned`);
|
|||
|
|
|
|||
|
|
stageExecutionStatus.value[stageId] = 'completed';
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: status set to completed`);
|
|||
|
|
|
|||
|
|
// 添加阶段完成消息,包含深度解读按钮
|
|||
|
|
const completionMsg = `✅ 阶段 ${stageName} 完成`;
|
|||
|
|
addAgentMessage('assistant', completionMsg);
|
|||
|
|
|
|||
|
|
// 添加深度解读按钮(阶段完成后显示)
|
|||
|
|
addInterpretationButton(stageId, stageName);
|
|||
|
|
|
|||
|
|
// 刷新结果展示
|
|||
|
|
await refreshResults();
|
|||
|
|
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: caught error: ${err.message}`);
|
|||
|
|
const shouldRetryWithFallback = await maybePromptEnrichBackgroundFallback(err.message, stageId);
|
|||
|
|
if (shouldRetryWithFallback) {
|
|||
|
|
try {
|
|||
|
|
addAgentMessage('assistant', `🔄 阶段 ${getStageLabel(stageId)} 重新提交(自动注释背景)...`);
|
|||
|
|
await executeSingleStageWithParams(stageId);
|
|||
|
|
stageExecutionStatus.value[stageId] = 'completed';
|
|||
|
|
addAgentMessage('assistant', `✅ 阶段 ${stageName} 完成`);
|
|||
|
|
await refreshResults();
|
|||
|
|
return;
|
|||
|
|
} catch (retryErr) {
|
|||
|
|
stageExecutionStatus.value[stageId] = 'failed';
|
|||
|
|
addAgentMessage('assistant', `❌ 阶段 ${stageName} 失败: ${retryErr.message}`);
|
|||
|
|
console.error('[ERROR] Stage retry failed:', stageId, retryErr);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
stageExecutionStatus.value[stageId] = 'failed';
|
|||
|
|
addAgentMessage('assistant', `❌ 阶段 ${stageName} 失败: ${err.message}`);
|
|||
|
|
console.error('[ERROR] Stage failed:', stageId, err);
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
runningStages.value.delete(stageId);
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: FINALLY - removed from runningStages`);
|
|||
|
|
console.log(`[DEBUG-STAGE] ${stageId}: final status:`, stageExecutionStatus.value[stageId]);
|
|||
|
|
console.log(`[DEBUG-STAGE] runningStages after:`, Array.from(runningStages.value));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 显示阶段确认对话框(队列机制,支持并行阶段的动态确认)
|
|||
|
|
const showStageConfirmDialog = (stage, current, total) => {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
// 将确认请求加入队列
|
|||
|
|
stageConfirmQueue.value.push({
|
|||
|
|
stage,
|
|||
|
|
current,
|
|||
|
|
total,
|
|||
|
|
resolve
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 尝试处理队列
|
|||
|
|
processStageConfirmQueue();
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理确认对话框队列
|
|||
|
|
const processStageConfirmQueue = () => {
|
|||
|
|
// 如果正在显示对话框或队列为空,则等待
|
|||
|
|
if (stageConfirmProcessing || stageConfirmQueue.value.length === 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 取出队列中的第一个确认请求
|
|||
|
|
const request = stageConfirmQueue.value.shift();
|
|||
|
|
stageConfirmProcessing = true;
|
|||
|
|
|
|||
|
|
// stage可能是对象或字符串
|
|||
|
|
const stageObj = typeof request.stage === 'object' ? request.stage : {};
|
|||
|
|
const stageId = typeof request.stage === 'object' ? request.stage.id : request.stage;
|
|||
|
|
const stageName = typeof request.stage === 'object' ? request.stage.name : request.stage;
|
|||
|
|
const stageParams = stageObj.params || {};
|
|||
|
|
|
|||
|
|
// 设置当前阶段参数
|
|||
|
|
selectedStage.value = stageId;
|
|||
|
|
confirmParams.stage = stageId;
|
|||
|
|
confirmParams.prefix = batchExecuteParams.value.params?.prefix || '';
|
|||
|
|
confirmParams.cpu = batchExecuteParams.value.params?.cpu || 8;
|
|||
|
|
|
|||
|
|
// 特殊处理:ppi 自动设置基因ID文件
|
|||
|
|
if (stageId === 'ppi') {
|
|||
|
|
confirmParams.ppiSpecies = 'ath';
|
|||
|
|
confirmParams.ppiScore = 700;
|
|||
|
|
}
|
|||
|
|
if (stageId === 'rnaseq_enrich' || stageId === 'rnaseq_go_enrich' || stageId === 'rnaseq_kegg_enrich') {
|
|||
|
|
// 富集阶段必须重新默认到 auto,避免沿用上一个 rnaseq_de 弹窗中的 family/global。
|
|||
|
|
// 用户可在当前富集弹窗中显式改为 family 或 global。
|
|||
|
|
confirmParams.rnaseqForegroundMode = stageParams.rnaseqForegroundMode || 'auto';
|
|||
|
|
confirmParams.enrichBackgroundSource = stageParams.enrichBackgroundSource || confirmParams.enrichBackgroundSource || 'auto';
|
|||
|
|
confirmParams.enrichSpecies = stageParams.enrichSpecies || confirmParams.enrichSpecies || '';
|
|||
|
|
confirmParams.enrichKeyType = stageParams.enrichKeyType || confirmParams.enrichKeyType || 'auto';
|
|||
|
|
confirmParams.enrichType = stageParams.enrichType || confirmParams.enrichType || 'go';
|
|||
|
|
if (stageId === 'rnaseq_go_enrich') confirmParams.enrichType = 'go';
|
|||
|
|
if (stageId === 'rnaseq_kegg_enrich') confirmParams.enrichType = 'kegg';
|
|||
|
|
confirmParams.keggOrganism = stageParams.keggOrganism || confirmParams.keggOrganism || '';
|
|||
|
|
}
|
|||
|
|
if (stageId === 'rnaseq_de') {
|
|||
|
|
confirmParams.rnaseqForegroundMode = stageParams.rnaseqForegroundMode || confirmParams.rnaseqForegroundMode || 'auto';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 显示确认对话框
|
|||
|
|
stageConfirmDialogVisible.value = true;
|
|||
|
|
stageConfirmCountdown.value = 15;
|
|||
|
|
stageConfirmResolve.value = request.resolve;
|
|||
|
|
stageConfirmCurrent.value = request.current;
|
|||
|
|
stageConfirmTotal.value = request.total;
|
|||
|
|
|
|||
|
|
console.log(`[DEBUG] Showing confirm dialog for ${stageName} (${request.current}/${request.total})`);
|
|||
|
|
|
|||
|
|
// 启动15秒倒计时
|
|||
|
|
clearStageConfirmTimer();
|
|||
|
|
stageConfirmTimer.value = setInterval(() => {
|
|||
|
|
stageConfirmCountdown.value--;
|
|||
|
|
if (stageConfirmCountdown.value <= 0) {
|
|||
|
|
clearStageConfirmTimer();
|
|||
|
|
stageConfirmDialogVisible.value = false;
|
|||
|
|
stageConfirmProcessing = false;
|
|||
|
|
request.resolve(true); // 自动确认执行
|
|||
|
|
|
|||
|
|
// 处理队列中的下一个
|
|||
|
|
setTimeout(processStageConfirmQueue, 100);
|
|||
|
|
}
|
|||
|
|
}, 1000);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const clearStageConfirmTimer = () => {
|
|||
|
|
if (stageConfirmTimer.value) {
|
|||
|
|
clearInterval(stageConfirmTimer.value);
|
|||
|
|
stageConfirmTimer.value = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const confirmStageExecute = () => {
|
|||
|
|
clearStageConfirmTimer();
|
|||
|
|
stageConfirmDialogVisible.value = false;
|
|||
|
|
if (stageConfirmResolve.value) {
|
|||
|
|
stageConfirmResolve.value(true);
|
|||
|
|
stageConfirmResolve.value = null;
|
|||
|
|
}
|
|||
|
|
stageConfirmProcessing = false;
|
|||
|
|
|
|||
|
|
// 处理队列中的下一个
|
|||
|
|
setTimeout(processStageConfirmQueue, 100);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const skipStageExecute = () => {
|
|||
|
|
clearStageConfirmTimer();
|
|||
|
|
stageConfirmDialogVisible.value = false;
|
|||
|
|
if (stageConfirmResolve.value) {
|
|||
|
|
stageConfirmResolve.value(false);
|
|||
|
|
stageConfirmResolve.value = null;
|
|||
|
|
}
|
|||
|
|
stageConfirmProcessing = false;
|
|||
|
|
|
|||
|
|
// 处理队列中的下一个
|
|||
|
|
setTimeout(processStageConfirmQueue, 100);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const stopBatchExecute = () => {
|
|||
|
|
shouldStopBatchExecute.value = true;
|
|||
|
|
clearStageConfirmTimer();
|
|||
|
|
stageConfirmDialogVisible.value = false;
|
|||
|
|
if (stageConfirmResolve.value) {
|
|||
|
|
stageConfirmResolve.value(false);
|
|||
|
|
stageConfirmResolve.value = null;
|
|||
|
|
}
|
|||
|
|
stageConfirmProcessing = false;
|
|||
|
|
|
|||
|
|
// 清空队列
|
|||
|
|
stageConfirmQueue.value = [];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 显示是否继续对话框
|
|||
|
|
const showContinueDialog = (stage, errorMsg) => {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
// 简化为自动继续,实际可以添加确认对话框
|
|||
|
|
resolve(true);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cancelBatchExecute = () => {
|
|||
|
|
clearBatchExecuteTimer();
|
|||
|
|
batchExecuteDialogVisible.value = false;
|
|||
|
|
isBatchExecuting.value = false;
|
|||
|
|
addAgentMessage('assistant', '❌ 批量执行已取消');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 执行单个阶段(带完整参数)
|
|||
|
|
const executeSingleStageWithParams = async (stageName) => {
|
|||
|
|
console.log(`[DEBUG-EXEC] executeSingleStageWithParams START: ${stageName}`);
|
|||
|
|
const backendStageName = (
|
|||
|
|
stageName === 'rnaseq_go_enrich' || stageName === 'rnaseq_kegg_enrich'
|
|||
|
|
) ? 'rnaseq_enrich' : stageName;
|
|||
|
|
|
|||
|
|
const requestBody = {
|
|||
|
|
stage: backendStageName,
|
|||
|
|
job_id: jobInfo.job_id,
|
|||
|
|
prefix: confirmParams.prefix,
|
|||
|
|
cpu: confirmParams.cpu
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 根据阶段添加特定参数
|
|||
|
|
if (stageName === 'ppi') {
|
|||
|
|
requestBody.ppiSpecies = confirmParams.ppiSpecies || 'ath';
|
|||
|
|
requestBody.ppiScore = confirmParams.ppiScore || 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (stageName === 'kaks') {
|
|||
|
|
requestBody.kaksNeutralRate = confirmParams.kaksNeutralRate || 7.08e-9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (stageName === 'expression') {
|
|||
|
|
requestBody.geoId = confirmParams.geoId || '';
|
|||
|
|
requestBody.idTransform = confirmParams.idTransform || 'remove_T01';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (stageName.startsWith('rnaseq')) {
|
|||
|
|
requestBody.rnaseqMode = confirmParams.rnaseqMode || 'upload';
|
|||
|
|
requestBody.speciesName = confirmParams.speciesName || '';
|
|||
|
|
requestBody.rnaseqForegroundMode = confirmParams.rnaseqForegroundMode || 'auto';
|
|||
|
|
}
|
|||
|
|
if (stageName === 'rnaseq_enrich' || stageName === 'rnaseq_go_enrich' || stageName === 'rnaseq_kegg_enrich') {
|
|||
|
|
if (stageName === 'rnaseq_go_enrich') {
|
|||
|
|
requestBody.enrichType = 'go';
|
|||
|
|
} else if (stageName === 'rnaseq_kegg_enrich') {
|
|||
|
|
requestBody.enrichType = 'kegg';
|
|||
|
|
}
|
|||
|
|
requestBody.enrichBackgroundSource = confirmParams.enrichBackgroundSource || 'auto';
|
|||
|
|
requestBody.enrichSpecies = confirmParams.enrichSpecies || '';
|
|||
|
|
requestBody.enrichKeyType = confirmParams.enrichKeyType || 'auto';
|
|||
|
|
requestBody.enrichType = requestBody.enrichType || confirmParams.enrichType || 'go';
|
|||
|
|
requestBody.keggOrganism = confirmParams.keggOrganism || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[DEBUG-EXEC] Executing stage with params:', stageName, requestBody);
|
|||
|
|
|
|||
|
|
if (stageName === 'interspecies_synteny') {
|
|||
|
|
requestBody.targetSpecies = confirmParams.targetSpecies || [];
|
|||
|
|
requestBody.refSpeciesList = confirmParams.refSpeciesList || [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch('/api/execute', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(requestBody)
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
console.log(`[DEBUG-EXEC] ${stageName} /api/execute response:`, data);
|
|||
|
|
|
|||
|
|
if (data.success) {
|
|||
|
|
if (data.task_id) {
|
|||
|
|
// 异步任务,轮询状态
|
|||
|
|
console.log(`[DEBUG-EXEC] ${stageName}: async task, task_id=${data.task_id}`);
|
|||
|
|
const result = await pollTaskUntilComplete(data.task_id, stageName);
|
|||
|
|
console.log(`[DEBUG-EXEC] ${stageName}: pollTaskUntilComplete returned`);
|
|||
|
|
return result;
|
|||
|
|
} else if (data.async === false) {
|
|||
|
|
// 同步执行完成,直接返回结果
|
|||
|
|
console.log(`[DEBUG-EXEC] ${stageName}: sync completed`);
|
|||
|
|
return data;
|
|||
|
|
} else {
|
|||
|
|
throw new Error(data.message || '执行失败');
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
throw new Error(data.message || data.error || '执行失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 执行单个阶段(内部函数)
|
|||
|
|
const executeSingleStage = async (stageName) => {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
// 启动执行
|
|||
|
|
|
|||
|
|
const doExecuteInternal = async () => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/execute', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
stage: stageName,
|
|||
|
|
job_id: jobInfo.job_id,
|
|||
|
|
prefix: confirmParams.prefix,
|
|||
|
|
cpu: confirmParams.cpu
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.success) {
|
|||
|
|
if (data.task_id) {
|
|||
|
|
// 异步任务,轮询状态
|
|||
|
|
await pollTaskUntilComplete(data.task_id, stageName);
|
|||
|
|
resolve(data);
|
|||
|
|
} else if (data.async === false) {
|
|||
|
|
// 同步执行完成
|
|||
|
|
resolve(data);
|
|||
|
|
} else {
|
|||
|
|
reject(new Error(data.message || '执行失败'));
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
reject(new Error(data.message || data.error || '执行失败'));
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
reject(err);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
doExecuteInternal();
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const maybePromptEnrichBackgroundFallback = async (errMsg, stageName) => {
|
|||
|
|
if (!['rnaseq_enrich', 'rnaseq_go_enrich', 'rnaseq_kegg_enrich'].includes(stageName)) return false;
|
|||
|
|
const text = String(errMsg || '');
|
|||
|
|
const foregroundMissing = (
|
|||
|
|
text.includes('前景依赖缺失') ||
|
|||
|
|
text.includes('family_DE_genes') ||
|
|||
|
|
text.includes('global DE genes') ||
|
|||
|
|
text.includes('全局 DE_genes') ||
|
|||
|
|
text.includes('No family-specific DE genes') ||
|
|||
|
|
text.includes('No global DE genes')
|
|||
|
|
);
|
|||
|
|
if (foregroundMissing) return false;
|
|||
|
|
const backgroundError = (
|
|||
|
|
text.includes('缺少背景注释文件') ||
|
|||
|
|
text.includes('缺少 gene2ko 背景文件') ||
|
|||
|
|
text.includes('Background annotation is required') ||
|
|||
|
|
text.includes('你选择了 gene2go 背景文件') ||
|
|||
|
|
text.includes('KEGG enrichment requires gene2ko')
|
|||
|
|
);
|
|||
|
|
if (!backgroundError) return false;
|
|||
|
|
try {
|
|||
|
|
await ElMessageBox.confirm(
|
|||
|
|
`${text}\n\n是否切换为“自动注释 + 继续富集”?`,
|
|||
|
|
'RNA-seq 富集背景缺失',
|
|||
|
|
{
|
|||
|
|
confirmButtonText: '自动注释并继续',
|
|||
|
|
cancelButtonText: '我先去上传背景文件',
|
|||
|
|
type: 'warning',
|
|||
|
|
distinguishCancelAndClose: true
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
} catch (_err) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
confirmParams.enrichBackgroundSource = 'annotation';
|
|||
|
|
addMessage('assistant', '🔄 已切换为自动注释模式,正在重新提交富集任务...');
|
|||
|
|
return true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 【关键修复】检查 manifest 文件是否真的存在
|
|||
|
|
const checkManifestExists = async (stageName, prefixOverride = null) => {
|
|||
|
|
try {
|
|||
|
|
const mappedStage = (
|
|||
|
|
stageName === 'rnaseq_go_enrich' || stageName === 'rnaseq_kegg_enrich'
|
|||
|
|
) ? 'rnaseq_enrich' : stageName;
|
|||
|
|
const prefix = prefixOverride || confirmParams.prefix || params.prefix || '';
|
|||
|
|
const jobId = jobInfo.job_id;
|
|||
|
|
const encodedPrefix = encodeURIComponent(prefix);
|
|||
|
|
const response = await fetch(`/api/stage-status?job_id=${jobId}&stage=${mappedStage}&prefix=${encodedPrefix}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
console.log(`[DEBUG-MANIFEST] checkManifestExists ${stageName}=>${mappedStage}:`, data.completed);
|
|||
|
|
return data.completed === true;
|
|||
|
|
} catch (e) {
|
|||
|
|
console.log(`[DEBUG-MANIFEST] checkManifestExists ${stageName} error:`, e);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const pollTaskUntilComplete = async (taskId, stageName, prefixOverride = null) => {
|
|||
|
|
console.log(`[DEBUG-POLL] pollTaskUntilComplete START: taskId=${taskId}, stageName=${stageName}`);
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
let pollCount = 0;
|
|||
|
|
const checkStatus = async () => {
|
|||
|
|
pollCount++;
|
|||
|
|
console.log(`[DEBUG-POLL] checkStatus #${pollCount}: taskId=${taskId}`);
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/task/${taskId}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
console.log(`[DEBUG-POLL] taskId=${taskId} response:`, data);
|
|||
|
|
|
|||
|
|
// 后端返回: completed, failed, running, pending
|
|||
|
|
// 【关键修复】验证 manifest 文件是否真的存在
|
|||
|
|
if (data.status === 'completed' || data.status === 'success') {
|
|||
|
|
const manifestValid = await checkManifestExists(stageName, prefixOverride);
|
|||
|
|
if (manifestValid) {
|
|||
|
|
console.log(`[DEBUG-POLL] taskId=${taskId} COMPLETED (manifest verified)`);
|
|||
|
|
resolve(data);
|
|||
|
|
} else {
|
|||
|
|
// API 返回 success 但 manifest 不存在,说明执行失败
|
|||
|
|
console.log(`[DEBUG-POLL] taskId=${taskId} FAILED: manifest not found`);
|
|||
|
|
reject(new Error(`${stageName} 执行失败:未找到输出文件`));
|
|||
|
|
}
|
|||
|
|
} else if (data.status === 'failed') {
|
|||
|
|
console.log(`[DEBUG-POLL] taskId=${taskId} FAILED: ${data.message}`);
|
|||
|
|
reject(new Error(data.message || '任务失败'));
|
|||
|
|
} else {
|
|||
|
|
// 继续轮询 (running 或 pending)
|
|||
|
|
console.log(`[DEBUG-POLL] taskId=${taskId} status: ${data.status}, continuing poll in 2s...`);
|
|||
|
|
setTimeout(checkStatus, 2000);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.log(`[DEBUG-POLL] taskId=${taskId} ERROR:`, err);
|
|||
|
|
reject(err);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
checkStatus();
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 执行
|
|||
|
|
let executeStartTime = null;
|
|||
|
|
let executeTimer = null;
|
|||
|
|
const executeElapsed = ref('0.0');
|
|||
|
|
const resultDialogVisible = ref(false);
|
|||
|
|
const resultDialogParams = ref({});
|
|||
|
|
|
|||
|
|
// 异步任务轮询相关
|
|||
|
|
let pollTimer = null;
|
|||
|
|
const currentTask = ref(null);
|
|||
|
|
|
|||
|
|
// Stop Execution相关
|
|||
|
|
const isExecuting = ref(false);
|
|||
|
|
const isStopping = ref(false);
|
|||
|
|
const currentTaskId = ref(null);
|
|||
|
|
|
|||
|
|
// 轮询任务状态
|
|||
|
|
const pollTaskStatus = async (taskId, stageName) => {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/task/${taskId}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (!data) return;
|
|||
|
|
|
|||
|
|
// 更新消息显示
|
|||
|
|
if (data.message) {
|
|||
|
|
// 找到最后一条assistant消息并更新
|
|||
|
|
const lastMsg = messages.value.filter(m => m.role === 'assistant').pop();
|
|||
|
|
if (lastMsg && lastMsg.content.includes('执行阶段') || lastMsg.content.includes('等待')) {
|
|||
|
|
lastMsg.content = `⏳ ${data.message}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新阶段状态
|
|||
|
|
const stageIdx = stages.findIndex(s => s.name === stageName);
|
|||
|
|
if (stageIdx >= 0) {
|
|||
|
|
stages[stageIdx].status = data.status === 'running' ? 'running' :
|
|||
|
|
data.status === 'success' ? 'success' :
|
|||
|
|
data.status === 'failed' ? 'failed' : 'pending';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (data.status === 'success') {
|
|||
|
|
// 任务完成
|
|||
|
|
if (pollTimer) {
|
|||
|
|
clearInterval(pollTimer);
|
|||
|
|
pollTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Stop计时器
|
|||
|
|
if (executeTimer) {
|
|||
|
|
clearInterval(executeTimer);
|
|||
|
|
executeTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const elapsed = executeStartTime ? Math.round((Date.now() - executeStartTime) / 1000) : 0;
|
|||
|
|
const elapsedStr = elapsed > 60 ? `${Math.floor(elapsed/60)}分${elapsed%60}s` : `${elapsed}s`;
|
|||
|
|
|
|||
|
|
addMessage('assistant', `✅ ${stageName} 完成! (耗时: ${elapsedStr})`);
|
|||
|
|
|
|||
|
|
// 更新阶段结果 - 如果有分stage的文件,则更新所有stage的文件
|
|||
|
|
if (stageIdx >= 0 && data.result) {
|
|||
|
|
const stageFiles = data.result.stage_files;
|
|||
|
|
if (stageFiles) {
|
|||
|
|
// 按stage分类更新各stage的文件
|
|||
|
|
stages.forEach((s, idx) => {
|
|||
|
|
if (stageFiles[s.name]) {
|
|||
|
|
stages[idx].images = stageFiles[s.name].images || [];
|
|||
|
|
stages[idx].files = stageFiles[s.name].files || [];
|
|||
|
|
if (stages[idx].images.length > 0 || stages[idx].files.length > 0) {
|
|||
|
|
stages[idx].status = 'success';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
// 兼容旧版本
|
|||
|
|
stages[stageIdx].images = data.result.images || [];
|
|||
|
|
stages[stageIdx].files = data.result.files || [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
refreshResults();
|
|||
|
|
executing.value = false;
|
|||
|
|
isExecuting.value = false;
|
|||
|
|
currentTask.value = null;
|
|||
|
|
currentTaskId.value = null;
|
|||
|
|
|
|||
|
|
// 显示结果对话框
|
|||
|
|
resultDialogParams.value = {
|
|||
|
|
stage: stageName,
|
|||
|
|
prefix: confirmParams.prefix,
|
|||
|
|
cpu: confirmParams.cpu,
|
|||
|
|
elapsed: elapsedStr
|
|||
|
|
};
|
|||
|
|
resultDialogVisible.value = true;
|
|||
|
|
|
|||
|
|
} else if (data.status === 'failed') {
|
|||
|
|
// 任务失败
|
|||
|
|
if (pollTimer) {
|
|||
|
|
clearInterval(pollTimer);
|
|||
|
|
pollTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (executeTimer) {
|
|||
|
|
clearInterval(executeTimer);
|
|||
|
|
executeTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const errMsg = data.result?.error || data.result?.message || data.message || '未知错误';
|
|||
|
|
const shouldRetry = await maybePromptEnrichBackgroundFallback(errMsg, stageName);
|
|||
|
|
if (shouldRetry) {
|
|||
|
|
executing.value = false;
|
|||
|
|
isExecuting.value = false;
|
|||
|
|
currentTask.value = null;
|
|||
|
|
currentTaskId.value = null;
|
|||
|
|
await doExecute();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
addMessage('assistant', `❌ 错误: ${errMsg}`);
|
|||
|
|
|
|||
|
|
// 设置日志内容(用于显示执行记录区域)
|
|||
|
|
logContent.value = data.log || errMsg || '';
|
|||
|
|
|
|||
|
|
if (stageIdx >= 0) {
|
|||
|
|
stages[stageIdx].status = 'failed';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
executing.value = false;
|
|||
|
|
isExecuting.value = false;
|
|||
|
|
currentTask.value = null;
|
|||
|
|
currentTaskId.value = null;
|
|||
|
|
}
|
|||
|
|
// 其他状态(pending/running)继续轮询
|
|||
|
|
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('轮询任务状态失败:', err);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Stop Execution任务
|
|||
|
|
const stopExecution = async () => {
|
|||
|
|
if (!currentTask.value && !currentTaskId.value) {
|
|||
|
|
ElMessage.warning('没有正在执行的任务');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const taskId = currentTask.value?.id || currentTaskId.value;
|
|||
|
|
if (!taskId) {
|
|||
|
|
ElMessage.warning('无法获取任务ID');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
isStopping.value = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/agent/cancel/${taskId}`, {
|
|||
|
|
method: 'POST'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.success) {
|
|||
|
|
ElMessage.success('任务已Stop');
|
|||
|
|
addMessage('assistant', '⏹ 任务已Stop');
|
|||
|
|
|
|||
|
|
// 清理状态
|
|||
|
|
if (pollTimer) {
|
|||
|
|
clearInterval(pollTimer);
|
|||
|
|
pollTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (executeTimer) {
|
|||
|
|
clearInterval(executeTimer);
|
|||
|
|
executeTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新阶段状态
|
|||
|
|
if (currentTask.value?.stage) {
|
|||
|
|
const stageIdx = stages.findIndex(s => s.name === currentTask.value.stage);
|
|||
|
|
if (stageIdx >= 0) {
|
|||
|
|
stages[stageIdx].status = 'failed';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
executing.value = false;
|
|||
|
|
isExecuting.value = false;
|
|||
|
|
currentTask.value = null;
|
|||
|
|
currentTaskId.value = null;
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error(data.message || 'Stop任务失败');
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Stop任务失败:', err);
|
|||
|
|
ElMessage.error('Stop任务失败: ' + err.message);
|
|||
|
|
} finally {
|
|||
|
|
isStopping.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const doExecute = async () => {
|
|||
|
|
console.log('[DEBUG] doExecute called, closing dialog');
|
|||
|
|
confirmDialogVisible.value = false;
|
|||
|
|
clearAutoExecuteTimer(); // 清除自动执行倒计时
|
|||
|
|
console.log('[DEBUG] doExecute: confirmDialogVisible =', confirmDialogVisible.value);
|
|||
|
|
executing.value = true;
|
|||
|
|
isExecuting.value = true;
|
|||
|
|
executeStartTime = Date.now();
|
|||
|
|
executeElapsed.value = '0.0';
|
|||
|
|
|
|||
|
|
// 启动计时器,每0.1s更新
|
|||
|
|
executeTimer = setInterval(() => {
|
|||
|
|
if (executeStartTime) {
|
|||
|
|
const elapsed = (Date.now() - executeStartTime) / 1000;
|
|||
|
|
executeElapsed.value = elapsed.toFixed(1);
|
|||
|
|
}
|
|||
|
|
}, 100);
|
|||
|
|
|
|||
|
|
addMessage('assistant', `⚙️ 执行阶段: ${confirmParams.stage}...`);
|
|||
|
|
|
|||
|
|
// RNA-seq 分步骤执行(现在统一使用 /api/execute)
|
|||
|
|
// 注意:rnaseq_qc 已经通过 /api/execute 支持
|
|||
|
|
// rnaseq_align/quant/de/enrich 现在也使用 /api/execute
|
|||
|
|
|
|||
|
|
// 更新阶段状态为运行中
|
|||
|
|
const stageIdx = stages.findIndex(s => s.name === confirmParams.stage);
|
|||
|
|
if (stageIdx >= 0) {
|
|||
|
|
stages[stageIdx].status = 'running';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const backendStage = (
|
|||
|
|
confirmParams.stage === 'rnaseq_go_enrich' || confirmParams.stage === 'rnaseq_kegg_enrich'
|
|||
|
|
) ? 'rnaseq_enrich' : confirmParams.stage;
|
|||
|
|
const response = await fetch('/api/execute', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
stage: backendStage,
|
|||
|
|
job_id: jobInfo.job_id, // 添加当前job_id
|
|||
|
|
prefix: confirmParams.prefix,
|
|||
|
|
cpu: confirmParams.cpu,
|
|||
|
|
idEvalue: confirmParams.idEvalue,
|
|||
|
|
idCoverage: confirmParams.idCoverage,
|
|||
|
|
blastEvalue: confirmParams.blastEvalue,
|
|||
|
|
requiredPfams: confirmParams.requiredPfams,
|
|||
|
|
refSpecies: confirmParams.refSpecies,
|
|||
|
|
mafftParams: confirmParams.mafftParams,
|
|||
|
|
iqtreeParams: confirmParams.iqtreeParams,
|
|||
|
|
nmotifs: confirmParams.nmotifs,
|
|||
|
|
minw: confirmParams.minw,
|
|||
|
|
maxw: confirmParams.maxw,
|
|||
|
|
domEvalue: confirmParams.domEvalue,
|
|||
|
|
domCoverage: confirmParams.domCoverage,
|
|||
|
|
idTransform: confirmParams.idTransform,
|
|||
|
|
geoId: confirmParams.geoId,
|
|||
|
|
// RNA-seq参数
|
|||
|
|
rnaseqMode: confirmParams.rnaseqMode,
|
|||
|
|
speciesName: confirmParams.speciesName,
|
|||
|
|
rnaseqForegroundMode: confirmParams.rnaseqForegroundMode,
|
|||
|
|
enrichBackgroundSource: confirmParams.enrichBackgroundSource,
|
|||
|
|
enrichSpecies: confirmParams.enrichSpecies,
|
|||
|
|
enrichKeyType: confirmParams.enrichKeyType,
|
|||
|
|
enrichType: confirmParams.stage === 'rnaseq_go_enrich'
|
|||
|
|
? 'go'
|
|||
|
|
: (confirmParams.stage === 'rnaseq_kegg_enrich' ? 'kegg' : confirmParams.enrichType),
|
|||
|
|
keggOrganism: confirmParams.keggOrganism,
|
|||
|
|
// RNA-seq QC 参数
|
|||
|
|
rnaseqDataMode: confirmParams.rnaseqDataMode,
|
|||
|
|
rnaseqDataPath: confirmParams.rnaseqDataPath,
|
|||
|
|
// PPI 参数
|
|||
|
|
ppiSpecies: confirmParams.ppiSpecies,
|
|||
|
|
ppiScore: confirmParams.ppiScore,
|
|||
|
|
// KaKs 参数
|
|||
|
|
kaksNeutralRate: confirmParams.kaksNeutralRate,
|
|||
|
|
// Genome Prepare 参数
|
|||
|
|
genomePrepareType: confirmParams.genomePrepareType,
|
|||
|
|
targetSpecies: confirmParams.targetSpecies,
|
|||
|
|
refSpeciesList: confirmParams.refSpeciesList
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (!data.success) {
|
|||
|
|
// 立即失败
|
|||
|
|
if (executeTimer) {
|
|||
|
|
clearInterval(executeTimer);
|
|||
|
|
executeTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const errMsg = data.error || '未知错误';
|
|||
|
|
addMessage('assistant', `❌ 错误: ${errMsg}`);
|
|||
|
|
|
|||
|
|
if (stageIdx >= 0) {
|
|||
|
|
stages[stageIdx].status = 'failed';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
executing.value = false;
|
|||
|
|
isExecuting.value = false;
|
|||
|
|
currentTaskId.value = null;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查是否是异步任务
|
|||
|
|
if (data.async) {
|
|||
|
|
// 异步任务 - 启动轮询
|
|||
|
|
currentTask.value = {
|
|||
|
|
id: data.task_id,
|
|||
|
|
stage: confirmParams.stage
|
|||
|
|
};
|
|||
|
|
currentTaskId.value = data.task_id;
|
|||
|
|
|
|||
|
|
addMessage('assistant', `⏳ ${data.message || '任务已提交,正在后台执行...'}`);
|
|||
|
|
|
|||
|
|
// 立即查询一次状态
|
|||
|
|
await pollTaskStatus(data.task_id, confirmParams.stage);
|
|||
|
|
|
|||
|
|
// 启动轮询定时器(每5s查询一次)
|
|||
|
|
pollTimer = setInterval(() => {
|
|||
|
|
pollTaskStatus(data.task_id, confirmParams.stage);
|
|||
|
|
}, 5000);
|
|||
|
|
|
|||
|
|
} else {
|
|||
|
|
// 同步任务 - 直接处理结果
|
|||
|
|
if (executeTimer) {
|
|||
|
|
clearInterval(executeTimer);
|
|||
|
|
executeTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const elapsed = executeStartTime ? Math.round((Date.now() - executeStartTime) / 1000) : 0;
|
|||
|
|
const elapsedStr = elapsed > 60 ? `${Math.floor(elapsed/60)}分${elapsed%60}s` : `${elapsed}s`;
|
|||
|
|
|
|||
|
|
resultDialogParams.value = {
|
|||
|
|
stage: confirmParams.stage,
|
|||
|
|
prefix: confirmParams.prefix,
|
|||
|
|
cpu: confirmParams.cpu,
|
|||
|
|
elapsed: elapsedStr
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
addMessage('assistant', `✅ ${confirmParams.stage} 完成! (耗时: ${elapsedStr})`);
|
|||
|
|
logContent.value = data.log || '';
|
|||
|
|
|
|||
|
|
if (stageIdx >= 0) {
|
|||
|
|
stages[stageIdx].status = 'success';
|
|||
|
|
stages[stageIdx].images = data.images || [];
|
|||
|
|
stages[stageIdx].files = data.files || [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
refreshResults();
|
|||
|
|
resultDialogVisible.value = true;
|
|||
|
|
executing.value = false;
|
|||
|
|
isExecuting.value = false;
|
|||
|
|
currentTaskId.value = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} catch (err) {
|
|||
|
|
addMessage('assistant', `❌ 请求失败: ${err.message}`);
|
|||
|
|
|
|||
|
|
if (executeTimer) {
|
|||
|
|
clearInterval(executeTimer);
|
|||
|
|
executeTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (stageIdx >= 0) {
|
|||
|
|
stages[stageIdx].status = 'failed';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
executing.value = false;
|
|||
|
|
isExecuting.value = false;
|
|||
|
|
currentTaskId.value = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 上传成功
|
|||
|
|
const handleUploadSuccess = (response) => {
|
|||
|
|
ElMessage.success('文件上传成功');
|
|||
|
|
refreshInfo();
|
|||
|
|
};
|
|||
|
|
const handleUploadError = (err) => {
|
|||
|
|
ElMessage.error('上传失败: ' + err.message);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Download Examples文件
|
|||
|
|
const handleDownloadExample = async (type) => {
|
|||
|
|
const examples = {
|
|||
|
|
samples: `# RNA-seq 完整分析配置
|
|||
|
|
# 结合 QC 和 DE 阶段的配置
|
|||
|
|
|
|||
|
|
# QC阶段:样本路径配置
|
|||
|
|
samples:
|
|||
|
|
SRR6847695:
|
|||
|
|
- /home/xia-lab/wutx/00test/bbav2/rnaseq/SRR6847695/SRR6847695_1.fastq
|
|||
|
|
- /home/xia-lab/wutx/00test/bbav2/rnaseq/SRR6847695/SRR6847695_2.fastq
|
|||
|
|
SRR6847696:
|
|||
|
|
- /home/xia-lab/wutx/00test/bbav2/rnaseq/SRR6847696/SRR6847696_1.fastq
|
|||
|
|
- /home/xia-lab/wutx/00test/bbav2/rnaseq/SRR6847696/SRR6847696_2.fastq
|
|||
|
|
SRR6847697:
|
|||
|
|
- /home/xia-lab/wutx/00test/bbav2/rnaseq/SRR6847697/SRR6847697_1.fastq
|
|||
|
|
- /home/xia-lab/wutx/00test/bbav2/rnaseq/SRR6847697/SRR6847697_2.fastq
|
|||
|
|
|
|||
|
|
# DE阶段:分组和比较配置
|
|||
|
|
groups:
|
|||
|
|
SRR6847695: treat
|
|||
|
|
SRR6847696: treat
|
|||
|
|
SRR6847697: control
|
|||
|
|
|
|||
|
|
compare:
|
|||
|
|
- ["treat", "control"]`,
|
|||
|
|
geneID: `Multi.SPL.00001
|
|||
|
|
Multi.SPL.00002
|
|||
|
|
Multi.SPL.00003`,
|
|||
|
|
sample: `X2 X2 Control
|
|||
|
|
X3 X3 Treatment`,
|
|||
|
|
fpkm: `geneID X2 X3
|
|||
|
|
Multi.SPL.00001 10.5 2.3
|
|||
|
|
Multi.SPL.00002 8.2 1.9
|
|||
|
|
Multi.SPL.00003 15.3 3.1`,
|
|||
|
|
annotation_config: `# 功能注释技能配置 (API模式)
|
|||
|
|
# 使用: python skills/annotation/run_api.py --input protein.fa --output ./annotation --config annotation_config.yaml
|
|||
|
|
|
|||
|
|
skill:
|
|||
|
|
name: annotation-api
|
|||
|
|
version: "1.0.0"
|
|||
|
|
description: "功能注释分析 - eggNOG API(在线模式)"
|
|||
|
|
|
|||
|
|
input:
|
|||
|
|
protein_fasta: null # 蛋白序列FASTAFile Path
|
|||
|
|
|
|||
|
|
parameters:
|
|||
|
|
api:
|
|||
|
|
delay: 1.0 # API请求间隔(s),建议1.0-2.0避免服务器限流
|
|||
|
|
timeout: 60 # 请求超时(s)
|
|||
|
|
db: "euk" # 数据库类型: euk(真核)/bact(细菌)/arch(古菌)/viruses
|
|||
|
|
min_seq_length: 10 # 最小序列长度
|
|||
|
|
max_seq_length: 50000 # 最大序列长度
|
|||
|
|
|
|||
|
|
output:
|
|||
|
|
format: ["gene2go"] # Output格式: gene2go
|
|||
|
|
include_reproducibility: true # 包含可复现性文件
|
|||
|
|
|
|||
|
|
dependencies:
|
|||
|
|
conda:
|
|||
|
|
channels:
|
|||
|
|
- conda-forge
|
|||
|
|
packages:
|
|||
|
|
- python=3.9
|
|||
|
|
- requests=2.31.0
|
|||
|
|
- pyyaml=6.0.1`
|
|||
|
|
};
|
|||
|
|
const content = examples[type];
|
|||
|
|
if (!content) return;
|
|||
|
|
|
|||
|
|
// 创建Download
|
|||
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = url;
|
|||
|
|
a.download = `${type}.yaml`;
|
|||
|
|
if (type === 'geneID') a.download = 'geneID';
|
|||
|
|
if (type === 'sample') a.download = 'sample.txt';
|
|||
|
|
if (type === 'fpkm') a.download = 'expression.fpkm.txt';
|
|||
|
|
if (type === 'annotation_config') a.download = 'annotation_config.yaml';
|
|||
|
|
if (type === 'samples') a.download = 'samples.yaml';
|
|||
|
|
a.click();
|
|||
|
|
URL.revokeObjectURL(url);
|
|||
|
|
ElMessage.success('示例文件已Download:' + a.download);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 从路径加载
|
|||
|
|
const loadFromPath = async () => {
|
|||
|
|
if (!inputPath.value) {
|
|||
|
|
ElMessage.warning('请InputFile Path');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/api/load_from_path', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
job_id: jobInfo.job_id,
|
|||
|
|
path: inputPath.value
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
const data = await response.json();
|
|||
|
|
if (data.success) {
|
|||
|
|
ElMessage.success(`已加载 ${data.count} files`);
|
|||
|
|
// Clear路径Input
|
|||
|
|
inputPath.value = '';
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error(data.message || '加载失败');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
ElMessage.error(`加载失败: ${e.message}`);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Agent Mode Methods
|
|||
|
|
const onModelChange = (model) => {
|
|||
|
|
const names = {
|
|||
|
|
kimi: '🧠 Kimi',
|
|||
|
|
glm: '🔬 GLM-4',
|
|||
|
|
minimax: '⚡ MiniMax',
|
|||
|
|
llama3: '🦙 Llama3',
|
|||
|
|
qwen2: '🤖 Qwen2',
|
|||
|
|
ollama: '📍 Ollama'
|
|||
|
|
};
|
|||
|
|
ElMessage.success(`Switched to ${names[model] || model}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const clearAgentSession = () => {
|
|||
|
|
agentSession.messages = [];
|
|||
|
|
agentSession.currentTask = null;
|
|||
|
|
ElMessage.success('对话历史已Clear');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTaskTypeColor = (type) => {
|
|||
|
|
const colors = { knowledge_query: 'info', complex_analysis: 'warning', code_generation: 'success' };
|
|||
|
|
return colors[type] || 'info';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getStageLabel = (stage) => {
|
|||
|
|
const labels = {
|
|||
|
|
identify: 'Gene Family Identification', phylogeny: 'Phylogenetic Analysis', motif: 'Motif Analysis',
|
|||
|
|
domain: '结构域分析', structure: 'Gene Structure分析', promoter: '启动子分析',
|
|||
|
|
synteny: 'Synteny Analysis', expression: '表达分析', pep: '蛋白理化性质',
|
|||
|
|
ppi: 'Protein Interaction', kaks: 'Ka/Ks分析', combo: 'Combined Plot',
|
|||
|
|
annotation: '背景注释',
|
|||
|
|
rnaseq_qc: 'RNA-seq质控',
|
|||
|
|
rnaseq_align: 'RNA-seq比对',
|
|||
|
|
rnaseq_quant: 'RNA-seq定量',
|
|||
|
|
rnaseq_de: '差异表达分析',
|
|||
|
|
rnaseq_enrich: 'RNA-seq富集分析',
|
|||
|
|
rnaseq_go_enrich: 'RNA-seq GO富集分析',
|
|||
|
|
rnaseq_kegg_enrich: 'RNA-seq KEGG富集分析'
|
|||
|
|
};
|
|||
|
|
return labels[stage] || stage;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ========== 智能内容格式化 ==========
|
|||
|
|
const formatMessage = (content, isAI = false) => {
|
|||
|
|
if (!content) return '';
|
|||
|
|
|
|||
|
|
// 检测内容类型
|
|||
|
|
const isStructured = isAI && detectStructuredContent(content);
|
|||
|
|
|
|||
|
|
let processed = content
|
|||
|
|
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
|||
|
|
.replace(/\*(.+?)\*/g, '<i>$1</i>')
|
|||
|
|
.replace(/`(.+?)`/g, '<code style="background: #f5f7fa; padding: 2px 4px; border-radius: 3px;">$1</code>');
|
|||
|
|
|
|||
|
|
if (isStructured) {
|
|||
|
|
// 结构化内容:智能处理RAG返回结果
|
|||
|
|
processed = formatStructuredContent(processed);
|
|||
|
|
} else {
|
|||
|
|
// 普通内容:智能压缩多余换行
|
|||
|
|
processed = processed
|
|||
|
|
.replace(/\n{4,}/g, '\n\n') // 4个以上换行压缩为2个
|
|||
|
|
.replace(/\n/g, '<br>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return processed;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 检测是否是结构化内容(RAG结果等)
|
|||
|
|
const detectStructuredContent = (content) => {
|
|||
|
|
// 检测特征:研究总结、编号列表、文献引用等
|
|||
|
|
const patterns = [
|
|||
|
|
/研究总结|基于.*文献|基于.*篇文献/i,
|
|||
|
|
/^\d+\.\s+.+$/m, // 1. 2. 3. 编号
|
|||
|
|
/转录因子|研究发现|研究结论/i,
|
|||
|
|
/DNA BINDING|TRANSCRIPTION|GENE FAMILY/i, // 标签
|
|||
|
|
/Arabidopsis|SPL.*BMC|doi\.org|PMID/i // 文献来源
|
|||
|
|
];
|
|||
|
|
return patterns.some(p => p.test(content));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 格式化结构化内容(RAG结果)
|
|||
|
|
const formatStructuredContent = (content) => {
|
|||
|
|
let html = content;
|
|||
|
|
|
|||
|
|
// 1. 提取头部信息(如"基于10篇文献...")
|
|||
|
|
html = html.replace(/^(基于\s*\d+\s*篇文献[^\n]*)/i,
|
|||
|
|
'<div class="rag-header">📚 $1</div>');
|
|||
|
|
|
|||
|
|
// 2. 处理编号段落(1. 2. 3.)为rag-section
|
|||
|
|
html = html.replace(/(\d+)\.\s*([^\n<]+)([\n<]|$)/g,
|
|||
|
|
'<div class="rag-section">' +
|
|||
|
|
'<div class="rag-section-title">$1. $2</div>');
|
|||
|
|
|
|||
|
|
// 3. 处理段落内容(直到下一个编号或标签行)
|
|||
|
|
// 将连续的非编号文本作为段落内容
|
|||
|
|
html = html.replace(/<div class="rag-section">([\s\S]*?)(?=<div class="rag-section">|<div class="rag-tags|$)/g,
|
|||
|
|
(match, content) => {
|
|||
|
|
// 提取标签行(如 DNA BINDING, TRANSCRIPTION FACTOR)
|
|||
|
|
const tagPattern = /([A-Z][A-Z\s]*[A-Z])(?=,|$|<)/g;
|
|||
|
|
const tags = [];
|
|||
|
|
let cleanContent = content.replace(tagPattern, (match) => {
|
|||
|
|
tags.push(match.trim());
|
|||
|
|
return '';
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 提取来源信息
|
|||
|
|
const sourcePattern = /(Arabidopsis[^,]*|doi[^\s]*|PMID[^\s]*)/i;
|
|||
|
|
const sourceMatch = cleanContent.match(sourcePattern);
|
|||
|
|
const source = sourceMatch ? sourceMatch[1] : '';
|
|||
|
|
cleanContent = cleanContent.replace(sourcePattern, '');
|
|||
|
|
|
|||
|
|
// 清理多余字符
|
|||
|
|
cleanContent = cleanContent.replace(/[,,]\s*$/, '').trim();
|
|||
|
|
|
|||
|
|
// 构建标签HTML
|
|||
|
|
let tagsHtml = '';
|
|||
|
|
if (tags.length > 0) {
|
|||
|
|
tagsHtml = '<div class="rag-tags-row">' +
|
|||
|
|
tags.map(t => `<span class="rag-tag">${t}</span>`).join('') +
|
|||
|
|
(source ? `<span class="rag-source">📄 ${source}</span>` : '') +
|
|||
|
|
'</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return `<div class="rag-section">` +
|
|||
|
|
(cleanContent ? `<div class="rag-section-content">${cleanContent}</div>` : '') +
|
|||
|
|
tagsHtml +
|
|||
|
|
`</div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 4. 处理底部提示(如"点击知识图谱查看更多关联")
|
|||
|
|
html = html.replace(/([💡📝].*知识图谱.*关联.*)/i,
|
|||
|
|
'<div class="rag-footer">$1</div>');
|
|||
|
|
|
|||
|
|
// 5. 包裹整个内容
|
|||
|
|
if (!html.includes('rag-content-wrapper')) {
|
|||
|
|
html = `<div class="rag-content-wrapper">${html}</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return html;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ========== 动态样式类判断 ==========
|
|||
|
|
// 根据内容判断消息间距类
|
|||
|
|
const getMessageSpacingClass = (content) => {
|
|||
|
|
if (!content) return 'compact';
|
|||
|
|
const length = content.length;
|
|||
|
|
const lines = content.split('\n').length;
|
|||
|
|
|
|||
|
|
if (length < 100 && lines <= 2) return 'compact';
|
|||
|
|
if (length > 1000 || lines > 20) return 'spacious';
|
|||
|
|
return 'normal';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 根据内容判断气泡样式类
|
|||
|
|
const getContentClass = (content) => {
|
|||
|
|
if (!content) return 'medium-content';
|
|||
|
|
const length = content.length;
|
|||
|
|
const lines = content.split('\n').length;
|
|||
|
|
const isStructured = detectStructuredContent(content);
|
|||
|
|
|
|||
|
|
if (isStructured) return 'structured-content';
|
|||
|
|
if (length < 150 && lines <= 3) return 'short-content';
|
|||
|
|
if (length > 800 || lines > 15) return 'long-content';
|
|||
|
|
return 'medium-content';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 添加Agent消息到会话
|
|||
|
|
const addAgentMessage = async (role, content, intent = null) => {
|
|||
|
|
const now = new Date();
|
|||
|
|
agentSession.messages.push({
|
|||
|
|
role,
|
|||
|
|
content,
|
|||
|
|
time: now.toLocaleTimeString(),
|
|||
|
|
intent
|
|||
|
|
});
|
|||
|
|
if (intent && intentHistory) {
|
|||
|
|
intentHistory.value.push(intent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 持久化到后端(如果有当前会话ID)
|
|||
|
|
if (currentConversationId.value) {
|
|||
|
|
try {
|
|||
|
|
await fetch('/api/conversations/message', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
conversation_id: currentConversationId.value,
|
|||
|
|
role: role,
|
|||
|
|
content: content,
|
|||
|
|
metadata: { intent }
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[会话] 消息持久化失败:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Clear错误日志
|
|||
|
|
const clearErrorLog = () => {
|
|||
|
|
logContent.value = '';
|
|||
|
|
ElMessage.success('错误日志已Clear');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ============ 深度解读功能 ============
|
|||
|
|
|
|||
|
|
// 添加深度解读按钮到消息
|
|||
|
|
const addInterpretationButton = (stageId, stageName) => {
|
|||
|
|
const now = new Date();
|
|||
|
|
const buttonId = `interpret-btn-${stageId}-${Date.now()}`;
|
|||
|
|
|
|||
|
|
agentSession.messages.push({
|
|||
|
|
role: 'assistant',
|
|||
|
|
content: `<div style="margin: 8px 0;">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="interpret-btn"
|
|||
|
|
data-stage-id="${stageId}"
|
|||
|
|
data-stage-name="${stageName}"
|
|||
|
|
id="${buttonId}"
|
|||
|
|
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: white; padding: 6px 12px; border-radius: 0px; cursor: pointer; font-size: 13px;">
|
|||
|
|
🔍 深度解读 (${stageName})
|
|||
|
|
</button>
|
|||
|
|
<span style="color: #909399; font-size: 12px; margin-left: 8px;">
|
|||
|
|
查看阶段性综合报告
|
|||
|
|
</span>
|
|||
|
|
</div>`,
|
|||
|
|
time: now.toLocaleTimeString(),
|
|||
|
|
isHtml: true,
|
|||
|
|
stageId: stageId
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 事件委托:处理深度解读按钮点击
|
|||
|
|
const handleInterpretButtonClick = (e) => {
|
|||
|
|
const btn = e.target.closest('.interpret-btn');
|
|||
|
|
if (btn) {
|
|||
|
|
const stageId = btn.dataset.stageId;
|
|||
|
|
const stageName = btn.dataset.stageName;
|
|||
|
|
if (stageId && stageName) {
|
|||
|
|
showInterpretationModal(stageId, stageName);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 显示深度解读弹窗
|
|||
|
|
const showInterpretationModal = async (stageId, stageName) => {
|
|||
|
|
console.log('[深度解读] 请求解读:', stageId, stageName);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/agent/interpret', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
session_id: sessionId.value || 'demo_session',
|
|||
|
|
stage: stageId,
|
|||
|
|
include_previous: true,
|
|||
|
|
mode: 'comprehensive'
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await res.json();
|
|||
|
|
|
|||
|
|
if (data.success) {
|
|||
|
|
// 打开解读弹窗
|
|||
|
|
interpretationDialogVisible.value = true;
|
|||
|
|
interpretationReport.value = data.report;
|
|||
|
|
currentInterpretStage.value = { id: stageId, name: stageName };
|
|||
|
|
} else {
|
|||
|
|
ElMessage.error(data.message || '生成解读失败');
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[深度解读] 请求失败:', err);
|
|||
|
|
ElMessage.error('请求解读服务失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 下载解读报告
|
|||
|
|
const downloadInterpretationReport = async (format) => {
|
|||
|
|
if (!interpretationReport.value) return;
|
|||
|
|
|
|||
|
|
const stage = currentInterpretStage.value;
|
|||
|
|
const session = sessionId.value || 'demo_session';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(`/api/agent/report/${session}/download?format=${format}&stage=${stage.id}`);
|
|||
|
|
|
|||
|
|
if (format === 'json') {
|
|||
|
|
const data = await res.json();
|
|||
|
|
console.log('报告数据:', data);
|
|||
|
|
ElMessage.success('JSON数据已加载到控制台');
|
|||
|
|
} else {
|
|||
|
|
// 下载文件
|
|||
|
|
const blob = await res.blob();
|
|||
|
|
const url = window.URL.createObjectURL(blob);
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = url;
|
|||
|
|
a.download = `report_${session}_${stage.id}.${format === 'markdown' ? 'md' : format}`;
|
|||
|
|
document.body.appendChild(a);
|
|||
|
|
a.click();
|
|||
|
|
window.URL.revokeObjectURL(url);
|
|||
|
|
document.body.removeChild(a);
|
|||
|
|
ElMessage.success(`${format.toUpperCase()}报告已下载`);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[下载报告] 失败:', err);
|
|||
|
|
ElMessage.error('下载报告失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 复制解读内容到剪贴板
|
|||
|
|
const copyInterpretationToClipboard = () => {
|
|||
|
|
if (!interpretationReport.value) return;
|
|||
|
|
|
|||
|
|
const story = interpretationReport.value.biological_story || '';
|
|||
|
|
navigator.clipboard.writeText(story).then(() => {
|
|||
|
|
ElMessage.success('生物学故事已复制到剪贴板');
|
|||
|
|
}).catch(() => {
|
|||
|
|
ElMessage.error('复制失败');
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 自动滚动
|
|||
|
|
const scrollToBottom = () => {
|
|||
|
|
nextTick(() => {
|
|||
|
|
const chatArea = document.querySelector('.agent-chat-history');
|
|||
|
|
if (chatArea) {
|
|||
|
|
chatArea.scrollTop = chatArea.scrollHeight;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 降级到简单模式的辅助函数(定义在使用之前)
|
|||
|
|
const fallbackToSimpleMode = (intent) => {
|
|||
|
|
const stages = intent.stages || [intent.stage || 'identify'];
|
|||
|
|
|
|||
|
|
// 添加AI回复
|
|||
|
|
addAgentMessage('assistant', intent.message || `我将帮您执行 ${stages.join(', ')} 分析。`, {
|
|||
|
|
stage: stages[0],
|
|||
|
|
stages: stages,
|
|||
|
|
taskType: 'batch_analysis',
|
|||
|
|
params: intent.params,
|
|||
|
|
confidence: intent.confidence || 0.7
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 存储批量执行信息
|
|||
|
|
if (isBatchExecuting.value) {
|
|||
|
|
startBackgroundBatchExecution(stages, {
|
|||
|
|
stages,
|
|||
|
|
stages_detail: stages.map(stage => ({
|
|||
|
|
id: getStageId(stage),
|
|||
|
|
name: getStageLabel(stage),
|
|||
|
|
params: intent.params || {}
|
|||
|
|
})),
|
|||
|
|
params: intent.params || {},
|
|||
|
|
prefix: intent.params?.prefix || params.prefix || ''
|
|||
|
|
}, { intent });
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
batchExecuteStages.value = stages;
|
|||
|
|
batchExecuteIndex.value = 0;
|
|||
|
|
batchExecuteParams.value = {
|
|||
|
|
...intent.params,
|
|||
|
|
stages: stages
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 显示批量执行确认对话框
|
|||
|
|
batchExecuteDialogVisible.value = true;
|
|||
|
|
addAgentMessage('assistant', `⚙️ 准备批量执行 ${stages.length} 个阶段: ${stages.join(', ')}`);
|
|||
|
|
|
|||
|
|
// 启动15秒倒计时
|
|||
|
|
startBatchExecuteTimer();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const showSyntenyChoice = (intent, userInput) => {
|
|||
|
|
syntenyChoiceQuery.value = userInput;
|
|||
|
|
syntenyChoiceBaseIntent.value = intent || {};
|
|||
|
|
syntenyChoiceOptions.value = intent.options || [
|
|||
|
|
{
|
|||
|
|
id: 'synteny',
|
|||
|
|
label: '种内共线性分析',
|
|||
|
|
description: '分析同一物种基因组内的共线性关系',
|
|||
|
|
stages: ['identify', 'synteny']
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'interspecies_synteny',
|
|||
|
|
label: '种间共线性分析',
|
|||
|
|
description: '比较不同物种之间的共线性关系',
|
|||
|
|
stages: ['identify', 'phylogeny', 'interspecies_synteny']
|
|||
|
|
}
|
|||
|
|
];
|
|||
|
|
syntenyChoiceDialogVisible.value = true;
|
|||
|
|
addAgentMessage('assistant', intent.clarification_question || '请选择要执行哪一种共线性分析。', {
|
|||
|
|
taskType: 'clarification',
|
|||
|
|
options: syntenyChoiceOptions.value
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const runPlanForIntent = async (userInput, intent) => {
|
|||
|
|
const planResponse = await fetch('/api/agent/plan', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
message: userInput,
|
|||
|
|
session_id: sessionId.value,
|
|||
|
|
job_id: jobInfo.job_id,
|
|||
|
|
prefix: params.prefix || '',
|
|||
|
|
history: agentSession.messages.slice(-5),
|
|||
|
|
intent: intent
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const planData = await planResponse.json();
|
|||
|
|
console.log('[DEBUG] Plan API response:', planData);
|
|||
|
|
|
|||
|
|
if (!planData.success || !planData.plan) {
|
|||
|
|
const errorMsg = planData.message || '规划失败';
|
|||
|
|
const planHint = /缺少|输入文件|upload|上传|file/i.test(errorMsg)
|
|||
|
|
? '\n\n请检查必要输入文件是否已上传。'
|
|||
|
|
: '\n\n规划阶段未能生成可执行流程,请查看后端日志中的具体原因。';
|
|||
|
|
ElMessage.error(`❌ ${errorMsg}`);
|
|||
|
|
addAgentMessage('assistant', `❌ 无法执行分析:${errorMsg}${planHint}`, {
|
|||
|
|
taskType: 'error'
|
|||
|
|
});
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const plan = planData.plan;
|
|||
|
|
const completedSet = new Set(planData.completed_stages || []);
|
|||
|
|
let stages = (plan.stages || []).filter(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
return !completedSet.has(stageId);
|
|||
|
|
});
|
|||
|
|
let stagesDetail = (plan.stages_detail || []).filter(stage => !completedSet.has(stage.id || stage.name));
|
|||
|
|
|
|||
|
|
if (stages.length === 0) {
|
|||
|
|
addAgentMessage('assistant', plan.description || '所有相关分析阶段已完成,无需重复执行。', {
|
|||
|
|
taskType: 'analysis_completed',
|
|||
|
|
plan: plan
|
|||
|
|
});
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let planMessage = `📋 分析计划 (${plan.mode || 'v2'})\n\n`;
|
|||
|
|
planMessage += `基因家族: ${plan.gene_family}\n`;
|
|||
|
|
planMessage += `物种: ${plan.species}\n`;
|
|||
|
|
planMessage += `预估时间: ${plan.estimated_time}\n`;
|
|||
|
|
planMessage += `执行策略: ${plan.execution_strategy === 'parallel' ? '并行执行' : '串行执行'}\n\n`;
|
|||
|
|
planMessage += `分析阶段 (${stages.length}个):\n`;
|
|||
|
|
|
|||
|
|
stagesDetail.forEach((stage, idx) => {
|
|||
|
|
const deps = stage.dependencies?.length > 0
|
|||
|
|
? `(依赖: ${stage.dependencies.join(', ')})`
|
|||
|
|
: '';
|
|||
|
|
const parallel = stage.can_parallel ? ' [可并行]' : '';
|
|||
|
|
const stageId = stage.id || stage.name;
|
|||
|
|
const isCompleted = planData.completed_stages && planData.completed_stages.includes(stageId);
|
|||
|
|
const statusTag = isCompleted ? ' ✅已完成' : ' ⏳待执行';
|
|||
|
|
planMessage += `${idx + 1}. ${stage.name} ${deps}${parallel}${statusTag}\n`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', planMessage, {
|
|||
|
|
stage: stages[0],
|
|||
|
|
stages: stages,
|
|||
|
|
taskType: 'batch_analysis',
|
|||
|
|
params: plan.params,
|
|||
|
|
plan: plan,
|
|||
|
|
confidence: planData.intent?.confidence || 0.9
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (isBatchExecuting.value) {
|
|||
|
|
batchExecuteDialogVisible.value = false;
|
|||
|
|
startBackgroundBatchExecution(stages, plan, planData);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
batchExecuteStages.value = stages;
|
|||
|
|
batchExecuteIndex.value = 0;
|
|||
|
|
batchExecuteParams.value = {
|
|||
|
|
...plan.params,
|
|||
|
|
stages: stages,
|
|||
|
|
plan: plan
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
batchExecuteDialogVisible.value = true;
|
|||
|
|
addAgentMessage('assistant', `⚙️ 准备执行 ${stages.length} 个分析阶段,请确认参数`);
|
|||
|
|
startBatchExecuteTimer();
|
|||
|
|
return true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const confirmSyntenyChoice = async (option) => {
|
|||
|
|
syntenyChoiceDialogVisible.value = false;
|
|||
|
|
nlLoading.value = true;
|
|||
|
|
|
|||
|
|
const baseIntent = syntenyChoiceBaseIntent.value || {};
|
|||
|
|
const selectedIntent = {
|
|||
|
|
...baseIntent,
|
|||
|
|
stages: option.stages,
|
|||
|
|
stage: option.stages[0],
|
|||
|
|
primary_stage: option.id,
|
|||
|
|
description: option.description,
|
|||
|
|
needs_clarification: false,
|
|||
|
|
clarification_type: null,
|
|||
|
|
taskType: 'analysis'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', `已选择:${option.label}`);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await runPlanForIntent(syntenyChoiceQuery.value || option.label, selectedIntent);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[DEBUG] Synteny choice plan error:', err);
|
|||
|
|
ElMessage.error(`❌ ${err.message || '规划请求失败'}`);
|
|||
|
|
addAgentMessage('assistant', `❌ 服务暂时不可用:${err.message || '规划请求失败'}\n\n请稍后重试。`, {
|
|||
|
|
taskType: 'error'
|
|||
|
|
});
|
|||
|
|
} finally {
|
|||
|
|
nlInput.value = '';
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cancelSyntenyChoice = () => {
|
|||
|
|
syntenyChoiceDialogVisible.value = false;
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
nlInput.value = '';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getStageId = (stage) => typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
const getStageName = (stage) => typeof stage === 'object' ? (stage.name || stage.id) : getStageLabel(stage);
|
|||
|
|
|
|||
|
|
const fetchStageCompleted = async (stageId, prefixValue = null) => {
|
|||
|
|
try {
|
|||
|
|
const jobId = jobInfo.job_id;
|
|||
|
|
const encodedPrefix = encodeURIComponent(prefixValue || params.prefix || '');
|
|||
|
|
const response = await fetch(`/api/stage-status?job_id=${jobId}&stage=${stageId}&prefix=${encodedPrefix}`);
|
|||
|
|
const data = await response.json();
|
|||
|
|
return data.completed === true;
|
|||
|
|
} catch (e) {
|
|||
|
|
console.log(`[BACKGROUND] stage-status check failed for ${stageId}:`, e);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const executeStageDirect = async (stageId, stageParams = {}) => {
|
|||
|
|
const backendStageId = (
|
|||
|
|
stageId === 'rnaseq_go_enrich' || stageId === 'rnaseq_kegg_enrich'
|
|||
|
|
) ? 'rnaseq_enrich' : stageId;
|
|||
|
|
const requestBody = {
|
|||
|
|
stage: backendStageId,
|
|||
|
|
job_id: jobInfo.job_id,
|
|||
|
|
prefix: stageParams.prefix || params.prefix || '',
|
|||
|
|
cpu: stageParams.cpu || 8
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (stageId === 'ppi') {
|
|||
|
|
requestBody.ppiSpecies = stageParams.ppiSpecies || confirmParams.ppiSpecies || 'ath';
|
|||
|
|
requestBody.ppiScore = stageParams.ppiScore || confirmParams.ppiScore || 700;
|
|||
|
|
}
|
|||
|
|
if (stageId === 'kaks') {
|
|||
|
|
requestBody.kaksNeutralRate = stageParams.kaksNeutralRate || confirmParams.kaksNeutralRate || 7.08e-9;
|
|||
|
|
}
|
|||
|
|
if (stageId === 'expression') {
|
|||
|
|
requestBody.geoId = stageParams.geoId || confirmParams.geoId || '';
|
|||
|
|
requestBody.idTransform = stageParams.idTransform || confirmParams.idTransform || 'remove_T01';
|
|||
|
|
}
|
|||
|
|
if (stageId.startsWith('rnaseq')) {
|
|||
|
|
requestBody.rnaseqMode = stageParams.rnaseqMode || confirmParams.rnaseqMode || 'upload';
|
|||
|
|
requestBody.speciesName = stageParams.speciesName || confirmParams.speciesName || '';
|
|||
|
|
requestBody.rnaseqForegroundMode = stageParams.rnaseqForegroundMode || confirmParams.rnaseqForegroundMode || 'auto';
|
|||
|
|
}
|
|||
|
|
if (stageId === 'rnaseq_enrich' || stageId === 'rnaseq_go_enrich' || stageId === 'rnaseq_kegg_enrich') {
|
|||
|
|
requestBody.rnaseqForegroundMode = stageParams.rnaseqForegroundMode || 'auto';
|
|||
|
|
if (stageId === 'rnaseq_go_enrich') {
|
|||
|
|
requestBody.enrichType = 'go';
|
|||
|
|
} else if (stageId === 'rnaseq_kegg_enrich') {
|
|||
|
|
requestBody.enrichType = 'kegg';
|
|||
|
|
}
|
|||
|
|
requestBody.enrichBackgroundSource = stageParams.enrichBackgroundSource || confirmParams.enrichBackgroundSource || 'auto';
|
|||
|
|
requestBody.enrichSpecies = stageParams.enrichSpecies || confirmParams.enrichSpecies || '';
|
|||
|
|
requestBody.enrichKeyType = stageParams.enrichKeyType || confirmParams.enrichKeyType || 'auto';
|
|||
|
|
requestBody.enrichType = requestBody.enrichType || stageParams.enrichType || confirmParams.enrichType || 'go';
|
|||
|
|
requestBody.keggOrganism = stageParams.keggOrganism || confirmParams.keggOrganism || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch('/api/execute', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(requestBody)
|
|||
|
|
});
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (!data.success) {
|
|||
|
|
throw new Error(data.message || data.error || '执行失败');
|
|||
|
|
}
|
|||
|
|
if (data.task_id) {
|
|||
|
|
return await pollTaskUntilComplete(data.task_id, stageId, requestBody.prefix);
|
|||
|
|
}
|
|||
|
|
if (data.async === false) {
|
|||
|
|
return data;
|
|||
|
|
}
|
|||
|
|
throw new Error(data.message || '执行失败');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const startBackgroundBatchExecution = async (stages, plan, planData = {}) => {
|
|||
|
|
const batchId = `bg_${Date.now()}`;
|
|||
|
|
const stageIds = stages.map(getStageId).filter(Boolean);
|
|||
|
|
const stageMap = {};
|
|||
|
|
stages.forEach(stage => {
|
|||
|
|
stageMap[getStageId(stage)] = stage;
|
|||
|
|
});
|
|||
|
|
const localStatus = {};
|
|||
|
|
const prefixValue = plan.prefix || params.prefix || '';
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', `📌 当前已有前台批次执行中,新任务已转为后台执行:${stageIds.map(getStageLabel).join(', ')}`, {
|
|||
|
|
taskType: 'background_batch',
|
|||
|
|
batchId,
|
|||
|
|
stages: stageIds
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const knownStages = new Set([
|
|||
|
|
...Object.keys(stageDependencies),
|
|||
|
|
...stageIds,
|
|||
|
|
'identify', 'phylogeny', 'ortholog'
|
|||
|
|
]);
|
|||
|
|
for (const stageId of knownStages) {
|
|||
|
|
localStatus[stageId] = await fetchStageCompleted(stageId, prefixValue) ? 'completed' : 'pending';
|
|||
|
|
}
|
|||
|
|
stageIds.forEach(stageId => {
|
|||
|
|
if (localStatus[stageId] !== 'completed') {
|
|||
|
|
localStatus[stageId] = 'pending';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let completed = 0;
|
|||
|
|
let failed = 0;
|
|||
|
|
|
|||
|
|
while (true) {
|
|||
|
|
const pending = stageIds.filter(stageId => localStatus[stageId] === 'pending');
|
|||
|
|
if (pending.length === 0) break;
|
|||
|
|
|
|||
|
|
const executable = pending.filter(stageId => {
|
|||
|
|
const deps = stageDependencies[stageId] || [];
|
|||
|
|
return deps.every(dep => localStatus[dep] === 'completed');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (executable.length === 0) {
|
|||
|
|
const blocked = pending.filter(stageId => {
|
|||
|
|
const deps = stageDependencies[stageId] || [];
|
|||
|
|
return deps.some(dep => localStatus[dep] === 'failed');
|
|||
|
|
});
|
|||
|
|
if (blocked.length > 0) {
|
|||
|
|
blocked.forEach(stageId => {
|
|||
|
|
localStatus[stageId] = 'failed';
|
|||
|
|
failed++;
|
|||
|
|
});
|
|||
|
|
addAgentMessage('assistant', `⚠️ 后台批次中以下阶段因依赖失败被跳过: ${blocked.map(getStageLabel).join(', ')}`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', `⚠️ 后台批次无法继续:${pending.map(getStageLabel).join(', ')} 的依赖尚未完成`);
|
|||
|
|
pending.forEach(stageId => {
|
|||
|
|
localStatus[stageId] = 'failed';
|
|||
|
|
failed++;
|
|||
|
|
});
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await Promise.all(executable.map(async (stageId) => {
|
|||
|
|
localStatus[stageId] = 'running';
|
|||
|
|
const stage = stageMap[stageId] || stageId;
|
|||
|
|
const stageName = getStageName(stage);
|
|||
|
|
addAgentMessage('assistant', `⚙️ 后台执行阶段: ${stageName}...`);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const stageParams = (plan.stages_detail || []).find(s => s.id === stageId)?.params || plan.params || {};
|
|||
|
|
await executeStageDirect(stageId, stageParams);
|
|||
|
|
localStatus[stageId] = 'completed';
|
|||
|
|
completed++;
|
|||
|
|
addAgentMessage('assistant', `✅ 后台阶段 ${stageName} 完成`);
|
|||
|
|
} catch (err) {
|
|||
|
|
localStatus[stageId] = 'failed';
|
|||
|
|
failed++;
|
|||
|
|
addAgentMessage('assistant', `❌ 后台阶段 ${stageName} 失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', `📦 后台批次完成:成功 ${completed}/${stageIds.length},失败 ${failed}/${stageIds.length}`);
|
|||
|
|
await refreshResults();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理自然语言Input
|
|||
|
|
const handleNlInput = async () => {
|
|||
|
|
if (!nlInput.value.trim()) {
|
|||
|
|
ElMessage.warning('请Input内容');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const userInput = nlInput.value.trim();
|
|||
|
|
nlLoading.value = true;
|
|||
|
|
|
|||
|
|
// V2: RAG模式 - 文献问答
|
|||
|
|
if (ragMode.value) {
|
|||
|
|
try {
|
|||
|
|
addMessage('user', userInput);
|
|||
|
|
ragHistory.value.push({ role: 'user', content: userInput, time: new Date().toLocaleTimeString() });
|
|||
|
|
|
|||
|
|
// 调用RAG API (gene-family-mas-v2 端口8000)
|
|||
|
|
const response = await fetch('http://localhost:7867/api/v1/rag/chat', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
query: userInput,
|
|||
|
|
mode: 'substring',
|
|||
|
|
top_k: 5
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
const answer = data.response || data.answer || '未找到相关信息';
|
|||
|
|
|
|||
|
|
addMessage('assistant', answer);
|
|||
|
|
ragHistory.value.push({ role: 'assistant', content: answer, time: new Date().toLocaleTimeString() });
|
|||
|
|
|
|||
|
|
// 滚动到底部
|
|||
|
|
setTimeout(() => {
|
|||
|
|
if (chatArea.value) {
|
|||
|
|
chatArea.value.scrollTop = chatArea.value.scrollHeight;
|
|||
|
|
}
|
|||
|
|
}, 100);
|
|||
|
|
|
|||
|
|
} catch (err) {
|
|||
|
|
ElMessage.error('RAG请求失败: ' + err.message);
|
|||
|
|
addMessage('assistant', '抱歉,文献检索服务暂时不可用。');
|
|||
|
|
} finally {
|
|||
|
|
nlInput.value = '';
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Agent模式 - 技能执行
|
|||
|
|
try {
|
|||
|
|
// 检查是否有绑定的 job
|
|||
|
|
if (!jobInfo.job_id || jobInfo.job_id === 'default_job') {
|
|||
|
|
ElMessage.warning('请先选择或创建 Working Directory');
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果没有当前会话,先自动创建一个
|
|||
|
|
if (!currentConversationId.value) {
|
|||
|
|
await createNewConversation();
|
|||
|
|
console.log('[会话] 自动创建会话:', currentConversationId.value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加用户消息到Agent会话
|
|||
|
|
addAgentMessage('user', userInput);
|
|||
|
|
|
|||
|
|
const response = await fetch('/api/agent/chat', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
message: userInput,
|
|||
|
|
model: selectedModel.value,
|
|||
|
|
session_id: sessionId.value,
|
|||
|
|
job_id: jobInfo.job_id, // 传入当前 job_id
|
|||
|
|
history: agentSession.messages.slice(-10) // 保留最近10条上下文
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.success && data.intent) {
|
|||
|
|
// 更新自动检测的模式
|
|||
|
|
autoDetectedMode.value = data.intent.taskType === 'faq' ? 'faq' : 'skill';
|
|||
|
|
|
|||
|
|
if (data.intent.needs_clarification || data.intent.taskType === 'clarification') {
|
|||
|
|
autoDetectedMode.value = 'skill';
|
|||
|
|
showSyntenyChoice(data.intent, userInput);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 判断使用哪种模式:自动检测优先,但考虑用户偏好
|
|||
|
|
const isFaqQuery = data.intent.taskType === 'faq' || !data.intent.stage;
|
|||
|
|
const useRagMode = isFaqQuery || (preferenceMode.value && !data.intent.stage);
|
|||
|
|
|
|||
|
|
if (useRagMode) {
|
|||
|
|
// FAQ查询或偏好为Literature:使用RAG回答
|
|||
|
|
autoDetectedMode.value = 'faq';
|
|||
|
|
|
|||
|
|
// 调用 bbav5 后端 RAG API(由后端转发到 gene-family-mas-v2)
|
|||
|
|
try {
|
|||
|
|
const ragResponse = await fetch('/api/agent/rag', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
query: userInput,
|
|||
|
|
mode: 'rag',
|
|||
|
|
top_k: 5
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const ragData = await ragResponse.json();
|
|||
|
|
const answer = ragData.success ? ragData.answer : (ragData.message || 'No relevant information found.');
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', answer, {
|
|||
|
|
stage: null,
|
|||
|
|
taskType: 'faq',
|
|||
|
|
params: {},
|
|||
|
|
confidence: data.intent.confidence,
|
|||
|
|
sources: ragData.sources || []
|
|||
|
|
});
|
|||
|
|
} catch (ragErr) {
|
|||
|
|
// RAG失败,使用意图识别的消息
|
|||
|
|
addAgentMessage('assistant', data.intent.message || 'Answer based on knowledge base.', {
|
|||
|
|
stage: null,
|
|||
|
|
taskType: 'faq',
|
|||
|
|
params: {},
|
|||
|
|
confidence: data.intent.confidence
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
console.log('[DEBUG] FAQ/Literature mode: displaying knowledge response');
|
|||
|
|
} else {
|
|||
|
|
// 分析任务:使用V2规划API获取详细执行计划
|
|||
|
|
autoDetectedMode.value = 'skill';
|
|||
|
|
|
|||
|
|
// 调用规划API获取详细计划
|
|||
|
|
console.log('[DEBUG] Calling /api/agent/plan for detailed planning...');
|
|||
|
|
console.log('[DEBUG] Passing intent from chat:', data.intent);
|
|||
|
|
try {
|
|||
|
|
const planResponse = await fetch('/api/agent/plan', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
message: userInput,
|
|||
|
|
session_id: sessionId.value,
|
|||
|
|
job_id: jobInfo.job_id, // 传递当前 job_id 用于查询已完成阶段
|
|||
|
|
prefix: params.prefix || '',
|
|||
|
|
history: agentSession.messages.slice(-5),
|
|||
|
|
intent: data.intent // 传递 chat 返回的 intent,避免重复调用 IntentAgentV2
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const planData = await planResponse.json();
|
|||
|
|
console.log('[DEBUG] Plan API response:', planData);
|
|||
|
|
|
|||
|
|
if (planData.success && planData.plan) {
|
|||
|
|
const plan = planData.plan;
|
|||
|
|
const completedSet = new Set(planData.completed_stages || []);
|
|||
|
|
let stages = (plan.stages || []).filter(stage => {
|
|||
|
|
const stageId = typeof stage === 'object' ? stage.id : stage;
|
|||
|
|
return !completedSet.has(stageId);
|
|||
|
|
});
|
|||
|
|
let stagesDetail = (plan.stages_detail || []).filter(stage => !completedSet.has(stage.id || stage.name));
|
|||
|
|
|
|||
|
|
if (stages.length === 0) {
|
|||
|
|
addAgentMessage('assistant', plan.description || '所有相关分析阶段已完成,无需重复执行。', {
|
|||
|
|
taskType: 'analysis_completed',
|
|||
|
|
plan: plan
|
|||
|
|
});
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
nlInput.value = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 显示智能规划结果
|
|||
|
|
let planMessage = `📋 分析计划 (${plan.mode || 'v2'})\n\n`;
|
|||
|
|
planMessage += `基因家族: ${plan.gene_family}\n`;
|
|||
|
|
planMessage += `物种: ${plan.species}\n`;
|
|||
|
|
planMessage += `预估时间: ${plan.estimated_time}\n`;
|
|||
|
|
planMessage += `执行策略: ${plan.execution_strategy === 'parallel' ? '并行执行' : '串行执行'}\n\n`;
|
|||
|
|
planMessage += `分析阶段 (${stages.length}个):\n`;
|
|||
|
|
|
|||
|
|
stagesDetail.forEach((stage, idx) => {
|
|||
|
|
const deps = stage.dependencies?.length > 0
|
|||
|
|
? `(依赖: ${stage.dependencies.join(', ')})`
|
|||
|
|
: '';
|
|||
|
|
const parallel = stage.can_parallel ? ' [可并行]' : '';
|
|||
|
|
// 检查是否已完成
|
|||
|
|
const stageId = stage.id || stage.name;
|
|||
|
|
const isCompleted = planData.completed_stages && planData.completed_stages.includes(stageId);
|
|||
|
|
const statusTag = isCompleted ? ' ✅已完成' : ' ⏳待执行';
|
|||
|
|
planMessage += `${idx + 1}. ${stage.name} ${deps}${parallel}${statusTag}\n`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
addAgentMessage('assistant', planMessage, {
|
|||
|
|
stage: stages[0],
|
|||
|
|
stages: stages,
|
|||
|
|
taskType: 'batch_analysis',
|
|||
|
|
params: plan.params,
|
|||
|
|
plan: plan,
|
|||
|
|
confidence: planData.intent?.confidence || 0.9
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (isBatchExecuting.value) {
|
|||
|
|
startBackgroundBatchExecution(stages, plan, planData);
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
nlInput.value = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 存储批量执行信息
|
|||
|
|
batchExecuteStages.value = stages;
|
|||
|
|
batchExecuteIndex.value = 0;
|
|||
|
|
batchExecuteParams.value = {
|
|||
|
|
...plan.params,
|
|||
|
|
stages: stages,
|
|||
|
|
plan: plan
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 显示批量执行确认对话框
|
|||
|
|
batchExecuteDialogVisible.value = true;
|
|||
|
|
addAgentMessage('assistant', `⚙️ 准备执行 ${stages.length} 个分析阶段,请确认参数`);
|
|||
|
|
|
|||
|
|
// 启动15秒倒计时
|
|||
|
|
startBatchExecuteTimer();
|
|||
|
|
return;
|
|||
|
|
} else {
|
|||
|
|
// 规划失败(如PreFlight验证失败),显示错误并停止
|
|||
|
|
const errorMsg = planData.message || "规划失败";
|
|||
|
|
console.log('[DEBUG] Plan API failed:', errorMsg);
|
|||
|
|
ElMessage.error(`❌ ${errorMsg}`);
|
|||
|
|
const planHint = /缺少|输入文件|upload|上传|file/i.test(errorMsg)
|
|||
|
|
? '\n\n请检查必要输入文件是否已上传。'
|
|||
|
|
: '\n\n规划阶段未能生成可执行流程,请查看后端日志中的具体原因。';
|
|||
|
|
addAgentMessage('assistant', `❌ 无法执行分析:${errorMsg}${planHint}`, {
|
|||
|
|
taskType: 'error'
|
|||
|
|
});
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
nlInput.value = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
} catch (planErr) {
|
|||
|
|
console.error('[DEBUG] Plan API error:', planErr);
|
|||
|
|
// API调用异常,显示错误并停止
|
|||
|
|
const errorMsg = planErr.message || "规划请求失败";
|
|||
|
|
ElMessage.error(`❌ ${errorMsg}`);
|
|||
|
|
addAgentMessage('assistant', `❌ 服务暂时不可用:${errorMsg}\n\n请稍后重试。`, {
|
|||
|
|
taskType: 'error'
|
|||
|
|
});
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
nlInput.value = '';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
addAgentMessage('assistant', data.message || '抱歉,我没有理解你的意思。请尝试更详细地描述。');
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
ElMessage.error('请求失败: ' + err.message);
|
|||
|
|
addAgentMessage('assistant', '抱歉,服务暂时不可用,请稍后重试。');
|
|||
|
|
} finally {
|
|||
|
|
nlInput.value = '';
|
|||
|
|
nlLoading.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Refresh结果
|
|||
|
|
const refreshResults = async (jobId = null) => {
|
|||
|
|
try {
|
|||
|
|
const url = jobId ? `/api/results?job_id=${jobId}` : '/api/results';
|
|||
|
|
const response = await fetch(url);
|
|||
|
|
const data = await response.json();
|
|||
|
|
stages.forEach(stage => {
|
|||
|
|
stage.images = data[stage.name]?.images || [];
|
|||
|
|
stage.files = data[stage.name]?.files || [];
|
|||
|
|
stage.elapsed_time = data[stage.name]?.elapsed_time || 0;
|
|||
|
|
if (stage.images.length > 0 || stage.files.length > 0) stage.status = 'success';
|
|||
|
|
});
|
|||
|
|
// 同时RefreshMCScanX HTML文件列表
|
|||
|
|
await refreshMcscanxHtml();
|
|||
|
|
} catch (err) { console.error(err); }
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 打开图片预览
|
|||
|
|
const openPreview = (img) => {
|
|||
|
|
previewImg.value = img;
|
|||
|
|
previewDialogVisible.value = true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 重新绘图
|
|||
|
|
const redrawImage = () => {
|
|||
|
|
ElMessage.info('重新绘图功能开发中...');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 组件卸载时清理定时器
|
|||
|
|
const cleanup = () => {
|
|||
|
|
if (executeTimer) {
|
|||
|
|
clearInterval(executeTimer);
|
|||
|
|
executeTimer = null;
|
|||
|
|
}
|
|||
|
|
if (pollTimer) {
|
|||
|
|
clearInterval(pollTimer);
|
|||
|
|
pollTimer = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
refreshInfo();
|
|||
|
|
refreshResults();
|
|||
|
|
fetchAgentTasks();
|
|||
|
|
startAgentTaskPolling();
|
|||
|
|
loadConversations(); // 加载会话列表
|
|||
|
|
|
|||
|
|
// 添加深度解读按钮的事件委托监听
|
|||
|
|
document.addEventListener('click', handleInterpretButtonClick);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 使用 Vue 3 的 onUnmounted
|
|||
|
|
const { onUnmounted } = Vue;
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
cleanup();
|
|||
|
|
stopAgentTaskPolling();
|
|||
|
|
// 移除深度解读按钮的事件监听
|
|||
|
|
document.removeEventListener('click', handleInterpretButtonClick);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
copyPath,
|
|||
|
|
nlInput,
|
|||
|
|
nlLoading,
|
|||
|
|
handleNlInput,
|
|||
|
|
// Agent Mode
|
|||
|
|
selectedModel,
|
|||
|
|
modelDisplayName,
|
|||
|
|
agentSession,
|
|||
|
|
onModelChange,
|
|||
|
|
clearAgentSession,
|
|||
|
|
getTaskTypeColor,
|
|||
|
|
getStageLabel,
|
|||
|
|
formatMessage,
|
|||
|
|
addAgentMessage,
|
|||
|
|
selectedStage,
|
|||
|
|
params,
|
|||
|
|
inputPath,
|
|||
|
|
jobInfo,
|
|||
|
|
messages,
|
|||
|
|
logContent,
|
|||
|
|
chatArea,
|
|||
|
|
confirmDialogVisible,
|
|||
|
|
confirmParams,
|
|||
|
|
interspeciesSpeciesOptions,
|
|||
|
|
interspeciesSpeciesLoading,
|
|||
|
|
executing,
|
|||
|
|
executeElapsed,
|
|||
|
|
autoExecute,
|
|||
|
|
autoExecuteCountdown,
|
|||
|
|
clearAutoExecuteTimer,
|
|||
|
|
// 批量执行
|
|||
|
|
batchExecuteDialogVisible,
|
|||
|
|
batchExecuteStages,
|
|||
|
|
batchExecuteIndex,
|
|||
|
|
batchExecuteCountdown,
|
|||
|
|
isBatchExecuting,
|
|||
|
|
shouldStopBatchExecute,
|
|||
|
|
currentBatchStage,
|
|||
|
|
startBatchExecute,
|
|||
|
|
cancelBatchExecute,
|
|||
|
|
stopBatchExecute,
|
|||
|
|
syntenyChoiceDialogVisible,
|
|||
|
|
syntenyChoiceOptions,
|
|||
|
|
confirmSyntenyChoice,
|
|||
|
|
cancelSyntenyChoice,
|
|||
|
|
// 依赖驱动并行执行
|
|||
|
|
stageDependencies,
|
|||
|
|
stageExecutionStatus,
|
|||
|
|
runningStages,
|
|||
|
|
maxParallelStages,
|
|||
|
|
// 阶段确认对话框
|
|||
|
|
stageConfirmDialogVisible,
|
|||
|
|
stageConfirmCountdown,
|
|||
|
|
stageConfirmCurrent,
|
|||
|
|
stageConfirmTotal,
|
|||
|
|
stageConfirmQueue,
|
|||
|
|
processStageConfirmQueue,
|
|||
|
|
confirmStageExecute,
|
|||
|
|
skipStageExecute,
|
|||
|
|
executeStagesWithDependencies,
|
|||
|
|
executeStageWithConfirmation,
|
|||
|
|
stages,
|
|||
|
|
// 异步任务轮询
|
|||
|
|
currentTask,
|
|||
|
|
// Stop Execution相关
|
|||
|
|
isExecuting,
|
|||
|
|
isStopping,
|
|||
|
|
currentTaskId,
|
|||
|
|
stopExecution,
|
|||
|
|
// 新版结果区域变量
|
|||
|
|
activeStages,
|
|||
|
|
selectedResultStage,
|
|||
|
|
selectedFile,
|
|||
|
|
selectedImage,
|
|||
|
|
selectedImageUrl,
|
|||
|
|
selectedImageSize,
|
|||
|
|
zoomLevel,
|
|||
|
|
previewContentRef,
|
|||
|
|
// 新版结果区域方法
|
|||
|
|
isImageFile,
|
|||
|
|
selectFile,
|
|||
|
|
zoomIn,
|
|||
|
|
zoomOut,
|
|||
|
|
resetZoom,
|
|||
|
|
toggleFullscreen,
|
|||
|
|
downloadImage,
|
|||
|
|
onImageLoad,
|
|||
|
|
onImageError,
|
|||
|
|
onZoomChange,
|
|||
|
|
getFileName,
|
|||
|
|
selectImageFile,
|
|||
|
|
// MCScanX HTML结果
|
|||
|
|
mcscanxHtmlFiles,
|
|||
|
|
openMcscanxHtml,
|
|||
|
|
// Agent Tasks
|
|||
|
|
agentTasks,
|
|||
|
|
intentHistory,
|
|||
|
|
uploadedFiles,
|
|||
|
|
formatTime,
|
|||
|
|
showTaskDetail,
|
|||
|
|
// 原有
|
|||
|
|
previewDialogVisible,
|
|||
|
|
previewImg,
|
|||
|
|
previewParams,
|
|||
|
|
resultDialogVisible,
|
|||
|
|
resultDialogParams,
|
|||
|
|
formatSize,
|
|||
|
|
onStageChange,
|
|||
|
|
showConfirm,
|
|||
|
|
doExecute,
|
|||
|
|
handleUploadSuccess,
|
|||
|
|
handleUploadError,
|
|||
|
|
handleDownloadExample,
|
|||
|
|
loadFromPath,
|
|||
|
|
refreshResults,
|
|||
|
|
openPreview,
|
|||
|
|
redrawImage,
|
|||
|
|
createNewJob,
|
|||
|
|
// Working DirectorySelect
|
|||
|
|
existingJobs,
|
|||
|
|
jobSelectorVisible,
|
|||
|
|
selectedJobId,
|
|||
|
|
showJobSelector,
|
|||
|
|
selectJob,
|
|||
|
|
// V2: RAG相关
|
|||
|
|
ragMode,
|
|||
|
|
userId,
|
|||
|
|
sessionId,
|
|||
|
|
ragHistory,
|
|||
|
|
toggleRagMode,
|
|||
|
|
clearRagSession,
|
|||
|
|
// 偏好模式和自动检测
|
|||
|
|
preferenceMode,
|
|||
|
|
autoDetectedMode,
|
|||
|
|
togglePreferenceMode,
|
|||
|
|
// 错误日志
|
|||
|
|
clearErrorLog,
|
|||
|
|
// 会话管理
|
|||
|
|
apiBaseUrl,
|
|||
|
|
currentConversationId,
|
|||
|
|
conversations,
|
|||
|
|
filteredConversations,
|
|||
|
|
createNewConversation,
|
|||
|
|
selectConversation,
|
|||
|
|
// 新会话选择器
|
|||
|
|
recentConversations,
|
|||
|
|
moreConversations,
|
|||
|
|
// 深度解读功能
|
|||
|
|
interpretationDialogVisible,
|
|||
|
|
interpretationReport,
|
|||
|
|
currentInterpretStage,
|
|||
|
|
addInterpretationButton,
|
|||
|
|
downloadInterpretationReport,
|
|||
|
|
copyInterpretationToClipboard,
|
|||
|
|
// 诊断修复确认
|
|||
|
|
diagnosisConfirmDialogVisible,
|
|||
|
|
diagnosisConfirmCountdown,
|
|||
|
|
diagnosisFixData,
|
|||
|
|
startDiagnosisConfirmTimer,
|
|||
|
|
clearDiagnosisConfirmTimer,
|
|||
|
|
showDiagnosisConfirm,
|
|||
|
|
confirmDiagnosisFix,
|
|||
|
|
rejectDiagnosisFix,
|
|||
|
|
startDiagnosisConfirmPolling,
|
|||
|
|
stopDiagnosisConfirmPolling,
|
|||
|
|
checkDiagnosisConfirmation,
|
|||
|
|
// 动态样式系统
|
|||
|
|
getMessageSpacingClass,
|
|||
|
|
getContentClass,
|
|||
|
|
detectStructuredContent,
|
|||
|
|
formatStructuredContent
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 注册Agent状态组件
|
|||
|
|
app.component('agent-status-component', AgentStatusComponent);
|
|||
|
|
|
|||
|
|
// 注册ConversationSidebar组件
|
|||
|
|
if (typeof ConversationSidebar !== 'undefined') {
|
|||
|
|
app.component('conversation-sidebar', ConversationSidebar);
|
|||
|
|
console.log('ConversationSidebar registered successfully');
|
|||
|
|
} else {
|
|||
|
|
console.warn('ConversationSidebar not found, using fallback');
|
|||
|
|
// 注册一个空的占位组件
|
|||
|
|
app.component('conversation-sidebar', {
|
|||
|
|
template: '<div style="width: 60px; background: #202123; height: 100%;"></div>'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 将 jobInfo 暴露给 Agent 组件使用
|
|||
|
|
window.parentJobInfo = null;
|
|||
|
|
|
|||
|
|
app.use(ElementPlus);
|
|||
|
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|||
|
|
app.component(key, component);
|
|||
|
|
}
|
|||
|
|
app.mount('#app');
|
|||
|
|
|
|||
|
|
// 隐藏加载提示,显示应用
|
|||
|
|
document.getElementById('loading-msg').style.display = 'none';
|
|||
|
|
document.getElementById('app').style.display = 'block';
|
|||
|
|
console.log('App mounted successfully');
|
|||
|
|
</script>
|
|||
|
|
<!-- 深度解读弹窗样式 -->
|
|||
|
|
<style>
|
|||
|
|
.interpretation-dialog .el-dialog__body { padding: 0; max-height: 70vh; overflow-y: auto; }
|
|||
|
|
.report-container { padding: 20px; }
|
|||
|
|
.report-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; margin: -20px -20px 20px -20px; }
|
|||
|
|
.report-title { font-size: 20px; font-weight: bold; margin-bottom: 10px; }
|
|||
|
|
.report-meta { font-size: 14px; opacity: 0.9; }
|
|||
|
|
.flow-visualization { display: flex; align-items: center; justify-content: center; padding: 20px; background: #f8f9fa; border-radius: 0px; margin: 20px 0; }
|
|||
|
|
.flow-stage { text-align: center; padding: 10px 20px; }
|
|||
|
|
.flow-stage-icon { font-size: 24px; margin-bottom: 5px; }
|
|||
|
|
.flow-stage-name { font-size: 14px; font-weight: bold; }
|
|||
|
|
.flow-stage-result { font-size: 12px; color: #666; margin-top: 5px; }
|
|||
|
|
.flow-arrow { font-size: 24px; color: #409eff; margin: 0 10px; }
|
|||
|
|
.section-title { font-size: 18px; font-weight: bold; margin: 30px 0 15px 0; padding-bottom: 10px; border-bottom: 2px solid #409eff; }
|
|||
|
|
.connection-card { background: #f0f9ff; border-left: 4px solid #409eff; padding: 15px; margin: 15px 0; border-radius: 0px; }
|
|||
|
|
.connection-header { font-weight: bold; color: #409eff; margin-bottom: 10px; }
|
|||
|
|
.finding-item { background: white; border: 1px solid #e4e7ed; padding: 15px; margin: 10px 0; border-radius: 0px; }
|
|||
|
|
.finding-title { font-weight: bold; color: #303133; margin-bottom: 8px; }
|
|||
|
|
.finding-desc { color: #606266; font-size: 14px; line-height: 1.6; }
|
|||
|
|
.finding-evidence { color: #909399; font-size: 12px; margin-top: 8px; font-style: italic; }
|
|||
|
|
.story-box { background: #fdf6ec; border: 1px solid #f5dab1; padding: 20px; border-radius: 0px; line-height: 1.8; color: #5d4a2a; }
|
|||
|
|
.next-step-item { display: flex; align-items: flex-start; padding: 12px; background: white; border: 1px solid #e4e7ed; margin: 8px 0; border-radius: 0px; }
|
|||
|
|
.next-step-priority { padding: 2px 8px; border-radius: 0px; font-size: 12px; margin-right: 12px; }
|
|||
|
|
.priority-high { background: #fef0f0; color: #f56c6c; }
|
|||
|
|
.priority-medium { background: #fdf6ec; color: #e6a23c; }
|
|||
|
|
.next-step-content { flex: 1; }
|
|||
|
|
.next-step-title { font-weight: bold; margin-bottom: 4px; }
|
|||
|
|
.next-step-desc { font-size: 13px; color: #606266; }
|
|||
|
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 15px 20px; border-top: 1px solid #e4e7ed; }
|
|||
|
|
</style>
|
|||
|
|
</body>
|
|||
|
|
</html>
|