From 8ba4f7050d1703e893631cc9210cb596f8c7c2ec Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 1 Apr 2026 13:47:01 +0800 Subject: [PATCH 1/8] =?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 a0cd444c2..dc2c18d1a 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 0d459150e..09620ccc9 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 c403fc9dc..66549b0b2 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 164db1236..199fb8c8d 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 create( 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 d48dfafc8..3fe211c6f 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 7afc65298..873612959 100644 --- a/web/src/views/ExtensionsView.vue +++ b/web/src/views/ExtensionsView.vue @@ -11,14 +11,14 @@ @@ -391,6 +447,8 @@ const theme = computed(() => (themeStore.isDark ? 'dark' : 'light')) const loading = ref(false) const importing = ref(false) +const listingRemoteSkills = ref(false) +const installingRemoteSkill = ref(false) const savingFile = ref(false) const creatingNode = ref(false) const savingDependencies = ref(false) @@ -410,7 +468,13 @@ const fileContent = ref('') const originalFileContent = ref('') const createModalVisible = ref(false) +const remoteInstallModalVisible = ref(false) const createForm = reactive({ path: '', isDir: false, content: '' }) +const remoteInstallForm = reactive({ + source: 'https://github.com/anthropics/skills', + skill: '' +}) +const remoteSkillOptions = ref([]) const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] }) const dependencyForm = reactive({ tool_dependencies: [], @@ -521,6 +585,15 @@ const skillDependencyOptions = computed(() => .filter((s) => s !== currentSkill.value?.slug) .map((i) => ({ label: i, value: i })) ) +const filteredRemoteSkillOptions = computed(() => { + const keyword = remoteInstallForm.skill.trim().toLowerCase() + return remoteSkillOptions.value + .filter((item) => !keyword || item.name.toLowerCase().includes(keyword)) + .map((item) => ({ + value: item.name, + label: item.description ? `${item.name} - ${item.description}` : item.name + })) +}) const normalizeTree = (nodes) => (nodes || []).map((node) => ({ @@ -858,6 +931,63 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => { } } +const handleListRemoteSkills = async () => { + const source = remoteInstallForm.source.trim() + if (!source) { + message.warning('请输入来源仓库') + return + } + listingRemoteSkills.value = true + try { + const result = await skillApi.listRemoteSkills(source) + remoteSkillOptions.value = result?.data || [] + if (!remoteSkillOptions.value.length) { + message.warning('未发现可安装的 Skills') + return + } + if (!remoteInstallForm.skill) { + remoteInstallForm.skill = remoteSkillOptions.value[0]?.name || '' + } + message.success(`已发现 ${remoteSkillOptions.value.length} 个 Skills`) + } catch (error) { + message.error(error?.response?.data?.detail || error.message || '获取远程 Skills 失败') + } finally { + listingRemoteSkills.value = false + } +} + +const handleInstallRemoteSkill = async () => { + const source = remoteInstallForm.source.trim() + const skill = remoteInstallForm.skill.trim() + if (!source || !skill) { + message.warning('请填写来源仓库和 Skill 名称') + return + } + installingRemoteSkill.value = true + try { + const result = await skillApi.installRemoteSkill({ source, skill }) + const installed = result?.data + remoteInstallForm.skill = installed?.slug || skill + await fetchSkills() + if (installed?.slug) { + const record = + skills.value.find((item) => item.slug === installed.slug) || + builtinSkills.value.find((item) => item.slug === installed.slug) + if (record) await selectSkill(record) + } + remoteInstallModalVisible.value = false + message.success('远程 Skill 安装成功') + } catch (error) { + message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败') + } finally { + installingRemoteSkill.value = false + } +} + +const openRemoteInstallModal = () => { + remoteInstallModalVisible.value = true +} + const saveDependencies = async () => { if (!currentSkill.value || !isInstalledSkill.value) return savingDependencies.value = true @@ -886,7 +1016,8 @@ onMounted(fetchSkills) // 暴露方法给父组件 defineExpose({ fetchSkills, - handleImportUpload + handleImportUpload, + openRemoteInstallModal }) @@ -913,6 +1044,62 @@ defineExpose({ overflow: hidden; } +.remote-install-panel { + background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-50) 100%); + border: 1px solid @border-color; + border-radius: 12px; + padding: 16px; + + &.modal-mode { + border: none; + border-radius: 0; + padding: 0; + background: transparent; + } + + .panel-header-text { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + + .title { + font-size: 14px; + font-weight: 600; + color: var(--gray-900); + } + + .desc { + font-size: 12px; + color: var(--gray-500); + } + } + + .remote-install-form { + :deep(.ant-form-item) { + margin-bottom: 12px; + } + } + + .remote-install-actions { + display: flex; + gap: 8px; + } + + .remote-skill-hints { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + } + + .remote-skill-summary { + margin-top: 12px; + font-size: 12px; + color: var(--gray-500); + } +} + /* 文件 tree */ .tree-container { width: 240px; diff --git a/web/src/views/ExtensionsView.vue b/web/src/views/ExtensionsView.vue index 873612959..84db1a74c 100644 --- a/web/src/views/ExtensionsView.vue +++ b/web/src/views/ExtensionsView.vue @@ -10,6 +10,14 @@