Merge pull request #695 from supreme0597/feat/skill-batch-install

feat(skills): 优化远程 Skill 批量安装,单次克隆完成多 skill 安装
This commit is contained in:
Wenjie Zhang 2026-05-14 18:17:51 +08:00 committed by GitHub
commit 46bd6d22ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 321 additions and 18 deletions

View File

@ -209,3 +209,104 @@ async def install_remote_skill(
)
finally:
shutil.rmtree(temp_home, ignore_errors=True)
async def install_remote_skills_batch(
db: AsyncSession,
*,
source: str,
skills: list[str],
created_by: str | None,
) -> list[dict]:
"""批量从同一个远程仓库安装多个 skills仅一次克隆
Args:
db: 数据库会话
source: 远程仓库来源 ``owner/repo`` GitHub URL
skills: 需要安装的 skill 名称列表
created_by: 操作者标识
Returns:
每个 skill 的安装结果列表顺序与请求一致: ``[{slug, success, error?}, ...]``
"""
normalized_source = _normalize_source(source)
if not skills:
raise ValueError("skills 列表不能为空")
# 预分配结果数组(按请求顺序),校验非法名并记录失败
results: list[dict] = [{"slug": "", "success": False, "error": "unset"}] * len(skills)
normalized_skills: list[str] = []
valid_indices: list[int] = []
for i, skill in enumerate(skills):
try:
normalized_skills.append(_normalize_skill_name(skill))
valid_indices.append(i)
except ValueError as e:
results[i] = {"slug": skill, "success": False, "error": str(e)}
if not normalized_skills:
return results
temp_home, env, workdir = _create_isolated_workdir()
try:
# Step 1: 一次 npx 调用安装所有 skill克隆一次
skill_args: list[str] = []
for name in normalized_skills:
skill_args.extend(["--skill", name])
cli_failed = False
try:
await _run_skills_cli(
[
"npx",
"-y",
"skills",
"add",
normalized_source,
*skill_args,
"-g",
"-y",
"--copy",
],
env=env,
cwd=workdir,
)
except ValueError:
# CLI 对不匹配的 skill 会退出码非零,但已安装的目录仍在
cli_failed = True
# Step 2: 从临时目录中找到各 skill 的安装目录并逐个导入
base_dir = Path(temp_home).resolve()
skills_dir = base_dir / ".agents" / "skills"
for original_index, name in zip(valid_indices, normalized_skills):
installed_dir = _find_skill_dir(skills_dir, name)
if installed_dir is None:
error_msg = "CLI 安装失败" if cli_failed else "skills CLI 未生成预期的技能目录"
results[original_index] = {"slug": name, "success": False, "error": error_msg}
continue
try:
item = await import_skill_dir(
db,
source_dir=installed_dir,
created_by=created_by,
)
results[original_index] = {"slug": item.slug, "success": True}
except Exception as e:
await db.rollback()
results[original_index] = {"slug": name, "success": False, "error": str(e)}
return results
finally:
shutil.rmtree(temp_home, ignore_errors=True)
def _find_skill_dir(skills_dir: Path, name: str) -> Path | None:
"""在 skills 安装目录下按名称查找 skill 子目录。"""
if not skills_dir.is_dir():
return None
for candidate in skills_dir.iterdir():
if candidate.name == name and candidate.is_dir():
return candidate
return None

View File

