feat(extension): 上传支持 zip/tar/目录三形态+结构自适应分类
- 前端零打包:归档文件(zip/tar/tar.gz/tgz)原样上传服务端解压; 目录拖入/选择器走 webkitRelativePath 多文件直传(filename=相对路径), 隐藏文件/__pycache__ 过滤 + 公共根剥离(参考 PlantAI-dev 主路径) - 后端 _read_archive 魔数分派(zip/tar.gz/tar)+保留嵌套路径; code_files 多文件字段;结构自适应分类(目录前缀→命令解析→扩展名, 未知归代码,纯数据 422 可读,纯脚本合法);store 嵌套落盘 - 存量 bug 修复:FormData 字段名 file→code_zip;golden File→JSON+test_file; http size hint 兼容 code_zip 与多文件求和 - 测试:后端 334/334(新增 10 个变体用例),前端 122/373 全绿 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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("")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
+15
-3
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div class="ext-drop-hint">{{ uploading ? BF.extUploadBusy : BF.extUploadDropHint }}</div>
|
||||
<input ref="fileInput" type="file" class="ext-input-hidden" accept=".zip" @change="onInputChange" />
|
||||
<input ref="fileInput" type="file" class="ext-input-hidden" accept=".zip,.tar,.tar.gz,.tgz" @change="onInputChange" />
|
||||
<input ref="dirInput" type="file" class="ext-input-hidden" webkitdirectory directory multiple @change="onDirInputChange" />
|
||||
<div class="ext-drop-extra">
|
||||
<button class="pane-btn" type="button" :disabled="uploading || created" @click.stop="onClickPickDir">{{ BF.extPickDir }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传记录(进度复用 prog-track/prog-fill) -->
|
||||
@@ -184,12 +266,15 @@ async function submit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="zip || dirFiles" class="ext-hint">
|
||||
{{ zip ? (BF.extArchive + ': ' + zip.name) : (BF.extDir + ': ' + dirRootName + '(' + dirFiles.length + ' 个文件)') }}
|
||||
</div>
|
||||
<div v-if="created" class="ext-created">{{ BF.extUploadCreated }}</div>
|
||||
<div class="ext-actions">
|
||||
<button
|
||||
class="ext-primary"
|
||||
type="button"
|
||||
:disabled="!zip || submitting || created || uploading"
|
||||
:disabled="(!zip && !dirFiles) || submitting || created || uploading"
|
||||
@click="submit"
|
||||
>{{ submitting ? BF.extUploadSubmitting : BF.extUploadSubmit }}</button>
|
||||
</div>
|
||||
|
||||
@@ -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: '如:测序质量统计',
|
||||
|
||||
+21
-4
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user