feat(skill): 支持直接上传单个 SKILL.md 文件,更新导入功能描述

This commit is contained in:
Wenjie Zhang 2026-04-01 13:47:01 +08:00
parent 1f792fa914
commit 8ba4f7050d
6 changed files with 113 additions and 22 deletions

View File

@ -492,31 +492,39 @@ async def import_skill_zip(
file_bytes: bytes,
created_by: str | None,
) -> Skill:
if not filename.lower().endswith(".zip"):
raise ValueError("仅支持上传 .zip 文件")
normalized_filename = filename.lower()
is_zip_upload = normalized_filename.endswith(".zip")
is_skill_md_upload = normalized_filename.endswith("skill.md")
if not is_zip_upload and not is_skill_md_upload:
raise ValueError("仅支持上传 .zip 或 SKILL.md 文件")
repo = SkillRepository(db)
skills_root = get_skills_root_dir()
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
temp_root_path = Path(temp_root)
zip_path = temp_root_path / "upload.zip"
extract_dir = temp_root_path / "extract"
stage_dir = temp_root_path / "stage"
extract_dir.mkdir(parents=True, exist_ok=True)
if is_zip_upload:
zip_path = temp_root_path / "upload.zip"
zip_path.write_bytes(file_bytes)
zip_path.write_bytes(file_bytes)
with zipfile.ZipFile(zip_path, "r") as zf:
_validate_zip_paths(zf)
zf.extractall(extract_dir)
with zipfile.ZipFile(zip_path, "r") as zf:
_validate_zip_paths(zf)
zf.extractall(extract_dir)
skill_md_files = list(extract_dir.rglob("SKILL.md"))
if len(skill_md_files) != 1:
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md")
skill_md_files = list(extract_dir.rglob("SKILL.md"))
if len(skill_md_files) != 1:
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md")
skill_md_path = skill_md_files[0]
source_skill_dir = skill_md_path.parent
else:
source_skill_dir = extract_dir
skill_md_path = source_skill_dir / "SKILL.md"
skill_md_path.write_bytes(file_bytes)
skill_md_path = skill_md_files[0]
source_skill_dir = skill_md_path.parent
content = skill_md_path.read_text(encoding="utf-8")
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)

View File

@ -182,7 +182,7 @@ async def import_skill_route(
current_user: User = Depends(get_superadmin_user),
db: AsyncSession = Depends(get_db),
):
"""导入技能压缩包(仅超级管理员)。"""
"""导入技能包(支持 ZIP 或单个 SKILL.md仅超级管理员)。"""
try:
file_bytes = await file.read()
item = await import_skill_zip(
@ -197,7 +197,7 @@ async def import_skill_route(
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to import skill zip: {e}")
logger.error(f"Failed to import skill package: {e}")
raise HTTPException(status_code=500, detail="导入技能失败")

View File

@ -72,6 +72,37 @@ def test_import_skill_requires_superadmin():
assert resp.status_code == 403
def test_import_skill_route_accepts_skill_md(monkeypatch):
captured: dict[str, str] = {}
async def fake_import_skill_zip(_db, *, filename, file_bytes, created_by):
captured["filename"] = filename
captured["file_bytes"] = file_bytes.decode("utf-8")
captured["created_by"] = created_by
return Skill(
slug="demo",
name="demo",
description="demo skill",
dir_path="skills/demo",
created_by=created_by,
updated_by=created_by,
)
monkeypatch.setattr("server.routers.skill_router.import_skill_zip", fake_import_skill_zip)
app = _build_app(allow_superadmin=True)
client = TestClient(app)
resp = client.post(
"/api/system/skills/import",
files={"file": ("SKILL.md", b"---\nname: demo\ndescription: demo skill\n---\n", "text/markdown")},
)
assert resp.status_code == 200, resp.text
assert captured["filename"] == "SKILL.md"
assert "name: demo" in captured["file_bytes"]
assert captured["created_by"] == "root"
def test_update_skill_file_passes_operator(monkeypatch):
captured: dict[str, str] = {}

View File

@ -165,6 +165,62 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
assert "name: demo-v2" in skill_md
@pytest.mark.asyncio
async def test_import_skill_md_creates_single_file_skill(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
created_item: Skill | None = None
def __init__(self, _db):
pass
async def exists_slug(self, slug: str) -> bool:
return False
async def create(
self,
*,
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
item = Skill(
slug=slug,
name=name,
description=description,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
dir_path=dir_path,
created_by=created_by,
updated_by=created_by,
)
self.__class__.created_item = item
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
skill_md = "---\nname: demo\ndescription: this is demo\n---\n# Demo\n"
item = await svc.import_skill_zip(
None,
filename="SKILL.md",
file_bytes=skill_md.encode("utf-8"),
created_by="root",
)
assert item.slug == "demo"
assert item.name == "demo"
assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md
@pytest.mark.asyncio
async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))

View File

@ -9,17 +9,11 @@
### 看板
- 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
- Langfuse 增加 self-host 模式支持,补齐私有化部署与配置说明
- 部分场景应该使用默认模型作为默认值而不是空值
- 检索测试中,添加问答
- 集成 Memory基于 deepagents 的文件后端实现
- 添加自定义向量模型和 rerank 模型的配置,在网页上面
- 调研轻便的文件展示与编辑器
- 移除 TODO 的模块与设计,移除这个中间件。
- Yuxi-cli 相关的功能,放在后续版本中实现(不是类似于编程助手,而是工具)
- 工作区实现长期记忆保存,要求模型只能将结果写入到 outputs 文件夹
- 新增 present file 工具,提供预览以及保存到工作区的选项
### Bugs
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi/issues/279)
@ -38,7 +32,9 @@
- 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
- 收紧文件系统安全边界viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2并移除 skill frontmatter 解析中的正则回溯风险。
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
---
历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。

View File

@ -11,14 +11,14 @@
<!-- Skills Tab 的按钮 -->
<template v-if="activeTab === 'skills'">
<a-upload
accept=".zip"
accept=".zip,.md"
:show-upload-list="false"
:custom-request="handleImportUpload"
:disabled="skillsLoading || skillsImporting"
>
<a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
<Upload :size="14" />
<span>上传 ZIP</span>
<span>上传 Skill</span>
</a-button>
</a-upload>
<a-button @click="handleSkillsRefresh" :disabled="skillsLoading" class="lucide-icon-btn">