多语言首页与首页引用页
This commit is contained in:
+2
-1
@@ -18,7 +18,7 @@ outputs/
|
||||
input/
|
||||
workspace/
|
||||
paper/
|
||||
memory/
|
||||
#memory/
|
||||
memory_storage/
|
||||
backup/
|
||||
backups/
|
||||
@@ -29,3 +29,4 @@ nohup.out
|
||||
Rplots.pdf
|
||||
*.pyc
|
||||
*.log
|
||||
.gitignore
|
||||
|
||||
Executable
+416
@@ -0,0 +1,416 @@
|
||||
<!-- Agent状态显示组件 - 可嵌入index.html -->
|
||||
<template id="agent-status-component">
|
||||
<div class="agent-status-panel">
|
||||
<div class="agent-header">
|
||||
<h3>{{ t('agent-assistant') }}</h3>
|
||||
<span class="agent-mode">{{ currentMode }}</span>
|
||||
</div>
|
||||
|
||||
<div class="agent-messages" ref="messageContainer">
|
||||
<div v-for="(msg, index) in messages" :key="index"
|
||||
:class="['message', msg.role]">
|
||||
<div class="message-role">{{ getRoleLabel(msg.role) }}</div>
|
||||
<div class="message-content" v-html="formatMessage(msg.content)"></div>
|
||||
<div class="message-time">{{ formatTime(msg.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-input">
|
||||
<textarea v-model="userInput"
|
||||
:placeholder="t('enter-question')"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
rows="3"></textarea>
|
||||
<button @click="sendMessage" :disabled="isProcessing">
|
||||
{{ isProcessing ? t('processing') : t('send') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="agent-tasks" v-if="tasks.length > 0">
|
||||
<h4>{{ t('task-list') }}</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 src="/static/i18n.js"></script>
|
||||
// Agent状态组件
|
||||
const AgentStatusComponent = {
|
||||
template: '#agent-status-component',
|
||||
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
userInput: '',
|
||||
isProcessing: false,
|
||||
currentMode: '',
|
||||
sessionId: '',
|
||||
tasks: [],
|
||||
currentTask: null,
|
||||
pollingInterval: null
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.sessionId = 'session_' + Date.now();
|
||||
this.currentMode = t('standby');
|
||||
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.addMessage('user', message);
|
||||
|
||||
try {
|
||||
// 调用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,
|
||||
model: 'kimi'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.currentTask = data.task_id;
|
||||
this.addMessage('system', t('msg-task-submitted') + data.task_id);
|
||||
this.loadTasks();
|
||||
} else {
|
||||
this.addMessage('error', data.message || t('msg-submit-failed'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.addMessage('error', t('msg-request-failed') + error.message);
|
||||
}
|
||||
|
||||
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(t('msg-load-task-failed'), 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 === 'completed' && task.result) {
|
||||
this.addMessage('assistant', task.result.response || t('msg-task-completed'));
|
||||
|
||||
// 显示执行结果摘要
|
||||
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',
|
||||
t('msg-analysis-complete') + (metrics.final_members || 0) + t('msg-gene-members'));
|
||||
}
|
||||
}
|
||||
} else if (task.status === 'failed') {
|
||||
this.addMessage('error', task.result?.response || t('msg-task-failed'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('msg-check-task-status-failed'), 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); // 每3秒轮询一次
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
}
|
||||
},
|
||||
|
||||
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 keys = {
|
||||
'user': 'user-role',
|
||||
'assistant': 'agent-role',
|
||||
'system': 'system-role',
|
||||
'error': 'error-role',
|
||||
'execution': 'execution-role',
|
||||
'planning': 'planning-role',
|
||||
'diagnosis': 'diagnosis-role'
|
||||
};
|
||||
return keys[role] ? t(keys[role]) : role;
|
||||
},
|
||||
|
||||
getStatusIcon(status) {
|
||||
const icons = {
|
||||
'pending': '⏳',
|
||||
'running': '🔄',
|
||||
'completed': '✅',
|
||||
'failed': '❌',
|
||||
'cancelled': '🚫'
|
||||
};
|
||||
return icons[status] || '❓';
|
||||
},
|
||||
|
||||
formatMessage(content) {
|
||||
// 简单的Markdown格式化
|
||||
return content
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
},
|
||||
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
},
|
||||
|
||||
t(key) {
|
||||
return window.t ? window.t(key) : key;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.agent-status-panel {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.agent-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.agent-mode {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #e0e0e0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.agent-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
border-left: 3px solid #ddd;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
border-left-color: #4CAF50;
|
||||
background: #E8F5E9;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
border-left-color: #2196F3;
|
||||
background: #E3F2FD;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
border-left-color: #f44336;
|
||||
background: #FFEBEE;
|
||||
}
|
||||
|
||||
.message.system {
|
||||
border-left-color: #9E9E9E;
|
||||
background: #F5F5F5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.agent-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.agent-input textarea {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.agent-input button {
|
||||
padding: 8px 16px;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-input button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-tasks {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.agent-tasks h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.task-item.pending {
|
||||
border-left: 3px solid #FFC107;
|
||||
}
|
||||
|
||||
.task-item.running {
|
||||
border-left: 3px solid #2196F3;
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
border-left: 3px solid #4CAF50;
|
||||
}
|
||||
|
||||
.task-item.failed {
|
||||
border-left: 3px solid #f44336;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-message {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
Executable
+391
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script src="/static/i18n.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BotanicalBaseAgent</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.3.14/dist/index.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.3.14/dist/index.full.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@2.1.0/dist/index.iife.js"></script>
|
||||
<link rel="stylesheet" href="conversation-sidebar.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
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;
|
||||
height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧控制面板 */
|
||||
.left-panel {
|
||||
width: 280px;
|
||||
background: white;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
gap: 15px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 中间内容区 */
|
||||
.center-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
gap: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 右侧结果区 */
|
||||
.right-panel {
|
||||
width: 350px;
|
||||
background: white;
|
||||
border-left: 1px solid #e4e7ed;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #409EFF;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 15px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
background: #ecf5ff;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stage-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.stage-tag.success { background: #67C23A; color: white; }
|
||||
.stage-tag.running { background: #E6A23C; color: white; }
|
||||
.stage-tag.pending { background: #909399; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>{{ t('gf-analysis-system') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 左侧:会话侧边栏 + 控制面板 -->
|
||||
<div class="left-panel">
|
||||
<!-- 会话侧边栏 -->
|
||||
<div class="conversation-sidebar" style="width: 100%; margin: -15px; margin-bottom: 0;">
|
||||
<div class="sidebar-header">
|
||||
<button class="new-chat-btn" @click="createNewConversation" style="flex: 1;">
|
||||
<el-icon><plus /></el-icon>
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-box" style="padding: 12px;">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('search-conv')"
|
||||
prefix-icon="search"
|
||||
clearable
|
||||
size="small">
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="conversation-list" style="max-height: 300px;">
|
||||
<div class="list-section">
|
||||
<div class="section-title">{{ t('conv-list') }}</div>
|
||||
<div v-for="conv in filteredConversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConv?.id === conv.id }"
|
||||
@click="selectConversation(conv)">
|
||||
<div class="item-icon">
|
||||
<el-icon><chat-dot-round /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ conv.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
|
||||
<span class="message-count">{{ conv.message_count }}{{ t('msg-count') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分析控制 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">{{ t('analysis-stage') }}</div>
|
||||
<el-select v-model="selectedStage" :placeholder="t('select-stage')" style="width: 100%;">
|
||||
<el-option :label="t('identify-label')" value="identify"></el-option>
|
||||
<el-option :label="t('phylogeny-label')" value="phylogeny"></el-option>
|
||||
<el-option :label="t('motif-label')" value="motif"></el-option>
|
||||
<el-option :label="t('domain-label')" value="domain"></el-option>
|
||||
<el-option :label="t('expression-label')" value="expression"></el-option>
|
||||
</el-select>
|
||||
<el-button type="primary" style="width: 100%; margin-top: 10px;" @click="executeAnalysis">
|
||||
{{ t('execute-analysis') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 参数设置 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-title">{{ t('param-settings') }}</div>
|
||||
<el-form :model="params" label-width="80px" size="small">
|
||||
<el-form-item :label="t('prefix')">
|
||||
<el-input v-model="params.prefix" :placeholder="t('prefix-placeholder')"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="CPU">
|
||||
<el-input-number v-model="params.cpu" :min="1" :max="32" style="width: 100%;"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:对话区 -->
|
||||
<div class="center-panel">
|
||||
<div class="chat-area">
|
||||
<div v-for="(msg, index) in messages" :key="index"
|
||||
class="chat-message" :class="msg.role">
|
||||
<div style="font-size: 12px; color: #666; margin-bottom: 4px;">
|
||||
{{ msg.role === 'user' ? t('user') : t('ai-assistant') }} · {{ msg.time }}
|
||||
</div>
|
||||
<div>{{ msg.content }}</div>
|
||||
</div>
|
||||
<div v-if="messages.length === 0" style="text-align: center; color: #999; padding: 40px;">
|
||||
<el-icon :size="48" style="display: block; margin-bottom: 10px;"><chat-line-round /></el-icon>
|
||||
<p>{{ t('start-new-chat') }}</p>
|
||||
<p style="font-size: 12px;">{{ t('or-continue') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<el-input
|
||||
v-model="userInput"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="t('describe-need')"
|
||||
@keyup.enter.ctrl="sendMessage">
|
||||
</el-input>
|
||||
<el-button type="primary" size="large" @click="sendMessage" :disabled="!userInput.trim()">
|
||||
{{ t('send') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:结果展示 -->
|
||||
<div class="right-panel">
|
||||
<div class="panel-title">📊 Analysis Results</div>
|
||||
|
||||
<div v-for="stage in stages" :key="stage.name" style="margin-bottom: 15px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-weight: 500;">{{ stage.label }}</span>
|
||||
<span class="stage-tag" :class="stage.status">{{ stage.statusText }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="stage.progress" :status="stage.status"></el-progress>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<div style="text-align: center; color: #999; padding: 20px;">
|
||||
<el-icon :size="48" style="display: block; margin-bottom: 10px;"><picture /></el-icon>
|
||||
<p>{{ t('results-here') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, reactive, computed } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const t = window.t;
|
||||
document.title = 'BotanicalBaseAgent - ' + t('gf-analysis');
|
||||
// 会话管理
|
||||
const conversations = ref([
|
||||
{ id: 1, title: 'Ft.SPL基因家族分析', gene_family: 'SPL', message_count: 12 },
|
||||
{ id: 2, title: 'Os.TCP进化分析', gene_family: 'TCP', message_count: 5 },
|
||||
{ id: 3, title: 'HMMER参数配置咨询', gene_family: null, message_count: 8 },
|
||||
]);
|
||||
const currentConv = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredConversations = computed(() => {
|
||||
if (!searchQuery.value) return conversations.value;
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return conversations.value.filter(conv =>
|
||||
conv.title.toLowerCase().includes(query) ||
|
||||
(conv.gene_family && conv.gene_family.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
const createNewConversation = () => {
|
||||
const newConv = {
|
||||
id: Date.now(),
|
||||
title: t('new-chat') + ' ' + new Date().toLocaleTimeString(),
|
||||
gene_family: null,
|
||||
message_count: 0
|
||||
};
|
||||
conversations.value.unshift(newConv);
|
||||
selectConversation(newConv);
|
||||
};
|
||||
|
||||
const selectConversation = (conv) => {
|
||||
currentConv.value = conv;
|
||||
// 清空消息并添加欢迎语
|
||||
messages.value = [{
|
||||
role: 'assistant',
|
||||
content: t('initial-welcome').replace('{title}', conv.title),
|
||||
time: new Date().toLocaleTimeString()
|
||||
}];
|
||||
};
|
||||
|
||||
// 分析参数
|
||||
const selectedStage = ref('');
|
||||
const params = reactive({
|
||||
prefix: 'Ft.SPL',
|
||||
cpu: 8
|
||||
});
|
||||
|
||||
// 消息
|
||||
const messages = ref([]);
|
||||
const userInput = ref('');
|
||||
|
||||
const sendMessage = () => {
|
||||
if (!userInput.value.trim()) return;
|
||||
|
||||
// 添加用户消息
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: userInput.value,
|
||||
time: new Date().toLocaleTimeString()
|
||||
});
|
||||
|
||||
// 更新当前会话消息数
|
||||
if (currentConv.value) {
|
||||
currentConv.value.message_count++;
|
||||
}
|
||||
|
||||
// 模拟AI回复
|
||||
setTimeout(() => {
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: `Received: "${userInput.value}". I will help analyze the gene family. Confirm parameters and click "${t('execute-analysis')}".`,
|
||||
time: new Date().toLocaleTimeString()
|
||||
});
|
||||
if (currentConv.value) {
|
||||
currentConv.value.message_count++;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
userInput.value = '';
|
||||
};
|
||||
|
||||
// 分析阶段
|
||||
const stages = ref([
|
||||
{ name: 'identify', label: 'Identify', status: 'success', statusText: t('completed'), progress: 100 },
|
||||
{ name: 'phylogeny', label: 'Phylogeny', status: 'running', statusText: t('running'), progress: 60 },
|
||||
{ name: 'motif', label: 'Motif', status: 'pending', statusText: t('pending'), progress: 0 },
|
||||
{ name: 'domain', label: 'Domain', status: 'pending', statusText: t('pending'), progress: 0 },
|
||||
]);
|
||||
|
||||
const executeAnalysis = () => {
|
||||
if (!selectedStage.value) {
|
||||
ElementPlus.ElMessage.warning(t('msg-select-stage-first'));
|
||||
return;
|
||||
}
|
||||
ElementPlus.ElMessage.success(t('msg-executing') + selectedStage.value + t('msg-analysis-text'));
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
conversations,
|
||||
currentConv,
|
||||
searchQuery,
|
||||
filteredConversations,
|
||||
createNewConversation,
|
||||
selectConversation,
|
||||
selectedStage,
|
||||
params,
|
||||
messages,
|
||||
userInput,
|
||||
sendMessage,
|
||||
stages,
|
||||
executeAnalysis
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.use(ElementPlus);
|
||||
// 注册 i18n 翻译函数到 Vue 模板作用域
|
||||
app.config.globalProperties.t = window.t || ((key) => key);
|
||||
|
||||
// 注册图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component);
|
||||
}
|
||||
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+121
-8
@@ -1389,10 +1389,13 @@
|
||||
<span>名称</span><el-input v-model="extensionBuild.displayName" size="small" placeholder="例如 HMMER hmmsearch"></el-input>
|
||||
<span>原始命令</span><el-input v-model="extensionBuild.command" size="small" placeholder="例如 bash run.sh input.fa"></el-input>
|
||||
<span>环境</span>
|
||||
<el-select v-model="extensionBuild.runtimeEnv" size="small">
|
||||
<el-option label="test4py311" value="test4py311"></el-option>
|
||||
<el-option label="rnaseq" value="rnaseq"></el-option>
|
||||
<el-option label="rnaR" value="rnaR"></el-option>
|
||||
<el-select v-model="extensionBuild.runtimeEnv" size="small" filterable>
|
||||
<el-option
|
||||
v-for="env in extensionEnvOptions"
|
||||
:key="env"
|
||||
:label="env"
|
||||
:value="env">
|
||||
</el-option>
|
||||
</el-select>
|
||||
<span>原始代码</span><input type="file" multiple @change="onExtensionCodeFilesChange" style="font-size:12px; min-width:0;">
|
||||
<span v-if="extensionBuild.codeFiles.length"></span>
|
||||
@@ -1419,6 +1422,10 @@
|
||||
</el-input>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; margin-top:9px; align-items:center; justify-content:space-between; flex-wrap:nowrap;">
|
||||
<div @dragover.prevent @drop.prevent="handleLightweightExtensionFolderDrop($event)"
|
||||
style="flex:1;border:1px dashed #409EFF;padding:8px;font-size:11px;color:#606266;background:#f8fbff;margin-right:8px;">
|
||||
拖入拓展包到此自动填充
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -1777,6 +1784,20 @@
|
||||
📂 Select
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- lightWeightExtensionTest:start -->
|
||||
<div id="lightweight-extension-test-drop-zone"
|
||||
style="margin-top:8px;border:1px dashed #409EFF;padding:10px;font-size:12px;color:#606266;background:#f8fbff;"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleLightweightExtensionFolderDrop($event)">
|
||||
<div style="font-weight:600;color:#303133;">拖入固定结构拓展包</div>
|
||||
<div style="margin-top:4px;line-height:1.5;">要求包含 raw_code/run.sh 与 raw_test_data/,系统会自动创建 Job、解析环境并开始 lightweight upload。</div>
|
||||
<input id="lightweight-extension-test-folder-input" type="file" webkitdirectory directory multiple style="display:none" @change="handleLightweightExtensionFolderPick($event)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:8px;">
|
||||
<span>{{ lightweightExtensionTestStatus }}</span>
|
||||
<el-button size="small" type="success" @click="openLightweightExtensionFolderPicker">选择文件夹</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- lightWeightExtensionTest:end -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7774,6 +7795,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
|
||||
working_dir: '',
|
||||
output_dir: ''
|
||||
});
|
||||
const lightweightExtensionTestStatus = ref('拖入固定结构文件夹,系统将自动创建 Job、解析环境并开始 upload。');
|
||||
const activeRnaseqScopeId = ref('');
|
||||
const activeGenotypeScopeId = ref('');
|
||||
|
||||
@@ -9128,6 +9150,7 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
|
||||
statusDialogVisible: false,
|
||||
statusEvents: []
|
||||
});
|
||||
const extensionEnvOptions = ref(['test4py311', 'rnaseq', 'rnaR']);
|
||||
const lightweightExtensions = ref([]);
|
||||
const lightweightExtensionConversation = reactive({
|
||||
cards: [],
|
||||
@@ -19489,6 +19512,10 @@ compare: [[flesh, peel]]`;
|
||||
};
|
||||
|
||||
const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`;
|
||||
const extensionUploadPath = (file) => {
|
||||
const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, '');
|
||||
return relative || String(file?.name || '').trim();
|
||||
};
|
||||
const appendExtensionFiles = (target, files) => {
|
||||
const current = Array.isArray(target) ? target : [];
|
||||
const merged = [...current];
|
||||
@@ -19514,6 +19541,53 @@ compare: [[flesh, peel]]`;
|
||||
if (selected.length) ElMessage.success(`已选择 ${selected.length} 个测试数据文件`);
|
||||
event.target.value = '';
|
||||
};
|
||||
const handleLightweightExtensionFolderDrop = (event) => {
|
||||
const droppedFiles = Array.from(event?.dataTransfer?.files || []);
|
||||
if (!droppedFiles.length) {
|
||||
ElMessage.warning('未检测到可用文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const codeExtPattern = /\.(py|r|sh|bash|pl|js|ts|java|c|cc|cpp|go|rs|jl|rb|m|smk|nf|ipynb)$/i;
|
||||
const dataExtPattern = /\.(fastq|fq|gz|fa|fasta|fna|faa|pep|cds|gff|gff3|gtf|csv|tsv|txt|xlsx|xls|vcf|sam|bam)$/i;
|
||||
|
||||
const normalized = droppedFiles.map((file) => {
|
||||
const relativePath = file.webkitRelativePath || file.name;
|
||||
if (!file.lightweightRelativePath) {
|
||||
Object.defineProperty(file, 'lightweightRelativePath', {
|
||||
value: relativePath,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
const codeFiles = [];
|
||||
const dataFiles = [];
|
||||
normalized.forEach((file) => {
|
||||
const filePath = extensionUploadPath(file).toLowerCase();
|
||||
if (codeExtPattern.test(filePath)) {
|
||||
codeFiles.push(file);
|
||||
} else if (dataExtPattern.test(filePath)) {
|
||||
dataFiles.push(file);
|
||||
} else {
|
||||
codeFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
extensionBuild.codeFiles = appendExtensionFiles(extensionBuild.codeFiles, codeFiles);
|
||||
extensionBuild.dataFiles = appendExtensionFiles(extensionBuild.dataFiles, dataFiles);
|
||||
|
||||
if (!extensionBuild.displayName) {
|
||||
const firstPath = normalized[0]?.webkitRelativePath || normalized[0]?.name || '';
|
||||
const folder = firstPath.includes('/') ? firstPath.split('/')[0] : '';
|
||||
if (folder) {
|
||||
extensionBuild.displayName = folder.replace(/[^a-zA-Z0-9_-]+/g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success(`已拖入 ${normalized.length} 个文件,已自动填充拓展包`);
|
||||
};
|
||||
const removeExtensionFile = (kind, file) => {
|
||||
const key = extensionFileKey(file);
|
||||
if (kind === 'code') {
|
||||
@@ -19546,9 +19620,10 @@ compare: [[flesh, peel]]`;
|
||||
};
|
||||
const extensionApiUrl = (path) => {
|
||||
const value = String(path || '');
|
||||
if (!value.startsWith('/api/extension-stages') && !value.startsWith('/api/extension-stage-runs')) return value;
|
||||
if (!value.startsWith('/api/extension-stages') && !value.startsWith('/api/extension-stage-runs') && !value.startsWith('/api/lightweight-extension-stages')) return value;
|
||||
const host = window.location.hostname || '127.0.0.1';
|
||||
return `${window.location.protocol}//${host}:7880${value}`;
|
||||
const port = window.location.port;
|
||||
return port ? `${window.location.protocol}//${host}:${port}${value}` : `${window.location.protocol}//${host}${value}`;
|
||||
};
|
||||
const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options);
|
||||
const pushExtensionStatus = (message, type = 'info', showDialog = false) => {
|
||||
@@ -19909,9 +19984,10 @@ compare: [[flesh, peel]]`;
|
||||
form.append('extension_id', extensionId);
|
||||
if (hasCommand) form.append('original_command', extensionBuild.command);
|
||||
form.append('operation_token', token);
|
||||
extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file));
|
||||
extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file));
|
||||
extensionBuild.codeFiles.forEach(file => form.append('raw_code_files', file, extensionUploadPath(file)));
|
||||
extensionBuild.dataFiles.forEach(file => form.append('raw_data_files', file, extensionUploadPath(file)));
|
||||
if (hasDataPaths) form.append('raw_data_paths', extensionBuild.dataPaths);
|
||||
form.append('runtime_env', extensionBuild.runtimeEnv);
|
||||
const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form });
|
||||
if (!response.ok) throw new Error(await extensionApiError(response, '原始包可复现检查提交失败'));
|
||||
ElMessage.success('文件上传成功,已开始生成并验收');
|
||||
@@ -20156,6 +20232,18 @@ compare: [[flesh, peel]]`;
|
||||
console.warn('[lightweight extension] list failed', err);
|
||||
}
|
||||
};
|
||||
const loadExtensionEnvOptions = async () => {
|
||||
try {
|
||||
const response = await extensionApiFetch('/api/extension-stages/envs');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.success && Array.isArray(data.envs) && data.envs.length) {
|
||||
extensionEnvOptions.value = data.envs;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[extension env options] load failed', err);
|
||||
}
|
||||
};
|
||||
const answerLightweightExtensionHelp = async (message) => {
|
||||
const query = String(message || '');
|
||||
const matched = (lightweightExtensions.value || []).find(item => query.includes(item.extension_id));
|
||||
@@ -23388,6 +23476,7 @@ compare: [[flesh, peel]]`;
|
||||
startRuntimeMonitor();
|
||||
loadExtensionStages();
|
||||
loadLightweightExtensionStages();
|
||||
loadExtensionEnvOptions();
|
||||
resumeLightweightOperation();
|
||||
loadExtensionRuns();
|
||||
loadMethodologyJobs();
|
||||
@@ -23718,6 +23807,7 @@ compare: [[flesh, peel]]`;
|
||||
intentHistory,
|
||||
uploadedFiles,
|
||||
extensionBuild,
|
||||
extensionEnvOptions,
|
||||
extensionStages,
|
||||
extensionCompletedStages,
|
||||
lightweightExtensionConversation,
|
||||
@@ -23736,6 +23826,7 @@ compare: [[flesh, peel]]`;
|
||||
extensionFileKey,
|
||||
onExtensionCodeFilesChange,
|
||||
onExtensionDataFilesChange,
|
||||
handleLightweightExtensionFolderDrop,
|
||||
removeExtensionFile,
|
||||
buildExtensionStage,
|
||||
confirmLightweightCandidate,
|
||||
@@ -23755,6 +23846,7 @@ compare: [[flesh, peel]]`;
|
||||
validateExtensionStage,
|
||||
publishExtensionStage,
|
||||
loadExtensionStages,
|
||||
loadExtensionEnvOptions,
|
||||
loadExtensionRuns,
|
||||
runExtensionStage,
|
||||
runExtensionStageFromIntent,
|
||||
@@ -23874,6 +23966,24 @@ compare: [[flesh, peel]]`;
|
||||
openPreview,
|
||||
redrawImage,
|
||||
createNewJob,
|
||||
lightweightExtensionTestStatus,
|
||||
handleLightweightExtensionFolderDrop: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handleDrop({
|
||||
jobInfo,
|
||||
extensionBuild,
|
||||
createNewJob,
|
||||
appendExtensionFiles,
|
||||
buildLightweightExtensionStage,
|
||||
lightweightExtensionTestStatus,
|
||||
}, event),
|
||||
handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({
|
||||
jobInfo,
|
||||
extensionBuild,
|
||||
createNewJob,
|
||||
appendExtensionFiles,
|
||||
buildLightweightExtensionStage,
|
||||
lightweightExtensionTestStatus,
|
||||
}, event),
|
||||
openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(),
|
||||
// Working DirectorySelect
|
||||
existingJobs,
|
||||
jobSelectorVisible,
|
||||
@@ -23990,6 +24100,9 @@ compare: [[flesh, peel]]`;
|
||||
document.getElementById('app').style.display = 'block';
|
||||
console.log('App mounted successfully');
|
||||
</script>
|
||||
<!-- lightWeightExtensionTest:wiring:start -->
|
||||
<script src="/lightWeightExtensionTest.js"></script>
|
||||
<!-- lightWeightExtensionTest:wiring:end -->
|
||||
<!-- 深度解读弹窗样式 -->
|
||||
<style>
|
||||
.interpretation-dialog .el-dialog__body { padding: 0; max-height: 70vh; overflow-y: auto; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+712
@@ -0,0 +1,712 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Knowledge Graph</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.full.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<script src="/static/i18n.js"></script>
|
||||
<style>
|
||||
body { margin: 0; font-family: 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.header h1 { margin: 0; font-size: 22px; }
|
||||
.header .subtitle { font-size: 12px; opacity: 0.8; margin-top: 4px; }
|
||||
.main-container { display: flex; padding: 15px; gap: 15px; height: calc(100vh - 70px); }
|
||||
.left-panel { width: 300px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.control-section { background: white; border-radius: 8px; padding: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.control-section h4 { margin: 0 0 10px 0; color: #409EFF; font-size: 14px; border-bottom: 2px solid #409EFF; padding-bottom: 8px; }
|
||||
.stat-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||
.stat-item:last-child { border-bottom: none; }
|
||||
.stat-label { color: #666; }
|
||||
.stat-value { font-weight: bold; color: #409EFF; }
|
||||
.filter-section { margin-top: 10px; }
|
||||
.filter-tag { margin: 4px; cursor: pointer; }
|
||||
.filter-tag.active { background: #409EFF; color: white; }
|
||||
.main-panel { flex: 1; display: flex; flex-direction: column; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
|
||||
.toolbar { padding: 10px 15px; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 10px; }
|
||||
#graph-container { flex: 1; position: relative; }
|
||||
.right-panel { width: 350px; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 15px; overflow-y: auto; }
|
||||
.right-panel h4 { margin: 0 0 10px 0; color: #67C23A; border-bottom: 2px solid #67C23A; padding-bottom: 8px; }
|
||||
.node-detail { padding: 10px; background: #f5f7fa; border-radius: 6px; margin-top: 10px; }
|
||||
.node-detail .label { color: #666; font-size: 12px; margin-bottom: 4px; }
|
||||
.node-detail .value { font-weight: 500; color: #333; }
|
||||
.relation-list { margin-top: 10px; }
|
||||
.relation-item { padding: 8px; background: #f0f9eb; border-radius: 4px; margin-bottom: 8px; font-size: 13px; }
|
||||
.relation-item .source { color: #409EFF; font-weight: 500; }
|
||||
.relation-item .rel { color: #67C23A; margin: 0 4px; }
|
||||
.relation-item .target { color: #E6A23C; font-weight: 500; }
|
||||
.search-box { margin-bottom: 10px; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; }
|
||||
.legend-color { width: 12px; height: 12px; border-radius: 50%; }
|
||||
.legend-symbol { width: 12px; height: 12px; border-radius: 2px; }
|
||||
.legend-line { width: 20px; height: 2px; }
|
||||
|
||||
/* PC语义分类样式 */
|
||||
.pc-category-header {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
margin: 8px 0 4px 0;
|
||||
padding-left: 4px;
|
||||
border-left: 3px solid #409EFF;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>{{ t('kg-visualization') }}</h1>
|
||||
<div class="subtitle">{{ t('kg-subtitle') }}</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="goBack">
|
||||
<el-icon><arrow-left /></el-icon> {{ t('back-to-main') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 左侧面板 -->
|
||||
<div class="left-panel">
|
||||
<div class="control-section">
|
||||
<h4>{{ t('kg-stats') }}</h4>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('node-count') }}</span>
|
||||
<span class="stat-value">{{ stats.nodes }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('edge-count') }}</span>
|
||||
<span class="stat-value">{{ stats.edges }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('node-types') }}</span>
|
||||
<span class="stat-value">{{ stats.nodeTypes }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('edge-types') }}</span>
|
||||
<span class="stat-value">{{ stats.edgeTypes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>{{ t('node-search') }}</h4>
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('enter-gene-name')"
|
||||
clearable
|
||||
@keyup.enter="searchNode"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="searchNode">
|
||||
<el-icon><search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>{{ t('relation-filter-pc') }}</h4>
|
||||
<div class="filter-section">
|
||||
<div v-for="(types, category) in relationTypesByCategory" :key="category" style="margin-bottom: 10px;">
|
||||
<div style="font-size: 12px; color: #666; margin-bottom: 5px; font-weight: bold;">
|
||||
{{ category === 'activation' ? t('activation-induction') :
|
||||
category === 'repression' ? t('repression-inhibition') :
|
||||
category === 'regulation' ? t('regulation-control') :
|
||||
category === 'expression' ? t('expression-detection') :
|
||||
category === 'association' ? t('association-interaction') :
|
||||
category === 'localization' ? t('localization') :
|
||||
category === 'containment' ? t('containment-composition') :
|
||||
category === 'participation' ? t('participation-function') : t('other-rel') }}
|
||||
</div>
|
||||
<el-tag
|
||||
v-for="type in types"
|
||||
:key="type"
|
||||
class="filter-tag"
|
||||
:class="{ active: selectedRelations.includes(type) }"
|
||||
@click="toggleRelationFilter(type)"
|
||||
size="small"
|
||||
:style="{ borderColor: relationEdgeStyles[category].color, color: selectedRelations.includes(type) ? '#fff' : relationEdgeStyles[category].color }"
|
||||
>
|
||||
{{ type }} ({{ relationCounts[type] }})
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>{{ t('node-legend-pc') }}</h4>
|
||||
<div class="pc-category-header">Gene/Protein</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item" v-for="type in ['gene', 'gene_id', 'gene_group', 'gene_family']" :key="type">
|
||||
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
|
||||
<span>{{ nodeTypeLabels[type] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-category-header">Phenotype</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item" v-for="type in ['phenotype', 'attribute']" :key="type">
|
||||
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
|
||||
<span>{{ nodeTypeLabels[type] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-category-header">Cell/Organ/Organism</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item" v-for="type in ['tissue', 'species']" :key="type">
|
||||
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
|
||||
<span>{{ nodeTypeLabels[type] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-category-header">Biological Process</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item" v-for="type in ['process', 'time', 'condition', 'stress']" :key="type">
|
||||
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
|
||||
<span>{{ nodeTypeLabels[type] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pc-category-header">Genomic</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item" v-for="type in ['location', 'sra_dataset', 'data_type', 'count']" :key="type">
|
||||
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
|
||||
<span>{{ nodeTypeLabels[type] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 style="margin-top: 15px;">🔗 {{ t('edge-legend-pc') }}</h4>
|
||||
<div class="legend">
|
||||
<div class="legend-item" v-for="(style, cat) in relationEdgeStyles" :key="cat">
|
||||
<div class="legend-line" :style="{ background: style.color, height: style.width + 'px', borderTop: style.type === 'dashed' ? '2px dashed ' + style.color : 'none' }"></div>
|
||||
<span>{{ cat === 'activation' ? 'Activation/Induction' :
|
||||
cat === 'repression' ? 'Repression/Inhibition' :
|
||||
cat === 'regulation' ? 'Regulation/Control' :
|
||||
cat === 'expression' ? 'Expression/Detection' :
|
||||
cat === 'association' ? 'Association/Interaction' :
|
||||
cat === 'localization' ? 'Localization' :
|
||||
cat === 'containment' ? 'Containment/Composition' :
|
||||
cat === 'participation' ? 'Participation/Function' : 'Other' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主面板 -->
|
||||
<div class="main-panel">
|
||||
<div class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button @click="fitGraph">
|
||||
<el-icon><full-screen /></el-icon> {{ t('fit-view') }}
|
||||
</el-button>
|
||||
<el-button @click="resetFilters">
|
||||
<el-icon><refresh /></el-icon> {{ t('reset-filters') }}
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
<el-divider direction="vertical"></el-divider>
|
||||
<span style="color: #666; font-size: 13px;">
|
||||
{{ t('click-drag-hint') }}
|
||||
</span>
|
||||
</div>
|
||||
<div id="graph-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板 -->
|
||||
<div class="right-panel">
|
||||
<h4>{{ t('node-detail') }}</h4>
|
||||
<div v-if="selectedNode" class="node-detail">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div class="label">{{ t('node-name') }}</div>
|
||||
<div class="value" style="font-size: 18px; color: #409EFF;">{{ selectedNode.name }}</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div class="label">{{ t('node-type-pc') }}</div>
|
||||
<div class="value">
|
||||
<el-tag :type="getNodeTypeTag(selectedNode.category)" :style="{ backgroundColor: nodeColors[selectedNode.category] + '20', borderColor: nodeColors[selectedNode.category], color: nodeColors[selectedNode.category] }">
|
||||
{{ nodeTypeLabels[selectedNode.category] || selectedNode.category }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">{{ t('connection-count') }}</div>
|
||||
<div class="value">{{ selectedNode.degree }}{{ t('num-connections') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="color: #999; text-align: center; padding: 40px;">
|
||||
{{ t('click-node-detail') }}
|
||||
</div>
|
||||
|
||||
<template v-if="selectedNodeRelations.length > 0">
|
||||
<h4 style="margin-top: 20px;">🔗 {{ t('related-relations') }} ({{ selectedNodeRelations.length }})</h4>
|
||||
<div class="relation-list">
|
||||
<div
|
||||
v-for="rel in selectedNodeRelations.slice(0, 10)"
|
||||
:key="rel.id"
|
||||
class="relation-item"
|
||||
:style="{ borderLeft: '3px solid ' + relationEdgeStyles[rel.category || relationCategories[rel.name] || 'other'].color }"
|
||||
>
|
||||
<span class="source">{{ rel.source }}</span>
|
||||
<span class="rel" :style="{ color: relationEdgeStyles[rel.category || relationCategories[rel.name] || 'other'].color }">--{{ rel.name }}--></span>
|
||||
<span class="target">{{ rel.target }}</span>
|
||||
</div>
|
||||
<div v-if="selectedNodeRelations.length > 10" style="text-align: center; color: #999; padding: 10px;">
|
||||
{{ tp('more-relations', { count: selectedNodeRelations.length - 10 }) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, onMounted, computed } = Vue;
|
||||
const { ElMessage } = ElementPlus;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const t = window.t || ((key) => key);
|
||||
const tp = window.tp || ((key, params) => {
|
||||
let s = t(key);
|
||||
if (params) Object.keys(params).forEach(k => { s = s.replace('{' + k + '}', params[k]); });
|
||||
return s;
|
||||
});
|
||||
document.title = t('kg-title');
|
||||
const graphData = ref({ nodes: [], edges: [] });
|
||||
const chart = ref(null);
|
||||
const searchQuery = ref('');
|
||||
const selectedNode = ref(null);
|
||||
const selectedRelations = ref([]);
|
||||
|
||||
// PC (Pathway Commons) 语义分类颜色映射
|
||||
const nodeColors = {
|
||||
// Gene/Protein - 蓝色
|
||||
'gene': '#5DADE2',
|
||||
'gene_id': '#85C1E9',
|
||||
'gene_group': '#AED6F1',
|
||||
'gene_family': '#5DADE2',
|
||||
|
||||
// Phenotype - 绿色
|
||||
'phenotype': '#58D68D',
|
||||
'attribute': '#82E0AA',
|
||||
|
||||
// Cell/Organ/Organism - 粉色
|
||||
'tissue': '#F5B7B1',
|
||||
'species': '#F1948A',
|
||||
|
||||
// Process - 紫色菱形
|
||||
'process': '#AF7AC5',
|
||||
'time': '#C39BD3',
|
||||
'condition': '#D7BDE2',
|
||||
'stress': '#E8DAEF',
|
||||
|
||||
// Location - 浅绿色
|
||||
'location': '#82E0AA',
|
||||
|
||||
// Data - 橙色(更显眼)
|
||||
'sra_dataset': '#E67E22',
|
||||
'data_type': '#F39C12',
|
||||
'count': '#EAEDED',
|
||||
|
||||
'unknown': '#95A5A6'
|
||||
,
|
||||
'article': '#2ecc71',
|
||||
'paper_title': '#9b59b6'
|
||||
};
|
||||
|
||||
// PC 语义分类显示名称
|
||||
const nodeTypeLabels = {
|
||||
'gene': 'Gene/Protein',
|
||||
'gene_id': 'Gene Identifier',
|
||||
'gene_group': 'Gene Group',
|
||||
'gene_family': 'Gene Family',
|
||||
'phenotype': 'Phenotype',
|
||||
'attribute': 'Attribute',
|
||||
'tissue': 'Cell/Organ/Tissue',
|
||||
'species': 'Organism',
|
||||
'process': 'Biological Process',
|
||||
'time': 'Time/Stage',
|
||||
'condition': 'Condition',
|
||||
'stress': 'Stress',
|
||||
'location': 'Genomic Location',
|
||||
'sra_dataset': 'SRA Dataset',
|
||||
'geo_dataset': 'GEO Dataset',
|
||||
'data_type': 'Data Type',
|
||||
'count': 'Count/Statistic',
|
||||
'article': 'Article (PMID)',
|
||||
'paper_title': 'Paper Title',
|
||||
'unknown': 'Other'
|
||||
};
|
||||
|
||||
// PC 关系类型分类
|
||||
const relationCategories = {
|
||||
// Activation/Induction - 绿色箭头
|
||||
'induces': 'activation',
|
||||
'promote': 'activation',
|
||||
'overexpression_increases': 'activation',
|
||||
'enhanced_tolerance_to': 'activation',
|
||||
|
||||
// Repression/Inhibition - 红色T型
|
||||
'down_regulated_during': 'repression',
|
||||
'down_regulation_improves': 'repression',
|
||||
|
||||
// Regulation - 蓝色
|
||||
'regulate': 'regulation',
|
||||
'regulates': 'regulation',
|
||||
'regulating': 'regulation',
|
||||
|
||||
// Expression - 橙色
|
||||
'expressed_in': 'expression',
|
||||
'expressed_during': 'expression',
|
||||
'was_expressed_during': 'expression',
|
||||
'expression_reached_peak_at': 'expression',
|
||||
|
||||
// Association - 紫色
|
||||
'collinear_with': 'association',
|
||||
'was_highly_orthologous_to': 'association',
|
||||
'related_to': 'association',
|
||||
'affect': 'association',
|
||||
|
||||
// Localization - 深绿
|
||||
'located_on': 'localization',
|
||||
'identified_in': 'localization',
|
||||
|
||||
// Containment - 青色
|
||||
'contain': 'containment',
|
||||
'had': 'containment',
|
||||
'has_gene_id': 'containment',
|
||||
'has_data_type': 'containment',
|
||||
|
||||
// Participation - 深红
|
||||
'involved_in': 'participation',
|
||||
'participate_in': 'participation',
|
||||
|
||||
'default': 'other'
|
||||
};
|
||||
|
||||
const relationEdgeStyles = {
|
||||
'activation': { color: '#27AE60', width: 2, type: 'solid' },
|
||||
'repression': { color: '#E74C3C', width: 2, type: 'solid' },
|
||||
'regulation': { color: '#3498DB', width: 2, type: 'solid' },
|
||||
'expression': { color: '#E67E22', width: 1.5, type: 'solid' },
|
||||
'association': { color: '#9B59B6', width: 1.5, type: 'dashed' },
|
||||
'localization': { color: '#16A085', width: 1.5, type: 'solid' },
|
||||
'containment': { color: '#1ABC9C', width: 1.5, type: 'solid' },
|
||||
'participation': { color: '#C0392B', width: 2, type: 'solid' },
|
||||
'other': { color: '#95A5A6', width: 1, type: 'solid' }
|
||||
};
|
||||
|
||||
const stats = computed(() => ({
|
||||
nodes: graphData.value.nodes.length,
|
||||
edges: graphData.value.edges.length,
|
||||
nodeTypes: Object.keys(nodeColors).length,
|
||||
edgeTypes: relationTypes.value.length
|
||||
}));
|
||||
|
||||
// 按 PC 语义分类的关系类型
|
||||
const relationTypesByCategory = computed(() => {
|
||||
const cats = {};
|
||||
graphData.value.edges.forEach(e => {
|
||||
const cat = relationCategories[e.relation] || 'other';
|
||||
if (!cats[cat]) cats[cat] = [];
|
||||
cats[cat].push(e.relation);
|
||||
});
|
||||
// 去重并排序
|
||||
for (const cat in cats) {
|
||||
cats[cat] = [...new Set(cats[cat])].sort();
|
||||
}
|
||||
return cats;
|
||||
});
|
||||
|
||||
const relationTypes = computed(() => {
|
||||
const types = new Set();
|
||||
graphData.value.edges.forEach(e => types.add(e.relation));
|
||||
return Array.from(types).sort();
|
||||
});
|
||||
|
||||
const relationCounts = computed(() => {
|
||||
const counts = {};
|
||||
graphData.value.edges.forEach(e => {
|
||||
counts[e.relation] = (counts[e.relation] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
});
|
||||
|
||||
const selectedNodeRelations = computed(() => {
|
||||
if (!selectedNode.value) return [];
|
||||
return graphData.value.edges.filter(e =>
|
||||
e.source === selectedNode.value.id || e.target === selectedNode.value.id
|
||||
);
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 优先加载包含多组学数据的知识图谱
|
||||
let response = await fetch('knowledge_graph_with_omics.json');
|
||||
if (!response.ok) {
|
||||
// 回退到标准知识图谱
|
||||
response = await fetch('knowledge_graph.json');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 验证数据结构
|
||||
if (!data.nodes || !Array.isArray(data.nodes)) {
|
||||
throw new Error(t('err-data-format'));
|
||||
}
|
||||
if (!data.edges || !Array.isArray(data.edges)) {
|
||||
console.warn(t('warn-edges-undefined'));
|
||||
data.edges = [];
|
||||
}
|
||||
|
||||
// 计算节点度数
|
||||
const nodeDegrees = {};
|
||||
data.edges.forEach(e => {
|
||||
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
|
||||
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
|
||||
});
|
||||
|
||||
// 根据 PC 语义分类设置节点形状
|
||||
const getNodeSymbol = (type) => {
|
||||
const symbolMap = {
|
||||
'gene': 'circle',
|
||||
'gene_id': 'circle',
|
||||
'gene_group': 'circle',
|
||||
'gene_family': 'circle',
|
||||
'phenotype': 'rect',
|
||||
'attribute': 'rect',
|
||||
'tissue': 'roundRect',
|
||||
'species': 'roundRect',
|
||||
'process': 'diamond',
|
||||
'time': 'diamond',
|
||||
'condition': 'diamond',
|
||||
'stress': 'diamond',
|
||||
'location': 'triangle',
|
||||
'sra_dataset': 'pin',
|
||||
'data_type': 'pin',
|
||||
'count': 'pin'
|
||||
};
|
||||
return symbolMap[type] || 'circle';
|
||||
};
|
||||
|
||||
graphData.value = {
|
||||
nodes: data.nodes.map(n => ({
|
||||
id: n.id,
|
||||
name: n.id,
|
||||
category: n.type || 'unknown',
|
||||
degree: nodeDegrees[n.id] || 0,
|
||||
symbol: getNodeSymbol(n.type),
|
||||
symbolSize: (n.type === 'gene' ? 20 : n.type === 'sra_dataset' ? 18 : 12) + Math.min((nodeDegrees[n.id] || 0) * 0.3, 15),
|
||||
itemStyle: {
|
||||
color: nodeColors[n.type] || nodeColors.unknown,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1
|
||||
}
|
||||
})),
|
||||
edges: data.edges.map((e, i) => {
|
||||
const cat = relationCategories[e.relation] || 'other';
|
||||
const style = relationEdgeStyles[cat];
|
||||
return {
|
||||
id: i,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
name: e.relation,
|
||||
relation: e.relation,
|
||||
category: cat,
|
||||
lineStyle: {
|
||||
color: style.color,
|
||||
width: style.width,
|
||||
type: style.type,
|
||||
curveness: 0.1
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
initChart();
|
||||
ElMessage.success(t('msg-kg-loaded'));
|
||||
} catch (error) {
|
||||
ElMessage.error(t('msg-load-data-failed') + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const initChart = () => {
|
||||
const container = document.getElementById('graph-container');
|
||||
chart.value = echarts.init(container);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
formatter: params => {
|
||||
if (params.dataType === 'node') {
|
||||
const pcType = nodeTypeLabels[params.data.category] || params.data.category;
|
||||
return `<b>${params.name}</b><br/>PC: ${pcType}<br/>${t('connection-count').replace('Connections', 'Deg')}: ${params.data.degree}`;
|
||||
}
|
||||
const cat = params.data.category || relationCategories[params.name] || 'other';
|
||||
const catName = cat === 'activation' ? 'Activation' :
|
||||
cat === 'repression' ? 'Repression' :
|
||||
cat === 'regulation' ? 'Regulation' :
|
||||
cat === 'expression' ? 'Expression' :
|
||||
cat === 'association' ? 'Association' :
|
||||
cat === 'localization' ? 'Localization' :
|
||||
cat === 'containment' ? 'Containment' :
|
||||
cat === 'participation' ? 'Participation' : 'Other';
|
||||
return `<b>${params.data.source}</b> <span style="color:${relationEdgeStyles[cat].color}">--${params.name}--></span> <b>${params.data.target}</b><br/><small>PC: ${catName}</small>`;
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
data: graphData.value.nodes,
|
||||
links: graphData.value.edges,
|
||||
roam: true,
|
||||
draggable: true,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: '{b}'
|
||||
},
|
||||
force: {
|
||||
repulsion: 300,
|
||||
gravity: 0.1,
|
||||
edgeLength: 100,
|
||||
layoutAnimation: true
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: { width: 4 }
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
chart.value.setOption(option);
|
||||
|
||||
chart.value.on('click', params => {
|
||||
if (params.dataType === 'node') {
|
||||
selectedNode.value = params.data;
|
||||
} else {
|
||||
selectedNode.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => chart.value.resize());
|
||||
};
|
||||
|
||||
const searchNode = () => {
|
||||
if (!searchQuery.value || !chart.value) return;
|
||||
|
||||
const node = graphData.value.nodes.find(n =>
|
||||
n.id.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
|
||||
if (node) {
|
||||
chart.value.dispatchAction({
|
||||
type: 'highlight',
|
||||
seriesIndex: 0,
|
||||
name: node.id
|
||||
});
|
||||
selectedNode.value = node;
|
||||
ElMessage.success(t('msg-node-found') + node.name);
|
||||
} else {
|
||||
ElMessage.warning(t('msg-node-not-found'));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRelationFilter = (type) => {
|
||||
const index = selectedRelations.value.indexOf(type);
|
||||
if (index > -1) {
|
||||
selectedRelations.value.splice(index, 1);
|
||||
} else {
|
||||
selectedRelations.value.push(type);
|
||||
}
|
||||
|
||||
if (selectedRelations.value.length === 0) {
|
||||
chart.value.setOption({
|
||||
series: [{ data: graphData.value.nodes, links: graphData.value.edges }]
|
||||
});
|
||||
} else {
|
||||
const filteredEdges = graphData.value.edges.filter(e =>
|
||||
selectedRelations.value.includes(e.relation)
|
||||
);
|
||||
const connectedNodes = new Set();
|
||||
filteredEdges.forEach(e => {
|
||||
connectedNodes.add(e.source);
|
||||
connectedNodes.add(e.target);
|
||||
});
|
||||
const filteredNodes = graphData.value.nodes.filter(n =>
|
||||
connectedNodes.has(n.id)
|
||||
);
|
||||
|
||||
chart.value.setOption({
|
||||
series: [{ data: filteredNodes, links: filteredEdges }]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fitGraph = () => {
|
||||
if (chart.value) {
|
||||
chart.value.dispatchAction({ type: 'restore' });
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
selectedRelations.value = [];
|
||||
selectedNode.value = null;
|
||||
searchQuery.value = '';
|
||||
if (chart.value) {
|
||||
chart.value.setOption({
|
||||
series: [{ data: graphData.value.nodes, links: graphData.value.edges }]
|
||||
});
|
||||
chart.value.dispatchAction({ type: 'restore' });
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeTypeTag = (type) => {
|
||||
const typeMap = { 'gene': '', 'gene_id': 'success', 'tissue': 'warning', 'location': 'info' };
|
||||
return typeMap[type] || '';
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData().then(() => {
|
||||
// 检查URL参数,如果有highlight则自动搜索高亮
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const highlightEntity = urlParams.get('highlight');
|
||||
if (highlightEntity) {
|
||||
searchQuery.value = highlightEntity;
|
||||
setTimeout(() => {
|
||||
searchNode();
|
||||
}, 500); // 等待图表初始化完成
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
t,
|
||||
tp,
|
||||
stats,
|
||||
searchQuery,
|
||||
selectedNode,
|
||||
selectedRelations,
|
||||
relationTypes,
|
||||
relationTypesByCategory,
|
||||
relationCounts,
|
||||
relationEdgeStyles,
|
||||
relationCategories,
|
||||
nodeTypeLabels,
|
||||
selectedNodeRelations,
|
||||
nodeColors,
|
||||
searchNode,
|
||||
toggleRelationFilter,
|
||||
fitGraph,
|
||||
resetFilters,
|
||||
getNodeTypeTag,
|
||||
goBack
|
||||
};
|
||||
}
|
||||
}).use(ElementPlus).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,304 @@
|
||||
(function () {
|
||||
const PY_EXTENSIONS = new Set(['.py']);
|
||||
const R_EXTENSIONS = new Set(['.r']);
|
||||
|
||||
function normalizedRelativePath(file) {
|
||||
const relative = String(file.webkitRelativePath || file.relativePath || file.name || '').replace(/^\/+/, '');
|
||||
return relative.split('\\').join('/');
|
||||
}
|
||||
|
||||
function parsePythonImports(content) {
|
||||
const imports = new Set();
|
||||
const lines = String(content || '').split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
let match = line.match(/^\s*import\s+([A-Za-z0-9_.,\s]+)/);
|
||||
if (match) {
|
||||
match[1].split(',').forEach((item) => {
|
||||
const token = item.trim().split(/\s+as\s+/i)[0].split('.')[0].trim();
|
||||
if (token) imports.add(token);
|
||||
});
|
||||
}
|
||||
match = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\s+/);
|
||||
if (match) {
|
||||
const token = match[1].split('.')[0].trim();
|
||||
if (token) imports.add(token);
|
||||
}
|
||||
}
|
||||
return Array.from(imports);
|
||||
}
|
||||
|
||||
function parseRPackages(content) {
|
||||
const packages = new Set();
|
||||
const pattern = /(?:library|require)\(\s*["']?([A-Za-z0-9_.-]+)["']?\s*\)/g;
|
||||
let match;
|
||||
const text = String(content || '');
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match[1]) packages.add(match[1]);
|
||||
}
|
||||
return Array.from(packages);
|
||||
}
|
||||
|
||||
function commonPathPrefix(files) {
|
||||
const paths = files.map((f) => normalizedRelativePath(f).split('/')).filter((parts) => parts.length > 0);
|
||||
if (!paths.length) return '';
|
||||
const first = paths[0];
|
||||
let prefixLen = 0;
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
if (!paths.every((parts) => parts[i] === first[i])) break;
|
||||
prefixLen = i + 1;
|
||||
}
|
||||
return first.slice(0, prefixLen).join('/');
|
||||
}
|
||||
|
||||
function sanitizeDisplayName(raw) {
|
||||
return String(raw || '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'lightweight_extension';
|
||||
}
|
||||
|
||||
function extractRootName(files) {
|
||||
const prefix = commonPathPrefix(files);
|
||||
const base = (prefix && prefix !== 'raw_code' && prefix !== 'raw_test_data')
|
||||
? sanitizeDisplayName(prefix.split('/')[0])
|
||||
: 'lightweight_extension_drop';
|
||||
const ts = Date.now();
|
||||
return base + '_' + ts;
|
||||
}
|
||||
|
||||
function splitFixedStructure(files) {
|
||||
const codeFiles = [];
|
||||
const dataFiles = [];
|
||||
let hasRunScript = false;
|
||||
let prefix = commonPathPrefix(files);
|
||||
if (prefix && !prefix.endsWith('/')) prefix += '/';
|
||||
|
||||
const sampled = [];
|
||||
for (const file of files) {
|
||||
let relativePath = normalizedRelativePath(file);
|
||||
if (prefix && relativePath.startsWith(prefix)) {
|
||||
relativePath = relativePath.slice(prefix.length);
|
||||
}
|
||||
if (sampled.length < 10) sampled.push(relativePath);
|
||||
if (relativePath.startsWith('raw_code/')) {
|
||||
file.lightweightRelativePath = relativePath.slice('raw_code/'.length) || file.name;
|
||||
codeFiles.push(file);
|
||||
if (relativePath === 'raw_code/run.sh') {
|
||||
hasRunScript = true;
|
||||
}
|
||||
} else if (relativePath.startsWith('raw_test_data/')) {
|
||||
file.lightweightRelativePath = relativePath.slice('raw_test_data/'.length) || file.name;
|
||||
dataFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (!codeFiles.length) {
|
||||
const preview = sampled.length ? sampled.slice(0, 10).join(', ') : '(无)';
|
||||
throw new Error('缺少 raw_code/ 目录(检测到的路径样例: ' + preview + ')');
|
||||
}
|
||||
if (!dataFiles.length) {
|
||||
throw new Error('缺少 raw_test_data/ 目录');
|
||||
}
|
||||
if (!hasRunScript) {
|
||||
throw new Error('缺少 raw_code/run.sh');
|
||||
}
|
||||
return { codeFiles, dataFiles };
|
||||
}
|
||||
|
||||
async function extractDependencies(files) {
|
||||
const pythonImports = new Set();
|
||||
const rPackages = new Set();
|
||||
for (const file of files) {
|
||||
const path = normalizedRelativePath(file);
|
||||
const lower = path.toLowerCase();
|
||||
const extension = lower.includes('.') ? lower.slice(lower.lastIndexOf('.')) : '';
|
||||
if (!PY_EXTENSIONS.has(extension) && !R_EXTENSIONS.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
const content = await file.text();
|
||||
if (PY_EXTENSIONS.has(extension)) {
|
||||
parsePythonImports(content).forEach((name) => pythonImports.add(name));
|
||||
} else {
|
||||
parseRPackages(content).forEach((name) => rPackages.add(name));
|
||||
}
|
||||
}
|
||||
return {
|
||||
python_imports: Array.from(pythonImports),
|
||||
r_packages: Array.from(rPackages),
|
||||
};
|
||||
}
|
||||
|
||||
async function encodeBase64(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function importFolderToJob(hooks, rootName, files) {
|
||||
const payload = {
|
||||
job_id: String(hooks.jobInfo.job_id || '').trim(),
|
||||
root_name: rootName,
|
||||
files: [],
|
||||
relative_paths: [],
|
||||
};
|
||||
for (const file of files) {
|
||||
payload.files.push(await encodeBase64(file));
|
||||
payload.relative_paths.push(normalizedRelativePath(file));
|
||||
}
|
||||
const response = await fetch('/api/job/import-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.detail || data.error || data.message || 'Job 导入失败');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function resolveRuntimeEnv(files) {
|
||||
const dependencies = await extractDependencies(files);
|
||||
console.log('[lightWeightExtensionTest] 提取的依赖:', JSON.stringify(dependencies), '| 扫描文件数:', files.length);
|
||||
try {
|
||||
const response = await fetch('/api/extension-stages/envs/resolve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dependencies),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('[lightWeightExtensionTest] 后端 env/resolve 原始响应:', response.status, JSON.stringify(data).slice(0, 500));
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.detail || data.error || data.message || '运行环境解析失败');
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('[lightWeightExtensionTest] env/resolve 调用失败:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyError(message) {
|
||||
if (window.ElementPlus && window.ElementPlus.ElMessage) {
|
||||
window.ElementPlus.ElMessage.error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureJob(hooks) {
|
||||
if (String(hooks.jobInfo.job_id || '').trim() && String(hooks.jobInfo.job_id) !== 'default_job') {
|
||||
return;
|
||||
}
|
||||
await hooks.createNewJob();
|
||||
if (!String(hooks.jobInfo.job_id || '').trim() || String(hooks.jobInfo.job_id) === 'default_job') {
|
||||
throw new Error('自动创建 Job 失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function processFolderFiles(hooks, inputFiles) {
|
||||
const files = Array.from(inputFiles || []).filter((file) => normalizedRelativePath(file));
|
||||
if (!files.length) {
|
||||
throw new Error('未读取到任何文件');
|
||||
}
|
||||
hooks.lightweightExtensionTestStatus.value = '正在校验目录结构...';
|
||||
const rootName = extractRootName(files);
|
||||
const fixed = splitFixedStructure(files);
|
||||
hooks.lightweightExtensionTestStatus.value = '正在确保 Job 可用...';
|
||||
await ensureJob(hooks);
|
||||
hooks.lightweightExtensionTestStatus.value = '正在解析运行环境...';
|
||||
const envResult = await resolveRuntimeEnv(files);
|
||||
hooks.extensionBuild.runtimeEnv = envResult.runtime_env || 'test4py311';
|
||||
console.log(
|
||||
'[lightWeightExtensionTest] ✓ 选定环境: ' + hooks.extensionBuild.runtimeEnv,
|
||||
'| 原因: ' + (envResult.reason || 'unknown'),
|
||||
'| 兜底: ' + (envResult.fallback ? '是' : '否'),
|
||||
envResult.matches ? '| 候选: ' + JSON.stringify(envResult.matches.map(m => m.env + '(py:' + (m.python_hits || []).join(',') + ' r:' + (m.r_hits || []).join(',') + ')')) : '',
|
||||
);
|
||||
hooks.lightweightExtensionTestStatus.value = '正在镜像导入当前 Job...';
|
||||
await importFolderToJob(hooks, rootName, files);
|
||||
hooks.extensionBuild.displayName = rootName;
|
||||
hooks.extensionBuild.command = 'bash run.sh';
|
||||
hooks.extensionBuild.codeFiles = hooks.appendExtensionFiles([], fixed.codeFiles);
|
||||
hooks.extensionBuild.dataFiles = hooks.appendExtensionFiles([], fixed.dataFiles);
|
||||
hooks.extensionBuild.dataPaths = '';
|
||||
hooks.lightweightExtensionTestStatus.value = `运行环境 ${hooks.extensionBuild.runtimeEnv} 已确定,正在触发 upload...`;
|
||||
await hooks.buildLightweightExtensionStage();
|
||||
hooks.lightweightExtensionTestStatus.value = '已开始 upload,系统正在自动轮询并等待候选卡。';
|
||||
}
|
||||
|
||||
async function readEntry(entry, basePath) {
|
||||
if (entry.isFile) {
|
||||
return new Promise((resolve) => {
|
||||
entry.file((file) => {
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: basePath ? basePath + '/' + entry.name : entry.name,
|
||||
writable: false,
|
||||
});
|
||||
resolve([file]);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
const entries = await new Promise((resolve) => {
|
||||
reader.readEntries(resolve);
|
||||
});
|
||||
const subPath = basePath ? basePath + '/' + entry.name : entry.name;
|
||||
const nested = await Promise.all(entries.map((e) => readEntry(e, subPath)));
|
||||
return nested.flat();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function readDroppedFiles(dataTransfer) {
|
||||
if (!dataTransfer || !dataTransfer.items) {
|
||||
return Array.from(dataTransfer?.files || []);
|
||||
}
|
||||
const items = Array.from(dataTransfer.items);
|
||||
const entries = items
|
||||
.map((item) => item.webkitGetAsEntry && item.webkitGetAsEntry())
|
||||
.filter(Boolean);
|
||||
if (!entries.length) {
|
||||
return Array.from(dataTransfer.files || []);
|
||||
}
|
||||
const results = await Promise.all(entries.map((entry) => readEntry(entry, '')));
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
function mountApi() {
|
||||
window.LightWeightExtensionTest = {
|
||||
async handleDrop(hooks, event) {
|
||||
try {
|
||||
const files = await readDroppedFiles(event?.dataTransfer);
|
||||
await processFolderFiles(hooks, files);
|
||||
} catch (error) {
|
||||
const message = `失败:${error.message || '目录处理失败'}`;
|
||||
hooks.lightweightExtensionTestStatus.value = message;
|
||||
notifyError(message);
|
||||
}
|
||||
},
|
||||
async handlePick(hooks, event) {
|
||||
const files = event?.target?.files || [];
|
||||
try {
|
||||
await processFolderFiles(hooks, files);
|
||||
} catch (error) {
|
||||
const message = `失败:${error.message || '目录处理失败'}`;
|
||||
hooks.lightweightExtensionTestStatus.value = message;
|
||||
notifyError(message);
|
||||
} finally {
|
||||
if (event?.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
openPicker() {
|
||||
const input = document.getElementById('lightweight-extension-test-folder-input');
|
||||
if (input) {
|
||||
input.click();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
mountApi();
|
||||
})();
|
||||
+203
-7
@@ -6,6 +6,7 @@ BotanicalBaseAgent - FastAPI Backend
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import csv
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -41,6 +42,7 @@ _builtins.print = _safe_print
|
||||
|
||||
# 添加项目路径
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
||||
DEFAULT_RUNTIME_ENV = os.environ.get("BBAV5_DEFAULT_RUNTIME_ENV", "test4py311")
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
from gfa_core.project_paths import get_conda_env_bin, get_conda_env_rscript, get_configured_path, get_wgs_bcftools, get_wgs_java, get_wgs_samtools
|
||||
|
||||
@@ -8573,6 +8575,19 @@ async def root():
|
||||
)
|
||||
|
||||
|
||||
@app.get("/ml")
|
||||
async def root_ml():
|
||||
"""返回Vue3前端多语言页面"""
|
||||
return FileResponse(
|
||||
Path(__file__).parent / "index_ml.html",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/new_job")
|
||||
async def create_new_job():
|
||||
"""创建新工作目录"""
|
||||
@@ -8780,6 +8795,56 @@ async def upload_file(files: List[UploadFile] = File(...), job_id: str = Form(""
|
||||
}
|
||||
|
||||
|
||||
class JobImportFolderRequest(BaseModel):
|
||||
job_id: str
|
||||
root_name: str = ""
|
||||
files: List[str] = Field(default_factory=list)
|
||||
relative_paths: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@app.post("/api/job/import-folder")
|
||||
async def import_job_folder(req: JobImportFolderRequest):
|
||||
job_id = str(req.job_id or "").strip()
|
||||
if not job_id:
|
||||
raise HTTPException(status_code=400, detail="job_id is required")
|
||||
if len(req.files) != len(req.relative_paths):
|
||||
raise HTTPException(status_code=400, detail="files and relative_paths length mismatch")
|
||||
|
||||
root_name = _safe_upload_name(req.root_name or "lightweight_extension_drop")
|
||||
import_root = INPUT_ROOT / job_id / root_name
|
||||
import_root.mkdir(parents=True, exist_ok=True)
|
||||
saved_files: List[str] = []
|
||||
|
||||
for encoded, raw_relative in zip(req.files, req.relative_paths):
|
||||
relative = Path(str(raw_relative or "").strip())
|
||||
if not raw_relative or relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
|
||||
raise HTTPException(status_code=400, detail=f"invalid relative path: {raw_relative}")
|
||||
try:
|
||||
content = base64.b64decode(str(encoded or "").encode("utf-8"), validate=True)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"invalid base64 payload for {raw_relative}: {exc}") from exc
|
||||
destination = import_root / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(content)
|
||||
saved_files.append(relative.as_posix())
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"job_id": job_id,
|
||||
"root_name": root_name,
|
||||
"import_root": str(import_root),
|
||||
"saved_files": saved_files,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/lightWeightExtensionTest.js")
|
||||
async def get_lightweight_extension_test_script():
|
||||
script_path = WEB_DIR / "lightWeightExtensionTest.js"
|
||||
if not script_path.exists():
|
||||
raise HTTPException(status_code=404, detail="lightWeightExtensionTest.js not found")
|
||||
return FileResponse(str(script_path), media_type="application/javascript")
|
||||
|
||||
|
||||
class ExtensionStageRunRequest(BaseModel):
|
||||
extension_id: str
|
||||
job_id: str = ""
|
||||
@@ -8850,6 +8915,137 @@ class ExtensionStageResolveIntentRequest(BaseModel):
|
||||
job_id: str = ""
|
||||
|
||||
|
||||
class ExtensionStageEnvResolveRequest(BaseModel):
|
||||
python_imports: List[str] = Field(default_factory=list)
|
||||
r_packages: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@app.get("/api/extension-stages/envs")
|
||||
async def list_extension_stage_envs():
|
||||
"""Return available conda environment names for the extension stage runtime dropdown."""
|
||||
import json as _json
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "env", "list", "--json",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
# fallback: try parsing plain-text output
|
||||
proc2 = await asyncio.create_subprocess_exec(
|
||||
"conda", "env", "list",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout2, _ = await proc2.communicate()
|
||||
envs = []
|
||||
for line in stdout2.decode("utf-8", errors="replace").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
# format: "env_name /path/to/env"
|
||||
parts = stripped.split()
|
||||
if parts:
|
||||
envs.append(parts[0])
|
||||
return {"success": True, "envs": envs}
|
||||
data = _json.loads(stdout.decode("utf-8"))
|
||||
envs = [entry.split("/")[-1] if "/" in str(entry) else str(entry) for entry in data.get("envs", [])]
|
||||
return {"success": True, "envs": envs}
|
||||
except Exception as exc:
|
||||
return {"success": False, "envs": ["test4py311", "rnaseq", "rnaR"], "error": str(exc)}
|
||||
|
||||
|
||||
def _normalized_dependency_names(values: List[str]) -> List[str]:
|
||||
normalized: List[str] = []
|
||||
seen: set[str] = set()
|
||||
stdlib_names = set(getattr(sys, "stdlib_module_names", set()))
|
||||
for raw in values or []:
|
||||
token = re.split(r"[<>=\[\]()\s]", str(raw or "").strip(), maxsplit=1)[0].strip()
|
||||
if not token:
|
||||
continue
|
||||
top_level = token.split(".")[0].strip()
|
||||
key = top_level.lower().replace("_", "-")
|
||||
if top_level in stdlib_names or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
normalized.append(top_level)
|
||||
return normalized
|
||||
|
||||
|
||||
async def _available_extension_env_names() -> List[str]:
|
||||
result = await list_extension_stage_envs()
|
||||
envs = result.get("envs") if isinstance(result, dict) else []
|
||||
if isinstance(envs, list) and envs:
|
||||
return [str(item) for item in envs if str(item).strip()]
|
||||
return ["test4py311", "rnaseq", "rnaR"]
|
||||
|
||||
|
||||
async def _python_packages_for_env(env_name: str) -> set[str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", env_name, "python", "-c",
|
||||
"import json, importlib.metadata as m; print(json.dumps(sorted({dist.metadata.get('Name', '') for dist in m.distributions() if dist.metadata.get('Name', '')})))",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return set()
|
||||
try:
|
||||
values = json.loads(stdout.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return set()
|
||||
return {str(item).strip().lower().replace("_", "-") for item in values if str(item).strip()}
|
||||
|
||||
|
||||
async def _r_packages_for_env(env_name: str) -> set[str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", env_name, "Rscript", "-e",
|
||||
"cat(paste(sort(rownames(installed.packages())), collapse='\n'))",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return set()
|
||||
return {line.strip().lower() for line in stdout.decode("utf-8", errors="replace").splitlines() if line.strip()}
|
||||
|
||||
|
||||
@app.post("/api/extension-stages/envs/resolve")
|
||||
async def resolve_extension_stage_env(req: ExtensionStageEnvResolveRequest):
|
||||
python_imports = _normalized_dependency_names(req.python_imports)
|
||||
r_packages = _normalized_dependency_names(req.r_packages)
|
||||
if not python_imports and not r_packages:
|
||||
return {
|
||||
"success": True,
|
||||
"runtime_env": "test4py311",
|
||||
"reason": "no_dependencies_detected",
|
||||
"fallback": True,
|
||||
}
|
||||
|
||||
for dep in list(dict.fromkeys(d.lower() for d in python_imports + r_packages)):
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", dep, "python", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"runtime_env": dep,
|
||||
"reason": "env_name_matches_dependency",
|
||||
"dependency": dep,
|
||||
"fallback": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"runtime_env": "test4py311",
|
||||
"reason": "no_conda_env_matches_dependency",
|
||||
"fallback": True,
|
||||
}
|
||||
@app.post("/api/extension-stages/build")
|
||||
async def build_extension_stage(
|
||||
display_name: str = Form(...),
|
||||
@@ -9750,7 +9946,7 @@ async def get_config_autofill_template():
|
||||
async def draft_config_autofill(req: AutofillDraftRequest):
|
||||
job_id, input_dir, workspace_dir = _autofill_job_dirs(req.job_id)
|
||||
cmd = [
|
||||
"/home/xzq/miniconda3/envs/test4py311/bin/python",
|
||||
f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python",
|
||||
"apps/cli/run_config_autofill.py",
|
||||
"--input-dir", str(input_dir),
|
||||
"--workspace", str(workspace_dir),
|
||||
@@ -9779,7 +9975,7 @@ async def draft_config_autofill(req: AutofillDraftRequest):
|
||||
async def draft_samples_autofill(req: AutofillDraftRequest):
|
||||
job_id, input_dir, workspace_dir = _autofill_job_dirs(req.job_id)
|
||||
cmd = [
|
||||
"/home/xzq/miniconda3/envs/test4py311/bin/python",
|
||||
f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python",
|
||||
"apps/cli/run_samples_autofill.py",
|
||||
"--input-dir", str(input_dir),
|
||||
"--workspace", str(workspace_dir),
|
||||
@@ -9910,7 +10106,7 @@ async def confirm_config_autofill(req: AutofillDraftRequest):
|
||||
_write_config_draft_rows(draft_path, req.rows, family, primary_reference_species, identify_blast_reference_species)
|
||||
draft_to_use = str(draft_path)
|
||||
cmd = [
|
||||
"/home/xzq/miniconda3/envs/test4py311/bin/python",
|
||||
f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python",
|
||||
"apps/cli/run_config_autofill.py",
|
||||
"--input-dir", str(input_dir),
|
||||
"--workspace", str(workspace_dir),
|
||||
@@ -9958,7 +10154,7 @@ async def confirm_samples_autofill(req: AutofillDraftRequest):
|
||||
req,
|
||||
)
|
||||
cmd = [
|
||||
"/home/xzq/miniconda3/envs/test4py311/bin/python",
|
||||
f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python",
|
||||
"apps/cli/run_samples_autofill.py",
|
||||
"--input-dir", str(input_dir),
|
||||
"--workspace", str(workspace_dir),
|
||||
@@ -10284,7 +10480,7 @@ async def confirm_identify_domain_review(req: IdentifyDomainReviewConfirmRequest
|
||||
curated_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
curated_file.write_text("\n".join(selected) + "\n", encoding="utf-8")
|
||||
cmd = [
|
||||
"/home/xzq/miniconda3/envs/test4py311/bin/python",
|
||||
f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python",
|
||||
"apps/cli/run_identify_refine.py",
|
||||
"--input-dir", str(input_dir),
|
||||
"--workspace", str(workspace_dir),
|
||||
@@ -10767,7 +10963,7 @@ def _split_joint_subfamily_confirmation(job_id: str, prefix: str, rows: list[dic
|
||||
f"{row.get('confidence','')}\t{row.get('note','')}\n"
|
||||
)
|
||||
cmd = [
|
||||
"/home/xzq/miniconda3/envs/test4py311/bin/python",
|
||||
f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python",
|
||||
"apps/cli/run_subfamily_confirm.py",
|
||||
"--input-dir", str(input_dir),
|
||||
"--workspace", str(workspace_dir),
|
||||
@@ -11104,7 +11300,7 @@ async def confirm_subfamily_review(req: SubfamilyReviewConfirmRequest):
|
||||
member_set_changed = bool(current_ids and confirmed_ids and not current_ids.issubset(confirmed_ids))
|
||||
|
||||
cmd = [
|
||||
"/home/xzq/miniconda3/envs/test4py311/bin/python",
|
||||
f"/home/xzq/miniconda3/envs/{DEFAULT_RUNTIME_ENV}/bin/python",
|
||||
"apps/cli/run_subfamily_confirm.py",
|
||||
"--input-dir", str(input_dir),
|
||||
"--workspace", str(workspace_dir),
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// global wrapper
|
||||
/*! Element Plus v2.3.14 */
|
||||
|
||||
window.en = {
|
||||
name: "en",
|
||||
el: {
|
||||
colorpicker: {
|
||||
confirm: "OK",
|
||||
clear: "Clear",
|
||||
defaultLabel: "color picker",
|
||||
description: "current color is {color}. press enter to select a new color."
|
||||
},
|
||||
datepicker: {
|
||||
now: "Now",
|
||||
today: "Today",
|
||||
cancel: "Cancel",
|
||||
clear: "Clear",
|
||||
confirm: "OK",
|
||||
dateTablePrompt: "Use the arrow keys and enter to select the day of the month",
|
||||
monthTablePrompt: "Use the arrow keys and enter to select the month",
|
||||
yearTablePrompt: "Use the arrow keys and enter to select the year",
|
||||
selectedDate: "Selected date",
|
||||
selectDate: "Select date",
|
||||
selectTime: "Select time",
|
||||
startDate: "Start Date",
|
||||
startTime: "Start Time",
|
||||
endDate: "End Date",
|
||||
endTime: "End Time",
|
||||
prevYear: "Previous Year",
|
||||
nextYear: "Next Year",
|
||||
prevMonth: "Previous Month",
|
||||
nextMonth: "Next Month",
|
||||
year: "",
|
||||
month1: "January",
|
||||
month2: "February",
|
||||
month3: "March",
|
||||
month4: "April",
|
||||
month5: "May",
|
||||
month6: "June",
|
||||
month7: "July",
|
||||
month8: "August",
|
||||
month9: "September",
|
||||
month10: "October",
|
||||
month11: "November",
|
||||
month12: "December",
|
||||
week: "week",
|
||||
weeks: {
|
||||
sun: "Sun",
|
||||
mon: "Mon",
|
||||
tue: "Tue",
|
||||
wed: "Wed",
|
||||
thu: "Thu",
|
||||
fri: "Fri",
|
||||
sat: "Sat"
|
||||
},
|
||||
weeksFull: {
|
||||
sun: "Sunday",
|
||||
mon: "Monday",
|
||||
tue: "Tuesday",
|
||||
wed: "Wednesday",
|
||||
thu: "Thursday",
|
||||
fri: "Friday",
|
||||
sat: "Saturday"
|
||||
},
|
||||
months: {
|
||||
jan: "Jan",
|
||||
feb: "Feb",
|
||||
mar: "Mar",
|
||||
apr: "Apr",
|
||||
may: "May",
|
||||
jun: "Jun",
|
||||
jul: "Jul",
|
||||
aug: "Aug",
|
||||
sep: "Sep",
|
||||
oct: "Oct",
|
||||
nov: "Nov",
|
||||
dec: "Dec"
|
||||
}
|
||||
},
|
||||
inputNumber: {
|
||||
decrease: "decrease number",
|
||||
increase: "increase number"
|
||||
},
|
||||
select: {
|
||||
loading: "Loading",
|
||||
noMatch: "No matching data",
|
||||
noData: "No data",
|
||||
placeholder: "Select"
|
||||
},
|
||||
dropdown: {
|
||||
toggleDropdown: "Toggle Dropdown"
|
||||
},
|
||||
cascader: {
|
||||
noMatch: "No matching data",
|
||||
loading: "Loading",
|
||||
placeholder: "Select",
|
||||
noData: "No data"
|
||||
},
|
||||
pagination: {
|
||||
goto: "Go to",
|
||||
pagesize: "/page",
|
||||
total: "Total {total}",
|
||||
pageClassifier: "",
|
||||
page: "Page",
|
||||
prev: "Go to previous page",
|
||||
next: "Go to next page",
|
||||
currentPage: "page {pager}",
|
||||
prevPages: "Previous {pager} pages",
|
||||
nextPages: "Next {pager} pages",
|
||||
deprecationWarning: "Deprecated usages detected, please refer to the el-pagination documentation for more details"
|
||||
},
|
||||
dialog: {
|
||||
close: "Close this dialog"
|
||||
},
|
||||
drawer: {
|
||||
close: "Close this dialog"
|
||||
},
|
||||
messagebox: {
|
||||
title: "Message",
|
||||
confirm: "OK",
|
||||
cancel: "Cancel",
|
||||
error: "Illegal input",
|
||||
close: "Close this dialog"
|
||||
},
|
||||
upload: {
|
||||
deleteTip: "press delete to remove",
|
||||
delete: "Delete",
|
||||
preview: "Preview",
|
||||
continue: "Continue"
|
||||
},
|
||||
slider: {
|
||||
defaultLabel: "slider between {min} and {max}",
|
||||
defaultRangeStartLabel: "pick start value",
|
||||
defaultRangeEndLabel: "pick end value"
|
||||
},
|
||||
table: {
|
||||
emptyText: "No Data",
|
||||
confirmFilter: "Confirm",
|
||||
resetFilter: "Reset",
|
||||
clearFilter: "All",
|
||||
sumText: "Sum"
|
||||
},
|
||||
tree: {
|
||||
emptyText: "No Data"
|
||||
},
|
||||
transfer: {
|
||||
noMatch: "No matching data",
|
||||
noData: "No data",
|
||||
titles: ["List 1", "List 2"],
|
||||
filterPlaceholder: "Enter keyword",
|
||||
noCheckedFormat: "{total} items",
|
||||
hasCheckedFormat: "{checked}/{total} checked"
|
||||
},
|
||||
image: {
|
||||
error: "FAILED"
|
||||
},
|
||||
pageHeader: {
|
||||
title: "Back"
|
||||
},
|
||||
popconfirm: {
|
||||
confirmButtonText: "Yes",
|
||||
cancelButtonText: "No"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
/*! Element Plus v2.3.14 */
|
||||
|
||||
var en = {
|
||||
name: "en",
|
||||
el: {
|
||||
colorpicker: {
|
||||
confirm: "OK",
|
||||
clear: "Clear",
|
||||
defaultLabel: "color picker",
|
||||
description: "current color is {color}. press enter to select a new color."
|
||||
},
|
||||
datepicker: {
|
||||
now: "Now",
|
||||
today: "Today",
|
||||
cancel: "Cancel",
|
||||
clear: "Clear",
|
||||
confirm: "OK",
|
||||
dateTablePrompt: "Use the arrow keys and enter to select the day of the month",
|
||||
monthTablePrompt: "Use the arrow keys and enter to select the month",
|
||||
yearTablePrompt: "Use the arrow keys and enter to select the year",
|
||||
selectedDate: "Selected date",
|
||||
selectDate: "Select date",
|
||||
selectTime: "Select time",
|
||||
startDate: "Start Date",
|
||||
startTime: "Start Time",
|
||||
endDate: "End Date",
|
||||
endTime: "End Time",
|
||||
prevYear: "Previous Year",
|
||||
nextYear: "Next Year",
|
||||
prevMonth: "Previous Month",
|
||||
nextMonth: "Next Month",
|
||||
year: "",
|
||||
month1: "January",
|
||||
month2: "February",
|
||||
month3: "March",
|
||||
month4: "April",
|
||||
month5: "May",
|
||||
month6: "June",
|
||||
month7: "July",
|
||||
month8: "August",
|
||||
month9: "September",
|
||||
month10: "October",
|
||||
month11: "November",
|
||||
month12: "December",
|
||||
week: "week",
|
||||
weeks: {
|
||||
sun: "Sun",
|
||||
mon: "Mon",
|
||||
tue: "Tue",
|
||||
wed: "Wed",
|
||||
thu: "Thu",
|
||||
fri: "Fri",
|
||||
sat: "Sat"
|
||||
},
|
||||
weeksFull: {
|
||||
sun: "Sunday",
|
||||
mon: "Monday",
|
||||
tue: "Tuesday",
|
||||
wed: "Wednesday",
|
||||
thu: "Thursday",
|
||||
fri: "Friday",
|
||||
sat: "Saturday"
|
||||
},
|
||||
months: {
|
||||
jan: "Jan",
|
||||
feb: "Feb",
|
||||
mar: "Mar",
|
||||
apr: "Apr",
|
||||
may: "May",
|
||||
jun: "Jun",
|
||||
jul: "Jul",
|
||||
aug: "Aug",
|
||||
sep: "Sep",
|
||||
oct: "Oct",
|
||||
nov: "Nov",
|
||||
dec: "Dec"
|
||||
}
|
||||
},
|
||||
inputNumber: {
|
||||
decrease: "decrease number",
|
||||
increase: "increase number"
|
||||
},
|
||||
select: {
|
||||
loading: "Loading",
|
||||
noMatch: "No matching data",
|
||||
noData: "No data",
|
||||
placeholder: "Select"
|
||||
},
|
||||
dropdown: {
|
||||
toggleDropdown: "Toggle Dropdown"
|
||||
},
|
||||
cascader: {
|
||||
noMatch: "No matching data",
|
||||
loading: "Loading",
|
||||
placeholder: "Select",
|
||||
noData: "No data"
|
||||
},
|
||||
pagination: {
|
||||
goto: "Go to",
|
||||
pagesize: "/page",
|
||||
total: "Total {total}",
|
||||
pageClassifier: "",
|
||||
page: "Page",
|
||||
prev: "Go to previous page",
|
||||
next: "Go to next page",
|
||||
currentPage: "page {pager}",
|
||||
prevPages: "Previous {pager} pages",
|
||||
nextPages: "Next {pager} pages",
|
||||
deprecationWarning: "Deprecated usages detected, please refer to the el-pagination documentation for more details"
|
||||
},
|
||||
dialog: {
|
||||
close: "Close this dialog"
|
||||
},
|
||||
drawer: {
|
||||
close: "Close this dialog"
|
||||
},
|
||||
messagebox: {
|
||||
title: "Message",
|
||||
confirm: "OK",
|
||||
cancel: "Cancel",
|
||||
error: "Illegal input",
|
||||
close: "Close this dialog"
|
||||
},
|
||||
upload: {
|
||||
deleteTip: "press delete to remove",
|
||||
delete: "Delete",
|
||||
preview: "Preview",
|
||||
continue: "Continue"
|
||||
},
|
||||
slider: {
|
||||
defaultLabel: "slider between {min} and {max}",
|
||||
defaultRangeStartLabel: "pick start value",
|
||||
defaultRangeEndLabel: "pick end value"
|
||||
},
|
||||
table: {
|
||||
emptyText: "No Data",
|
||||
confirmFilter: "Confirm",
|
||||
resetFilter: "Reset",
|
||||
clearFilter: "All",
|
||||
sumText: "Sum"
|
||||
},
|
||||
tree: {
|
||||
emptyText: "No Data"
|
||||
},
|
||||
transfer: {
|
||||
noMatch: "No matching data",
|
||||
noData: "No data",
|
||||
titles: ["List 1", "List 2"],
|
||||
filterPlaceholder: "Enter keyword",
|
||||
noCheckedFormat: "{total} items",
|
||||
hasCheckedFormat: "{checked}/{total} checked"
|
||||
},
|
||||
image: {
|
||||
error: "FAILED"
|
||||
},
|
||||
pageHeader: {
|
||||
title: "Back"
|
||||
},
|
||||
popconfirm: {
|
||||
confirmButtonText: "Yes",
|
||||
cancelButtonText: "No"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// global wrapper
|
||||
/*! Element Plus v2.3.14 */
|
||||
|
||||
window.zhCn = {
|
||||
name: "zh-cn",
|
||||
el: {
|
||||
colorpicker: {
|
||||
confirm: "\u786E\u5B9A",
|
||||
clear: "\u6E05\u7A7A"
|
||||
},
|
||||
datepicker: {
|
||||
now: "\u6B64\u523B",
|
||||
today: "\u4ECA\u5929",
|
||||
cancel: "\u53D6\u6D88",
|
||||
clear: "\u6E05\u7A7A",
|
||||
confirm: "\u786E\u5B9A",
|
||||
selectDate: "\u9009\u62E9\u65E5\u671F",
|
||||
selectTime: "\u9009\u62E9\u65F6\u95F4",
|
||||
startDate: "\u5F00\u59CB\u65E5\u671F",
|
||||
startTime: "\u5F00\u59CB\u65F6\u95F4",
|
||||
endDate: "\u7ED3\u675F\u65E5\u671F",
|
||||
endTime: "\u7ED3\u675F\u65F6\u95F4",
|
||||
prevYear: "\u524D\u4E00\u5E74",
|
||||
nextYear: "\u540E\u4E00\u5E74",
|
||||
prevMonth: "\u4E0A\u4E2A\u6708",
|
||||
nextMonth: "\u4E0B\u4E2A\u6708",
|
||||
year: "\u5E74",
|
||||
month1: "1 \u6708",
|
||||
month2: "2 \u6708",
|
||||
month3: "3 \u6708",
|
||||
month4: "4 \u6708",
|
||||
month5: "5 \u6708",
|
||||
month6: "6 \u6708",
|
||||
month7: "7 \u6708",
|
||||
month8: "8 \u6708",
|
||||
month9: "9 \u6708",
|
||||
month10: "10 \u6708",
|
||||
month11: "11 \u6708",
|
||||
month12: "12 \u6708",
|
||||
weeks: {
|
||||
sun: "\u65E5",
|
||||
mon: "\u4E00",
|
||||
tue: "\u4E8C",
|
||||
wed: "\u4E09",
|
||||
thu: "\u56DB",
|
||||
fri: "\u4E94",
|
||||
sat: "\u516D"
|
||||
},
|
||||
months: {
|
||||
jan: "\u4E00\u6708",
|
||||
feb: "\u4E8C\u6708",
|
||||
mar: "\u4E09\u6708",
|
||||
apr: "\u56DB\u6708",
|
||||
may: "\u4E94\u6708",
|
||||
jun: "\u516D\u6708",
|
||||
jul: "\u4E03\u6708",
|
||||
aug: "\u516B\u6708",
|
||||
sep: "\u4E5D\u6708",
|
||||
oct: "\u5341\u6708",
|
||||
nov: "\u5341\u4E00\u6708",
|
||||
dec: "\u5341\u4E8C\u6708"
|
||||
}
|
||||
},
|
||||
select: {
|
||||
loading: "\u52A0\u8F7D\u4E2D",
|
||||
noMatch: "\u65E0\u5339\u914D\u6570\u636E",
|
||||
noData: "\u65E0\u6570\u636E",
|
||||
placeholder: "\u8BF7\u9009\u62E9"
|
||||
},
|
||||
cascader: {
|
||||
noMatch: "\u65E0\u5339\u914D\u6570\u636E",
|
||||
loading: "\u52A0\u8F7D\u4E2D",
|
||||
placeholder: "\u8BF7\u9009\u62E9",
|
||||
noData: "\u6682\u65E0\u6570\u636E"
|
||||
},
|
||||
pagination: {
|
||||
goto: "\u524D\u5F80",
|
||||
pagesize: "\u6761/\u9875",
|
||||
total: "\u5171 {total} \u6761",
|
||||
pageClassifier: "\u9875",
|
||||
page: "\u9875",
|
||||
prev: "\u4E0A\u4E00\u9875",
|
||||
next: "\u4E0B\u4E00\u9875",
|
||||
currentPage: "\u7B2C {pager} \u9875",
|
||||
prevPages: "\u5411\u524D {pager} \u9875",
|
||||
nextPages: "\u5411\u540E {pager} \u9875",
|
||||
deprecationWarning: "\u4F60\u4F7F\u7528\u4E86\u4E00\u4E9B\u5DF2\u88AB\u5E9F\u5F03\u7684\u7528\u6CD5\uFF0C\u8BF7\u53C2\u8003 el-pagination \u7684\u5B98\u65B9\u6587\u6863"
|
||||
},
|
||||
messagebox: {
|
||||
title: "\u63D0\u793A",
|
||||
confirm: "\u786E\u5B9A",
|
||||
cancel: "\u53D6\u6D88",
|
||||
error: "\u8F93\u5165\u7684\u6570\u636E\u4E0D\u5408\u6CD5!"
|
||||
},
|
||||
upload: {
|
||||
deleteTip: "\u6309 delete \u952E\u53EF\u5220\u9664",
|
||||
delete: "\u5220\u9664",
|
||||
preview: "\u67E5\u770B\u56FE\u7247",
|
||||
continue: "\u7EE7\u7EED\u4E0A\u4F20"
|
||||
},
|
||||
table: {
|
||||
emptyText: "\u6682\u65E0\u6570\u636E",
|
||||
confirmFilter: "\u7B5B\u9009",
|
||||
resetFilter: "\u91CD\u7F6E",
|
||||
clearFilter: "\u5168\u90E8",
|
||||
sumText: "\u5408\u8BA1"
|
||||
},
|
||||
tree: {
|
||||
emptyText: "\u6682\u65E0\u6570\u636E"
|
||||
},
|
||||
transfer: {
|
||||
noMatch: "\u65E0\u5339\u914D\u6570\u636E",
|
||||
noData: "\u65E0\u6570\u636E",
|
||||
titles: ["\u5217\u8868 1", "\u5217\u8868 2"],
|
||||
filterPlaceholder: "\u8BF7\u8F93\u5165\u641C\u7D22\u5185\u5BB9",
|
||||
noCheckedFormat: "\u5171 {total} \u9879",
|
||||
hasCheckedFormat: "\u5DF2\u9009 {checked}/{total} \u9879"
|
||||
},
|
||||
image: {
|
||||
error: "\u52A0\u8F7D\u5931\u8D25"
|
||||
},
|
||||
pageHeader: {
|
||||
title: "\u8FD4\u56DE"
|
||||
},
|
||||
popconfirm: {
|
||||
confirmButtonText: "\u786E\u5B9A",
|
||||
cancelButtonText: "\u53D6\u6D88"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/*! Element Plus v2.3.14 */
|
||||
|
||||
var zhCn = {
|
||||
name: "zh-cn",
|
||||
el: {
|
||||
colorpicker: {
|
||||
confirm: "\u786E\u5B9A",
|
||||
clear: "\u6E05\u7A7A"
|
||||
},
|
||||
datepicker: {
|
||||
now: "\u6B64\u523B",
|
||||
today: "\u4ECA\u5929",
|
||||
cancel: "\u53D6\u6D88",
|
||||
clear: "\u6E05\u7A7A",
|
||||
confirm: "\u786E\u5B9A",
|
||||
selectDate: "\u9009\u62E9\u65E5\u671F",
|
||||
selectTime: "\u9009\u62E9\u65F6\u95F4",
|
||||
startDate: "\u5F00\u59CB\u65E5\u671F",
|
||||
startTime: "\u5F00\u59CB\u65F6\u95F4",
|
||||
endDate: "\u7ED3\u675F\u65E5\u671F",
|
||||
endTime: "\u7ED3\u675F\u65F6\u95F4",
|
||||
prevYear: "\u524D\u4E00\u5E74",
|
||||
nextYear: "\u540E\u4E00\u5E74",
|
||||
prevMonth: "\u4E0A\u4E2A\u6708",
|
||||
nextMonth: "\u4E0B\u4E2A\u6708",
|
||||
year: "\u5E74",
|
||||
month1: "1 \u6708",
|
||||
month2: "2 \u6708",
|
||||
month3: "3 \u6708",
|
||||
month4: "4 \u6708",
|
||||
month5: "5 \u6708",
|
||||
month6: "6 \u6708",
|
||||
month7: "7 \u6708",
|
||||
month8: "8 \u6708",
|
||||
month9: "9 \u6708",
|
||||
month10: "10 \u6708",
|
||||
month11: "11 \u6708",
|
||||
month12: "12 \u6708",
|
||||
weeks: {
|
||||
sun: "\u65E5",
|
||||
mon: "\u4E00",
|
||||
tue: "\u4E8C",
|
||||
wed: "\u4E09",
|
||||
thu: "\u56DB",
|
||||
fri: "\u4E94",
|
||||
sat: "\u516D"
|
||||
},
|
||||
months: {
|
||||
jan: "\u4E00\u6708",
|
||||
feb: "\u4E8C\u6708",
|
||||
mar: "\u4E09\u6708",
|
||||
apr: "\u56DB\u6708",
|
||||
may: "\u4E94\u6708",
|
||||
jun: "\u516D\u6708",
|
||||
jul: "\u4E03\u6708",
|
||||
aug: "\u516B\u6708",
|
||||
sep: "\u4E5D\u6708",
|
||||
oct: "\u5341\u6708",
|
||||
nov: "\u5341\u4E00\u6708",
|
||||
dec: "\u5341\u4E8C\u6708"
|
||||
}
|
||||
},
|
||||
select: {
|
||||
loading: "\u52A0\u8F7D\u4E2D",
|
||||
noMatch: "\u65E0\u5339\u914D\u6570\u636E",
|
||||
noData: "\u65E0\u6570\u636E",
|
||||
placeholder: "\u8BF7\u9009\u62E9"
|
||||
},
|
||||
cascader: {
|
||||
noMatch: "\u65E0\u5339\u914D\u6570\u636E",
|
||||
loading: "\u52A0\u8F7D\u4E2D",
|
||||
placeholder: "\u8BF7\u9009\u62E9",
|
||||
noData: "\u6682\u65E0\u6570\u636E"
|
||||
},
|
||||
pagination: {
|
||||
goto: "\u524D\u5F80",
|
||||
pagesize: "\u6761/\u9875",
|
||||
total: "\u5171 {total} \u6761",
|
||||
pageClassifier: "\u9875",
|
||||
page: "\u9875",
|
||||
prev: "\u4E0A\u4E00\u9875",
|
||||
next: "\u4E0B\u4E00\u9875",
|
||||
currentPage: "\u7B2C {pager} \u9875",
|
||||
prevPages: "\u5411\u524D {pager} \u9875",
|
||||
nextPages: "\u5411\u540E {pager} \u9875",
|
||||
deprecationWarning: "\u4F60\u4F7F\u7528\u4E86\u4E00\u4E9B\u5DF2\u88AB\u5E9F\u5F03\u7684\u7528\u6CD5\uFF0C\u8BF7\u53C2\u8003 el-pagination \u7684\u5B98\u65B9\u6587\u6863"
|
||||
},
|
||||
messagebox: {
|
||||
title: "\u63D0\u793A",
|
||||
confirm: "\u786E\u5B9A",
|
||||
cancel: "\u53D6\u6D88",
|
||||
error: "\u8F93\u5165\u7684\u6570\u636E\u4E0D\u5408\u6CD5!"
|
||||
},
|
||||
upload: {
|
||||
deleteTip: "\u6309 delete \u952E\u53EF\u5220\u9664",
|
||||
delete: "\u5220\u9664",
|
||||
preview: "\u67E5\u770B\u56FE\u7247",
|
||||
continue: "\u7EE7\u7EED\u4E0A\u4F20"
|
||||
},
|
||||
table: {
|
||||
emptyText: "\u6682\u65E0\u6570\u636E",
|
||||
confirmFilter: "\u7B5B\u9009",
|
||||
resetFilter: "\u91CD\u7F6E",
|
||||
clearFilter: "\u5168\u90E8",
|
||||
sumText: "\u5408\u8BA1"
|
||||
},
|
||||
tree: {
|
||||
emptyText: "\u6682\u65E0\u6570\u636E"
|
||||
},
|
||||
transfer: {
|
||||
noMatch: "\u65E0\u5339\u914D\u6570\u636E",
|
||||
noData: "\u65E0\u6570\u636E",
|
||||
titles: ["\u5217\u8868 1", "\u5217\u8868 2"],
|
||||
filterPlaceholder: "\u8BF7\u8F93\u5165\u641C\u7D22\u5185\u5BB9",
|
||||
noCheckedFormat: "\u5171 {total} \u9879",
|
||||
hasCheckedFormat: "\u5DF2\u9009 {checked}/{total} \u9879"
|
||||
},
|
||||
image: {
|
||||
error: "\u52A0\u8F7D\u5931\u8D25"
|
||||
},
|
||||
pageHeader: {
|
||||
title: "\u8FD4\u56DE"
|
||||
},
|
||||
popconfirm: {
|
||||
confirmButtonText: "\u786E\u5B9A",
|
||||
cancelButtonText: "\u53D6\u6D88"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,373 +1,373 @@
|
||||
/**
|
||||
* Conversation Sidebar Component
|
||||
* 类似ChatGPT的会话侧边栏组件
|
||||
* Conversation Sidebar component
|
||||
*/
|
||||
|
||||
const ConversationSidebar = {
|
||||
template: `
|
||||
<div class="conversation-sidebar" :class="{ collapsed: isCollapsed }">
|
||||
<!-- 头部:New Chat按钮 -->
|
||||
<div class="sidebar-header">
|
||||
<button class="new-chat-btn" @click="createNewConversation">
|
||||
<el-icon><plus /></el-icon>
|
||||
<span v-if="!isCollapsed">New Chat</span>
|
||||
</button>
|
||||
<button class="toggle-btn" @click="toggleSidebar">
|
||||
<el-icon><arrow-left v-if="!isCollapsed" /><arrow-right v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-box" v-if="!isCollapsed">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索会话..."
|
||||
prefix-icon="search"
|
||||
clearable
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<div class="conversation-list" v-if="!isCollapsed">
|
||||
<!-- 今日会话 -->
|
||||
<div class="list-section" v-if="todayConversations.length > 0">
|
||||
<div class="section-title">今天</div>
|
||||
<div
|
||||
v-for="conv in todayConversations"
|
||||
:key="conv.conversation_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.conversation_id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<el-icon><chat-dot-round /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ conv.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
|
||||
<span class="message-count">{{ conv.message_count }}条消息</span>
|
||||
</div>
|
||||
<div class="item-preview">{{ conv.last_message }}</div>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<el-dropdown trigger="click">
|
||||
<el-icon><more /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="renameConversation(conv)">
|
||||
<el-icon><edit /></el-icon>重命名
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="archiveConversation(conv)">
|
||||
<el-icon><folder /></el-icon>归档
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="deleteConversation(conv)" type="danger">
|
||||
<el-icon><delete /></el-icon>删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 昨日会话 -->
|
||||
<div class="list-section" v-if="yesterdayConversations.length > 0">
|
||||
<div class="section-title">昨天</div>
|
||||
<div
|
||||
v-for="conv in yesterdayConversations"
|
||||
:key="conv.conversation_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.conversation_id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<el-icon><chat-dot-round /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ conv.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
|
||||
<span class="message-count">{{ conv.message_count }}条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 更早会话 -->
|
||||
<div class="list-section" v-if="olderConversations.length > 0">
|
||||
<div class="section-title">过去7天</div>
|
||||
<div
|
||||
v-for="conv in olderConversations"
|
||||
:key="conv.conversation_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.conversation_id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<el-icon><chat-dot-round /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ conv.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredConversations.length === 0" class="empty-state">
|
||||
<el-icon><chat-line-round /></el-icon>
|
||||
<p>暂无会话</p>
|
||||
<p class="hint">点击上方"New Chat"开始新的基因家族分析</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:用户信息 -->
|
||||
<div class="sidebar-footer" v-if="!isCollapsed">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :icon="UserFilled" />
|
||||
<span class="username">{{ userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
props: {
|
||||
userId: {
|
||||
type: String,
|
||||
default: 'xia-lab'
|
||||
},
|
||||
apiBaseUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:8000/api'
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isCollapsed: false,
|
||||
searchQuery: '',
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
loading: false,
|
||||
// 图标
|
||||
Plus: ElementPlusIconsVue.Plus,
|
||||
ArrowLeft: ElementPlusIconsVue.ArrowLeft,
|
||||
ArrowRight: ElementPlusIconsVue.ArrowRight,
|
||||
ChatDotRound: ElementPlusIconsVue.ChatDotRound,
|
||||
ChatLineRound: ElementPlusIconsVue.ChatLineRound,
|
||||
More: ElementPlusIconsVue.More,
|
||||
Edit: ElementPlusIconsVue.Edit,
|
||||
Folder: ElementPlusIconsVue.Folder,
|
||||
Delete: ElementPlusIconsVue.Delete,
|
||||
UserFilled: ElementPlusIconsVue.UserFilled,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredConversations() {
|
||||
if (!this.searchQuery) return this.conversations;
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.conversations.filter(conv =>
|
||||
conv.title.toLowerCase().includes(query) ||
|
||||
(conv.gene_family && conv.gene_family.toLowerCase().includes(query)) ||
|
||||
conv.last_message.toLowerCase().includes(query)
|
||||
);
|
||||
},
|
||||
|
||||
todayConversations() {
|
||||
const today = new Date().toDateString();
|
||||
return this.filteredConversations.filter(conv => {
|
||||
const convDate = new Date(conv.updated_at).toDateString();
|
||||
return convDate === today;
|
||||
});
|
||||
},
|
||||
|
||||
yesterdayConversations() {
|
||||
const yesterday = new Date(Date.now() - 86400000).toDateString();
|
||||
return this.filteredConversations.filter(conv => {
|
||||
const convDate = new Date(conv.updated_at).toDateString();
|
||||
return convDate === yesterday;
|
||||
});
|
||||
},
|
||||
|
||||
olderConversations() {
|
||||
const yesterday = new Date(Date.now() - 86400000).toDateString();
|
||||
return this.filteredConversations.filter(conv => {
|
||||
const convDate = new Date(conv.updated_at).toDateString();
|
||||
return convDate < yesterday;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConversations();
|
||||
// 定时刷新
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadConversations();
|
||||
}, 30000); // 30秒刷新一次
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadConversations() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const response = await fetch(`${this.apiBaseUrl}/conversations/list/${this.userId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.conversations = data.conversations || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error);
|
||||
// 使用模拟数据
|
||||
this.conversations = this.getMockConversations();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getMockConversations() {
|
||||
// 模拟数据,用于测试
|
||||
return [
|
||||
{
|
||||
conversation_id: 'conv_001',
|
||||
title: 'At.WRKY基因家族分析',
|
||||
gene_family: 'WRKY',
|
||||
species: 'At',
|
||||
message_count: 12,
|
||||
last_message: '系统发育树已生成完成',
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
conversation_id: 'conv_002',
|
||||
title: 'Os.TCP进化分析',
|
||||
gene_family: 'TCP',
|
||||
species: 'Os',
|
||||
message_count: 5,
|
||||
last_message: '正在构建进化树...',
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString()
|
||||
},
|
||||
{
|
||||
conversation_id: 'conv_003',
|
||||
title: 'HMMER参数配置咨询',
|
||||
message_count: 8,
|
||||
last_message: 'E-value建议设置为1e-5',
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString()
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.$emit('toggle', this.isCollapsed);
|
||||
},
|
||||
|
||||
async createNewConversation() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/conversations/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: this.userId,
|
||||
title: '新会话 ' + new Date().toLocaleTimeString()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.currentConversationId = data.conversation_id;
|
||||
this.$emit('new-conversation', data);
|
||||
await this.loadConversations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create conversation:', error);
|
||||
// 创建本地会话
|
||||
const newConv = {
|
||||
conversation_id: 'conv_' + Date.now(),
|
||||
title: '新会话',
|
||||
message_count: 0,
|
||||
last_message: '',
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
this.conversations.unshift(newConv);
|
||||
this.currentConversationId = newConv.conversation_id;
|
||||
this.$emit('new-conversation', newConv);
|
||||
}
|
||||
},
|
||||
|
||||
selectConversation(conv) {
|
||||
this.currentConversationId = conv.conversation_id;
|
||||
this.$emit('select-conversation', conv);
|
||||
},
|
||||
|
||||
async renameConversation(conv) {
|
||||
const newTitle = prompt('请输入新标题:', conv.title);
|
||||
if (newTitle && newTitle !== conv.title) {
|
||||
try {
|
||||
await fetch(`${this.apiBaseUrl}/conversations/update-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conv.conversation_id,
|
||||
title: newTitle
|
||||
})
|
||||
});
|
||||
conv.title = newTitle;
|
||||
} catch (error) {
|
||||
console.error('Failed to rename:', error);
|
||||
conv.title = newTitle; // 本地更新
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async archiveConversation(conv) {
|
||||
try {
|
||||
await fetch(`${this.apiBaseUrl}/conversations/archive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conv.conversation_id
|
||||
})
|
||||
});
|
||||
await this.loadConversations();
|
||||
} catch (error) {
|
||||
console.error('Failed to archive:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteConversation(conv) {
|
||||
if (confirm(`确定要删除会话 "${conv.title}" 吗?`)) {
|
||||
try {
|
||||
await fetch(`${this.apiBaseUrl}/conversations/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conv.conversation_id
|
||||
})
|
||||
});
|
||||
await this.loadConversations();
|
||||
if (this.currentConversationId === conv.conversation_id) {
|
||||
this.currentConversationId = null;
|
||||
this.$emit('select-conversation', null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
template: `
|
||||
<div class="conversation-sidebar" :class="{ collapsed: isCollapsed }">
|
||||
<!-- Header: New Chat button -->
|
||||
<div class="sidebar-header">
|
||||
<button class="new-chat-btn" @click="createNewConversation">
|
||||
<el-icon><plus /></el-icon>
|
||||
<span v-if="!isCollapsed">New Chat</span>
|
||||
</button>
|
||||
<button class="toggle-btn" @click="toggleSidebar">
|
||||
<el-icon><arrow-left v-if="!isCollapsed" /><arrow-right v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search box -->
|
||||
<div class="search-box" v-if="!isCollapsed">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search conversations..."
|
||||
prefix-icon="search"
|
||||
clearable
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Conversation list -->
|
||||
<div class="conversation-list" v-if="!isCollapsed">
|
||||
<!-- Todays conversations -->
|
||||
<div class="list-section" v-if="todayConversations.length > 0">
|
||||
<div class="section-title">Today</div>
|
||||
<div
|
||||
v-for="conv in todayConversations"
|
||||
:key="conv.conversation_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.conversation_id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<el-icon><chat-dot-round /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ conv.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
|
||||
<span class="message-count">{{ conv.message_count }} messages</span>
|
||||
</div>
|
||||
<div class="item-preview">{{ conv.last_message }}</div>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<el-dropdown trigger="click">
|
||||
<el-icon><more /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="renameConversation(conv)">
|
||||
<el-icon><edit /></el-icon>Rename
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="archiveConversation(conv)">
|
||||
<el-icon><folder /></el-icon>Archive
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="deleteConversation(conv)" type="danger">
|
||||
<el-icon><delete /></el-icon>Delete
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Yesterdays conversations -->
|
||||
<div class="list-section" v-if="yesterdayConversations.length > 0">
|
||||
<div class="section-title">Yesterday</div>
|
||||
<div
|
||||
v-for="conv in yesterdayConversations"
|
||||
:key="conv.conversation_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.conversation_id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<el-icon><chat-dot-round /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ conv.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
|
||||
<span class="message-count">{{ conv.message_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Older conversations -->
|
||||
<div class="list-section" v-if="olderConversations.length > 0">
|
||||
<div class="section-title">Past 7 days</div>
|
||||
<div
|
||||
v-for="conv in olderConversations"
|
||||
:key="conv.conversation_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.conversation_id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<el-icon><chat-dot-round /></el-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ conv.title }}</div>
|
||||
<div class="item-meta">
|
||||
<span class="gene-family" v-if="conv.gene_family">{{ conv.gene_family }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="filteredConversations.length === 0" class="empty-state">
|
||||
<el-icon><chat-line-round /></el-icon>
|
||||
<p>No conversations yet</p>
|
||||
<p class="hint">Click "New Chat" to start gene family analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer: user info -->
|
||||
<div class="sidebar-footer" v-if="!isCollapsed">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :icon="UserFilled" />
|
||||
<span class="username">{{ userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
props: {
|
||||
userId: {
|
||||
type: String,
|
||||
default: 'xia-lab'
|
||||
},
|
||||
apiBaseUrl: {
|
||||
type: String,
|
||||
default: 'http://localhost:8000/api'
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isCollapsed: false,
|
||||
searchQuery: '',
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
loading: false,
|
||||
// Icons
|
||||
Plus: ElementPlusIconsVue.Plus,
|
||||
ArrowLeft: ElementPlusIconsVue.ArrowLeft,
|
||||
ArrowRight: ElementPlusIconsVue.ArrowRight,
|
||||
ChatDotRound: ElementPlusIconsVue.ChatDotRound,
|
||||
ChatLineRound: ElementPlusIconsVue.ChatLineRound,
|
||||
More: ElementPlusIconsVue.More,
|
||||
Edit: ElementPlusIconsVue.Edit,
|
||||
Folder: ElementPlusIconsVue.Folder,
|
||||
Delete: ElementPlusIconsVue.Delete,
|
||||
UserFilled: ElementPlusIconsVue.UserFilled,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredConversations() {
|
||||
if (!this.searchQuery) return this.conversations;
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.conversations.filter(conv =>
|
||||
conv.title.toLowerCase().includes(query) ||
|
||||
(conv.gene_family && conv.gene_family.toLowerCase().includes(query)) ||
|
||||
conv.last_message.toLowerCase().includes(query)
|
||||
);
|
||||
},
|
||||
|
||||
todayConversations() {
|
||||
const today = new Date().toDateString();
|
||||
return this.filteredConversations.filter(conv => {
|
||||
const convDate = new Date(conv.updated_at).toDateString();
|
||||
return convDate === today;
|
||||
});
|
||||
},
|
||||
|
||||
yesterdayConversations() {
|
||||
const yesterday = new Date(Date.now() - 86400000).toDateString();
|
||||
return this.filteredConversations.filter(conv => {
|
||||
const convDate = new Date(conv.updated_at).toDateString();
|
||||
return convDate === yesterday;
|
||||
});
|
||||
},
|
||||
|
||||
olderConversations() {
|
||||
const yesterday = new Date(Date.now() - 86400000).toDateString();
|
||||
return this.filteredConversations.filter(conv => {
|
||||
const convDate = new Date(conv.updated_at).toDateString();
|
||||
return convDate < yesterday;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadConversations();
|
||||
// Auto-refresh
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadConversations();
|
||||
}, 30000); // 30 seconds refresh interval
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadConversations() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const response = await fetch(`${this.apiBaseUrl}/conversations/list/${this.userId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.conversations = data.conversations || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error);
|
||||
// Use mock data
|
||||
this.conversations = this.getMockConversations();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getMockConversations() {
|
||||
// Mock data for testing
|
||||
return [
|
||||
{
|
||||
conversation_id: 'conv_001',
|
||||
title: 'At.WRKY gene family analysis',
|
||||
gene_family: 'WRKY',
|
||||
species: 'At',
|
||||
message_count: 12,
|
||||
last_message: 'Phylogenetic tree done',
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
conversation_id: 'conv_002',
|
||||
title: 'Os.TCP evolution analysis',
|
||||
gene_family: 'TCP',
|
||||
species: 'Os',
|
||||
message_count: 5,
|
||||
last_message: 'Building tree...',
|
||||
updated_at: new Date(Date.now() - 3600000).toISOString()
|
||||
},
|
||||
{
|
||||
conversation_id: 'conv_003',
|
||||
title: 'HMMER config consultation',
|
||||
message_count: 8,
|
||||
last_message: 'E-value 1e-5 recommended',
|
||||
updated_at: new Date(Date.now() - 86400000).toISOString()
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.$emit('toggle', this.isCollapsed);
|
||||
},
|
||||
|
||||
async createNewConversation() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/conversations/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: this.userId,
|
||||
title: 'New Chat ' + new Date().toLocaleTimeString()
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.currentConversationId = data.conversation_id;
|
||||
this.$emit('new-conversation', data);
|
||||
await this.loadConversations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create conversation:', error);
|
||||
// Create local conversation
|
||||
const newConv = {
|
||||
conversation_id: 'conv_' + Date.now(),
|
||||
title: 'New Chat',
|
||||
message_count: 0,
|
||||
last_message: '',
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
this.conversations.unshift(newConv);
|
||||
this.currentConversationId = newConv.conversation_id;
|
||||
this.$emit('new-conversation', newConv);
|
||||
}
|
||||
},
|
||||
|
||||
selectConversation(conv) {
|
||||
this.currentConversationId = conv.conversation_id;
|
||||
this.$emit('select-conversation', conv);
|
||||
},
|
||||
|
||||
async renameConversation(conv) {
|
||||
const newTitle = prompt('Enter new title:', conv.title);
|
||||
if (newTitle && newTitle !== conv.title) {
|
||||
try {
|
||||
await fetch(`${this.apiBaseUrl}/conversations/update-title`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conv.conversation_id,
|
||||
title: newTitle
|
||||
})
|
||||
});
|
||||
conv.title = newTitle;
|
||||
} catch (error) {
|
||||
console.error('Failed to rename:', error);
|
||||
conv.title = newTitle; // local update
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async archiveConversation(conv) {
|
||||
try {
|
||||
await fetch(`${this.apiBaseUrl}/conversations/archive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conv.conversation_id
|
||||
})
|
||||
});
|
||||
await this.loadConversations();
|
||||
} catch (error) {
|
||||
console.error('Failed to archive:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteConversation(conv) {
|
||||
if (confirm(`Delete conversation "${conv.title}"?`)) {
|
||||
try {
|
||||
await fetch(`${this.apiBaseUrl}/conversations/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conv.conversation_id
|
||||
})
|
||||
});
|
||||
await this.loadConversations();
|
||||
if (this.currentConversationId === conv.conversation_id) {
|
||||
this.currentConversationId = null;
|
||||
this.$emit('select-conversation', null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 导出组件
|
||||
// Export component
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { ConversationSidebar };
|
||||
module.exports = { ConversationSidebar };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
||||
window.__I18N_EN__ = {
|
||||
"Agent状态": "AgentStatus",
|
||||
"Self-RAG反思": "Self-RAG Reflection",
|
||||
"三角形": "Triangle",
|
||||
"下一步": "Next Step",
|
||||
"下载": "Download",
|
||||
"不受支持标记处理策略": "Unsupported Marker Handling Policy",
|
||||
"中性替换率": "Neutral Substitution Rate",
|
||||
"中性速率": "Neutral Rate",
|
||||
"中文": "Chinese",
|
||||
"丰度引擎": "Abundance Engine",
|
||||
"主参考": "Primary Reference",
|
||||
"主参考物种": "Primary reference species",
|
||||
"主要结果": "Main Results",
|
||||
"亚家族分类确认": "Subfamily Classification Confirmation",
|
||||
"亚家族名称距离": "Subfamily Name Distance",
|
||||
"亚家族外圈弧线": "Subfamily Outer Arc",
|
||||
"亚家族确认参数": "Subfamily Confirmation Parameters",
|
||||
"交集": "Intersection",
|
||||
"仅圈图": "Circle Plot Only",
|
||||
"仅比较物种": "Comparative Species Only",
|
||||
"仅目标": "Target Only",
|
||||
"仅目标物种": "Target Species Only",
|
||||
"仅矩形图": "Rectangular Only",
|
||||
"任务列表": "Task List",
|
||||
"位置分布图": "Position Distribution Map",
|
||||
"使用拟南芥作为显式兜底": "Use Arabidopsis as Explicit Fallback",
|
||||
"使用推荐区间": "Use Recommended Range",
|
||||
"保存方法学方案": "Save Methodology Plan",
|
||||
"保留": "Retain",
|
||||
"修订": "Revision",
|
||||
"倒计时已暂停": "Countdown paused",
|
||||
"全屏": "Full Screen",
|
||||
"全屏展示": "Full Screen Display",
|
||||
"全部": "All",
|
||||
"全部行": "All Rows",
|
||||
"关系总数": "Total Edges",
|
||||
"关闭": "Close",
|
||||
"内容": "Content",
|
||||
"内置物种代码": "Built-in Species Code",
|
||||
"分析家族": "Analysis Family",
|
||||
"分组角色": "Group Role",
|
||||
"分组角色不改": "Group Role (Unchanged)",
|
||||
"删除": "Deleted",
|
||||
"删除会话": "Delete Session",
|
||||
"刷新": "Refresh",
|
||||
"刷新结果": "Refresh Results",
|
||||
"前景基因": "Foreground Genes",
|
||||
"功能类别": "Feature Category",
|
||||
"加入正式列表": "Add to Formal List",
|
||||
"包含关键词": "Contains Keyword",
|
||||
"匹配方式": "Match Mode",
|
||||
"匹配行": "Matched Rows",
|
||||
"原因": "Reason",
|
||||
"原始代码": "Original Code",
|
||||
"原始命令": "Original Command",
|
||||
"原始文件名": "Original Filename",
|
||||
"原始证据": "Original Evidence",
|
||||
"原推荐参考": "Original Recommended Reference",
|
||||
"参数": "Parameters",
|
||||
"参考分类": "Reference Classification",
|
||||
"参考前缀": "Reference Prefix",
|
||||
"参考基因": "Reference Gene",
|
||||
"参考基因编号": "Reference Gene ID",
|
||||
"参考文献": "References",
|
||||
"参考物种": "Reference Species",
|
||||
"发送": "Send",
|
||||
"取消": "Cancel",
|
||||
"取消预览": "Cancel Preview",
|
||||
"只会执行已勾选且可执行的阶段": "Only checked and executable stages will run",
|
||||
"可改": "Editable",
|
||||
"同时处理的样本数": "Number of Samples Processed Simultaneously",
|
||||
"名称": "Name",
|
||||
"后续主参考物种": "Downstream Primary Reference Species",
|
||||
"启动子元件": "Promoter Elements",
|
||||
"启动子自定义绘图": "Promoter Custom Plot",
|
||||
"图例颜色": "Legend Color",
|
||||
"图类型": "Plot Type",
|
||||
"图表标题清单": "Chart Title List",
|
||||
"图表标题驱动复现规划预览": "Chart Title-Driven Reproduction Plan Preview",
|
||||
"圆形": "Circle",
|
||||
"基因注释": "Gene Annotation",
|
||||
"基本参数": "Basic Parameters",
|
||||
"基础参数": "Basic Parameters",
|
||||
"处理中...": "Processing...",
|
||||
"处理方式": "Processing Method",
|
||||
"多物种处理方式": "Multi-species Processing",
|
||||
"大小": "Size",
|
||||
"定量模式": "Quantification Mode",
|
||||
"实验设计": "Experimental Design",
|
||||
"实验设计模块": "Experimental Design Module",
|
||||
"审核备注": "Review Notes",
|
||||
"富集类型": "Enrichment Type",
|
||||
"差异表达范围": "Differential Expression Scope",
|
||||
"已写阶段": "Registered Stages",
|
||||
"已完成": "Completed",
|
||||
"已完成阶段": "Completed Stages",
|
||||
"已应用": "Applied",
|
||||
"建议参数": "Suggested Parameters",
|
||||
"开始验证": "Start Validation",
|
||||
"弧线避让距离": "Arc Avoidance Distance",
|
||||
"当前参数": "Current Parameters",
|
||||
"当前参考物种": "Current Reference Species",
|
||||
"当前选择": "Current Selection",
|
||||
"待补数据": "Awaiting Data",
|
||||
"必填": "Required",
|
||||
"我知道了": "Got It",
|
||||
"手动区间": "Manual Range",
|
||||
"执行": "Execute",
|
||||
"扫描双链": "Scan Both Strands",
|
||||
"批量执行确认": "Batch Execution Confirmation",
|
||||
"拉丁名": "Latin Name",
|
||||
"拓展分析运行参数确认": "Extension Analysis Run Parameter Confirmation",
|
||||
"拓展分析阶段": "Extension Analysis Stage",
|
||||
"拓展分析阶段状态": "Extension Analysis Stage Status",
|
||||
"拓展分析阶段确认": "Extension Analysis Stage Confirmation",
|
||||
"拓展分析阶段运行结果": "Extension Analysis Stage Run Result",
|
||||
"按元件": "By Element",
|
||||
"按单个物种分别分析与确认": "Per-Species Analysis & Confirmation",
|
||||
"按当前筛选重建成员蛋白": "Rebuild Member Proteins by Current Filter",
|
||||
"按类别": "By Category",
|
||||
"授权发布": "Authorize Publish",
|
||||
"推荐参考数据下载失败": "Recommended Ref Data Download Failed",
|
||||
"提出修订意见": "Submit Revision Suggestions",
|
||||
"操作": "Operation",
|
||||
"数据准备": "Data Preparation",
|
||||
"数据路径": "Data Path",
|
||||
"数据需求": "Data Needs",
|
||||
"文件": "File",
|
||||
"文件类型": "File Type",
|
||||
"文件范围": "File Scope",
|
||||
"文献参数": "Literature Parameters",
|
||||
"文献工具": "Literature Tools",
|
||||
"文章涉及物种与下载入口": "Article Species & Download Entry",
|
||||
"新增": "Added",
|
||||
"方形": "Square",
|
||||
"方法参考": "Method Reference",
|
||||
"方法学文献": "Methodology Papers",
|
||||
"无": "None",
|
||||
"明确指定": "Explicitly Specify",
|
||||
"显示": "Show",
|
||||
"暂停倒计时": "Pause Countdown",
|
||||
"暂无已上传文件": "No Uploaded Files Yet",
|
||||
"暂无拓展分析阶段结果": "No Extension Analysis Stage Results Yet",
|
||||
"暂无状态记录": "No Status Record Yet",
|
||||
"最大宽度": "Max Width",
|
||||
"最小宽度": "Min Width",
|
||||
"最小非缺口比例": "Min Non-gap Ratio",
|
||||
"最近到属": "Closest to Genus",
|
||||
"最近到目": "Closest to Order",
|
||||
"最近到科": "Closest to Family",
|
||||
"最近到纲": "Closest to Class",
|
||||
"有损转换警告": "Lossy Conversion Warning",
|
||||
"服务器测试数据路径": "Server Test Data Path",
|
||||
"未从图表标题推断出额外阶段": "No Additional Stages Inferred from Chart Titles",
|
||||
"未提取到图表标题": "No Chart Title Extracted",
|
||||
"未识别到内置下载链接": "No Built-in DL Link Identified",
|
||||
"未识别到参数": "No Parameters Identified",
|
||||
"未识别到文献工具": "No Literature Tool Identified",
|
||||
"本次成员鉴定物种": "Current Member Identification Species",
|
||||
"来源": "Sources",
|
||||
"染色体标注位置": "Chromosome Annotation Position",
|
||||
"染色体标注大小": "Chromosome Annotation Size",
|
||||
"染色体策略": "Chromosome Strategy",
|
||||
"染色体色块粗细": "Chromosome Block Width",
|
||||
"查看本次知识检索过程": "View knowledge retrieval process",
|
||||
"标记大小": "Mark Size",
|
||||
"标记模式": "Mark Mode",
|
||||
"标题推断的候选阶段": "Candidate Stages Inferred from Titles",
|
||||
"树枝粗细": "Branch Width",
|
||||
"树枝颜色": "Branch Color",
|
||||
"样本并行数量": "Sample Parallel Count",
|
||||
"检索结果": "Retrieval Results",
|
||||
"检索表格内容": "Search Table Content",
|
||||
"正则匹配": "Regex Match",
|
||||
"此拓展阶段没有可配置参数": "No Configurable Parameters",
|
||||
"测试数据": "Test Data",
|
||||
"涉及阶段": "Involved Stages",
|
||||
"添加行": "Add Row",
|
||||
"灰色": "Gray",
|
||||
"物种": "Species",
|
||||
"物种代码": "Species Code",
|
||||
"物种代码不改": "Species Code (Unchanged)",
|
||||
"物种名": "Species Name",
|
||||
"物种角色": "Species Role",
|
||||
"物种角色不改": "Species Role (Unchanged)",
|
||||
"环境": "Environment",
|
||||
"留空则自动发现": "Leave empty for auto-discovery",
|
||||
"目标前缀": "Target Prefix",
|
||||
"目标物种": "Target Species",
|
||||
"知识图谱": "Knowledge Graph",
|
||||
"确定": "Confirm",
|
||||
"确认亚家族标签": "Confirm Subfamily Labels",
|
||||
"确认保存": "Confirm Save",
|
||||
"确认并写入正式配置": "Confirm & Write to Official Config",
|
||||
"确认并执行": "Confirm & Execute",
|
||||
"确认执行": "Confirm Execution",
|
||||
"确认自动注释": "Confirm Auto-Annotation",
|
||||
"确认重绘": "Confirm Replot",
|
||||
"种间共线性比较物种": "Interspecies Synteny Comparative Species",
|
||||
"种间共线性绘图设置": "Interspecies Synteny Plot Settings",
|
||||
"移除": "Remove",
|
||||
"窗口长度": "Window Length",
|
||||
"类型": "Types",
|
||||
"精确匹配": "Exact Match",
|
||||
"红色": "Red",
|
||||
"终止位点": "End Site",
|
||||
"终止残基": "End Residue",
|
||||
"绘图模式": "Plot Mode",
|
||||
"绿色": "Green",
|
||||
"置信度": "Confidence",
|
||||
"置信度阈值": "Confidence Threshold",
|
||||
"群体遗传输入": "Population Genetic Input",
|
||||
"背景来源": "Background Source",
|
||||
"背景知识": "Background knowledge",
|
||||
"自动": "Auto",
|
||||
"自动从注释提取": "Auto-extract from Annotation",
|
||||
"自动保守窗口": "Auto Conserved Window",
|
||||
"自动判断": "Auto-detect",
|
||||
"节点名称": "Node Name",
|
||||
"节点总数": "Total Nodes",
|
||||
"节点类型": "Node Types",
|
||||
"菱形": "Diamond",
|
||||
"蓝色": "Blue",
|
||||
"蛋白": "Protein",
|
||||
"蛋白文件": "Protein Files",
|
||||
"表达矩阵": "Expression Matrix",
|
||||
"诊断动作": "Diagnostic Actions",
|
||||
"说明": "Description",
|
||||
"请先试运行以记录真实输出": "Please Run a Trial First to Record Actual Output",
|
||||
"请选择共线性分析类型": "Please select synteny analysis type",
|
||||
"请选择模式物种": "Select Model Species",
|
||||
"起始位点": "Start Site",
|
||||
"起始残基": "Start Residue",
|
||||
"跳数": "Hops",
|
||||
"输出": "Output",
|
||||
"输出前缀": "Output Prefix",
|
||||
"输出标签": "Output Label",
|
||||
"运行值": "Run Value",
|
||||
"运行结果": "Run Result",
|
||||
"进化树图修订参数": "Phylogenetic Tree Revision Parameters",
|
||||
"连接关系数": "Connections",
|
||||
"迭代": "Iteration",
|
||||
"选择其他提取结果": "Select Other Extraction Result",
|
||||
"选择工具": "Select Tool",
|
||||
"选择已完成撰写的拓展阶段": "Select Completed Extension Stage",
|
||||
"通常留空": "Usually leave empty",
|
||||
"重写查询": "Rewrite Query",
|
||||
"重新生成说明": "Regenerate Description",
|
||||
"重置": "Reset",
|
||||
"重置视图": "Reset View",
|
||||
"链接": "Link",
|
||||
"链特异性": "Library Strandedness",
|
||||
"隐藏": "Hide",
|
||||
"需检查": "Needs Review",
|
||||
"需要补充": "Need to Supplement",
|
||||
"非时间实验留空": "Leave empty for non-time experiments",
|
||||
"项目": "Project",
|
||||
"预览": "Preview",
|
||||
"预览到表格": "Preview to Table",
|
||||
"预览命令": "Preview Commands",
|
||||
"黑色": "Black",
|
||||
"默认值": "Default Value",
|
||||
"提取方法学": "Extract",
|
||||
"刷新记录": "Refresh",
|
||||
"打开最新提取结果": "Open Latest Extraction",
|
||||
"查看最新提取状态": "View Latest Status",
|
||||
"打开所选提取结果": "Open Selected Extraction",
|
||||
"查看所选提取状态": "View Selected Status",
|
||||
"生成并发布": "Generate & Publish",
|
||||
"运行已发布": "Run Published",
|
||||
"当前会话": "Current Session",
|
||||
"更多会话": "More Sessions",
|
||||
"新建": "New",
|
||||
"新建会话": "New Session",
|
||||
"使用小Tip": "Tips",
|
||||
"一、分析模块快速指令": "1. Analysis Module Quick Commands",
|
||||
"选择输入框上方\"Analysis\"": "Select \"Analysis\" above input box",
|
||||
"粘贴数据位置": "Paste data location",
|
||||
"核对配置文件": "Review config files",
|
||||
"执行数据准备": "Run data preparation",
|
||||
"执行XX物种的亚家族分类": "Run subfamily classification for XX species",
|
||||
"执行XX物种的基因家族分析": "Run gene family analysis for XX species",
|
||||
"执行rnaseq分析": "Run RNA-seq analysis",
|
||||
"二、文章复现步骤": "2. Article Reproduction Steps",
|
||||
"点击\"浏览\"上传文献": "Click \"Browse\" to upload paper",
|
||||
"点击提取方法学": "Click Extract Methodology",
|
||||
"核对分析步骤": "Review analysis steps",
|
||||
"点击保存方案": "Click Save Plan",
|
||||
"上传数据": "Upload data",
|
||||
"发送\"按已保存的方法学方案继续执行\"": "Send \"Continue with saved methodology plan\"",
|
||||
"三、RAG快速指令": "3. RAG Quick Commands",
|
||||
"选择输入框上方\"RAG\"": "Select \"RAG\" above input box",
|
||||
"输入问题": "Enter question",
|
||||
"点击\"知识图谱\"查看关系": "Click \"Knowledge Graph\" to view relations",
|
||||
"知识图谱": "Knowledge Graph",
|
||||
"重新加载": "Reload",
|
||||
"重置视图": "Reset View",
|
||||
"2.2 MSA Replot / 区间重绘": "2.2 MSA Replot",
|
||||
"2.3 Tree Replot / 进化树重绘": "2.3 Tree Replot",
|
||||
"2.5 亚家族确认": "2.5 Subfamily Confirmation",
|
||||
"6.1 启动子自定义绘图": "6.1 Promoter Custom Plot",
|
||||
"7️⃣ 种内Synteny": "7️⃣ Intraspecies Synteny",
|
||||
"7.1 种内共线性 Circos 重绘": "7.1 Intraspecies Synteny Circos Replot",
|
||||
"8️⃣ Karyotype": "8️⃣ Karyotype",
|
||||
"9️⃣ 种间Synteny": "9️⃣ Interspecies Synteny",
|
||||
"基因家族": "Gene Family",
|
||||
"WGS2GWAS(遗传变异与群体分析)": "WGS2GWAS",
|
||||
"基因家族 promoter 扫描": "Gene family promoter scanning",
|
||||
"新会话": "New Session",
|
||||
"新会话已创建,请选择或创建 Working Directory": "New session created. Please select or create a Working Directory",
|
||||
"创建会话失败": "Failed to create session",
|
||||
"会话已删除": "Session deleted",
|
||||
"删除会话失败": "Failed to delete session",
|
||||
"对话历史已清空": "Chat history cleared",
|
||||
"错误日志已清空": "Error logs cleared",
|
||||
"重启 Celery": "Restart Celery",
|
||||
"重启项目": "Restart Project",
|
||||
"提交配置好的项目重启脚本": "Submit configured project restart script",
|
||||
"未配置 PLANTAI_RESTART_SCRIPT 或 restart_plantai_9000.sh": "PLANTAI_RESTART_SCRIPT or restart_plantai_9000.sh not configured",
|
||||
"未配置项目重启脚本": "Project restart script not configured",
|
||||
"这会提交项目重启脚本,当前页面可能短暂断开。": "This will submit the project restart script. The page may briefly disconnect.",
|
||||
"确认重启": "Confirm Restart",
|
||||
"项目重启脚本已提交": "Project restart script submitted",
|
||||
"项目重启未提交": "Project restart not submitted",
|
||||
"基因家族鉴定": "Gene Family Identification",
|
||||
"系统发育分析": "Phylogenetic Analysis",
|
||||
"Motif分析": "Motif Analysis",
|
||||
"结构域分析": "Domain Analysis",
|
||||
"基因结构分析": "Gene Structure Analysis",
|
||||
"启动子元件预测": "Promoter Element Prediction",
|
||||
"TF结合位点预测": "TF Binding Site Prediction",
|
||||
"种内共线性分析": "Intraspecies Synteny Analysis",
|
||||
"种间共线性分析": "Interspecies Synteny Analysis",
|
||||
"核型/染色体分布分析": "Karyotype/Chromosome Distribution",
|
||||
"PPI分析": "PPI Analysis",
|
||||
"Ka/Ks分析": "Ka/Ks Analysis",
|
||||
"蛋白理化性质与亚细胞定位": "Protein Properties & Subcellular Localization",
|
||||
"表达矩阵分析": "Expression Matrix Analysis",
|
||||
"RNA-seq QC": "RNA-seq QC",
|
||||
"RNA-seq Align": "RNA-seq Align",
|
||||
"RNA-seq Quant": "RNA-seq Quant",
|
||||
"RNA-seq Sample QC": "RNA-seq Sample QC",
|
||||
"GWAS VCF排序与HapMap转换": "GWAS VCF Sort & HapMap Conversion",
|
||||
"GWAS TASSEL亲缘关系矩阵": "GWAS TASSEL Kinship Matrix",
|
||||
"GWAS表型准备": "GWAS Phenotype Preparation",
|
||||
"GWAS协变量准备": "GWAS Covariate Preparation",
|
||||
"GWAS TASSEL MLM/CMLM": "GWAS TASSEL MLM/CMLM",
|
||||
"GWAS MLM/CMLM绘图": "GWAS MLM/CMLM Plotting",
|
||||
"GWAS EMMAX": "GWAS EMMAX",
|
||||
"GWAS EMMAX绘图": "GWAS EMMAX Plotting",
|
||||
"GWAS TASSEL GLM": "GWAS TASSEL GLM",
|
||||
"群体PCA分析": "Population PCA Analysis",
|
||||
"群体系统树分析": "Population Phylogenetic Tree",
|
||||
"群体结构分析": "Population Structure Analysis",
|
||||
"群体LD衰减分析": "Population LD Decay Analysis",
|
||||
"SNP密度分析": "SNP Density Analysis",
|
||||
"RNA-seq DE": "RNA-seq DE",
|
||||
"组合图": "Combo Plot",
|
||||
"参考亚家族分类": "Reference Subfamily Classification",
|
||||
"亚家族标签确认": "Subfamily Label Confirmation",
|
||||
"PPI/KaKs 参考数据": "PPI/KaKs Reference Data",
|
||||
"PPI/KaKs 兜底确认": "PPI/KaKs Fallback Confirmation",
|
||||
"亚家族确认输出": "Subfamily Confirmation Output",
|
||||
"MSA区间重绘": "MSA Region Replot",
|
||||
"MSA区间重绘已提交": "MSA Replot submitted",
|
||||
"MSA区间重绘完成": "MSA Replot completed",
|
||||
"MSA区间重绘失败": "MSA Replot failed",
|
||||
"启动子自定义绘图已提交": "Promoter custom plot submitted",
|
||||
"启动子自定义绘图完成": "Promoter custom plot completed",
|
||||
"启动子自定义绘图失败": "Promoter custom plot failed",
|
||||
"种内共线性 Circos 重绘已提交": "Intraspecies Circos replot submitted",
|
||||
"种内共线性 Circos 重绘完成,已切换到新图": "Intraspecies Circos replot completed, switched to new figure",
|
||||
"种内共线性 Circos 重绘失败": "Intraspecies Circos replot failed",
|
||||
"亚家族确认 - Reference Classification": "Subfamily Confirmation - Reference Classification",
|
||||
"这会停止当前 profile 的 Celery worker 并重新启动。若有任务正在运行,后端会默认拒绝执行。": "This will stop and restart the Celery worker for the current profile. Running tasks will be rejected by default.",
|
||||
"Celery 已重启并完成队列检查": "Celery restarted and queue check complete",
|
||||
"Celery 重启未完成": "Celery restart incomplete"
|
||||
};
|
||||
|
||||
@@ -361,6 +361,7 @@ def execute_queued_operation(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
settings = LightweightApiSettings.from_environment()
|
||||
action = str(payload["action"])
|
||||
runtime_env = str(payload.get("runtime_env", "test4py311"))
|
||||
extension_id = _safe_component(str(payload["extension_id"]), name="extension_id")
|
||||
token = _safe_component(str(payload["operation_token"]), name="operation_token")
|
||||
_set_receipt(settings, token, state="running", status="running")
|
||||
@@ -373,6 +374,7 @@ def execute_queued_operation(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_code=stage / "raw_code",
|
||||
raw_data=stage / "raw_test_data",
|
||||
original_command=str(payload["original_command"]),
|
||||
runtime_env=runtime_env,
|
||||
operation_token=token,
|
||||
)
|
||||
elif action == "rebuild":
|
||||
@@ -479,6 +481,7 @@ def create_lightweight_extension_router(
|
||||
raw_code_files: list[UploadFile] | None = File(None),
|
||||
raw_data_files: list[UploadFile] | None = File(None),
|
||||
raw_data_paths: str | None = Form(None),
|
||||
runtime_env: str = Form("test4py311"),
|
||||
) -> dict[str, Any]:
|
||||
temporary_stage: Path | None = None
|
||||
try:
|
||||
@@ -555,6 +558,7 @@ def create_lightweight_extension_router(
|
||||
"action": "upload",
|
||||
"operation_token": token,
|
||||
"original_command": normalized_command,
|
||||
"runtime_env": runtime_env,
|
||||
}
|
||||
)
|
||||
except BaseException as exc:
|
||||
|
||||
@@ -109,6 +109,18 @@ class LightweightExtensionService:
|
||||
def get(self, extension_id: str) -> dict[str, Any]:
|
||||
return self._read_record(extension_id)
|
||||
|
||||
def _make_env(self, runtime_env: str) -> dict[str, str]:
|
||||
"""Build a process env that activates the selected conda environment."""
|
||||
|
||||
conda_prefix = f"/home/xzq/miniconda3/envs/{runtime_env}"
|
||||
conda_bin = f"{conda_prefix}/bin"
|
||||
env = dict(self.environment or {})
|
||||
existing_path = env.get("PATH", os.environ.get("PATH", ""))
|
||||
env["PATH"] = f"{conda_bin}:{existing_path}" if existing_path else conda_bin
|
||||
env["CONDA_PREFIX"] = conda_prefix
|
||||
env["CONDA_DEFAULT_ENV"] = runtime_env
|
||||
return env
|
||||
|
||||
def _deepseek_candidate(self, facts: dict[str, Any], raw_code: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
return generate_semantic_candidate(
|
||||
execution_facts=facts, raw_code=raw_code, environ=self.environment
|
||||
@@ -324,12 +336,13 @@ class LightweightExtensionService:
|
||||
build: dict[str, Any] | None = None
|
||||
failure: tuple[str, BaseException | str] | None = None
|
||||
try:
|
||||
runtime_env = str(record.get("runtime_env") or "test4py311")
|
||||
evidence = run_raw_baseline(
|
||||
command=record["original_command"],
|
||||
raw_code=root / "raw_code",
|
||||
raw_data=root / "raw_test_data",
|
||||
run_dir=root / "baseline" / run_id,
|
||||
env=self.environment,
|
||||
env=self._make_env(runtime_env),
|
||||
on_process_start=lambda identity: self._mark_process(
|
||||
extension_id, token=token, phase="baseline", identity=identity
|
||||
),
|
||||
@@ -365,6 +378,7 @@ class LightweightExtensionService:
|
||||
raw_code: Path,
|
||||
raw_data: Path,
|
||||
original_command: str,
|
||||
runtime_env: str = "test4py311",
|
||||
operation_token: str,
|
||||
) -> dict[str, Any]:
|
||||
raw_code, raw_data = Path(raw_code), Path(raw_data)
|
||||
@@ -394,6 +408,7 @@ class LightweightExtensionService:
|
||||
"schema_version": "lightweight-extension-service/v1",
|
||||
"extension_id": extension_id,
|
||||
"original_command": original_command,
|
||||
"runtime_env": runtime_env,
|
||||
"status": "baseline_running",
|
||||
"operations": {
|
||||
operation_token: {
|
||||
@@ -605,9 +620,10 @@ class LightweightExtensionService:
|
||||
rendered = render_runtime_package(
|
||||
record["runtime_binding_contract"], root / "raw_code", resolved, run_dir
|
||||
)
|
||||
runtime_env = str(record.get("runtime_env") or "test4py311")
|
||||
result = replay_runtime_binding(
|
||||
rendered,
|
||||
env=self.environment,
|
||||
env=self._make_env(runtime_env),
|
||||
on_process_start=lambda identity: self._mark_process(
|
||||
record["extension_id"], token=operation_token, phase=phase, identity=identity
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user