Merge pull request #727 from supreme0597/feat/skills-remote-install
feat(skills): 支持远程安装搜索、仓库历史记录与批量删除
This commit is contained in:
commit
c77311afe7
@ -15,8 +15,11 @@ class SkillRepository:
|
|||||||
result = await self.db.execute(select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc()))
|
result = await self.db.execute(select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc()))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def get_by_slug(self, slug: str) -> Skill | None:
|
async def get_by_slug(self, slug: str, *, for_update: bool = False) -> Skill | None:
|
||||||
result = await self.db.execute(select(Skill).where(Skill.slug == slug))
|
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()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def exists_slug(self, slug: str) -> bool:
|
async def exists_slug(self, slug: str) -> bool:
|
||||||
|
|||||||
@ -25,9 +25,9 @@ class RemoteSkillsBatchPreparation:
|
|||||||
temp_home: str | None
|
temp_home: str | None
|
||||||
results: list[dict]
|
results: list[dict]
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
if self.temp_home:
|
if self.temp_home:
|
||||||
shutil.rmtree(self.temp_home, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, self.temp_home, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_source(source: str) -> str:
|
def _normalize_source(source: str) -> str:
|
||||||
@ -152,7 +152,7 @@ async def list_remote_skills(source: str) -> list[dict[str, str]]:
|
|||||||
cwd=workdir,
|
cwd=workdir,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(temp_home, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
|
||||||
|
|
||||||
skills = _parse_available_skills(output)
|
skills = _parse_available_skills(output)
|
||||||
if not skills:
|
if not skills:
|
||||||
@ -219,7 +219,7 @@ async def install_remote_skill(
|
|||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(temp_home, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
async def install_remote_skills_batch(
|
async def install_remote_skills_batch(
|
||||||
@ -262,7 +262,7 @@ async def install_remote_skills_batch(
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
finally:
|
finally:
|
||||||
preparation.cleanup()
|
await preparation.cleanup()
|
||||||
|
|
||||||
|
|
||||||
async def prepare_remote_skills_batch(
|
async def prepare_remote_skills_batch(
|
||||||
@ -327,7 +327,7 @@ async def prepare_remote_skills_batch(
|
|||||||
|
|
||||||
return RemoteSkillsBatchPreparation(temp_home=temp_home, results=results)
|
return RemoteSkillsBatchPreparation(temp_home=temp_home, results=results)
|
||||||
except Exception:
|
except Exception:
|
||||||
shutil.rmtree(temp_home, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@ -339,3 +339,49 @@ def _find_skill_dir(skills_dir: Path, name: str) -> Path | None:
|
|||||||
if candidate.name == name and candidate.is_dir():
|
if candidate.name == name and candidate.is_dir():
|
||||||
return candidate
|
return candidate
|
||||||
return None
|
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:
|
||||||
|
await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
|
||||||
|
|
||||||
|
return _parse_search_skills(output)
|
||||||
|
|||||||
@ -459,12 +459,12 @@ async def _import_skill_dir_impl(
|
|||||||
|
|
||||||
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
|
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
|
||||||
if temp_target.exists():
|
if temp_target.exists():
|
||||||
shutil.rmtree(temp_target)
|
await asyncio.to_thread(shutil.rmtree, temp_target)
|
||||||
shutil.move(str(stage_dir), str(temp_target))
|
shutil.move(str(stage_dir), str(temp_target))
|
||||||
|
|
||||||
final_dir = skills_root / final_slug
|
final_dir = skills_root / final_slug
|
||||||
if final_dir.exists():
|
if final_dir.exists():
|
||||||
shutil.rmtree(temp_target, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, temp_target, ignore_errors=True)
|
||||||
raise ValueError(f"技能目录冲突,请重试: {final_slug}")
|
raise ValueError(f"技能目录冲突,请重试: {final_slug}")
|
||||||
temp_target.rename(final_dir)
|
temp_target.rename(final_dir)
|
||||||
|
|
||||||
@ -480,7 +480,7 @@ async def _import_skill_dir_impl(
|
|||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
shutil.rmtree(final_dir, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, final_dir, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return item
|
return item
|
||||||
@ -728,7 +728,7 @@ async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str)
|
|||||||
raise ValueError("不允许删除根目录 SKILL.md")
|
raise ValueError("不允许删除根目录 SKILL.md")
|
||||||
|
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
shutil.rmtree(target)
|
await asyncio.to_thread(shutil.rmtree, target)
|
||||||
else:
|
else:
|
||||||
target.unlink()
|
target.unlink()
|
||||||
|
|
||||||
@ -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:
|
async def delete_skill(db: AsyncSession, *, slug: str) -> None:
|
||||||
repo = SkillRepository(db)
|
repo = SkillRepository(db)
|
||||||
item = await repo.get_by_slug(slug)
|
item = await repo.get_by_slug(slug, for_update=True)
|
||||||
if not item:
|
if not item:
|
||||||
raise ValueError(f"技能 '{slug}' 不存在")
|
raise ValueError(f"技能 '{slug}' 不存在")
|
||||||
|
|
||||||
@ -774,7 +774,23 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
if trash_dir and trash_dir.exists():
|
if trash_dir and trash_dir.exists():
|
||||||
shutil.rmtree(trash_dir, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, trash_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_skills_batch(db: AsyncSession, *, slugs: list[str]) -> list[dict]:
|
||||||
|
"""批量删除多个 skills(单技能独立的子事务与回滚)。"""
|
||||||
|
if len(slugs) > 50:
|
||||||
|
raise ValueError("批量删除的技能数量不能超过 50 个")
|
||||||
|
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:
|
async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> None:
|
||||||
@ -883,7 +899,7 @@ async def install_builtin_skill(db: AsyncSession, slug: str, *, installed_by: st
|
|||||||
created_by=installed_by or BUILTIN_SKILL_OPERATOR,
|
created_by=installed_by or BUILTIN_SKILL_OPERATOR,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
shutil.rmtree(target_dir, ignore_errors=True)
|
await asyncio.to_thread(shutil.rmtree, target_dir, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from yuxi.services.skill_service import (
|
|||||||
create_skill_node,
|
create_skill_node,
|
||||||
delete_skill,
|
delete_skill,
|
||||||
delete_skill_node,
|
delete_skill_node,
|
||||||
|
delete_skills_batch,
|
||||||
export_skill_zip,
|
export_skill_zip,
|
||||||
get_skill_dependency_options,
|
get_skill_dependency_options,
|
||||||
get_skill_tree,
|
get_skill_tree,
|
||||||
@ -71,6 +72,14 @@ class RemoteSkillBatchInstallRequest(RemoteSkillSourceRequest):
|
|||||||
skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)")
|
skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)")
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteSkillSearchRequest(BaseModel):
|
||||||
|
query: str = Field(..., description="搜索关键字")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillBatchDeleteRequest(BaseModel):
|
||||||
|
slugs: list[str] = Field(..., max_length=50, description="需要批量删除的 skill slug 列表,最多支持 50 个")
|
||||||
|
|
||||||
|
|
||||||
def _raise_from_value_error(e: ValueError) -> None:
|
def _raise_from_value_error(e: ValueError) -> None:
|
||||||
message = str(e)
|
message = str(e)
|
||||||
status_code = 404 if "不存在" in message else 400
|
status_code = 404 if "不存在" in message else 400
|
||||||
@ -303,6 +312,24 @@ async def install_remote_skills_batch_route(
|
|||||||
raise HTTPException(status_code=500, detail="批量安装远程 skills 失败")
|
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")
|
@skills.get("/{slug}/tree")
|
||||||
async def get_skill_tree_route(
|
async def get_skill_tree_route(
|
||||||
slug: str,
|
slug: str,
|
||||||
@ -484,3 +511,28 @@ async def delete_skill_route(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete skill '{slug}': {e}")
|
logger.error(f"Failed to delete skill '{slug}': {e}")
|
||||||
raise HTTPException(status_code=500, detail="删除技能失败")
|
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="批量删除技能失败")
|
||||||
|
|||||||
@ -308,3 +308,61 @@ async def test_install_remote_skills_batch_handles_invalid_names(monkeypatch: py
|
|||||||
assert "--skill" in str(calls[0][0])
|
assert "--skill" in str(calls[0][0])
|
||||||
assert "valid-skill" in str(calls[0][0])
|
assert "valid-skill" in str(calls[0][0])
|
||||||
assert "Bad" not 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"]
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -1010,3 +1011,102 @@ async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pyte
|
|||||||
content="new content",
|
content="new content",
|
||||||
updated_by="root",
|
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_skills_batch_limit_exceeded():
|
||||||
|
slugs = [f"skill-{i}" for i in range(51)]
|
||||||
|
with pytest.raises(ValueError, match="批量删除的技能数量不能超过 50 个"):
|
||||||
|
await svc.delete_skills_batch(None, slugs=slugs)
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export const installRemoteSkillsBatch = async (payload) => {
|
|||||||
return apiAdminPost(`${BASE_URL}/remote/install-batch`, 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 () => {
|
export const getSkillDependencyOptions = async () => {
|
||||||
return apiAdminGet(`${BASE_URL}/dependency-options`)
|
return apiAdminGet(`${BASE_URL}/dependency-options`)
|
||||||
}
|
}
|
||||||
@ -76,12 +80,17 @@ export const deleteSkill = async (slug) => {
|
|||||||
return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}`)
|
return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deleteSkillsBatch = async (slugs) => {
|
||||||
|
return apiAdminPost(`${BASE_URL}/delete-batch`, { slugs })
|
||||||
|
}
|
||||||
|
|
||||||
export const skillApi = {
|
export const skillApi = {
|
||||||
listSkills,
|
listSkills,
|
||||||
importSkillZip,
|
importSkillZip,
|
||||||
listRemoteSkills,
|
listRemoteSkills,
|
||||||
installRemoteSkill,
|
installRemoteSkill,
|
||||||
installRemoteSkillsBatch,
|
installRemoteSkillsBatch,
|
||||||
|
searchRemoteSkills,
|
||||||
getSkillDependencyOptions,
|
getSkillDependencyOptions,
|
||||||
listBuiltinSkills,
|
listBuiltinSkills,
|
||||||
installBuiltinSkill,
|
installBuiltinSkill,
|
||||||
@ -93,7 +102,8 @@ export const skillApi = {
|
|||||||
updateSkillDependencies,
|
updateSkillDependencies,
|
||||||
deleteSkillFile,
|
deleteSkillFile,
|
||||||
exportSkill,
|
exportSkill,
|
||||||
deleteSkill
|
deleteSkill,
|
||||||
|
deleteSkillsBatch
|
||||||
}
|
}
|
||||||
|
|
||||||
export default skillApi
|
export default skillApi
|
||||||
|
|||||||
@ -1614,10 +1614,10 @@ const hasVisibleAssistantBody = (message) => {
|
|||||||
const { content, reasoningContent } = extractAssistantMessageBody(message)
|
const { content, reasoningContent } = extractAssistantMessageBody(message)
|
||||||
return Boolean(
|
return Boolean(
|
||||||
content ||
|
content ||
|
||||||
reasoningContent ||
|
reasoningContent ||
|
||||||
message.error_type ||
|
message.error_type ||
|
||||||
message.extra_metadata?.error_type ||
|
message.extra_metadata?.error_type ||
|
||||||
message.isStoppedByUser
|
message.isStoppedByUser
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -100,7 +100,10 @@ const copySvgAsPng = async (svgEl, btn) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) 回退
|
// 3) 回退
|
||||||
if (!width || !height) { width = 800; height = 600 }
|
if (!width || !height) {
|
||||||
|
width = 800
|
||||||
|
height = 600
|
||||||
|
}
|
||||||
|
|
||||||
const img = await new Promise((resolve, reject) => {
|
const img = await new Promise((resolve, reject) => {
|
||||||
const image = new Image()
|
const image = new Image()
|
||||||
@ -117,11 +120,9 @@ const copySvgAsPng = async (svgEl, btn) => {
|
|||||||
// 背景色由 SVG 自身决定
|
// 背景色由 SVG 自身决定
|
||||||
ctx.drawImage(img, 0, 0, width, height)
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
|
||||||
const pngBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'))
|
const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||||
if (pngBlob) {
|
if (pngBlob) {
|
||||||
await navigator.clipboard.write([
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||||
new ClipboardItem({ 'image/png': pngBlob })
|
|
||||||
])
|
|
||||||
showCopiedFeedback(btn)
|
showCopiedFeedback(btn)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,10 @@ const run = () => {
|
|||||||
const lines = result.split('\n')
|
const lines = result.split('\n')
|
||||||
assert.equal(lines.length, 1, 'Should be compressed to single line')
|
assert.equal(lines.length, 1, 'Should be compressed to single line')
|
||||||
assert.ok(result.includes('svg-inline-render'), 'Should contain wrapper')
|
assert.ok(result.includes('svg-inline-render'), 'Should contain wrapper')
|
||||||
assert.ok(result.includes('<stop offset="0%"/><stop offset="100%"/>'), 'Blank lines should be removed between tags')
|
assert.ok(
|
||||||
|
result.includes('<stop offset="0%"/><stop offset="100%"/>'),
|
||||||
|
'Blank lines should be removed between tags'
|
||||||
|
)
|
||||||
assert.ok(result.includes('svg-copy-btn'), 'Buttons should be inside single-line output')
|
assert.ok(result.includes('svg-copy-btn'), 'Buttons should be inside single-line output')
|
||||||
console.log('T3 Blank lines compressed: PASS')
|
console.log('T3 Blank lines compressed: PASS')
|
||||||
}
|
}
|
||||||
@ -162,4 +165,4 @@ const run = () => {
|
|||||||
console.log('\nAll 16 tests passed!')
|
console.log('\nAll 16 tests passed!')
|
||||||
}
|
}
|
||||||
|
|
||||||
run()
|
run()
|
||||||
|
|||||||
@ -32,10 +32,10 @@ export function renderSvgBlocks(markdown) {
|
|||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const closeMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*$/)
|
const closeMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*$/)
|
||||||
if (
|
if (
|
||||||
closeMatch
|
closeMatch &&
|
||||||
&& closeMatch[1].length <= indent.length
|
closeMatch[1].length <= indent.length &&
|
||||||
&& closeMatch[2][0] === fenceChar[0]
|
closeMatch[2][0] === fenceChar[0] &&
|
||||||
&& closeMatch[2].length >= fenceChar.length
|
closeMatch[2].length >= fenceChar.length
|
||||||
) {
|
) {
|
||||||
closed = true
|
closed = true
|
||||||
// 压缩 SVG 为单行,防止 markdown-it HTML 块解析截断
|
// 压缩 SVG 为单行,防止 markdown-it HTML 块解析截断
|
||||||
@ -70,4 +70,4 @@ export function renderSvgBlocks(markdown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return output.join('\n')
|
return output.join('\n')
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user