fix(lightweight-extension): 环境激活、前缀重命名、数据复制与上传进度
- service.py: _make_env 用 conda run 激活环境(执行 activate.d,补 PYTHONPATH) - index_ml.html: 环境下拉框动态加载 + 补传 runtime_env - runtime_binding.py: 前缀误判修复、放宽 ambiguous、can_rename 判断、子目录数据复制补 mkdir - api.py: 缺输出目录时友好提示 - main.py + lightWeightExtensionTest.js: run.sh 环境声明 # lightweight-env - index.html/index_ml.html: 拖拽上传进度条 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1422,6 +1422,12 @@
|
||||
placeholder="大文件可直接引用;每行填写一个绝对路径">
|
||||
</el-input>
|
||||
</div>
|
||||
<div v-if="lightweightUploadProgress.active" style="margin-top:9px;">
|
||||
<el-progress :percentage="lightweightUploadProgress.percent" :stroke-width="8"></el-progress>
|
||||
<div style="font-size:11px;color:#909399;margin-top:4px;">
|
||||
{{ lightweightUploadProgress.label }} · {{ formatBytes(lightweightUploadProgress.loaded) }} / {{ formatBytes(lightweightUploadProgress.total) }} · {{ formatBytes(lightweightUploadProgress.speed) }}/s
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; margin-top:9px; align-items:center; justify-content:space-between; flex-wrap:nowrap;">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -9148,6 +9154,14 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
|
||||
statusEvents: []
|
||||
});
|
||||
const extensionEnvOptions = ref(['test4py311', 'rnaseq', 'rnaR']);
|
||||
const lightweightUploadProgress = reactive({
|
||||
active: false,
|
||||
percent: 0,
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
speed: 0,
|
||||
label: '',
|
||||
});
|
||||
const lightweightExtensions = ref([]);
|
||||
const lightweightExtensionConversation = reactive({
|
||||
cards: [],
|
||||
@@ -19508,6 +19522,13 @@ compare: [[flesh, peel]]`;
|
||||
ElMessage.error('上传失败: ' + err.message);
|
||||
};
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const v = bytes / Math.pow(1024, i);
|
||||
return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||
};
|
||||
const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`;
|
||||
const extensionUploadPath = (file) => {
|
||||
const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, '');
|
||||
@@ -19623,6 +19644,36 @@ compare: [[flesh, peel]]`;
|
||||
return port ? `${window.location.protocol}//${host}:${port}${value}` : `${window.location.protocol}//${host}${value}`;
|
||||
};
|
||||
const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options);
|
||||
const uploadExtensionWithProgress = (path, body, label, headers = {}) => {
|
||||
let lastLoaded = 0, lastTime = Date.now();
|
||||
const p = lightweightUploadProgress;
|
||||
p.active = true; p.percent = 0; p.loaded = 0; p.total = 0; p.speed = 0; p.label = label;
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', extensionApiUrl(path));
|
||||
Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v));
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
const now = Date.now(), dt = now - lastTime;
|
||||
if (dt >= 200) { p.speed = (e.loaded - lastLoaded) / (dt / 1000); lastLoaded = e.loaded; lastTime = now; }
|
||||
p.loaded = e.loaded; p.total = e.total;
|
||||
p.percent = e.total > 0 ? Math.min(100, Math.round((e.loaded / e.total) * 100)) : 0;
|
||||
};
|
||||
xhr.onload = () => {
|
||||
p.active = false;
|
||||
resolve({
|
||||
ok: xhr.status >= 200 && xhr.status < 300,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
json: () => Promise.resolve().then(() => JSON.parse(xhr.responseText)),
|
||||
text: () => Promise.resolve(xhr.responseText),
|
||||
});
|
||||
};
|
||||
xhr.onerror = () => { p.active = false; reject(new Error('上传失败:网络错误')); };
|
||||
xhr.onabort = () => { p.active = false; reject(new Error('上传已取消')); };
|
||||
xhr.send(body);
|
||||
});
|
||||
};
|
||||
const pushExtensionStatus = (message, type = 'info', showDialog = false) => {
|
||||
const text = String(message || '');
|
||||
extensionBuild.statusText = text;
|
||||
@@ -19985,7 +20036,7 @@ compare: [[flesh, peel]]`;
|
||||
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 });
|
||||
const response = await uploadExtensionWithProgress('/api/lightweight-extension-stages/upload', form, '正在上传拓展包');
|
||||
if (!response.ok) throw new Error(await extensionApiError(response, '原始包可复现检查提交失败'));
|
||||
ElMessage.success('文件上传成功,已开始生成并验收');
|
||||
rememberLightweightOperation(extensionId, 'upload', token, card.card_id);
|
||||
@@ -23805,6 +23856,7 @@ compare: [[flesh, peel]]`;
|
||||
uploadedFiles,
|
||||
extensionBuild,
|
||||
extensionEnvOptions,
|
||||
lightweightUploadProgress,
|
||||
extensionStages,
|
||||
extensionCompletedStages,
|
||||
lightweightExtensionConversation,
|
||||
@@ -23821,6 +23873,7 @@ compare: [[flesh, peel]]`;
|
||||
cancelExtensionRunParameters,
|
||||
confirmExtensionRunParameters,
|
||||
extensionFileKey,
|
||||
formatBytes,
|
||||
onExtensionCodeFilesChange,
|
||||
onExtensionDataFilesChange,
|
||||
handleLightweightExtensionFolderDrop,
|
||||
@@ -23971,6 +24024,7 @@ compare: [[flesh, peel]]`;
|
||||
appendExtensionFiles,
|
||||
buildLightweightExtensionStage,
|
||||
lightweightExtensionTestStatus,
|
||||
uploadExtensionWithProgress,
|
||||
}, event),
|
||||
handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({
|
||||
jobInfo,
|
||||
@@ -23979,6 +24033,7 @@ compare: [[flesh, peel]]`;
|
||||
appendExtensionFiles,
|
||||
buildLightweightExtensionStage,
|
||||
lightweightExtensionTestStatus,
|
||||
uploadExtensionWithProgress,
|
||||
}, event),
|
||||
openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(),
|
||||
// Working DirectorySelect
|
||||
|
||||
@@ -1397,10 +1397,8 @@
|
||||
<span>{{ t('名称') }}</span><el-input v-model="extensionBuild.displayName" size="small" placeholder="例如 HMMER hmmsearch"></el-input>
|
||||
<span>{{ t('原始命令') }}</span><el-input v-model="extensionBuild.command" size="small" placeholder="例如 bash run.sh input.fa"></el-input>
|
||||
<span>{{ t('环境') }}</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>{{ t('原始代码') }}</span><input type="file" multiple @change="onExtensionCodeFilesChange" style="font-size:12px; min-width:0;">
|
||||
<span v-if="extensionBuild.codeFiles.length"></span>
|
||||
@@ -1426,6 +1424,12 @@
|
||||
placeholder="大文件可直接引用;每行填写一个绝对路径">
|
||||
</el-input>
|
||||
</div>
|
||||
<div v-if="lightweightUploadProgress.active" style="margin-top:9px;">
|
||||
<el-progress :percentage="lightweightUploadProgress.percent" :stroke-width="8"></el-progress>
|
||||
<div style="font-size:11px;color:#909399;margin-top:4px;">
|
||||
{{ lightweightUploadProgress.label }} · {{ formatBytes(lightweightUploadProgress.loaded) }} / {{ formatBytes(lightweightUploadProgress.total) }} · {{ formatBytes(lightweightUploadProgress.speed) }}/s
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:6px; margin-top:9px; align-items:center; justify-content:space-between; flex-wrap:nowrap;">
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -9200,6 +9204,15 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
|
||||
statusDialogVisible: false,
|
||||
statusEvents: []
|
||||
});
|
||||
const extensionEnvOptions = ref(['test4py311', 'rnaseq', 'rnaR']);
|
||||
const lightweightUploadProgress = reactive({
|
||||
active: false,
|
||||
percent: 0,
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
speed: 0,
|
||||
label: '',
|
||||
});
|
||||
const lightweightExtensions = ref([]);
|
||||
const lightweightExtensionConversation = reactive({
|
||||
cards: [],
|
||||
@@ -9252,6 +9265,18 @@ RNA-seq KEGG/fgsea 会优先使用物种级 KEGG gene set;没有物种级缓
|
||||
extensionBuild.runtimeEnv = (item.runtime && item.runtime.env) || extensionBuild.runtimeEnv || 'test4py311';
|
||||
extensionBuild.nlTriggersText = (item.nl_triggers || []).join(', ');
|
||||
};
|
||||
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 extensionRuns = ref([]);
|
||||
const extensionResultDialogVisible = ref(false);
|
||||
const extensionRun = reactive({
|
||||
@@ -19325,6 +19350,13 @@ compare: [[flesh, peel]]`;
|
||||
ElMessage.error('上传失败: ' + err.message);
|
||||
};
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const v = bytes / Math.pow(1024, i);
|
||||
return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||
};
|
||||
const extensionFileKey = (file) => `${file?.name || ''}::${file?.size || 0}::${file?.lastModified || 0}`;
|
||||
const extensionUploadPath = (file) => {
|
||||
const relative = String(file?.lightweightRelativePath || '').trim().replace(/^\/+/, '');
|
||||
@@ -19439,6 +19471,36 @@ compare: [[flesh, peel]]`;
|
||||
return `${window.location.protocol}//${host}:${window.location.port}${value}`;
|
||||
};
|
||||
const extensionApiFetch = async (path, options = {}) => fetch(extensionApiUrl(path), options);
|
||||
const uploadExtensionWithProgress = (path, body, label, headers = {}) => {
|
||||
let lastLoaded = 0, lastTime = Date.now();
|
||||
const p = lightweightUploadProgress;
|
||||
p.active = true; p.percent = 0; p.loaded = 0; p.total = 0; p.speed = 0; p.label = label;
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', extensionApiUrl(path));
|
||||
Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v));
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
const now = Date.now(), dt = now - lastTime;
|
||||
if (dt >= 200) { p.speed = (e.loaded - lastLoaded) / (dt / 1000); lastLoaded = e.loaded; lastTime = now; }
|
||||
p.loaded = e.loaded; p.total = e.total;
|
||||
p.percent = e.total > 0 ? Math.min(100, Math.round((e.loaded / e.total) * 100)) : 0;
|
||||
};
|
||||
xhr.onload = () => {
|
||||
p.active = false;
|
||||
resolve({
|
||||
ok: xhr.status >= 200 && xhr.status < 300,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
json: () => Promise.resolve().then(() => JSON.parse(xhr.responseText)),
|
||||
text: () => Promise.resolve(xhr.responseText),
|
||||
});
|
||||
};
|
||||
xhr.onerror = () => { p.active = false; reject(new Error('上传失败:网络错误')); };
|
||||
xhr.onabort = () => { p.active = false; reject(new Error('上传已取消')); };
|
||||
xhr.send(body);
|
||||
});
|
||||
};
|
||||
const pushExtensionStatus = (message, type = 'info', showDialog = false) => {
|
||||
const text = String(message || '');
|
||||
extensionBuild.statusText = text;
|
||||
@@ -19800,7 +19862,8 @@ compare: [[flesh, peel]]`;
|
||||
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);
|
||||
const response = await extensionApiFetch('/api/lightweight-extension-stages/upload', { method: 'POST', body: form });
|
||||
form.append('runtime_env', extensionBuild.runtimeEnv);
|
||||
const response = await uploadExtensionWithProgress('/api/lightweight-extension-stages/upload', form, '正在上传拓展包');
|
||||
if (!response.ok) throw new Error(await extensionApiError(response, t('原始包可复现检查提交失败')));
|
||||
ElMessage.success(t('文件上传成功,已开始生成并验收'));
|
||||
rememberLightweightOperation(extensionId, 'upload', token, card.card_id);
|
||||
@@ -23225,6 +23288,7 @@ compare: [[flesh, peel]]`;
|
||||
startAgentTaskPolling();
|
||||
startRuntimeMonitor();
|
||||
loadExtensionStages();
|
||||
loadExtensionEnvOptions();
|
||||
loadLightweightExtensionStages();
|
||||
resumeLightweightOperation();
|
||||
loadExtensionRuns();
|
||||
@@ -23556,6 +23620,8 @@ compare: [[flesh, peel]]`;
|
||||
intentHistory,
|
||||
uploadedFiles,
|
||||
extensionBuild,
|
||||
extensionEnvOptions,
|
||||
lightweightUploadProgress,
|
||||
extensionStages,
|
||||
extensionCompletedStages,
|
||||
lightweightExtensionConversation,
|
||||
@@ -23572,6 +23638,7 @@ compare: [[flesh, peel]]`;
|
||||
cancelExtensionRunParameters,
|
||||
confirmExtensionRunParameters,
|
||||
extensionFileKey,
|
||||
formatBytes,
|
||||
onExtensionCodeFilesChange,
|
||||
onExtensionDataFilesChange,
|
||||
removeExtensionFile,
|
||||
@@ -23593,6 +23660,7 @@ compare: [[flesh, peel]]`;
|
||||
validateExtensionStage,
|
||||
publishExtensionStage,
|
||||
loadExtensionStages,
|
||||
loadExtensionEnvOptions,
|
||||
loadExtensionRuns,
|
||||
runExtensionStage,
|
||||
runExtensionStageFromIntent,
|
||||
@@ -23607,6 +23675,7 @@ compare: [[flesh, peel]]`;
|
||||
appendExtensionFiles,
|
||||
buildLightweightExtensionStage,
|
||||
lightweightExtensionTestStatus,
|
||||
uploadExtensionWithProgress,
|
||||
}, event),
|
||||
handleLightweightExtensionFolderPick: (event) => window.LightWeightExtensionTest && window.LightWeightExtensionTest.handlePick({
|
||||
jobInfo,
|
||||
@@ -23615,6 +23684,7 @@ compare: [[flesh, peel]]`;
|
||||
appendExtensionFiles,
|
||||
buildLightweightExtensionStage,
|
||||
lightweightExtensionTestStatus,
|
||||
uploadExtensionWithProgress,
|
||||
}, event),
|
||||
openLightweightExtensionFolderPicker: () => window.LightWeightExtensionTest && window.LightWeightExtensionTest.openPicker(),
|
||||
methodologyPaperId,
|
||||
|
||||
@@ -124,6 +124,17 @@
|
||||
};
|
||||
}
|
||||
|
||||
async function extractEnvDeclaration(files) {
|
||||
for (const file of files) {
|
||||
const path = normalizedRelativePath(file).toLowerCase();
|
||||
if (!path.endsWith('.sh')) continue;
|
||||
const content = await file.text();
|
||||
const m = content.match(/^\s*#\s*lightweight-env\s*:\s*([A-Za-z0-9_.-]+)/m);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function encodeBase64(file) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
@@ -146,11 +157,12 @@
|
||||
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 response = await hooks.uploadExtensionWithProgress(
|
||||
'/api/job/import-folder',
|
||||
JSON.stringify(payload),
|
||||
'正在镜像导入 Job',
|
||||
{ 'Content-Type': 'application/json' },
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.detail || data.error || data.message || 'Job 导入失败');
|
||||
@@ -160,6 +172,8 @@
|
||||
|
||||
async function resolveRuntimeEnv(files) {
|
||||
const dependencies = await extractDependencies(files);
|
||||
const envDeclaration = await extractEnvDeclaration(files);
|
||||
if (envDeclaration) dependencies.env_declaration = envDeclaration;
|
||||
console.log('[lightWeightExtensionTest] 提取的依赖:', JSON.stringify(dependencies), '| 扫描文件数:', files.length);
|
||||
try {
|
||||
const response = await fetch('/api/extension-stages/envs/resolve', {
|
||||
|
||||
@@ -8919,6 +8919,7 @@ class ExtensionStageResolveIntentRequest(BaseModel):
|
||||
class ExtensionStageEnvResolveRequest(BaseModel):
|
||||
python_imports: List[str] = Field(default_factory=list)
|
||||
r_packages: List[str] = Field(default_factory=list)
|
||||
env_declaration: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
@@ -9015,6 +9016,24 @@ async def _r_packages_for_env(env_name: str) -> set[str]:
|
||||
|
||||
@app.post("/api/extension-stages/envs/resolve")
|
||||
async def resolve_extension_stage_env(req: ExtensionStageEnvResolveRequest):
|
||||
# 工具显式声明的环境优先(run.sh 里 # lightweight-env: <env>)
|
||||
if req.env_declaration:
|
||||
env = req.env_declaration.strip()
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"conda", "run", "-n", env, "python", "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"runtime_env": env,
|
||||
"reason": "explicit_env_declaration",
|
||||
"dependency": env,
|
||||
"fallback": False,
|
||||
}
|
||||
|
||||
python_imports = _normalized_dependency_names(req.python_imports)
|
||||
r_packages = _normalized_dependency_names(req.r_packages)
|
||||
if not python_imports and not r_packages:
|
||||
|
||||
Reference in New Issue
Block a user