From a828d7154b356caf840c210291b17c29a45bfdd4 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 1 Apr 2026 14:44:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20=E6=B7=BB=E5=8A=A0=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E6=8A=80=E8=83=BD=E5=AE=89=E8=A3=85=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=8F=8A=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 remote_skill_install_service.py 支持远程技能安装 - 更新 skill_service.py 增强技能管理功能 - 更新 skill_router.py 添加相关路由 - 更新前端组件 SkillsManagerComponent.vue - 更新 ExtensionsView.vue - 添加对应单元测试 Co-Authored-By: Claude Opus 4.6 --- .../services/remote_skill_install_service.py | 190 ++++++++++++++++++ .../package/yuxi/services/skill_service.py | 115 +++++++---- backend/server/routers/skill_router.py | 50 +++++ .../test/unit/routers/test_skill_router.py | 49 +++++ .../test_remote_skill_install_service.py | 145 +++++++++++++ .../test/unit/services/test_skill_service.py | 14 ++ docs/agents/skills-management.md | 22 +- docs/develop-guides/roadmap.md | 1 + web/src/apis/skill_api.js | 10 + web/src/components/SkillsManagerComponent.vue | 189 ++++++++++++++++- web/src/views/ExtensionsView.vue | 16 +- 11 files changed, 757 insertions(+), 44 deletions(-) create mode 100644 backend/package/yuxi/services/remote_skill_install_service.py create mode 100644 backend/test/unit/services/test_remote_skill_install_service.py diff --git a/backend/package/yuxi/services/remote_skill_install_service.py b/backend/package/yuxi/services/remote_skill_install_service.py new file mode 100644 index 00000000..d7683e84 --- /dev/null +++ b/backend/package/yuxi/services/remote_skill_install_service.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import asyncio +import os +import re +import tempfile +from pathlib import Path + +from sqlalchemy.ext.asyncio import AsyncSession + +from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug + +ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") +CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-z0-9]") +SKILL_LINE_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +CLI_TIMEOUT_SECONDS = 300 + + +def _normalize_source(source: str) -> str: + value = str(source or "").strip() + if not value: + raise ValueError("source 不能为空") + if any(ch in value for ch in ("\n", "\r", "\x00")): + raise ValueError("source 包含非法字符") + return value + + +def _normalize_skill_name(skill: str) -> str: + value = str(skill or "").strip() + if not is_valid_skill_slug(value): + raise ValueError("skill 名称不合法") + return value + + +def _clean_cli_output(output: str) -> list[str]: + cleaned = ANSI_ESCAPE_RE.sub("", output or "") + cleaned = CONTROL_SEQUENCE_RE.sub("", cleaned) + cleaned = cleaned.replace("\r", "\n") + normalized_lines: list[str] = [] + for line in cleaned.splitlines(): + stripped = line.strip() + stripped = re.sub(r"^[│┌└◇◒◐◓◑■●]+\s*", "", stripped) + normalized_lines.append(stripped.strip()) + return normalized_lines + + +def _parse_available_skills(output: str) -> list[dict[str, str]]: + lines = _clean_cli_output(output) + items: list[dict[str, str]] = [] + seen: set[str] = set() + collecting = False + + for idx, line in enumerate(lines): + if not collecting: + if "Available Skills" in line: + collecting = True + continue + + if not line: + continue + if "Use --skill " in line: + break + if not SKILL_LINE_RE.fullmatch(line): + continue + if line in seen: + continue + + description = "" + next_index = idx + 1 + while next_index < len(lines): + next_line = lines[next_index] + next_index += 1 + if not next_line: + continue + if "Use --skill " in next_line: + break + if SKILL_LINE_RE.fullmatch(next_line): + break + if next_line and next_line[0].isalpha(): + description = next_line + break + + seen.add(line) + items.append({"name": line, "description": description}) + + return items + + +async def _run_skills_cli( + args: list[str], + *, + env: dict[str, str], + cwd: str, +) -> str: + process = await asyncio.create_subprocess_exec( + *args, + cwd=cwd, + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=CLI_TIMEOUT_SECONDS) + except TimeoutError: + process.kill() + await process.communicate() + raise ValueError("skills CLI 执行超时") from None + + output = (stdout or b"").decode("utf-8", errors="replace") + error_output = (stderr or b"").decode("utf-8", errors="replace") + combined = "\n".join(part for part in [output.strip(), error_output.strip()] if part) + if process.returncode != 0: + raise ValueError(combined or "skills CLI 执行失败") + return combined + + +async def list_remote_skills(source: str) -> list[dict[str, str]]: + normalized_source = _normalize_source(source) + + with tempfile.TemporaryDirectory(prefix=".remote-skills-") as temp_home: + env = os.environ.copy() + env["HOME"] = temp_home + workdir = str(Path(temp_home) / "workspace") + Path(workdir).mkdir(parents=True, exist_ok=True) + + output = await _run_skills_cli( + ["npx", "-y", "skills", "add", normalized_source, "--list"], + env=env, + cwd=workdir, + ) + + skills = _parse_available_skills(output) + if not skills: + raise ValueError("未发现可安装的 skills") + return skills + + +async def install_remote_skill( + db: AsyncSession, + *, + source: str, + skill: str, + created_by: str | None, +) -> object: + normalized_source = _normalize_source(source) + normalized_skill = _normalize_skill_name(skill) + + with tempfile.TemporaryDirectory(prefix=".remote-skills-") as temp_home: + env = os.environ.copy() + env["HOME"] = temp_home + workdir = str(Path(temp_home) / "workspace") + Path(workdir).mkdir(parents=True, exist_ok=True) + + available_skills = _parse_available_skills( + await _run_skills_cli( + ["npx", "-y", "skills", "add", normalized_source, "--list"], + env=env, + cwd=workdir, + ) + ) + available_names = {item["name"] for item in available_skills} + if normalized_skill not in available_names: + raise ValueError(f"远程仓库中不存在 skill: {normalized_skill}") + + await _run_skills_cli( + [ + "npx", + "-y", + "skills", + "add", + normalized_source, + "--skill", + normalized_skill, + "-g", + "-y", + "--copy", + ], + env=env, + cwd=workdir, + ) + + installed_dir = Path(temp_home) / ".agents" / "skills" / normalized_skill + if not installed_dir.exists() or not installed_dir.is_dir(): + raise ValueError("skills CLI 未生成预期的技能目录") + + return await import_skill_dir( + db, + source_dir=installed_dir, + created_by=created_by, + ) diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index dc2c18d1..fd57dc85 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -429,6 +429,63 @@ async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str idx += 1 +async def _import_skill_dir_impl( + db: AsyncSession, + *, + source_skill_dir: Path, + created_by: str | None, +) -> Skill: + repo = SkillRepository(db) + skills_root = get_skills_root_dir() + + skill_md_path = source_skill_dir / "SKILL.md" + if not skill_md_path.exists() or not skill_md_path.is_file(): + raise ValueError("技能目录缺少根级 SKILL.md") + + content = skill_md_path.read_text(encoding="utf-8") + parsed_name, parsed_desc, _ = _parse_skill_markdown(content) + + final_slug = await _generate_available_slug(repo, parsed_name) + final_name = parsed_name + with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root: + temp_root_path = Path(temp_root) + stage_dir = temp_root_path / "stage" + shutil.copytree(source_skill_dir, stage_dir) + + if final_slug != parsed_name: + final_name = final_slug + content = _rewrite_frontmatter_name(content, final_name) + (stage_dir / "SKILL.md").write_text(content, encoding="utf-8") + + temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}" + if temp_target.exists(): + shutil.rmtree(temp_target) + shutil.move(str(stage_dir), str(temp_target)) + + final_dir = skills_root / final_slug + if final_dir.exists(): + shutil.rmtree(temp_target, ignore_errors=True) + raise ValueError(f"技能目录冲突,请重试: {final_slug}") + temp_target.rename(final_dir) + + try: + item = await repo.create( + slug=final_slug, + name=final_name, + description=parsed_desc, + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + dir_path=(Path("skills") / final_slug).as_posix(), + created_by=created_by, + ) + except Exception: + shutil.rmtree(final_dir, ignore_errors=True) + raise + + return item + + def _resolve_skill_dir(item: Skill) -> Path: dir_path = Path(item.dir_path) if dir_path.is_absolute(): @@ -498,13 +555,11 @@ async def import_skill_zip( if not is_zip_upload and not is_skill_md_upload: raise ValueError("仅支持上传 .zip 或 SKILL.md 文件") - repo = SkillRepository(db) skills_root = get_skills_root_dir() with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root: temp_root_path = Path(temp_root) extract_dir = temp_root_path / "extract" - stage_dir = temp_root_path / "stage" extract_dir.mkdir(parents=True, exist_ok=True) if is_zip_upload: zip_path = temp_root_path / "upload.zip" @@ -525,45 +580,27 @@ async def import_skill_zip( skill_md_path = source_skill_dir / "SKILL.md" skill_md_path.write_bytes(file_bytes) - content = skill_md_path.read_text(encoding="utf-8") - parsed_name, parsed_desc, _ = _parse_skill_markdown(content) + return await _import_skill_dir_impl( + db, + source_skill_dir=source_skill_dir, + created_by=created_by, + ) - final_slug = await _generate_available_slug(repo, parsed_name) - final_name = parsed_name - if final_slug != parsed_name: - final_name = final_slug - content = _rewrite_frontmatter_name(content, final_name) - skill_md_path.write_text(content, encoding="utf-8") - shutil.copytree(source_skill_dir, stage_dir) - - temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}" - if temp_target.exists(): - shutil.rmtree(temp_target) - shutil.move(str(stage_dir), str(temp_target)) - - final_dir = skills_root / final_slug - if final_dir.exists(): - shutil.rmtree(temp_target, ignore_errors=True) - raise ValueError(f"技能目录冲突,请重试: {final_slug}") - temp_target.rename(final_dir) - - try: - item = await repo.create( - slug=final_slug, - name=final_name, - description=parsed_desc, - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=[], - dir_path=(Path("skills") / final_slug).as_posix(), - created_by=created_by, - ) - except Exception: - shutil.rmtree(final_dir, ignore_errors=True) - raise - - return item +async def import_skill_dir( + db: AsyncSession, + *, + source_dir: Path | str, + created_by: str | None, +) -> Skill: + source_skill_dir = Path(source_dir).resolve() + if not source_skill_dir.exists() or not source_skill_dir.is_dir(): + raise ValueError("技能目录不存在") + return await _import_skill_dir_impl( + db, + source_skill_dir=source_skill_dir, + created_by=created_by, + ) async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill: diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index 09620ccc..14933a5e 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user +from yuxi.services.remote_skill_install_service import install_remote_skill, list_remote_skills from yuxi.services.skill_service import ( BuiltinSkillUpdateConflictError, create_skill_node, @@ -54,6 +55,14 @@ class BuiltinSkillUpdateRequest(BaseModel): force: bool = Field(False, description="是否强制覆盖本地已安装内容") +class RemoteSkillSourceRequest(BaseModel): + source: str = Field(..., description="skills 仓库来源,如 owner/repo 或 GitHub URL") + + +class RemoteSkillInstallRequest(RemoteSkillSourceRequest): + skill: str = Field(..., description="需要安装的 skill 名称") + + def _raise_from_value_error(e: ValueError) -> None: message = str(e) status_code = 404 if "不存在" in message else 400 @@ -201,6 +210,47 @@ async def import_skill_route( raise HTTPException(status_code=500, detail="导入技能失败") +@skills.post("/remote/list") +async def list_remote_skills_route( + payload: RemoteSkillSourceRequest, + _current_user: User = Depends(get_superadmin_user), +): + try: + return {"success": True, "data": await list_remote_skills(payload.source)} + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to list remote skills from '{payload.source}': {e}") + raise HTTPException(status_code=500, detail="获取远程 skills 列表失败") + + +@skills.post("/remote/install") +async def install_remote_skill_route( + payload: RemoteSkillInstallRequest, + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + try: + item = await install_remote_skill( + db, + source=payload.source, + skill=payload.skill, + created_by=current_user.username, + ) + return {"success": True, "data": item.to_dict()} + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + logger.error( + f"Failed to install remote skill '{payload.skill}' from '{payload.source}': {e}" + ) + raise HTTPException(status_code=500, detail="安装远程 skill 失败") + + @skills.get("/{slug}/tree") async def get_skill_tree_route( slug: str, diff --git a/backend/test/unit/routers/test_skill_router.py b/backend/test/unit/routers/test_skill_router.py index 66549b0b..a3fe5188 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -194,3 +194,52 @@ def test_update_skill_dependencies_route(monkeypatch): assert captured["mcp_dependencies"] == ["mcp-a"] assert captured["skill_dependencies"] == ["other-skill"] assert captured["updated_by"] == "root" + + +def test_list_remote_skills_route(monkeypatch): + async def fake_list_remote_skills(source: str): + assert source == "anthropics/skills" + return [{"name": "frontend-design", "description": "demo"}] + + monkeypatch.setattr("server.routers.skill_router.list_remote_skills", fake_list_remote_skills) + + app = _build_app(allow_superadmin=True) + client = TestClient(app) + resp = client.post("/api/system/skills/remote/list", json={"source": "anthropics/skills"}) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert payload["data"] == [{"name": "frontend-design", "description": "demo"}] + + +def test_install_remote_skill_route(monkeypatch): + captured: dict[str, str] = {} + + async def fake_install_remote_skill(_db, *, source, skill, created_by): + captured["source"] = source + captured["skill"] = skill + captured["created_by"] = created_by + return Skill( + slug="frontend-design", + name="frontend-design", + description="demo skill", + dir_path="skills/frontend-design", + created_by=created_by, + updated_by=created_by, + ) + + monkeypatch.setattr("server.routers.skill_router.install_remote_skill", fake_install_remote_skill) + + app = _build_app(allow_superadmin=True) + client = TestClient(app) + resp = client.post( + "/api/system/skills/remote/install", + json={"source": "anthropics/skills", "skill": "frontend-design"}, + ) + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert payload["data"]["slug"] == "frontend-design" + assert captured["source"] == "anthropics/skills" + assert captured["skill"] == "frontend-design" + assert captured["created_by"] == "root" diff --git a/backend/test/unit/services/test_remote_skill_install_service.py b/backend/test/unit/services/test_remote_skill_install_service.py new file mode 100644 index 00000000..93be69a3 --- /dev/null +++ b/backend/test/unit/services/test_remote_skill_install_service.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from yuxi.services import remote_skill_install_service as svc + + +def test_parse_available_skills_from_cli_output() -> None: + output = """ + \x1b[38;5;250m███████╗\x1b[0m + ◇ Available Skills + Claude Api + + claude-api + + Build apps with the Claude API. + + Example Skills + + frontend-design + + Create distinctive frontend interfaces. + + └ Use --skill to install specific skills + """ + + skills = svc._parse_available_skills(output) + + assert skills == [ + {"name": "claude-api", "description": "Build apps with the Claude API."}, + {"name": "frontend-design", "description": "Create distinctive frontend interfaces."}, + ] + + +@pytest.mark.asyncio +async def test_list_remote_skills_uses_isolated_home(monkeypatch: pytest.MonkeyPatch): + captured: dict[str, object] = {} + + async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str: + captured["args"] = args + captured["home"] = env["HOME"] + captured["cwd"] = cwd + return """ + ◇ Available Skills + + frontend-design + + Create distinctive frontend interfaces. + + └ Use --skill to install specific skills + """ + + monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli) + + items = await svc.list_remote_skills("anthropics/skills") + + assert items == [{"name": "frontend-design", "description": "Create distinctive frontend interfaces."}] + assert captured["args"] == ["npx", "-y", "skills", "add", "anthropics/skills", "--list"] + assert str(captured["cwd"]).startswith(str(captured["home"])) + + +@pytest.mark.asyncio +async def test_install_remote_skill_imports_from_cli_output_dir(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"]) + if "--list" in args: + return """ + ◇ Available Skills + + frontend-design + + Create distinctive frontend interfaces. + + └ Use --skill to install specific skills + """ + skill_dir = home / ".agents" / "skills" / "frontend-design" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: frontend-design\ndescription: demo\n---\n# Demo\n", + encoding="utf-8", + ) + return "installed" + + captured: dict[str, object] = {} + + async def fake_import_skill_dir(_db, *, source_dir, created_by): + captured["source_dir"] = Path(source_dir) + captured["created_by"] = created_by + return {"slug": "frontend-design"} + + monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli) + monkeypatch.setattr(svc, "import_skill_dir", fake_import_skill_dir) + + item = await svc.install_remote_skill( + None, + source="anthropics/skills", + skill="frontend-design", + created_by="root", + ) + + assert item == {"slug": "frontend-design"} + assert calls[0][0] == ["npx", "-y", "skills", "add", "anthropics/skills", "--list"] + assert calls[1][0] == [ + "npx", + "-y", + "skills", + "add", + "anthropics/skills", + "--skill", + "frontend-design", + "-g", + "-y", + "--copy", + ] + assert captured["source_dir"] == Path(calls[1][1]) / ".agents" / "skills" / "frontend-design" + assert captured["created_by"] == "root" + + +@pytest.mark.asyncio +async def test_install_remote_skill_rejects_missing_remote_skill(monkeypatch: pytest.MonkeyPatch): + async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str: + return """ + ◇ Available Skills + + other-skill + + Description + + └ Use --skill to install specific skills + """ + + monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli) + + with pytest.raises(ValueError, match="不存在 skill"): + await svc.install_remote_skill( + None, + source="anthropics/skills", + skill="frontend-design", + created_by="root", + ) diff --git a/backend/test/unit/services/test_skill_service.py b/backend/test/unit/services/test_skill_service.py index 199fb8c8..5d118b4a 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/test_skill_service.py @@ -221,6 +221,20 @@ async def test_import_skill_md_creates_single_file_skill( assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md +@pytest.mark.asyncio +async def test_import_skill_dir_requires_root_skill_md(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + source_dir = tmp_path / "source-skill" + source_dir.mkdir(parents=True, exist_ok=True) + + with pytest.raises(ValueError, match="根级 SKILL.md"): + await svc.import_skill_dir( + None, + source_dir=source_dir, + created_by="root", + ) + + @pytest.mark.asyncio async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) diff --git a/docs/agents/skills-management.md b/docs/agents/skills-management.md index 50f08e63..3717f33b 100644 --- a/docs/agents/skills-management.md +++ b/docs/agents/skills-management.md @@ -42,11 +42,12 @@ Skills 系统采用「文件系统存内容,数据库存索引」的分离架 ## 创建方式 -系统提供三种方式创建 Skills: +系统提供四种方式创建 Skills: 1. **ZIP 导入(推荐)**:将 Skill 目录打包成 ZIP,通过管理界面上传导入 2. **在线创建**:通过 Skills 管理页面在线创建目录和文件 -3. **手动导入**:直接操作数据库(不推荐,需要手动同步文件系统和数据库) +3. **远程仓库安装**:在 Skills 管理页面填写 skills 仓库地址和 skill 名称,由后端调用 `npx skills` 下载后再导入系统 +4. **手动导入**:直接操作数据库(不推荐,需要手动同步文件系统和数据库) ## Skills 来源 @@ -102,7 +103,7 @@ description: 这是一个用于处理特定任务的技能 ### 导入 Skill -有两种方式可以导入 Skill: +有三种方式可以导入 Skill: **方式一:通过 ZIP 包导入(推荐)** @@ -122,6 +123,21 @@ description: 这是一个用于处理特定任务的技能 - 在线编辑文本文件(支持 .md、.py、.js、.json 等格式) - 直接在网页上编写 SKILL.md 内容 +**方式三:从远程 skills 仓库安装** + +1. 在 Skills 管理页面的“远程安装”面板中填写仓库来源,例如 `anthropics/skills` 或完整 GitHub URL +2. 点击“查看可安装 Skills”获取该仓库中可发现的 skills 列表 +3. 选择或输入目标 skill 名称后点击“安装” + +系统会在后端: +- 调用 `npx skills add --list` 校验来源并发现可安装的 skills +- 使用隔离的临时 `HOME` 执行 `npx skills add --skill -g -y --copy` +- 从临时目录中提取对应 skill,再按现有导入流程写入 `/app/saves/skills` 与数据库 + +::: tip 远程安装不会把 ~/.agents/skills 作为系统主存储 +远程安装只把 `skills.sh` CLI 作为“下载器”使用。Yuxi 仍然以 `/app/saves/skills + skills 表` 作为正式来源,这样才能与现有的权限、线程可见性和沙盒挂载机制保持一致。 +::: + ## 依赖系统 Skills 之间可以建立依赖关系,形成一个松耦合的技能网络。 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 3fe211c6..57706a2b 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -33,6 +33,7 @@ - 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。 - 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。 - 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能 +- 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储 --- diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index e9d1fc36..e09a4518 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -18,6 +18,14 @@ export const importSkillZip = async (file) => { return apiSuperAdminPost(`${BASE_URL}/import`, formData) } +export const listRemoteSkills = async (source) => { + return apiSuperAdminPost(`${BASE_URL}/remote/list`, { source }) +} + +export const installRemoteSkill = async (payload) => { + return apiSuperAdminPost(`${BASE_URL}/remote/install`, payload) +} + export const getSkillDependencyOptions = async () => { return apiSuperAdminGet(`${BASE_URL}/dependency-options`) } @@ -73,6 +81,8 @@ export const deleteSkill = async (slug) => { export const skillApi = { listSkills, importSkillZip, + listRemoteSkills, + installRemoteSkill, getSkillDependencyOptions, listBuiltinSkills, installBuiltinSkill, diff --git a/web/src/components/SkillsManagerComponent.vue b/web/src/components/SkillsManagerComponent.vue index 98da01b5..890af28b 100644 --- a/web/src/components/SkillsManagerComponent.vue +++ b/web/src/components/SkillsManagerComponent.vue @@ -352,6 +352,62 @@ + + + + @@ -391,6 +447,8 @@ const theme = computed(() => (themeStore.isDark ? 'dark' : 'light')) const loading = ref(false) const importing = ref(false) +const listingRemoteSkills = ref(false) +const installingRemoteSkill = ref(false) const savingFile = ref(false) const creatingNode = ref(false) const savingDependencies = ref(false) @@ -410,7 +468,13 @@ const fileContent = ref('') const originalFileContent = ref('') const createModalVisible = ref(false) +const remoteInstallModalVisible = ref(false) const createForm = reactive({ path: '', isDir: false, content: '' }) +const remoteInstallForm = reactive({ + source: 'https://github.com/anthropics/skills', + skill: '' +}) +const remoteSkillOptions = ref([]) const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] }) const dependencyForm = reactive({ tool_dependencies: [], @@ -521,6 +585,15 @@ const skillDependencyOptions = computed(() => .filter((s) => s !== currentSkill.value?.slug) .map((i) => ({ label: i, value: i })) ) +const filteredRemoteSkillOptions = computed(() => { + const keyword = remoteInstallForm.skill.trim().toLowerCase() + return remoteSkillOptions.value + .filter((item) => !keyword || item.name.toLowerCase().includes(keyword)) + .map((item) => ({ + value: item.name, + label: item.description ? `${item.name} - ${item.description}` : item.name + })) +}) const normalizeTree = (nodes) => (nodes || []).map((node) => ({ @@ -858,6 +931,63 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => { } } +const handleListRemoteSkills = async () => { + const source = remoteInstallForm.source.trim() + if (!source) { + message.warning('请输入来源仓库') + return + } + listingRemoteSkills.value = true + try { + const result = await skillApi.listRemoteSkills(source) + remoteSkillOptions.value = result?.data || [] + if (!remoteSkillOptions.value.length) { + message.warning('未发现可安装的 Skills') + return + } + if (!remoteInstallForm.skill) { + remoteInstallForm.skill = remoteSkillOptions.value[0]?.name || '' + } + message.success(`已发现 ${remoteSkillOptions.value.length} 个 Skills`) + } catch (error) { + message.error(error?.response?.data?.detail || error.message || '获取远程 Skills 失败') + } finally { + listingRemoteSkills.value = false + } +} + +const handleInstallRemoteSkill = async () => { + const source = remoteInstallForm.source.trim() + const skill = remoteInstallForm.skill.trim() + if (!source || !skill) { + message.warning('请填写来源仓库和 Skill 名称') + return + } + installingRemoteSkill.value = true + try { + const result = await skillApi.installRemoteSkill({ source, skill }) + const installed = result?.data + remoteInstallForm.skill = installed?.slug || skill + await fetchSkills() + if (installed?.slug) { + const record = + skills.value.find((item) => item.slug === installed.slug) || + builtinSkills.value.find((item) => item.slug === installed.slug) + if (record) await selectSkill(record) + } + remoteInstallModalVisible.value = false + message.success('远程 Skill 安装成功') + } catch (error) { + message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败') + } finally { + installingRemoteSkill.value = false + } +} + +const openRemoteInstallModal = () => { + remoteInstallModalVisible.value = true +} + const saveDependencies = async () => { if (!currentSkill.value || !isInstalledSkill.value) return savingDependencies.value = true @@ -886,7 +1016,8 @@ onMounted(fetchSkills) // 暴露方法给父组件 defineExpose({ fetchSkills, - handleImportUpload + handleImportUpload, + openRemoteInstallModal }) @@ -913,6 +1044,62 @@ defineExpose({ overflow: hidden; } +.remote-install-panel { + background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-50) 100%); + border: 1px solid @border-color; + border-radius: 12px; + padding: 16px; + + &.modal-mode { + border: none; + border-radius: 0; + padding: 0; + background: transparent; + } + + .panel-header-text { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + + .title { + font-size: 14px; + font-weight: 600; + color: var(--gray-900); + } + + .desc { + font-size: 12px; + color: var(--gray-500); + } + } + + .remote-install-form { + :deep(.ant-form-item) { + margin-bottom: 12px; + } + } + + .remote-install-actions { + display: flex; + gap: 8px; + } + + .remote-skill-hints { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + } + + .remote-skill-summary { + margin-top: 12px; + font-size: 12px; + color: var(--gray-500); + } +} + /* 文件 tree */ .tree-container { width: 240px; diff --git a/web/src/views/ExtensionsView.vue b/web/src/views/ExtensionsView.vue index 87361295..84db1a74 100644 --- a/web/src/views/ExtensionsView.vue +++ b/web/src/views/ExtensionsView.vue @@ -10,6 +10,14 @@