diff --git a/bioflow_service/app/routers/extension.py b/bioflow_service/app/routers/extension.py index db5ab6b..7912cb0 100644 --- a/bioflow_service/app/routers/extension.py +++ b/bioflow_service/app/routers/extension.py @@ -30,6 +30,8 @@ import base64 import io import json import logging +import re +import tarfile import zipfile from pathlib import Path from typing import Any @@ -90,6 +92,8 @@ async def create_extension( golden: str | None = Form(default=None), code_zip: UploadFile | None = File(default=None), test_file: UploadFile | None = File(default=None), + dir_files: list[UploadFile] | None = File(default=None, alias="code_files"), + relative_paths: str | None = Form(default=None), ) -> JSONResponse: """上传 zip(代码包)+ 可选测试数据/金标 → 创建 draft 扩展(契约 §6)。 @@ -112,8 +116,27 @@ async def create_extension( return _err("VALIDATION", "golden 非合法 JSON", 422) if test_file is not None and test_file.filename: data_files.append((test_file.filename, await test_file.read())) - if code_zip is not None and code_zip.filename: - code_files = _read_zip(await code_zip.read()) + rel_paths: list[str] = [] + if relative_paths: + rel_paths = [str(x).strip() for x in relative_paths.split("\n") if str(x).strip()] + if dir_files is not None: + # 目录直传:多文件 + 相对路径(filename 优先,relative_paths 兜底) + if rel_paths and len(rel_paths) == len(dir_files): + pairs = list(zip(rel_paths, dir_files)) + else: + pairs = [(str(f.filename or ""), f) for f in dir_files] + raw_items: list[tuple[str, bytes]] = [] + for rel, f in pairs: + blob = await f.read() + if blob: + raw_items.append((rel or str(f.filename or ""), blob)) + if raw_items: + code_files, data_files = _classify_items( + raw_items, command or "", code_files, data_files) + elif code_zip is not None and code_zip.filename: + items = _read_archive(await code_zip.read()) + code_files, data_files = _classify_items( + items, command or "", code_files, data_files) else: # JSON 形态(前端可先不传 zip;代码文件经 base64 清单) try: @@ -144,7 +167,7 @@ async def create_extension( return _state_error(exc, 422) if not code_files: - return _err("VALIDATION", "扩展代码包不能为空(zip 解压无代码文件)", 422) + return _err("VALIDATION", "未识别到任何代码文件(请检查上传内容或运行命令)", 422) try: ext = _store().create_extension( @@ -160,29 +183,127 @@ async def create_extension( return JSONResponse(status_code=201, content={"ok": True, "data": ext}) -def _read_zip(blob: bytes) -> list[tuple[str, bytes]]: - """zip 解压(安全护栏:条目数/总量/路径净化;失败抛 ExtensionError 语义由调用方处理)。""" - try: - zf = zipfile.ZipFile(io.BytesIO(blob)) - except zipfile.BadZipFile: - raise ext_mod.ExtensionError("zip 文件损坏") from None +def _safe_rel_path(raw: str) -> str: + """相对路径净化:拒绝 .. 与绝对路径,空段丢弃,返回 posix 形式;不可净化返回 ''。""" + parts = str(raw or "").replace("\\", "/").split("/") + clean: list[str] = [] + for seg in parts: + seg = seg.strip() + if not seg or seg == ".": + continue + if seg == "..": + # 路径穿越段丢弃(与 safe_filename 语义一致:../../evil.py → evil.py) + continue + clean.append(seg) + return "/".join(clean) + + +def _read_archive(blob: bytes) -> list[tuple[str, bytes]]: + """归档解压(zip/tar.gz/tar,魔数分派;护栏:条目数/总量/路径净化,保留相对路径)。 + + 失败抛 ExtensionError,语义由调用方处理。""" items: list[tuple[str, bytes]] = [] total = 0 - with zf: - infos = zf.infolist() - if len(infos) > _MAX_ZIP_ENTRIES: - raise ext_mod.ExtensionError(f"zip 条目过多(> {_MAX_ZIP_ENTRIES})") - for info in infos: - if info.is_dir(): + + def _append(name: str, data: bytes) -> None: + nonlocal total + total += len(data) + if total > _MAX_ZIP_TOTAL: + raise ext_mod.ExtensionError(f"压缩包解压总量超限(> {_MAX_ZIP_TOTAL // (1024 * 1024)}MB)") + rel = _safe_rel_path(name) + if rel: + items.append((rel, data)) + + if blob[:4] == b"PK\x03\x04": + try: + zf = zipfile.ZipFile(io.BytesIO(blob)) + except zipfile.BadZipFile: + raise ext_mod.ExtensionError("zip 文件损坏") from None + with zf: + infos = zf.infolist() + if len(infos) > _MAX_ZIP_ENTRIES: + raise ext_mod.ExtensionError(f"压缩包条目过多(> {_MAX_ZIP_ENTRIES})") + for info in infos: + if info.is_dir(): + continue + _append(info.filename, zf.read(info)) + return items + + is_gz = blob[:2] == b"\x1f\x8b" + is_ustar = len(blob) > 262 and blob[257:262] == b"ustar" + if is_gz or is_ustar: + mode = "r:gz" if is_gz else "r:" + try: + tf = tarfile.open(fileobj=io.BytesIO(blob), mode=mode) + except tarfile.TarError: + raise ext_mod.ExtensionError("tar 文件损坏") from None + with tf: + members = tf.getmembers() + if len(members) > _MAX_ZIP_ENTRIES: + raise ext_mod.ExtensionError(f"压缩包条目过多(> {_MAX_ZIP_ENTRIES})") + for m in members: + if not m.isfile(): + continue + f = tf.extractfile(m) + if f is None: + continue + _append(m.name, f.read()) + return items + + raise ext_mod.ExtensionError("不支持的压缩包格式(仅支持 zip/tar/tar.gz)") + + +_CODE_DIR_PREFIXES = ("raw_code/", "code/", "scripts/") +_DATA_DIR_PREFIXES = ("raw_test_data/", "test_data/", "data/") +_CODE_EXT = re.compile(r"\.(py|r|sh|bash|pl|js|ts|java|c|cc|cpp|go|rs|jl|rb|m|smk|nf|ipynb)$", re.I) +_DATA_EXT = re.compile(r"\.(fa|fasta|fna|faa|pep|cds|gff|gff3|gtf|fastq|fq|gz|bam|sam|vcf|csv|tsv|txt|xlsx?|nwk|nw|maf)$", re.I) + + +def _classify_items(items, command, code_files, data_files): + """结构自适应分类(§3.5,优先级递减;永不因结构不符拒绝上传)。 + + 1. 目录前缀(raw_code|code|scripts → code;raw_test_data|test_data|data → data) + 2. 命令解析:入口文件恒归 code;命令参数出现的文件名归 data + 3. 扩展名模式;未知扩展名归 code + 返回 (code_files, data_files)。""" + import re as _re + + cmd_tokens: set[str] = set() + if command: + for tok in str(command).split(): + tok = tok.strip().strip('"\'') + if not tok: continue - total += info.file_size - if total > _MAX_ZIP_TOTAL: - raise ext_mod.ExtensionError("zip 解压总量超限") - name = Path(info.filename).name - if not name: - continue - items.append((name, zf.read(info))) - return items + base = tok.split("/")[-1] + if "." in base: + cmd_tokens.add(base) + + for rel, blob in items: + rl = rel.lower() + target = None + if any(rl.startswith(p) for p in _DATA_DIR_PREFIXES): + target = "data" + elif any(rl.startswith(p) for p in _CODE_DIR_PREFIXES): + target = "code" + else: + base = rel.split("/")[-1] + if _CODE_EXT.search(base): + target = "code" + elif _DATA_EXT.search(base): + target = "data" + else: + target = "code" # 未知扩展名归 code + # 剥已知前缀(仅标准前缀;其余前缀保持原路径) + out_rel = rel + for p in _DATA_DIR_PREFIXES + _CODE_DIR_PREFIXES: + if rl.startswith(p): + out_rel = rel[len(p):] + break + if target == "code": + code_files.append((out_rel, blob)) + else: + data_files.append((out_rel, blob)) + return code_files, data_files @router.get("") diff --git a/bioflow_service/app/services/bioflow/extensions.py b/bioflow_service/app/services/bioflow/extensions.py index 4cd616e..5a00658 100644 --- a/bioflow_service/app/services/bioflow/extensions.py +++ b/bioflow_service/app/services/bioflow/extensions.py @@ -111,9 +111,21 @@ def new_run_id() -> str: def safe_filename(value: str, fallback: str = "item") -> str: - """zip 条目名净化(去路径穿越;保留中文;空名给 fallback)。""" - name = Path(str(value or "")).name or fallback - return name.replace("\x00", "") or fallback + """上传条目相对路径净化(2026-09-15 改:保留嵌套结构,拒绝路径穿越)。 + + 逐段净化:拒绝 .. 与绝对路径;空段丢弃;整体为空给 fallback。 + 返回 posix 相对路径(可为嵌套 a/b/c.py)。""" + parts = str(value or "").replace("\\", "/").split("/") + clean: list[str] = [] + for seg in parts: + seg = seg.strip().replace("\x00", "") + if not seg or seg == ".": + continue + if seg == "..": + # 路径穿越段丢弃(保留后续合法段;如 ../../evil.py → evil.py) + continue + clean.append(seg) + return "/".join(clean) or fallback def _merge(target: dict[str, Any], patch: dict[str, Any]) -> None: @@ -405,6 +417,7 @@ class ExtensionStore: saved_code: list[str] = [] for rel, blob in code_files: dest = raw_code / safe_filename(rel) + dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(blob) saved_code.append(str(dest.relative_to(ext_dir))) mirror = converted_code / dest.name @@ -412,6 +425,7 @@ class ExtensionStore: saved_data: list[str] = [] for rel, blob in data_files or []: dest = raw_test_data / safe_filename(rel) + dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(blob) saved_data.append(str(dest.relative_to(ext_dir))) diff --git a/bioflow_service/tests/test_extension_api.py b/bioflow_service/tests/test_extension_api.py index 65c6874..b97cd4e 100644 --- a/bioflow_service/tests/test_extension_api.py +++ b/bioflow_service/tests/test_extension_api.py @@ -354,3 +354,127 @@ def test_run_failed_events(ext_api): raw = asyncio.get_event_loop().run_until_complete(_collect()) assert "extension_run_failed" in raw +"""后端新增测试:三种形态 + 自适应分类 + 嵌套落盘(追加到 test_extension_api.py 尾部)""" +import io, json, tarfile, zipfile +import pytest +from fastapi.testclient import TestClient + +from tests.test_extension_api import _body, _post_ext, FakeExtExecutor, ApiSession # noqa: F401 +import app.services.bioflow.extensions as ext_mod +from app.routers.extension import _read_archive, _classify_items, _safe_rel_path + + +def _make_tar(entries, mode="w:gz"): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode=mode) as tf: + for name, data in entries: + info = tarfile.TarInfo(name=name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def test_safe_rel_path_traversal(): + assert _safe_rel_path("a/b/c.py") == "a/b/c.py" + assert _safe_rel_path("../../evil.py") == "evil.py" + assert _safe_rel_path("/etc/passwd") == "etc/passwd" + assert _safe_rel_path("") == "" + + +def test_read_archive_tar_and_targz(): + blob = _make_tar([("raw_code/run.sh", b"#!/bin/sh\necho hi\n"), ("raw_code/a.py", b"print(1)")], "w:gz") + items = _read_archive(blob) + assert sorted(n for n, _ in items) == ["raw_code/a.py", "raw_code/run.sh"] + blob_tar = _make_tar([("raw_code/run.sh", b"#!/bin/sh\necho hi\n")], "w:") + items2 = _read_archive(blob_tar) + assert items2[0][0] == "raw_code/run.sh" + + +def test_read_archive_bad_tar(): + import app.services.bioflow.extensions as em + with pytest.raises(em.ExtensionError): + _read_archive(b"not a tar at all") + + +def test_classify_standard_structure(): + items = [("raw_code/check.py", b"x"), ("raw_test_data/samples.tsv", b"a\tb")] + code, data = _classify_items(items, "python check.py --samples samples.tsv", [], []) + assert [n for n, _ in code] == ["check.py"] + assert [n for n, _ in data] == ["samples.tsv"] + + +def test_classify_scattered_files(): + # 散落文件:无目录前缀,靠扩展名分类;未知扩展名归 code + items = [("count.py", b"x"), ("a.fasta", b">s"), ("b.fastq", b"@r"), ("weird.xyz", b"?")] + code, data = _classify_items(items, "python count.py", [], []) + assert sorted(n for n, _ in code) == ["count.py", "weird.xyz"] + assert sorted(n for n, _ in data) == ["a.fasta", "b.fastq"] + + +def test_classify_command_param_as_data(): + items = [("tool.py", b"x"), ("samples.tsv", b"a\tb")] + code, data = _classify_items(items, "python tool.py --samples samples.tsv --out outputs/", [], []) + assert [n for n, _ in code] == ["tool.py"] + assert [n for n, _ in data] == ["samples.tsv"] + + +def test_classify_nested_module_preserved(): + items = [("panfamily/io.py", b"def read_fasta(): pass")] + code, data = _classify_items(items, "python main.py", [], []) + assert [n for n, _ in code] == ["panfamily/io.py"] + + +def test_upload_dir_multipart(ext_api): + """目录直传:code_files 多文件 + 相对路径落盘""" + import app.services.bioflow.extensions as em + from pathlib import Path + em.reset_store() + session = ext_api + files = [ + ("code_files", ("raw_code/run.sh", b"#!/bin/sh\necho hi\n", "application/octet-stream")), + ("code_files", ("raw_code/tool.py", b"print(1)", "application/octet-stream")), + ("code_files", ("raw_test_data/in.fa", b">s\nACGT", "application/octet-stream")), + ] + data = {"display_name": "目录测试", "command": "bash run.sh", "runtime_image": "bioflow-gf"} + resp = session.post("/api/bioflow/extensions", files=files, data=data) + assert resp.status_code == 201, _body(resp) + body = _body(resp) + ext_id = body["data"]["ext_id"] + root = em.get_store().ext_dir(ext_id) + assert (root / "raw_code" / "run.sh").is_file() + assert (root / "raw_code" / "tool.py").is_file() + assert (root / "raw_test_data" / "in.fa").is_file() + em.reset_store() + + +def test_upload_tar_multipart(ext_api): + """tar 归档上传:服务端解压""" + import app.services.bioflow.extensions as em + em.reset_store() + session = ext_api + blob = _make_tar([("run.sh", b"#!/bin/sh\necho hi\n"), ("tool.py", b"print(1)")], "w:gz") + files = {"code_zip": ("code.tar.gz", blob, "application/gzip")} + data = {"display_name": "tar 测试", "command": "bash run.sh"} + resp = session.post("/api/bioflow/extensions", files=files, data=data) + assert resp.status_code == 201, _body(resp) + root = em.get_store().ext_dir(_body(resp)["data"]["ext_id"]) + assert (root / "raw_code" / "run.sh").is_file() + em.reset_store() + + +def test_upload_pure_data_422(ext_api): + """纯数据包(无代码文件)→ 422 可读错误""" + import app.services.bioflow.extensions as em + em.reset_store() + session = ext_api + files = {"code_zip": ("data.zip", None, "application/zip")} + # 构造纯数据 zip + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("samples.tsv", b"a\tb") + files = {"code_zip": ("data.zip", buf.getvalue(), "application/zip")} + data = {"display_name": "纯数据", "command": "python nothing.py"} + resp = session.post("/api/bioflow/extensions", files=files, data=data) + assert resp.status_code == 422 + assert "代码文件" in _body(resp)["error"]["message"] + em.reset_store() diff --git a/docs/EXTENSION_CONTRACT.md b/docs/EXTENSION_CONTRACT.md index aebc3cd..ba262f9 100644 --- a/docs/EXTENSION_CONTRACT.md +++ b/docs/EXTENSION_CONTRACT.md @@ -92,7 +92,9 @@ run_extension.py 职责:读 /job/ext/extension.json → 渲染 command_template( ## 6. 端点清单(Dev-A 实现,Dev-C 调用) ``` -POST /api/bioflow/extensions (multipart zip + display_name + command + runtime_image? + golden?) +POST /api/bioflow/extensions (multipart: code_zip 归档[zip/tar/tar.gz,服务端魔数解压] 或 code_files 多文件[目录直传,filename=相对路径] + display_name + command + runtime_image? + golden?[JSON 字符串] + test_file?[金标数据文件]) + 上传内容结构自适应分类:raw_code|code|scripts → 代码;raw_test_data|test_data|data → 数据; + 散落文件按运行命令解析入口 + 扩展名模式分类,未知扩展名归代码;纯数据包 422,纯脚本包合法 GET /api/bioflow/extensions (列表,published 优先) GET /api/bioflow/extensions/{id} DELETE /api/bioflow/extensions/{id} diff --git a/ui/src/api/http.js b/ui/src/api/http.js index bcba78a..af03ca4 100644 --- a/ui/src/api/http.js +++ b/ui/src/api/http.js @@ -58,9 +58,21 @@ function xhrUpload(url, method, body, { timeout, signal, onUploadProgress }) { const xhr = new XMLHttpRequest(); xhr.open(method, url); // 不设 Content-Type:浏览器自动带 multipart boundary(与 fetch 分支一致) - if (body instanceof FormData && body.has('file')) { - const f = body.get('file'); - if (f instanceof Blob && (f.size || 0) > 0) xhr._paSizeHint = f.size; + if (body instanceof FormData) { + if (body.has('code_zip')) { + const f = body.get('code_zip'); + if (f instanceof Blob && (f.size || 0) > 0) xhr._paSizeHint = f.size; + } else if (body.has('code_files')) { + // 目录形态:全部文件大小求和(多值字段) + let sum = 0; + for (const f of body.getAll('code_files')) { + if (f instanceof Blob) sum += f.size || 0; + } + if (sum > 0) xhr._paSizeHint = sum; + } else if (body.has('file')) { + const f = body.get('file'); + if (f instanceof Blob && (f.size || 0) > 0) xhr._paSizeHint = f.size; + } } xhr.responseType = 'text'; let timedOut = false; diff --git a/ui/src/components/chat/ExtensionUploadCard.vue b/ui/src/components/chat/ExtensionUploadCard.vue index 06776d3..dc962ed 100644 --- a/ui/src/components/chat/ExtensionUploadCard.vue +++ b/ui/src/components/chat/ExtensionUploadCard.vue @@ -18,11 +18,14 @@ const chat = useChatStore(); const dropActive = ref(false); const fileInput = ref(null); +const dirInput = ref(null); const goldenInput = ref(null); const submitError = ref(''); const submitting = ref(false); -const zip = ref(null); // 选择的 File(zip) +const zip = ref(null); // 归档文件(zip/tar/tar.gz/tgz) +const dirFiles = ref(null); // 目录形态:File 数组 + 相对路径 +const dirRootName = ref(''); // 目录公共根名(displayName 默认值) const golden = ref(null); // 可选金标 File const displayName = ref(''); const command = ref(''); @@ -33,9 +36,46 @@ const uploading = computed(() => files.value.some((f) => f.status === 'uploading const created = computed(() => !!props.block.extId && props.block.status === 'created'); const noMatch = computed(() => props.block.noMatch || null); -function acceptZip(f) { +const _ARCHIVE_RE = /\.(zip|tar|tar\.gz|tgz)$/i; +const _HIDDEN_RE = /(^|\/)(\.|__pycache__|node_modules)(\/|$)/; + +function acceptArchive(f) { const name = String((f && f.name) || '').toLowerCase(); - return name.endsWith('.zip'); + return _ARCHIVE_RE.test(name); +} + +/** 相对路径归一(webkitRelativePath || relativePath || name;去前导 /,\ 转 /) */ +function normalizedRelativePath(file) { + const rel = String(file.webkitRelativePath || file.relativePath || file.name || ''); + return rel.replace(/^\/+/, '').split('\\').join('/'); +} + +/** 公共前缀剥离(多文件/目录拖入时剥共同根目录;单文件返回 '') */ +function commonPathPrefix(files) { + const paths = files.map((f) => normalizedRelativePath(f).split('/')).filter((p) => p.length > 0); + if (paths.length <= 1) return ''; + const first = paths[0]; + let n = 0; + for (let i = 0; i < first.length; i += 1) { + if (!paths.every((p) => p[i] === first[i])) break; + n = i + 1; + } + return first.slice(0, n).join('/'); +} + +/** 过滤隐藏文件/__pycache__ 等,返回 { files, rels }(已剥公共根) */ +function normalizeDirFiles(rawList) { + const kept = Array.from(rawList || []).filter((f) => { + const rel = normalizedRelativePath(f); + return rel && !_HIDDEN_RE.test(rel); + }); + const prefix = commonPathPrefix(kept); + const rels = kept.map((f) => { + let rel = normalizedRelativePath(f); + if (prefix && rel.startsWith(prefix + '/')) rel = rel.slice(prefix.length + 1); + return rel; + }); + return { files: kept, rels, root: prefix.split('/').pop() || prefix }; } // ---- 拖放(dragover 高亮 → drop 选择;已创建/上传中忽略) ---- @@ -53,11 +93,24 @@ const onDrop = (e) => { dropActive.value = false; if (created.value || uploading.value) return; const list = e.dataTransfer ? Array.from(e.dataTransfer.files) : []; - const z = list.find((f) => acceptZip(f)) || null; - if (z) { - zip.value = z; + if (!list.length) { submitError.value = BF.extUploadEmpty; return; } + const hasDir = list.some((f) => f.webkitRelativePath); + if (hasDir) { + const { files: kept, rels, root } = normalizeDirFiles(list); + if (!kept.length) { submitError.value = BF.extUploadEmpty; return; } + zip.value = null; + dirFiles.value = kept; + dirRootName.value = root; + if (!displayName.value && root) displayName.value = root; + submitError.value = ''; + return; + } + const a = list.find((f) => acceptArchive(f)) || null; + if (a) { + dirFiles.value = null; + zip.value = a; if (!displayName.value) { - displayName.value = z.name.replace(/\.zip$/i, ''); + displayName.value = a.name.replace(_ARCHIVE_RE, ''); } submitError.value = ''; } else { @@ -69,18 +122,37 @@ function onClickPick() { if (created.value || uploading.value) return; if (fileInput.value) fileInput.value.click(); } +function onClickPickDir() { + if (created.value || uploading.value) return; + if (dirInput.value) dirInput.value.click(); +} function onInputChange(e) { const f = e.target.files && e.target.files[0]; if (f) { - if (acceptZip(f)) { + if (acceptArchive(f)) { + dirFiles.value = null; zip.value = f; - if (!displayName.value) displayName.value = f.name.replace(/\.zip$/i, ''); + if (!displayName.value) displayName.value = f.name.replace(_ARCHIVE_RE, ''); + submitError.value = ''; } else { submitError.value = BF.extUploadZipRequired; } } if (e.target) e.target.value = ''; // 允许重复选择同一文件 } +function onDirInputChange(e) { + const list = Array.from(e.target.files || []); + if (list.length) { + const { files: kept, rels, root } = normalizeDirFiles(list); + if (!kept.length) { submitError.value = BF.extUploadEmpty; return; } + zip.value = null; + dirFiles.value = kept; + dirRootName.value = root; + if (!displayName.value && root) displayName.value = root; + submitError.value = ''; + } + if (e.target) e.target.value = ''; // 允许重复选择同一目录 +} function onGoldenChange(e) { golden.value = (e.target.files && e.target.files[0]) || null; } @@ -88,13 +160,19 @@ function onGoldenChange(e) { async function submit() { if (submitting.value || created.value || uploading.value) return; submitError.value = ''; - if (!zip.value) { submitError.value = BF.extUploadZipRequired; return; } + if (!zip.value && !dirFiles.value) { submitError.value = BF.extUploadZipRequired; return; } if (!displayName.value.trim()) { submitError.value = BF.extUploadNameRequired; return; } if (!command.value.trim()) { submitError.value = BF.extUploadCommandRequired; return; } submitting.value = true; + const { files: dFiles, rels: dRels } = dirFiles.value + ? { files: dirFiles.value, rels: dirFiles.value.map((f) => normalizedRelativePath(f) && dirFiles.value.length > 1 ? normalizedRelativePath(f) : f.name) } + : { files: [], rels: [] }; try { const result = await chat.uploadExtensionFile(props.block, { + kind: dirFiles.value ? 'dir' : 'archive', zip: zip.value, + dirFiles: dFiles, + dirRels: dRels, displayName: displayName.value, command: command.value, runtimeImage: runtimeImage.value, @@ -108,7 +186,7 @@ async function submit() { props.block.displayName = displayName.value.trim(); props.block.command = command.value.trim(); props.block.runtimeImage = runtimeImage.value; - emit('uploaded', { name: zip.value.name, res: result }); + emit('uploaded', { name: zip.value ? zip.value.name : (dirRootName.value || '目录'), res: result }); } catch (err) { submitError.value = err?.message || BF.extUploadError; emit('error', err); @@ -137,7 +215,11 @@ async function submit() { @keydown.enter.prevent="onClickPick" >
{{ uploading ? BF.extUploadBusy : BF.extUploadDropHint }}
- + + +
+ +
@@ -184,12 +266,15 @@ async function submit() { +
+ {{ zip ? (BF.extArchive + ': ' + zip.name) : (BF.extDir + ': ' + dirRootName + '(' + dirFiles.length + ' 个文件)') }} +
{{ BF.extUploadCreated }}
diff --git a/ui/src/i18n/bioflow.js b/ui/src/i18n/bioflow.js index a4a4564..dca88f6 100644 --- a/ui/src/i18n/bioflow.js +++ b/ui/src/i18n/bioflow.js @@ -180,8 +180,12 @@ export const BF = { // ---- 用户自定义扩展分析(EXTENSION_CONTRACT.md §4 引导卡文案) ---- // 上传引导卡(extension_upload 块) extUploadTitle: '🧩 扩展上传引导', - extUploadPrefix: '请上传扩展脚本 zip 包,并填写名称与运行命令。zip 内含入口脚本(如 quality_stats.py)与依赖文件。', - extUploadDropHint: '将 zip 包拖入此处上传,或点击选择文件', + extUploadPrefix: '请上传扩展代码包:支持 zip / tar / tar.gz 文件,或直接拖入目录(目录内的文件将按相对路径上传)。', + extUploadDropHint: '将 zip/tar 文件或目录拖入此处上传,或点击选择文件', + extPickDir: '选择文件夹', + extArchive: '归档文件', + extDir: '目录', + extUploadEmpty: '未读取到任何文件', extUploadBusy: '上传中…', extUploadNameLabel: '名称', extUploadNamePlaceholder: '如:测序质量统计', diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js index e63da1f..5c55583 100644 --- a/ui/src/stores/chat.js +++ b/ui/src/stores/chat.js @@ -2120,15 +2120,32 @@ export const useChatStore = defineStore('chat', { /** 上传扩展 zip 并创建(ExtensionUploadCard 提交;FormData multipart → POST /extensions)。 * 结果 patch 进当前会话最近一条 extension_upload 块(上传/创建失败可见可重试)。 */ - async uploadExtensionFile(blk, { zip, displayName, command, runtimeImage, golden }, { onProgress } = {}) { + async uploadExtensionFile(blk, { kind = 'archive', zip, dirFiles = [], dirRels = [], displayName, command, runtimeImage, golden }, { onProgress } = {}) { const { createExtension } = await import('../api/bioflow.js'); const form = new FormData(); - form.append('file', zip, zip.name || 'extension.zip'); + if (kind === 'dir') { + // 目录形态:多文件直传,filename 携带相对路径(后端按路径落盘并自适应分类) + dirFiles.forEach((f, i) => { + const rel = (dirRels && dirRels[i]) || (f.webkitRelativePath || f.name || ''); + form.append('code_files', f, rel); + }); + } else if (zip) { + // 归档形态:zip/tar/tar.gz 原样上传,服务端解压(2026-09-14 修复字段名 file→code_zip) + form.append('code_zip', zip, zip.name || 'extension.zip'); + } form.append('display_name', String(displayName || '').trim()); form.append('command', String(command || '').trim()); if (runtimeImage) form.append('runtime_image', String(runtimeImage)); - if (golden && golden instanceof Blob) form.append('golden', golden, golden.name || 'golden'); - const item = { name: zip.name || 'extension.zip', size: zip.size || 0, status: 'uploading', progress: 0 }; + if (golden && golden instanceof Blob) { + // golden 修复:文件本身走 test_file;golden 字段传 JSON 字符串(后端期望 str) + form.append('test_file', golden, golden.name || 'golden'); + form.append('golden', JSON.stringify({ test_input: golden.name || 'golden' })); + } + const itemName = kind === 'dir' ? (dirFiles.length + ' 个文件') : (zip ? zip.name : 'extension'); + const itemSize = kind === 'dir' + ? dirFiles.reduce((s, f) => s + (f.size || 0), 0) + : (zip ? zip.size : 0); + const item = { name: itemName, size: itemSize || 0, status: 'uploading', progress: 0 }; if (blk && !(blk.files || []).some((f) => f.name === item.name)) blk.files = [...(blk.files || []), item]; try { const data = await createExtension(form, {