From df08fea2b8b19b1904ac621e4ee8ca3b68818f75 Mon Sep 17 00:00:00 2001 From: supreme0597 Date: Sun, 24 May 2026 01:20:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E6=94=AF=E6=8C=81=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E5=AE=89=E8=A3=85=E6=90=9C=E7=B4=A2=E3=80=81=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增全局搜索 skills.sh API (POST /system/skills/remote/search) - 后端新增批量删除接口 (POST /system/skills/delete-batch),使用行锁防并发 - skill_repository.get_by_slug 支持 for_update 参数 - 前端远程安装弹框重构:Tab 双视图(按仓库拉取/全局搜索)、分页列表 - 前端仓库输入框新增历史记录功能(localStorage 持久化),支持逐条/批量清空 - 前端批量管理模式:全选/反选/清空 + 二次确认批量删除 - 修复 dropdown overlay teleport 到 body 导致 scoped CSS 无法穿透的样式问题 - 补充批量删除及并发锁单元测试 --- .../yuxi/repositories/skill_repository.py | 7 +- .../services/remote_skill_install_service.py | 46 + .../package/yuxi/services/skill_service.py | 16 +- backend/server/routers/skill_router.py | 59 +- .../test_remote_skill_install_service.py | 58 + .../test/unit/services/test_skill_service.py | 93 ++ web/src/apis/skill_api.js | 12 +- web/src/components/AgentChatComponent.vue | 8 +- .../components/extensions/SkillCardList.vue | 1352 ++++++++++++++--- 9 files changed, 1462 insertions(+), 189 deletions(-) diff --git a/backend/package/yuxi/repositories/skill_repository.py b/backend/package/yuxi/repositories/skill_repository.py index d858d50d..645f3210 100644 --- a/backend/package/yuxi/repositories/skill_repository.py +++ b/backend/package/yuxi/repositories/skill_repository.py @@ -15,8 +15,11 @@ class SkillRepository: result = await self.db.execute(select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc())) return list(result.scalars().all()) - async def get_by_slug(self, slug: str) -> Skill | None: - result = await self.db.execute(select(Skill).where(Skill.slug == slug)) + async def get_by_slug(self, slug: str, *, for_update: bool = False) -> Skill | None: + stmt = select(Skill).where(Skill.slug == slug) + if for_update: + stmt = stmt.with_for_update() + result = await self.db.execute(stmt) return result.scalar_one_or_none() async def exists_slug(self, slug: str) -> bool: diff --git a/backend/package/yuxi/services/remote_skill_install_service.py b/backend/package/yuxi/services/remote_skill_install_service.py index 7a65383d..e5f8fa6f 100644 --- a/backend/package/yuxi/services/remote_skill_install_service.py +++ b/backend/package/yuxi/services/remote_skill_install_service.py @@ -339,3 +339,49 @@ def _find_skill_dir(skills_dir: Path, name: str) -> Path | None: if candidate.name == name and candidate.is_dir(): return candidate return None + + +def _parse_search_skills(output: str) -> list[dict[str, str]]: + """解析 npx skills find 命令的输出。""" + lines = _clean_cli_output(output) + results: list[dict[str, str]] = [] + # 匹配形如 "owner/repo@skill-name [installs]" + # 例如:vercel-labs/agent-skills@web-design-guidelines 339.3K installs + pattern = re.compile(r"^([a-zA-Z0-9_\-\.]+/[a-zA-Z0-9_\-\.]+)\@([a-zA-Z0-9_\-\.]+)(?:\s+(.*))?$") + for line in lines: + line = line.strip() + if not line: + continue + match = pattern.match(line) + if match: + source, name, extra = match.groups() + installs = extra.strip() if extra else "" + results.append( + { + "source": source, + "name": name, + "installs": installs, + } + ) + return results + + +async def search_remote_skills(query: str) -> list[dict[str, str]]: + """使用 npx skills find 搜索远程 skills。""" + query_val = str(query or "").strip() + if not query_val: + return [] + if any(ch in query_val for ch in ("\n", "\r", "\x00")): + raise ValueError("搜索关键字包含非法字符") + + temp_home, env, workdir = _create_isolated_workdir() + try: + output = await _run_skills_cli( + ["npx", "-y", "skills", "find", query_val], + env=env, + cwd=workdir, + ) + finally: + shutil.rmtree(temp_home, ignore_errors=True) + + return _parse_search_skills(output) diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index e1714f09..34fb5958 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -755,7 +755,7 @@ async def export_skill_zip(db: AsyncSession, slug: str) -> tuple[str, str]: async def delete_skill(db: AsyncSession, *, slug: str) -> None: repo = SkillRepository(db) - item = await repo.get_by_slug(slug) + item = await repo.get_by_slug(slug, for_update=True) if not item: raise ValueError(f"技能 '{slug}' 不存在") @@ -777,6 +777,20 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None: shutil.rmtree(trash_dir, ignore_errors=True) +async def delete_skills_batch(db: AsyncSession, *, slugs: list[str]) -> list[dict]: + """批量删除多个 skills(单技能独立的子事务与回滚)。""" + results = [] + for slug in slugs: + try: + await delete_skill(db, slug=slug) + results.append({"slug": slug, "success": True}) + except Exception as e: + if hasattr(db, "rollback"): + await db.rollback() + results.append({"slug": slug, "success": False, "error": str(e)}) + return results + + async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> None: """校验内置 skills 配置,不执行安装。""" specs = get_builtin_skill_specs() diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index 2d951b99..817f81b6 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -10,12 +10,18 @@ from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from server.utils.auth_middleware import get_admin_user, get_db -from yuxi.services.remote_skill_install_service import install_remote_skill, install_remote_skills_batch, list_remote_skills +from yuxi.services.remote_skill_install_service import ( + install_remote_skill, + install_remote_skills_batch, + list_remote_skills, + search_remote_skills, +) from yuxi.services.skill_service import ( BuiltinSkillUpdateConflictError, create_skill_node, delete_skill, delete_skill_node, + delete_skills_batch, export_skill_zip, get_skill_dependency_options, get_skill_tree, @@ -67,6 +73,14 @@ class RemoteSkillBatchInstallRequest(RemoteSkillSourceRequest): skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)") +class RemoteSkillSearchRequest(BaseModel): + query: str = Field(..., description="搜索关键字") + + +class SkillBatchDeleteRequest(BaseModel): + slugs: list[str] = Field(..., description="需要批量删除的 skill slug 列表") + + def _raise_from_value_error(e: ValueError) -> None: message = str(e) status_code = 404 if "不存在" in message else 400 @@ -287,6 +301,24 @@ async def install_remote_skills_batch_route( raise HTTPException(status_code=500, detail="批量安装远程 skills 失败") +@skills.post("/remote/search") +async def search_remote_skills_route( + payload: RemoteSkillSearchRequest, + _current_user: User = Depends(get_admin_user), +): + """搜索远程公开的 skills(管理员)。""" + try: + data = await search_remote_skills(payload.query) + return {"success": True, "data": data} + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to search remote skills with query '{payload.query}': {e}") + raise HTTPException(status_code=500, detail="搜索远程 skills 失败") + + @skills.get("/{slug}/tree") async def get_skill_tree_route( slug: str, @@ -468,3 +500,28 @@ async def delete_skill_route( except Exception as e: logger.error(f"Failed to delete skill '{slug}': {e}") raise HTTPException(status_code=500, detail="删除技能失败") + + +@skills.post("/delete-batch") +async def delete_skills_batch_route( + payload: SkillBatchDeleteRequest, + _current_user: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """批量删除技能(目录 + 数据库记录,管理员)。""" + try: + results = await delete_skills_batch(db, slugs=payload.slugs) + success_count = sum(1 for r in results if r["success"]) + failed_count = sum(1 for r in results if not r["success"]) + return { + "success": True, + "data": results, + "summary": {"total": len(results), "success": success_count, "failed": failed_count}, + } + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete skills batch: {e}") + raise HTTPException(status_code=500, detail="批量删除技能失败") diff --git a/backend/test/unit/services/test_remote_skill_install_service.py b/backend/test/unit/services/test_remote_skill_install_service.py index 5d00836a..e3d6f65d 100644 --- a/backend/test/unit/services/test_remote_skill_install_service.py +++ b/backend/test/unit/services/test_remote_skill_install_service.py @@ -308,3 +308,61 @@ async def test_install_remote_skills_batch_handles_invalid_names(monkeypatch: py assert "--skill" in str(calls[0][0]) assert "valid-skill" in str(calls[0][0]) assert "Bad" not in str(calls[0][0]) + + +def test_parse_search_skills() -> None: + output = """ + Install with npx skills add + + vercel-labs/agent-skills@web-design-guidelines 339.3K installs + └ https://skills.sh/vercel-labs/agent-skills/web-design-guidelines + + xixu-me/skills@secure-linux-web-hosting 158.6K installs + └ https://skills.sh/xixu-me/skills/secure-linux-web-hosting + + anthropics/skills@webapp-testing + └ https://skills.sh/anthropics/skills/webapp-testing + """ + + results = svc._parse_search_skills(output) + assert results == [ + { + "source": "vercel-labs/agent-skills", + "name": "web-design-guidelines", + "installs": "339.3K installs", + }, + { + "source": "xixu-me/skills", + "name": "secure-linux-web-hosting", + "installs": "158.6K installs", + }, + { + "source": "anthropics/skills", + "name": "webapp-testing", + "installs": "", + }, + ] + + +@pytest.mark.asyncio +async def test_search_remote_skills(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str: + captured["args"] = args + return """ + vercel-labs/agent-skills@web-design-guidelines 339.3K installs + """ + + monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli) + + items = await svc.search_remote_skills("web") + assert items == [ + { + "source": "vercel-labs/agent-skills", + "name": "web-design-guidelines", + "installs": "339.3K installs", + } + ] + assert captured["args"] == ["npx", "-y", "skills", "find", "web"] + diff --git a/backend/test/unit/services/test_skill_service.py b/backend/test/unit/services/test_skill_service.py index 94ac800a..3d99e8fb 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/test_skill_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import io import zipfile from pathlib import Path @@ -1010,3 +1011,95 @@ async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pyte content="new content", updated_by="root", ) + + +@pytest.mark.asyncio +async def test_delete_skills_batch_ok(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + # 模拟两个已安装的技能 + (tmp_path / "skills" / "skill-a").mkdir(parents=True, exist_ok=True) + (tmp_path / "skills" / "skill-b").mkdir(parents=True, exist_ok=True) + + item_a = Skill(slug="skill-a", name="skill-a", description="a", dir_path="skills/skill-a") + item_b = Skill(slug="skill-b", name="skill-b", description="b", dir_path="skills/skill-b") + + db_items = {"skill-a": item_a, "skill-b": item_b} + deleted_slugs = [] + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str, *, for_update: bool = False): + return db_items.get(slug) + + async def delete(self, item: Skill): + deleted_slugs.append(item.slug) + db_items.pop(item.slug, None) + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + # 执行批量删除,skill-a, skill-b, skill-c (不存在) + results = await svc.delete_skills_batch(None, slugs=["skill-a", "skill-b", "skill-c"]) + + assert results == [ + {"slug": "skill-a", "success": True}, + {"slug": "skill-b", "success": True}, + {"slug": "skill-c", "success": False, "error": "技能 'skill-c' 不存在"}, + ] + assert deleted_slugs == ["skill-a", "skill-b"] + assert not (tmp_path / "skills" / "skill-a").exists() + assert not (tmp_path / "skills" / "skill-b").exists() + + +@pytest.mark.asyncio +async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + (tmp_path / "skills" / "concurrent-skill").mkdir(parents=True, exist_ok=True) + + item = Skill(slug="concurrent-skill", name="concurrent-skill", description="desc", dir_path="skills/concurrent-skill") + + db_items = {"concurrent-skill": item} + lock_active = asyncio.Lock() + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str, *, for_update: bool = False): + # 用 asyncio.Lock 模拟 with_for_update() 的排他锁 + if for_update: + await lock_active.acquire() + try: + return db_items.get(slug) + finally: + pass + else: + return db_items.get(slug) + + async def delete(self, item: Skill): + db_items.pop(item.slug, None) + if lock_active.locked(): + lock_active.release() + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + # 同时发起两个 delete_skill 调用 + task1 = asyncio.create_task(svc.delete_skill(None, slug="concurrent-skill")) + task2 = asyncio.create_task(svc.delete_skill(None, slug="concurrent-skill")) + + results = await asyncio.gather(task1, task2, return_exceptions=True) + + success_count = 0 + error_count = 0 + for r in results: + if r is None: + success_count += 1 + elif isinstance(r, ValueError) and "不存在" in str(r): + error_count += 1 + + assert success_count == 1 + assert error_count == 1 + assert not (tmp_path / "skills" / "concurrent-skill").exists() + diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index a33f289e..4ffb2fbb 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -24,6 +24,10 @@ export const installRemoteSkillsBatch = async (payload) => { return apiAdminPost(`${BASE_URL}/remote/install-batch`, payload) } +export const searchRemoteSkills = async (query) => { + return apiAdminPost(`${BASE_URL}/remote/search`, { query }) +} + export const getSkillDependencyOptions = async () => { return apiAdminGet(`${BASE_URL}/dependency-options`) } @@ -76,12 +80,17 @@ export const deleteSkill = async (slug) => { return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}`) } +export const deleteSkillsBatch = async (slugs) => { + return apiAdminPost(`${BASE_URL}/delete-batch`, { slugs }) +} + export const skillApi = { listSkills, importSkillZip, listRemoteSkills, installRemoteSkill, installRemoteSkillsBatch, + searchRemoteSkills, getSkillDependencyOptions, listBuiltinSkills, installBuiltinSkill, @@ -93,7 +102,8 @@ export const skillApi = { updateSkillDependencies, deleteSkillFile, exportSkill, - deleteSkill + deleteSkill, + deleteSkillsBatch } export default skillApi diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 2c78e1ca..48d9c59d 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -1614,10 +1614,10 @@ const hasVisibleAssistantBody = (message) => { const { content, reasoningContent } = extractAssistantMessageBody(message) return Boolean( content || - reasoningContent || - message.error_type || - message.extra_metadata?.error_type || - message.isStoppedByUser + reasoningContent || + message.error_type || + message.extra_metadata?.error_type || + message.isStoppedByUser ) } diff --git a/web/src/components/extensions/SkillCardList.vue b/web/src/components/extensions/SkillCardList.vue index 87a96d49..8acbd532 100644 --- a/web/src/components/extensions/SkillCardList.vue +++ b/web/src/components/extensions/SkillCardList.vue @@ -2,31 +2,55 @@
@@ -42,17 +66,32 @@ 已添加 Skills
- - + + + +
@@ -77,65 +116,325 @@ v-model:open="remoteInstallModalVisible" title="远程安装 Skill" :footer="null" - width="560px" + width="760px" :closable="!installingRemoteSkill" :mask-closable="!installingRemoteSkill" :keyboard="!installingRemoteSkill" >