@ -10,7 +10,7 @@ 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, list_remote_skills
from yuxi.services.remote_skill_install_service import install_remote_skill, install_remote_skills_batch, list_remote_skills
from yuxi.services.skill_service import (
BuiltinSkillUpdateConflictError,
create_skill_node,
@ -63,6 +63,10 @@ class RemoteSkillInstallRequest(RemoteSkillSourceRequest):
skill: str = Field(..., description="需要安装的 skill 名称")
class RemoteSkillBatchInstallRequest(RemoteSkillSourceRequest):
skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)")
def _raise_from_value_error(e: ValueError) -> None:
message = str(e)
status_code = 404 if "不存在" in message else 400
@ -251,6 +255,38 @@ async def install_remote_skill_route(
raise HTTPException(status_code=500, detail="安装远程 skill 失败")
@skills.post("/remote/install-batch")
async def install_remote_skills_batch_route(
payload: RemoteSkillBatchInstallRequest,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""批量从同一远程仓库安装多个 skills仅一次克隆不存在的 skill 静默跳过)。"""
try:
results = await install_remote_skills_batch(
db,
source=payload.source,
skills=payload.skills,
created_by=current_user.username,
)
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 install remote skills batch from '{payload.source}': {e}"
)
raise HTTPException(status_code=500, detail="批量安装远程 skills 失败")
@skills.get("/{slug}/tree")
async def get_skill_tree_route(
slug: str,

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -143,3 +144,167 @@ async def test_install_remote_skill_rejects_missing_remote_skill(monkeypatch: py
skill="frontend-design",
created_by="root",
)
@pytest.mark.asyncio
async def test_install_remote_skills_batch_installs_all(monkeypatch: pytest.MonkeyPatch):
calls: list[tuple[list[str], str]] = []
imported_skills: list[str] = []
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
calls.append((args, env["HOME"]))
home = Path(env["HOME"])
skill_dir_base = home / ".agents" / "skills"
for skill_name in ("frontend-design", "claude-api", "code-review"):
(skill_dir_base / skill_name).mkdir(parents=True, exist_ok=True)
(skill_dir_base / skill_name / "SKILL.md").write_text(
f"---\nname: {skill_name}\ndescription: demo\n---\n# {skill_name}\n",
encoding="utf-8",
)
return "installed"
async def fake_import_skill_dir(_db, *, source_dir, created_by):
imported_skills.append(source_dir.name)
return SimpleNamespace(slug=source_dir.name)
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
monkeypatch.setattr(svc, "import_skill_dir", fake_import_skill_dir)
results = await svc.install_remote_skills_batch(
None,
source="anthropics/skills",
skills=["frontend-design", "claude-api", "code-review"],
created_by="root",
)
# Should only have 1 CLI call (no --list, direct batch install)
assert len(calls) == 1
assert calls[0][0] == [
"npx",
"-y",
"skills",
"add",
"anthropics/skills",
"--skill",
"frontend-design",
"--skill",
"claude-api",
"--skill",
"code-review",
"-g",
"-y",
"--copy",
]
assert len(results) == 3
assert all(r["success"] for r in results)
assert [r["slug"] for r in results] == ["frontend-design", "claude-api", "code-review"]
assert imported_skills == ["frontend-design", "claude-api", "code-review"]
@pytest.mark.asyncio
async def test_install_remote_skills_batch_skips_missing(monkeypatch: pytest.MonkeyPatch):
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
home = Path(env["HOME"])
skill_dir_base = home / ".agents" / "skills"
(skill_dir_base / "frontend-design").mkdir(parents=True, exist_ok=True)
(skill_dir_base / "frontend-design" / "SKILL.md").write_text(
"---\nname: frontend-design\ndescription: demo\n---\n# Demo\n",
encoding="utf-8",
)
return "installed"
async def fake_import_skill_dir(_db, *, source_dir, created_by):
return SimpleNamespace(slug=source_dir.name)
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
monkeypatch.setattr(svc, "import_skill_dir", fake_import_skill_dir)
results = await svc.install_remote_skills_batch(
None,
source="anthropics/skills",
skills=["frontend-design", "nonexistent-skill"],
created_by="root",
)
assert len(results) == 2
assert results[0] == {"slug": "frontend-design", "success": True}
assert results[1] == {"slug": "nonexistent-skill", "success": False, "error": "skills CLI 未生成预期的技能目录"}
@pytest.mark.asyncio
async def test_install_remote_skills_batch_partial_failure(monkeypatch: pytest.MonkeyPatch):
calls: list[tuple[list[str], str]] = []
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
calls.append((args, env["HOME"]))
home = Path(env["HOME"])
skill_dir_base = home / ".agents" / "skills"
(skill_dir_base / "skill-a").mkdir(parents=True, exist_ok=True)
(skill_dir_base / "skill-a" / "SKILL.md").write_text(
"---\nname: skill-a\ndescription: demo\n---\n# A\n", encoding="utf-8",
)
# skill-b directory missing (simulate install failure from CLI side)
(skill_dir_base / "skill-c").mkdir(parents=True, exist_ok=True)
(skill_dir_base / "skill-c" / "SKILL.md").write_text(
"---\nname: skill-c\ndescription: demo\n---\n# C\n", encoding="utf-8",
)
return "installed"
async def fake_import_skill_dir(_db, *, source_dir, created_by):
return SimpleNamespace(slug=source_dir.name)
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
monkeypatch.setattr(svc, "import_skill_dir", fake_import_skill_dir)
results = await svc.install_remote_skills_batch(
None,
source="test/repo",
skills=["skill-a", "skill-b", "skill-c"],
created_by="root",
)
assert len(results) == 3
assert results[0] == {"slug": "skill-a", "success": True}
assert results[1] == {"slug": "skill-b", "success": False, "error": "skills CLI 未生成预期的技能目录"}
assert results[2] == {"slug": "skill-c", "success": True}
@pytest.mark.asyncio
async def test_install_remote_skills_batch_handles_invalid_names(monkeypatch: pytest.MonkeyPatch):
calls: list[tuple[list[str], str]] = []
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
calls.append((args, env["HOME"]))
home = Path(env["HOME"])
skill_dir_base = home / ".agents" / "skills"
(skill_dir_base / "valid-skill").mkdir(parents=True, exist_ok=True)
(skill_dir_base / "valid-skill" / "SKILL.md").write_text(
"---\nname: valid-skill\ndescription: demo\n---\n# Valid\n", encoding="utf-8",
)
return "installed"
async def fake_import_skill_dir(_db, *, source_dir, created_by):
return SimpleNamespace(slug=source_dir.name)
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
monkeypatch.setattr(svc, "import_skill_dir", fake_import_skill_dir)
results = await svc.install_remote_skills_batch(
None,
source="test/repo",
skills=["valid-skill", "Bad Name", "another-valid"],
created_by="root",
)
assert len(results) == 3
assert results[0] == {"slug": "valid-skill", "success": True}
assert results[1]["success"] is False
assert "不合法" in results[1]["error"]
assert results[2] == {"slug": "another-valid", "success": False, "error": "skills CLI 未生成预期的技能目录"}
# Only valid skills passed to the CLI
assert len(calls) == 1
assert "--skill" in str(calls[0][0])
assert "valid-skill" in str(calls[0][0])
assert "Bad" not in str(calls[0][0])

View File

@ -53,6 +53,7 @@
- 合并智能体对话导航:移除 `AgentChatComponent` 内部聊天侧边栏,将新建对话入口和对话历史移动到 `AppLayout` 主侧边栏,并通过共享线程 store 统一管理历史列表、当前线程、重命名、删除、置顶和分页加载。
- 新增独立模型配置模块:增加 `model_providers` 表、独立管理接口和”模型配置”页面,支持 provider 基础信息、可配置模型列表端点、远端候选模型、`enabled_models` 的早期配置验证;启动时会补齐内置 provider 模板,`provider_type` 暂统一默认为 `openai`,该模块暂不接入现有运行时模型选择逻辑。远端模型加载默认使用 `/models` 获取 chat/通用模型provider 声明 `embedding` 能力时使用 `/embeddings/models` 获取 embedding 候选rerank 模型列表端点按供应商文档显式配置后加载;修复路由请求模型未接收 `embedding_base_url`/`rerank_base_url` 导致前端已填写仍被后端校验拦截的问题。补充手动添加模型能力:`enabled_models[i]` 新增可选 `source: "manual"|"remote"` 字段(默认 `remote`),管理员可通过”+ 手动添加”入口录入远端清单未覆盖的模型(典型:自部署 embedding/rerank手动模型在前端跳过”远端不存在”的 stale 警告并显示「手动」标签type 选项受 `provider.capabilities` 约束,后端在 `_normalize_payload``update_provider_config` 双层一致性校验中拦截越权写入。
- 统一前端 Markdown 预览渲染:新增共享 `MarkdownPreview` 组件与 `markdown_preview` 渲染工具,替换 Agent 消息、文件预览、知识库 chunk、任务工具结果、聊天导出等场景中的旧 `md-editor-v3/marked` 预览;支持 KaTeX、任务列表、frontmatter 卡片、Shiki 代码高亮、DOMPurify 清洗和浅层渲染缓存,并抽取 HTML 转义与代码语言归一化工具。Skill 详情页复用 `AgentFilePreview`,统一文件预览、编辑、保存和全屏交互。
- 优化远程 Skill 批量安装:`remote_skill_install_service.py` 新增 `install_remote_skills_batch()`,利用 `npx skills add --skill A --skill B --skill C` 原生多 skill 支持,将安装 N 个 skill 的仓库克隆次数从 2N 降至 1配套新增路由 `POST /remote/install-batch`、前端 `installRemoteSkillsBatch()` API 方法和批处理 UI 逻辑
---

View File

@ -20,6 +20,10 @@ export const installRemoteSkill = async (payload) => {
return apiAdminPost(`${BASE_URL}/remote/install`, payload)
}
export const installRemoteSkillsBatch = async (payload) => {
return apiAdminPost(`${BASE_URL}/remote/install-batch`, payload)
}
export const getSkillDependencyOptions = async () => {
return apiAdminGet(`${BASE_URL}/dependency-options`)
}
@ -77,6 +81,7 @@ export const skillApi = {
importSkillZip,
listRemoteSkills,
installRemoteSkill,
installRemoteSkillsBatch,
getSkillDependencyOptions,
listBuiltinSkills,
installBuiltinSkill,

View File

@ -379,23 +379,18 @@ const handleInstallRemoteSkill = async () => {
remoteInstallProgress.visible = true
remoteInstallProgress.total = skillsToInstall.length
try {
for (const skill of skillsToInstall) {
remoteInstallProgress.currentSkill = skill
try {
const result = await skillApi.installRemoteSkill({ source, skill })
const installedSlug = result?.data?.slug || skill
remoteInstallResults.success.push(installedSlug)
remoteInstallProgress.success += 1
} catch (error) {
remoteInstallResults.failed.push({
skill,
error: error?.response?.data?.detail || error.message || '远程 Skill 安装失败'
})
remoteInstallProgress.failed += 1
} finally {
remoteInstallProgress.completed += 1
}
}
const result = await skillApi.installRemoteSkillsBatch({
source,
skills: skillsToInstall
})
const results = result?.data || []
remoteInstallResults.success = results.filter((r) => r.success).map((r) => r.slug)
remoteInstallResults.failed = results
.filter((r) => !r.success)
.map((r) => ({ skill: r.slug, error: r.error || '安装失败' }))
remoteInstallProgress.success = remoteInstallResults.success.length
remoteInstallProgress.failed = remoteInstallResults.failed.length
remoteInstallProgress.completed = results.length
remoteInstallProgress.currentSkill = ''
await fetchSkills()
if (remoteInstallResults.failed.length === 0) {