Merge pull request #695 from supreme0597/feat/skill-batch-install
feat(skills): 优化远程 Skill 批量安装,单次克隆完成多 skill 安装
This commit is contained in:
commit
46bd6d22ef
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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 逻辑
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user