diff --git a/backend/package/yuxi/agents/middlewares/summary_middleware.py b/backend/package/yuxi/agents/middlewares/summary_middleware.py index 713238400..1fad49366 100644 --- a/backend/package/yuxi/agents/middlewares/summary_middleware.py +++ b/backend/package/yuxi/agents/middlewares/summary_middleware.py @@ -7,10 +7,10 @@ from __future__ import annotations -from pathlib import Path import uuid from collections.abc import Callable, Iterable, Mapping from functools import partial +from pathlib import Path from typing import Any, Literal, cast, override from langchain.agents import AgentState 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 000000000..b74958823 --- /dev/null +++ b/backend/package/yuxi/services/remote_skill_install_service.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug + +if TYPE_CHECKING: + from yuxi.storage.postgres.models_business import Skill + +ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") +CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-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 is_valid_skill_slug(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 is_valid_skill_slug(next_line): + break + if next_line and next_line[0].isalpha(): + description = next_line + else: + continue + 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: + cleaned_lines = _clean_cli_output(combined) + error_msg = "\n".join(line for line in cleaned_lines if line)[:500] + raise ValueError(error_msg or "skills CLI 执行失败") + return combined + + +def _create_isolated_workdir() -> tuple[str, dict[str, str], str]: + temp_home = tempfile.mkdtemp(prefix=".remote-skills-") + env = os.environ.copy() + env["HOME"] = temp_home + workdir = str(Path(temp_home) / "workspace") + Path(workdir).mkdir(parents=True, exist_ok=True) + return temp_home, env, workdir + + +async def list_remote_skills(source: str) -> list[dict[str, str]]: + normalized_source = _normalize_source(source) + + temp_home, env, workdir = _create_isolated_workdir() + try: + output = await _run_skills_cli( + ["npx", "-y", "skills", "add", normalized_source, "--list"], + env=env, + cwd=workdir, + ) + finally: + shutil.rmtree(temp_home, ignore_errors=True) + + 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, +) -> Skill: + normalized_source = _normalize_source(source) + normalized_skill = _normalize_skill_name(skill) + + temp_home, env, workdir = _create_isolated_workdir() + try: + 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, + ) + + base_dir = Path(temp_home).resolve() + skills_dir = base_dir / ".agents" / "skills" + # Scan for the installed skill directory rather than constructing the path + # from user input, to avoid path traversal concerns + installed_dir = None + if skills_dir.is_dir(): + for candidate in skills_dir.iterdir(): + if candidate.name == normalized_skill and candidate.is_dir(): + installed_dir = candidate + break + if installed_dir is None: + raise ValueError("skills CLI 未生成预期的技能目录") + + return await import_skill_dir( + db, + source_dir=installed_dir, + created_by=created_by, + ) + finally: + shutil.rmtree(temp_home, ignore_errors=True) diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index a0cd444c2..05ea66acd 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(): @@ -492,70 +549,62 @@ async def import_skill_zip( file_bytes: bytes, created_by: str | None, ) -> Skill: - if not filename.lower().endswith(".zip"): - raise ValueError("仅支持上传 .zip 文件") + normalized_filename = filename.lower() + is_zip_upload = normalized_filename.endswith(".zip") + is_skill_md_upload = normalized_filename.endswith("skill.md") + 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) - zip_path = temp_root_path / "upload.zip" 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" + zip_path.write_bytes(file_bytes) - zip_path.write_bytes(file_bytes) - - with zipfile.ZipFile(zip_path, "r") as zf: - _validate_zip_paths(zf) - zf.extractall(extract_dir) - - skill_md_files = list(extract_dir.rglob("SKILL.md")) - if len(skill_md_files) != 1: - raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)") - - skill_md_path = skill_md_files[0] - source_skill_dir = skill_md_path.parent - 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 - 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") + with zipfile.ZipFile(zip_path, "r") as zf: + _validate_zip_paths(zf) + zf.extractall(extract_dir) - shutil.copytree(source_skill_dir, stage_dir) + skill_md_files = list(extract_dir.rglob("SKILL.md")) + if len(skill_md_files) != 1: + raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)") - 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) + skill_md_path = skill_md_files[0] + source_skill_dir = skill_md_path.parent + else: + source_skill_dir = extract_dir + skill_md_path = source_skill_dir / "SKILL.md" + skill_md_path.write_bytes(file_bytes) + + return await _import_skill_dir_impl( + db, + source_skill_dir=source_skill_dir, + created_by=created_by, + ) - 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() + # Confine to the system temp directory to prevent path traversal + tmp_root = Path(tempfile.gettempdir()).resolve() + if not source_skill_dir.is_relative_to(tmp_root): + raise ValueError("技能目录路径不合法") + 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 0d459150e..14933a5e6 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -10,6 +10,7 @@ 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 @@ -182,7 +191,7 @@ async def import_skill_route( current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): - """导入技能压缩包(仅超级管理员)。""" + """导入技能包(支持 ZIP 或单个 SKILL.md,仅超级管理员)。""" try: file_bytes = await file.read() item = await import_skill_zip( @@ -197,10 +206,51 @@ async def import_skill_route( except HTTPException: raise except Exception as e: - logger.error(f"Failed to import skill zip: {e}") + logger.error(f"Failed to import skill package: {e}") 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 c403fc9dc..a3fe5188d 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -72,6 +72,37 @@ def test_import_skill_requires_superadmin(): assert resp.status_code == 403 +def test_import_skill_route_accepts_skill_md(monkeypatch): + captured: dict[str, str] = {} + + async def fake_import_skill_zip(_db, *, filename, file_bytes, created_by): + captured["filename"] = filename + captured["file_bytes"] = file_bytes.decode("utf-8") + captured["created_by"] = created_by + return Skill( + slug="demo", + name="demo", + description="demo skill", + dir_path="skills/demo", + created_by=created_by, + updated_by=created_by, + ) + + monkeypatch.setattr("server.routers.skill_router.import_skill_zip", fake_import_skill_zip) + + app = _build_app(allow_superadmin=True) + client = TestClient(app) + + resp = client.post( + "/api/system/skills/import", + files={"file": ("SKILL.md", b"---\nname: demo\ndescription: demo skill\n---\n", "text/markdown")}, + ) + assert resp.status_code == 200, resp.text + assert captured["filename"] == "SKILL.md" + assert "name: demo" in captured["file_bytes"] + assert captured["created_by"] == "root" + + def test_update_skill_file_passes_operator(monkeypatch): captured: dict[str, str] = {} @@ -163,3 +194,52 @@ async def fake_update_skill_dependencies( 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 000000000..93be69a39 --- /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 164db1236..5d118b4a5 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/test_skill_service.py @@ -165,6 +165,76 @@ async def create( assert "name: demo-v2" in skill_md +@pytest.mark.asyncio +async def test_import_skill_md_creates_single_file_skill( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + class FakeRepo: + created_item: Skill | None = None + + def __init__(self, _db): + pass + + async def exists_slug(self, slug: str) -> bool: + return False + + async def create( + self, + *, + slug: str, + name: str, + description: str, + tool_dependencies: list[str] | None, + mcp_dependencies: list[str] | None, + skill_dependencies: list[str] | None, + dir_path: str, + created_by: str | None, + ) -> Skill: + item = Skill( + slug=slug, + name=name, + description=description, + tool_dependencies=tool_dependencies or [], + mcp_dependencies=mcp_dependencies or [], + skill_dependencies=skill_dependencies or [], + dir_path=dir_path, + created_by=created_by, + updated_by=created_by, + ) + self.__class__.created_item = item + return item + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + skill_md = "---\nname: demo\ndescription: this is demo\n---\n# Demo\n" + item = await svc.import_skill_zip( + None, + filename="SKILL.md", + file_bytes=skill_md.encode("utf-8"), + created_by="root", + ) + + assert item.slug == "demo" + assert item.name == "demo" + 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/docker/api.Dockerfile b/docker/api.Dockerfile index 0aff0d549..08430867c 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -15,7 +15,9 @@ ENV TZ=Asia/Shanghai \ UV_COMPILE_BYTECODE=1 \ DEBIAN_FRONTEND=noninteractive -RUN npm install -g npm@latest && npm cache clean --force +# 设置 npm 镜像源,为 MCP 和 Skills 安装依赖 +RUN npm config set registry https://registry.npmmirror.com --global \ + && npm cache clean --force # 设置代理和时区,更换镜像源,安装系统依赖 - 合并为一个RUN减少层数 RUN set -ex \ @@ -29,6 +31,7 @@ RUN set -ex \ && apt-get install -y --no-install-recommends --fix-missing \ curl \ ffmpeg \ + git \ libpq5 \ libsm6 \ libxext6 \ @@ -36,7 +39,6 @@ RUN set -ex \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* - # 复制项目配置文件 COPY ../backend/pyproject.toml /app/pyproject.toml COPY ../backend/.python-version /app/.python-version diff --git a/docs/agents/skills-management.md b/docs/agents/skills-management.md index 50f08e63e..3717f33b3 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 d48dfafc8..fd94c1592 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -9,17 +9,11 @@ ### 看板 -- 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息 - Langfuse 增加 self-host 模式支持,补齐私有化部署与配置说明 -- 部分场景应该使用默认模型作为默认值而不是空值 - 检索测试中,添加问答 - 集成 Memory,基于 deepagents 的文件后端实现 - 添加自定义向量模型和 rerank 模型的配置,在网页上面 -- 调研轻便的文件展示与编辑器 -- 移除 TODO 的模块与设计,移除这个中间件。 - Yuxi-cli 相关的功能,放在后续版本中实现(不是类似于编程助手,而是工具) -- 工作区实现长期记忆保存,要求模型只能将结果写入到 outputs 文件夹 -- 新增 present file 工具,提供预览以及保存到工作区的选项 ### Bugs - 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi/issues/279) @@ -38,7 +32,10 @@ - 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared//workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。 - 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。 - 收紧文件系统安全边界: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` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求 +--- 历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。 diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index e9d1fc36e..e09a4518d 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 98da01b5f..f9b7eb5b4 100644 --- a/web/src/components/SkillsManagerComponent.vue +++ b/web/src/components/SkillsManagerComponent.vue @@ -352,6 +352,72 @@ + + + + @@ -391,6 +457,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 +478,25 @@ 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', + skills: [] +}) +const remoteSkillOptions = ref([]) +const remoteInstallProgress = reactive({ + visible: false, + total: 0, + completed: 0, + success: 0, + failed: 0, + currentSkill: '' +}) +const remoteInstallResults = reactive({ + success: [], + failed: [] +}) const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] }) const dependencyForm = reactive({ tool_dependencies: [], @@ -521,6 +607,49 @@ const skillDependencyOptions = computed(() => .filter((s) => s !== currentSkill.value?.slug) .map((i) => ({ label: i, value: i })) ) +const filteredRemoteSkillOptions = computed(() => + remoteSkillOptions.value.map((item) => ({ + value: item.name, + label: item.description ? `${item.name} - ${item.description}` : item.name + })) +) +const remoteInstallStatusText = computed(() => { + if (!remoteInstallProgress.visible || !remoteInstallProgress.total) return '' + const progressText = `[${remoteInstallProgress.completed}/${remoteInstallProgress.total}]` + const currentSkill = remoteInstallProgress.currentSkill || '' + const failedText = + remoteInstallProgress.failed > 0 ? `, ${remoteInstallProgress.failed} failed` : '' + return `${progressText} ${currentSkill}${failedText}`.trim() +}) +const filterRemoteSkillOption = (input, option) => { + const keyword = input.trim().toLowerCase() + if (!keyword) return true + const value = String(option?.value || '').toLowerCase() + const label = String(option?.label || '').toLowerCase() + return value.includes(keyword) || label.includes(keyword) +} + +const normalizeRemoteSkillNames = (skills) => { + const seen = new Set() + return (skills || []).reduce((acc, skill) => { + const normalized = String(skill || '').trim() + if (!normalized || seen.has(normalized)) return acc + seen.add(normalized) + acc.push(normalized) + return acc + }, []) +} + +const resetRemoteInstallState = () => { + remoteInstallProgress.visible = false + remoteInstallProgress.total = 0 + remoteInstallProgress.completed = 0 + remoteInstallProgress.success = 0 + remoteInstallProgress.failed = 0 + remoteInstallProgress.currentSkill = '' + remoteInstallResults.success = [] + remoteInstallResults.failed = [] +} const normalizeTree = (nodes) => (nodes || []).map((node) => ({ @@ -539,7 +668,7 @@ const resetFileState = () => { expandedKeys.value = [] fileContent.value = '' originalFileContent.value = '' - viewMode.value = 'edit' + viewMode.value = 'preview' } const expandAllKeys = (nodes) => @@ -858,6 +987,103 @@ 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 || [] + remoteInstallForm.skills = normalizeRemoteSkillNames(remoteInstallForm.skills) + if (!remoteSkillOptions.value.length) { + message.warning('未发现可安装的 Skills') + return + } + 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 skillsToInstall = normalizeRemoteSkillNames(remoteInstallForm.skills) + if (!source || !skillsToInstall.length) { + message.warning('请填写来源仓库和 Skill 名称') + return + } + remoteInstallForm.skills = skillsToInstall + resetRemoteInstallState() + installingRemoteSkill.value = true + remoteInstallProgress.visible = true + remoteInstallProgress.total = skillsToInstall.length + let lastInstalledSlug = '' + try { + for (const skill of skillsToInstall) { + remoteInstallProgress.currentSkill = skill + try { + const result = await skillApi.installRemoteSkill({ source, skill }) + const installed = result?.data + const installedSlug = installed?.slug || skill + remoteInstallResults.success.push(installedSlug) + remoteInstallProgress.success += 1 + lastInstalledSlug = installedSlug + } catch (error) { + remoteInstallResults.failed.push({ + skill, + error: error?.response?.data?.detail || error.message || '远程 Skill 安装失败' + }) + remoteInstallProgress.failed += 1 + } finally { + remoteInstallProgress.completed += 1 + } + } + remoteInstallProgress.currentSkill = '' + await fetchSkills() + if (lastInstalledSlug) { + const record = + skills.value.find((item) => item.slug === lastInstalledSlug) || + builtinSkills.value.find((item) => item.slug === lastInstalledSlug) + if (record) await selectSkill(record) + } + if (remoteInstallResults.failed.length === 0) { + remoteInstallModalVisible.value = false + message.success(`远程 Skills 安装成功,共 ${remoteInstallResults.success.length} 个`) + resetRemoteInstallState() + remoteInstallForm.skills = [] + return + } + message.warning( + `远程 Skills 安装完成,成功 ${remoteInstallResults.success.length} 个,失败 ${remoteInstallResults.failed.length} 个` + ) + } catch (error) { + message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败') + } finally { + remoteInstallProgress.currentSkill = '' + installingRemoteSkill.value = false + } +} + +const openRemoteInstallModal = () => { + if (!remoteInstallModalVisible.value) { + remoteInstallForm.skills = [] + resetRemoteInstallState() + } + remoteInstallModalVisible.value = true +} + +watch(remoteInstallModalVisible, (visible) => { + if (!visible && !installingRemoteSkill.value) { + remoteInstallForm.skills = [] + resetRemoteInstallState() + } +}) + const saveDependencies = async () => { if (!currentSkill.value || !isInstalledSkill.value) return savingDependencies.value = true @@ -886,7 +1112,8 @@ onMounted(fetchSkills) // 暴露方法给父组件 defineExpose({ fetchSkills, - handleImportUpload + handleImportUpload, + openRemoteInstallModal }) @@ -913,6 +1140,73 @@ 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; + align-items: center; + gap: 8px; + } + + .remote-install-status { + min-width: 0; + flex: 1; + font-size: 12px; + color: var(--gray-600); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .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 7afc65298..d59f93eac 100644 --- a/web/src/views/ExtensionsView.vue +++ b/web/src/views/ExtensionsView.vue @@ -10,15 +10,24 @@