- runtime_binding: 验证时排除输入 sidecar 输出,目录参数跟随输出前缀重命名 - import-folder: 改 multipart 二进制上传,规避大文件 base64 报 Invalid string length - main: 前端入口切回 index.html,conda 路径兜底 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
315 lines
13 KiB
JavaScript
315 lines
13 KiB
JavaScript
(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 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 importFolderToJob(hooks, rootName, files) {
|
||
const form = new FormData();
|
||
form.append('job_id', String(hooks.jobInfo.job_id || '').trim());
|
||
form.append('root_name', rootName);
|
||
for (const file of files) {
|
||
form.append('files', file, normalizedRelativePath(file));
|
||
form.append('relative_paths', normalizedRelativePath(file));
|
||
}
|
||
const response = await hooks.uploadExtensionWithProgress(
|
||
'/api/job/import-folder',
|
||
form,
|
||
'正在镜像导入 Job',
|
||
);
|
||
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);
|
||
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', {
|
||
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) {
|
||
const progress = hooks.lightweightUploadProgress;
|
||
try {
|
||
if (progress) {
|
||
progress.active = true;
|
||
progress.percent = 0;
|
||
progress.loaded = 0;
|
||
progress.total = 0;
|
||
progress.speed = 0;
|
||
progress.label = '本地文件读取中';
|
||
}
|
||
hooks.lightweightExtensionTestStatus.value = '本地文件读取中...';
|
||
const files = await readDroppedFiles(event?.dataTransfer);
|
||
await processFolderFiles(hooks, files);
|
||
} catch (error) {
|
||
const message = `失败:${error.message || '目录处理失败'}`;
|
||
hooks.lightweightExtensionTestStatus.value = message;
|
||
if (progress) progress.active = false;
|
||
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();
|
||
})();
|