feat(skills): 支持远程安装搜索、仓库历史记录与批量删除

- 后端新增全局搜索 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 无法穿透的样式问题
- 补充批量删除及并发锁单元测试
This commit is contained in:
supreme0597 2026-05-24 01:20:45 +08:00
parent c3656439de
commit df08fea2b8
9 changed files with 1462 additions and 189 deletions

View File

@ -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:

View File

@ -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 <query> 搜索远程 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)

View File

@ -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()

View File

@ -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="批量删除技能失败")

View File

@ -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 <owner/repo@skill>
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"]

View File

@ -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()

View File

@ -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

View File

@ -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
)
}

File diff suppressed because it is too large Load Diff