From 8ba4f7050d1703e893631cc9210cb596f8c7c2ec Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 1 Apr 2026 13:47:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20=E6=94=AF=E6=8C=81=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E4=B8=8A=E4=BC=A0=E5=8D=95=E4=B8=AA=20SKILL.md=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../package/yuxi/services/skill_service.py | 32 +++++++---- backend/server/routers/skill_router.py | 4 +- .../test/unit/routers/test_skill_router.py | 31 ++++++++++ .../test/unit/services/test_skill_service.py | 56 +++++++++++++++++++ docs/develop-guides/roadmap.md | 8 +-- web/src/views/ExtensionsView.vue | 4 +- 6 files changed, 113 insertions(+), 22 deletions(-) diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index a0cd444c..dc2c18d1 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -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) diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index 0d459150..09620ccc 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -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="导入技能失败") diff --git a/backend/test/unit/routers/test_skill_router.py b/backend/test/unit/routers/test_skill_router.py index c403fc9d..66549b0b 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -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] = {} diff --git a/backend/test/unit/services/test_skill_service.py b/backend/test/unit/services/test_skill_service.py index 164db123..199fb8c8 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/test_skill_service.py @@ -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)) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index d48dfafc..3fe211c6 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -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//workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。 - 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。 - 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。 +- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能 +--- 历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。 diff --git a/web/src/views/ExtensionsView.vue b/web/src/views/ExtensionsView.vue index 7afc6529..87361295 100644 --- a/web/src/views/ExtensionsView.vue +++ b/web/src/views/ExtensionsView.vue @@ -11,14 +11,14 @@