diff --git a/backend/package/yuxi/agents/skills/buildin/__init__.py b/backend/package/yuxi/agents/skills/buildin/__init__.py index 6b738aa2..eebb61fe 100644 --- a/backend/package/yuxi/agents/skills/buildin/__init__.py +++ b/backend/package/yuxi/agents/skills/buildin/__init__.py @@ -12,6 +12,7 @@ class BuiltinSkillSpec: slug: str source_dir: Path description: str = "" + version: str = "1.0.0" tool_dependencies: tuple[str, ...] = () mcp_dependencies: tuple[str, ...] = () skill_dependencies: tuple[str, ...] = () @@ -24,6 +25,7 @@ BUILTIN_SKILLS: list[BuiltinSkillSpec] = [ slug="reporter", source_dir=_SKILLS_ROOT / "reporter", description="生成 SQL 查询报表并生成可视化图表。", + version="1.0.0", tool_dependencies=[t["name"] for t in get_tool_info(get_mysql_tools())], mcp_dependencies=("mcp-server-chart",), ), diff --git a/backend/package/yuxi/repositories/skill_repository.py b/backend/package/yuxi/repositories/skill_repository.py index 34270868..d858d50d 100644 --- a/backend/package/yuxi/repositories/skill_repository.py +++ b/backend/package/yuxi/repositories/skill_repository.py @@ -32,6 +32,9 @@ class SkillRepository: mcp_dependencies: list[str] | None, skill_dependencies: list[str] | None, dir_path: str, + version: str | None = None, + is_builtin: bool = False, + content_hash: str | None = None, created_by: str | None, ) -> Skill: now = utc_now_naive() @@ -43,6 +46,9 @@ class SkillRepository: mcp_dependencies=mcp_dependencies or [], skill_dependencies=skill_dependencies or [], dir_path=dir_path, + version=version, + is_builtin=is_builtin, + content_hash=content_hash, created_by=created_by, updated_by=created_by, created_at=now, @@ -53,6 +59,23 @@ class SkillRepository: await self.db.refresh(item) return item + async def update_builtin_install( + self, + item: Skill, + *, + version: str, + content_hash: str, + updated_by: str | None, + ) -> Skill: + item.version = version + item.content_hash = content_hash + item.is_builtin = True + item.updated_by = updated_by + item.updated_at = utc_now_naive() + await self.db.commit() + await self.db.refresh(item) + return item + async def update_dependencies( self, item: Skill, diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index c7235426..e43b42ec 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import re import shutil import tempfile @@ -57,6 +58,12 @@ _THREAD_SKILLS_LOCK = threading.Lock() _THREAD_SKILLS_LOCKS: dict[str, threading.Lock] = {} +class BuiltinSkillUpdateConflictError(ValueError): + def __init__(self, message: str): + super().__init__(message) + self.needs_confirm = True + + def _get_thread_skills_lock(thread_id: str) -> threading.Lock: with _THREAD_SKILLS_LOCK: lock = _THREAD_SKILLS_LOCKS.get(thread_id) @@ -163,6 +170,14 @@ def get_builtin_skill_specs() -> list[Any]: return BUILTIN_SKILLS +def _get_builtin_skill_spec_or_raise(slug: str) -> Any: + normalized_slug = slug.strip() if isinstance(slug, str) else "" + for spec in get_builtin_skill_specs(): + if getattr(spec, "slug", "").strip() == normalized_slug: + return spec + raise ValueError(f"内置 skill '{slug}' 不存在") + + def _build_builtin_skill_dir_path(slug: str) -> str: return (Path("skills") / slug).as_posix() @@ -183,6 +198,20 @@ def _dirs_equal(dir1: Path, dir2: Path) -> bool: return list1 == list2 +def _compute_dir_hash(source_dir: Path) -> str: + hasher = hashlib.sha256() + file_paths = sorted(path for path in source_dir.rglob("*") if path.is_file()) + for file_path in file_paths: + relative_path = file_path.relative_to(source_dir).as_posix() + hasher.update(relative_path.encode("utf-8")) + hasher.update(b"\0") + with file_path.open("rb") as f: + while chunk := f.read(1024 * 1024): + hasher.update(chunk) + hasher.update(b"\0") + return hasher.hexdigest() + + def _copy_skill_target(target_dir: Path, source_dir: Path) -> None: if target_dir.is_symlink(): target_dir.unlink() @@ -194,6 +223,28 @@ def _copy_skill_target(target_dir: Path, source_dir: Path) -> None: shutil.copytree(source_dir, target_dir, symlinks=False, dirs_exist_ok=True) +def _replace_skill_target(target_dir: Path, source_dir: Path) -> None: + temp_target = target_dir.with_name(f".{target_dir.name}.tmp-{uuid.uuid4().hex[:8]}") + trash_dir: Path | None = None + if temp_target.exists(): + shutil.rmtree(temp_target, ignore_errors=True) + + shutil.copytree(source_dir, temp_target, symlinks=False) + try: + if target_dir.exists(): + trash_dir = target_dir.with_name(f".{target_dir.name}.bak-{uuid.uuid4().hex[:8]}") + target_dir.rename(trash_dir) + temp_target.rename(target_dir) + except Exception: + shutil.rmtree(temp_target, ignore_errors=True) + if trash_dir and trash_dir.exists() and not target_dir.exists(): + trash_dir.rename(target_dir) + raise + + if trash_dir and trash_dir.exists(): + shutil.rmtree(trash_dir, ignore_errors=True) + + async def get_skill_dependency_options(db: AsyncSession) -> dict[str, list[str] | list[dict]]: # 并行执行三个独立操作 from yuxi.services.tool_service import get_tool_metadata @@ -540,6 +591,8 @@ async def create_skill_node( updated_by: str | None, ) -> None: item = await get_skill_or_raise(db, slug) + if item.is_builtin: + raise ValueError("内置 skill 不允许直接修改文件") skill_dir = _resolve_skill_dir(item) target, _ = _resolve_relative_path(skill_dir, relative_path) if target.exists(): @@ -569,6 +622,8 @@ async def update_skill_file( updated_by: str | None, ) -> None: item = await get_skill_or_raise(db, slug) + if item.is_builtin: + raise ValueError("内置 skill 不允许直接修改文件") skill_dir = _resolve_skill_dir(item) target, _ = _resolve_relative_path(skill_dir, relative_path) if not target.exists() or not target.is_file(): @@ -600,6 +655,8 @@ async def _update_skill_metadata_if_skills_md( async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str) -> None: item = await get_skill_or_raise(db, slug) + if item.is_builtin: + raise ValueError("内置 skill 不允许直接修改文件") skill_dir = _resolve_skill_dir(item) target, rel = _resolve_relative_path(skill_dir, relative_path, allow_root=False) if not target.exists(): @@ -659,18 +716,46 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None: async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> None: - """初始化内置 skills(软链接目录 + 启动时同步元数据)。""" - repo = SkillRepository(db) - skills_root = get_skills_root_dir() + """校验内置 skills 配置,不执行安装。""" specs = get_builtin_skill_specs() for spec in specs: slug = str(getattr(spec, "slug", "")).strip() source_dir = Path(str(getattr(spec, "source_dir", ""))).resolve() - configured_description = str(getattr(spec, "description", "")).strip() - configured_tools = _normalize_string_list(getattr(spec, "tool_dependencies", None)) - configured_mcps = _normalize_string_list(getattr(spec, "mcp_dependencies", None)) - configured_skills = _normalize_string_list(getattr(spec, "skill_dependencies", None)) + + if not is_valid_skill_slug(slug): + raise ValueError(f"内置 skill slug 非法: {slug}") + if not source_dir.exists() or not source_dir.is_dir(): + logger.warning(f"跳过不存在的内置 skill 目录: {source_dir}") + continue + + skill_md = source_dir / "SKILL.md" + if not skill_md.exists(): + legacy_skill_md = source_dir / "SKILLS.md" + if not legacy_skill_md.exists(): + raise ValueError(f"内置 skill 缺少 SKILL.md: {source_dir}") + skill_md = legacy_skill_md + + content = skill_md.read_text(encoding="utf-8") + parsed_name, _, meta = _parse_skill_markdown(content) + if parsed_name != slug: + raise ValueError(f"内置 skill frontmatter.name 必须等于 slug: {slug}") + _normalize_string_list(meta.get("tool_dependencies")) + _normalize_string_list(meta.get("mcp_dependencies")) + _normalize_string_list(meta.get("skill_dependencies")) + _compute_dir_hash(source_dir) + + +def list_builtin_skill_specs() -> list[dict[str, Any]]: + specs: list[dict[str, Any]] = [] + for raw_spec in get_builtin_skill_specs(): + slug = str(getattr(raw_spec, "slug", "")).strip() + source_dir = Path(str(getattr(raw_spec, "source_dir", ""))).resolve() + configured_description = str(getattr(raw_spec, "description", "")).strip() + version = str(getattr(raw_spec, "version", "1.0.0")).strip() or "1.0.0" + configured_tools = _normalize_string_list(getattr(raw_spec, "tool_dependencies", None)) + configured_mcps = _normalize_string_list(getattr(raw_spec, "mcp_dependencies", None)) + configured_skills = _normalize_string_list(getattr(raw_spec, "skill_dependencies", None)) if not is_valid_skill_slug(slug): raise ValueError(f"内置 skill slug 非法: {slug}") @@ -690,46 +775,102 @@ async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") - if parsed_name != slug: raise ValueError(f"内置 skill frontmatter.name 必须等于 slug: {slug}") - description = configured_description or parsed_desc - tool_dependencies = configured_tools or _normalize_string_list(meta.get("tool_dependencies")) - mcp_dependencies = configured_mcps or _normalize_string_list(meta.get("mcp_dependencies")) - skill_dependencies = configured_skills or _normalize_string_list(meta.get("skill_dependencies")) + specs.append( + { + "slug": slug, + "name": slug, + "description": configured_description or parsed_desc, + "version": version, + "tool_dependencies": configured_tools or _normalize_string_list(meta.get("tool_dependencies")), + "mcp_dependencies": configured_mcps or _normalize_string_list(meta.get("mcp_dependencies")), + "skill_dependencies": configured_skills or _normalize_string_list(meta.get("skill_dependencies")), + "content_hash": _compute_dir_hash(source_dir), + "source_dir": source_dir, + } + ) - target_dir = skills_root / slug - _copy_skill_target(target_dir, source_dir) + return specs - item = await repo.get_by_slug(slug) - if item and not _is_builtin_managed(item, slug): - logger.warning(f"发现同名非内置 skill,跳过自动同步: {slug}") - continue - expected_dir_path = _build_builtin_skill_dir_path(slug) +async def install_builtin_skill(db: AsyncSession, slug: str, *, installed_by: str | None) -> Skill: + _get_builtin_skill_spec_or_raise(slug) + repo = SkillRepository(db) + spec = next(item for item in list_builtin_skill_specs() if item["slug"] == slug) - if not item: - await repo.create( - slug=slug, - name=slug, - description=description, - tool_dependencies=tool_dependencies, - mcp_dependencies=mcp_dependencies, - skill_dependencies=skill_dependencies, - dir_path=expected_dir_path, - created_by=BUILTIN_SKILL_OPERATOR, - ) - continue + existing = await repo.get_by_slug(slug) + if existing: + raise ValueError(f"内置 skill '{slug}' 已安装") - if item.name != slug or item.description != description: - await repo.update_metadata(item, name=slug, description=description, updated_by=created_by) + target_dir = get_skills_root_dir() / slug + if target_dir.exists(): + raise ValueError(f"技能目录已存在: {slug}") - if ( - _normalize_string_list(item.tool_dependencies or []) != tool_dependencies - or _normalize_string_list(item.mcp_dependencies or []) != mcp_dependencies - or _normalize_string_list(item.skill_dependencies or []) != skill_dependencies - ): - await repo.update_dependencies( - item, - tool_dependencies=tool_dependencies, - mcp_dependencies=mcp_dependencies, - skill_dependencies=skill_dependencies, - updated_by=created_by, - ) + shutil.copytree(Path(spec["source_dir"]), target_dir, symlinks=False) + try: + return await repo.create( + slug=slug, + name=spec["name"], + description=spec["description"], + tool_dependencies=spec["tool_dependencies"], + mcp_dependencies=spec["mcp_dependencies"], + skill_dependencies=spec["skill_dependencies"], + dir_path=_build_builtin_skill_dir_path(slug), + version=spec["version"], + is_builtin=True, + content_hash=spec["content_hash"], + created_by=installed_by or BUILTIN_SKILL_OPERATOR, + ) + except Exception: + shutil.rmtree(target_dir, ignore_errors=True) + raise + + +async def update_builtin_skill( + db: AsyncSession, + slug: str, + *, + force: bool = False, + updated_by: str | None, +) -> Skill: + _get_builtin_skill_spec_or_raise(slug) + repo = SkillRepository(db) + spec = next(item for item in list_builtin_skill_specs() if item["slug"] == slug) + item = await repo.get_by_slug(slug) + if not item: + raise ValueError(f"内置 skill '{slug}' 未安装") + if not item.is_builtin: + raise ValueError(f"技能 '{slug}' 不是内置 skill") + + if item.content_hash != spec["content_hash"] and not force: + raise BuiltinSkillUpdateConflictError("检测到你修改过此 skill,更新将覆盖你的修改,是否继续?") + + target_dir = _resolve_skill_dir(item) + _replace_skill_target(target_dir, Path(spec["source_dir"])) + + if item.name != spec["name"] or item.description != spec["description"]: + await repo.update_metadata( + item, + name=spec["name"], + description=spec["description"], + updated_by=updated_by, + ) + + if ( + _normalize_string_list(item.tool_dependencies or []) != spec["tool_dependencies"] + or _normalize_string_list(item.mcp_dependencies or []) != spec["mcp_dependencies"] + or _normalize_string_list(item.skill_dependencies or []) != spec["skill_dependencies"] + ): + await repo.update_dependencies( + item, + tool_dependencies=spec["tool_dependencies"], + mcp_dependencies=spec["mcp_dependencies"], + skill_dependencies=spec["skill_dependencies"], + updated_by=updated_by, + ) + + return await repo.update_builtin_install( + item, + version=spec["version"], + content_hash=spec["content_hash"], + updated_by=updated_by, + ) diff --git a/backend/package/yuxi/storage/postgres/manager.py b/backend/package/yuxi/storage/postgres/manager.py index 080cad9a..50095ea1 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -188,6 +188,9 @@ class PostgresManager(metaclass=SingletonMeta): "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb", + "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS version VARCHAR(64)", + "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS is_builtin BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS content_hash VARCHAR(128)", "ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE", "ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB", """ diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index cb3ffdf9..e1147c69 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -186,6 +186,9 @@ class Skill(Base): mcp_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的 MCP 服务名列表") skill_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的其他 skill slug 列表") dir_path = Column(String(512), nullable=False, comment="技能目录路径(相对 save_dir)") + version = Column(String(64), nullable=True, comment="技能版本(内置 skill 使用语义化版本)") + is_builtin = Column(Boolean, nullable=False, default=False, comment="是否为内置 skill") + content_hash = Column(String(128), nullable=True, comment="技能目录内容哈希(内置 skill 安装时计算)") created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, default=utc_now_naive) @@ -201,6 +204,9 @@ class Skill(Base): "mcp_dependencies": self.mcp_dependencies or [], "skill_dependencies": self.skill_dependencies or [], "dir_path": self.dir_path, + "version": self.version, + "is_builtin": self.is_builtin, + "content_hash": self.content_hash, "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index e99e95b1..0d459150 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user from yuxi.services.skill_service import ( + BuiltinSkillUpdateConflictError, create_skill_node, delete_skill, delete_skill_node, @@ -18,8 +19,11 @@ from yuxi.services.skill_service import ( get_skill_dependency_options, get_skill_tree, import_skill_zip, + install_builtin_skill, + list_builtin_skill_specs, list_skills, read_skill_file, + update_builtin_skill, update_skill_dependencies, update_skill_file, ) @@ -46,6 +50,10 @@ class SkillDependenciesUpdateRequest(BaseModel): skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表") +class BuiltinSkillUpdateRequest(BaseModel): + force: bool = Field(False, description="是否强制覆盖本地已安装内容") + + def _raise_from_value_error(e: ValueError) -> None: message = str(e) status_code = 404 if "不存在" in message else 400 @@ -86,6 +94,88 @@ async def get_skill_dependency_options_route( raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败") +@skills.get("/builtin") +async def list_builtin_skills_route( + _current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + try: + installed_map = {item.slug: item for item in await list_skills(db)} + data = [] + for spec in list_builtin_skill_specs(): + installed = installed_map.get(spec["slug"]) + status = "not_installed" + if installed: + status = "installed" + if installed.version != spec["version"] or installed.content_hash != spec["content_hash"]: + status = "update_available" + data.append( + { + "slug": spec["slug"], + "name": spec["name"], + "description": spec["description"], + "version": spec["version"], + "status": status, + "installed_record": installed.to_dict() if installed else None, + } + ) + return {"success": True, "data": data} + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to list builtin skills: {e}") + raise HTTPException(status_code=500, detail="获取内置 skill 列表失败") + + +@skills.post("/builtin/{slug}/install") +async def install_builtin_skill_route( + slug: str, + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + try: + item = await install_builtin_skill(db, slug, installed_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 builtin skill '{slug}': {e}") + raise HTTPException(status_code=500, detail="安装内置 skill 失败") + + +@skills.post("/builtin/{slug}/update") +async def update_builtin_skill_route( + slug: str, + payload: BuiltinSkillUpdateRequest, + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + try: + item = await update_builtin_skill( + db, + slug, + force=payload.force, + updated_by=current_user.username, + ) + return {"success": True, "data": item.to_dict()} + except BuiltinSkillUpdateConflictError as e: + raise HTTPException( + status_code=409, + detail={"needs_confirm": True, "message": str(e)}, + ) + except ValueError as e: + _raise_from_value_error(e) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update builtin skill '{slug}': {e}") + raise HTTPException(status_code=500, detail="更新内置 skill 失败") + + @skills.post("/import") async def import_skill_route( file: UploadFile = File(...), diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index f968584a..1e0d0293 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -5,7 +5,6 @@ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from yuxi.services.task_service import tasker from yuxi.services.mcp_service import init_mcp_servers -from yuxi.services.skill_service import init_builtin_skills from yuxi.services.subagent_service import init_builtin_subagents from yuxi.services.run_queue_service import close_queue_clients, get_redis_client from yuxi.storage.postgres.manager import pg_manager @@ -40,14 +39,6 @@ async def lifespan(app: FastAPI): logger.error(f"Failed to initialize builtin subagents during startup: {e}") raise - # 初始化内置 Skills - try: - async with pg_manager.get_async_session_context() as session: - await init_builtin_skills(session) - except Exception as e: - logger.error(f"Failed to initialize builtin skills during startup: {e}") - raise - # 初始化知识库管理器 import os if os.environ.get("LITE_MODE", "").lower() in ("true", "1"): diff --git a/backend/test/test_skill_service.py b/backend/test/test_skill_service.py index f6a9c1ef..f5498e95 100644 --- a/backend/test/test_skill_service.py +++ b/backend/test/test_skill_service.py @@ -381,20 +381,8 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p await svc.init_builtin_skills(None) - assert len(FakeRepo.created) == 1 - created = FakeRepo.created[0] - assert created["slug"] == "reporter" - assert created["name"] == "reporter" - assert created["description"] == "SQL report from python" - assert created["tool_dependencies"] == ["mysql_query"] - assert created["mcp_dependencies"] == ["charts"] - assert created["skill_dependencies"] == ["common-report"] - assert created["created_by"] == svc.BUILTIN_SKILL_OPERATOR - - target_dir = tmp_path / "skills" / "reporter" - assert target_dir.exists() - assert target_dir.is_dir() - assert (target_dir / "SKILL.md").exists() + assert FakeRepo.created == [] + assert not (tmp_path / "skills" / "reporter").exists() @pytest.mark.asyncio @@ -481,12 +469,351 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke await svc.init_builtin_skills(None, created_by="release-bot") + assert not (tmp_path / "skills" / "reporter").exists() + assert captured == {} + + +def test_compute_dir_hash_stable(tmp_path: Path): + source_dir = tmp_path / "skill" + (source_dir / "nested").mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text("hello", encoding="utf-8") + (source_dir / "nested" / "prompt.md").write_text("world", encoding="utf-8") + + assert svc._compute_dir_hash(source_dir) == svc._compute_dir_hash(source_dir) + + +def test_compute_dir_hash_changes_on_content_change(tmp_path: Path): + source_dir = tmp_path / "skill" + source_dir.mkdir(parents=True, exist_ok=True) + target_file = source_dir / "SKILL.md" + target_file.write_text("hello", encoding="utf-8") + + first_hash = svc._compute_dir_hash(source_dir) + target_file.write_text("updated", encoding="utf-8") + second_hash = svc._compute_dir_hash(source_dir) + + assert first_hash != second_hash + + +def test_compute_dir_hash_does_not_use_read_bytes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + source_dir = tmp_path / "skill" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text("hello", encoding="utf-8") + + def fail_read_bytes(self: Path) -> bytes: + raise AssertionError("read_bytes should not be used") + + monkeypatch.setattr(Path, "read_bytes", fail_read_bytes) + + assert svc._compute_dir_hash(source_dir) + + +@pytest.mark.asyncio +async def test_install_builtin_skill_ok(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + source_dir = tmp_path / "builtin" / "reporter" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text( + "---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n", + encoding="utf-8", + ) + (source_dir / "prompt.md").write_text("prompt", encoding="utf-8") + + monkeypatch.setattr( + svc, + "list_builtin_skill_specs", + lambda: [ + { + "slug": "reporter", + "name": "reporter", + "description": "SQL report", + "version": "1.0.0", + "tool_dependencies": ["mysql_query"], + "mcp_dependencies": ["charts"], + "skill_dependencies": [], + "content_hash": "hash-v1", + "source_dir": source_dir, + } + ], + ) + monkeypatch.setattr( + svc, + "get_builtin_skill_specs", + lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)], + ) + + class FakeRepo: + created_payload: dict | None = None + + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + assert slug == "reporter" + return None + + async def create(self, **kwargs): + self.__class__.created_payload = kwargs + return Skill(**kwargs, updated_by=kwargs["created_by"]) + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + item = await svc.install_builtin_skill(None, "reporter", installed_by="root") + + assert item.slug == "reporter" + assert item.is_builtin is True + assert item.version == "1.0.0" + assert item.content_hash == "hash-v1" + assert (tmp_path / "skills" / "reporter" / "SKILL.md").exists() + assert FakeRepo.created_payload["created_by"] == "root" + + +@pytest.mark.asyncio +async def test_install_builtin_skill_already_installed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + source_dir = tmp_path / "builtin" / "reporter" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text( + "---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n", + encoding="utf-8", + ) + + monkeypatch.setattr( + svc, + "list_builtin_skill_specs", + lambda: [ + { + "slug": "reporter", + "name": "reporter", + "description": "SQL report", + "version": "1.0.0", + "tool_dependencies": [], + "mcp_dependencies": [], + "skill_dependencies": [], + "content_hash": "hash-v1", + "source_dir": source_dir, + } + ], + ) + monkeypatch.setattr( + svc, + "get_builtin_skill_specs", + lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)], + ) + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + return Skill(slug=slug, name=slug, description="installed", dir_path=f"skills/{slug}") + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + with pytest.raises(ValueError, match="已安装"): + await svc.install_builtin_skill(None, "reporter", installed_by="root") + + +@pytest.mark.asyncio +async def test_update_builtin_skill_needs_confirm_when_hash_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + source_dir = tmp_path / "builtin" / "reporter" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text( + "---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n", + encoding="utf-8", + ) + + monkeypatch.setattr( + svc, + "list_builtin_skill_specs", + lambda: [ + { + "slug": "reporter", + "name": "reporter", + "description": "SQL report", + "version": "1.0.1", + "tool_dependencies": [], + "mcp_dependencies": [], + "skill_dependencies": [], + "content_hash": "hash-v2", + "source_dir": source_dir, + } + ], + ) + monkeypatch.setattr( + svc, + "get_builtin_skill_specs", + lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)], + ) + + installed = Skill( + slug="reporter", + name="reporter", + description="installed", + dir_path="skills/reporter", + is_builtin=True, + version="1.0.0", + content_hash="hash-v1", + ) + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + return installed + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + with pytest.raises(svc.BuiltinSkillUpdateConflictError) as exc_info: + await svc.update_builtin_skill(None, "reporter", updated_by="root") + + assert exc_info.value.needs_confirm is True + + +@pytest.mark.asyncio +async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + source_dir = tmp_path / "builtin" / "reporter" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "SKILL.md").write_text( + "---\nname: reporter\ndescription: builtin new\n---\n# SQL Reporter\n", + encoding="utf-8", + ) + (source_dir / "prompt.md").write_text("new builtin content", encoding="utf-8") + target_dir = tmp_path / "skills" / "reporter" - assert target_dir.exists() - assert target_dir.is_dir() - assert captured["description"] == "new description" - assert captured["tool_dependencies"] == ["mysql_query"] - assert captured["mcp_dependencies"] == ["charts"] - assert captured["skill_dependencies"] == [] - assert captured["updated_by"] == "release-bot" - assert captured["updated_by_deps"] == "release-bot" + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "prompt.md").write_text("old content", encoding="utf-8") + + monkeypatch.setattr( + svc, + "list_builtin_skill_specs", + lambda: [ + { + "slug": "reporter", + "name": "reporter", + "description": "builtin new", + "version": "1.0.1", + "tool_dependencies": ["mysql_query"], + "mcp_dependencies": ["charts"], + "skill_dependencies": [], + "content_hash": "hash-v2", + "source_dir": source_dir, + } + ], + ) + monkeypatch.setattr( + svc, + "get_builtin_skill_specs", + lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)], + ) + + installed = Skill( + slug="reporter", + name="reporter", + description="old", + dir_path="skills/reporter", + is_builtin=True, + version="1.0.0", + content_hash="hash-v1", + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + ) + + captured: dict[str, object] = {} + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + return installed + + async def update_metadata(self, item: Skill, *, name: str, description: str, updated_by: str | None): + item.name = name + item.description = description + captured["metadata_updated_by"] = updated_by + return item + + async def update_dependencies( + self, + item: Skill, + *, + tool_dependencies: list[str], + mcp_dependencies: list[str], + skill_dependencies: list[str], + updated_by: str | None, + ): + item.tool_dependencies = tool_dependencies + item.mcp_dependencies = mcp_dependencies + item.skill_dependencies = skill_dependencies + captured["deps_updated_by"] = updated_by + return item + + async def update_builtin_install( + self, + item: Skill, + *, + version: str, + content_hash: str, + updated_by: str | None, + ): + item.version = version + item.content_hash = content_hash + item.updated_by = updated_by + captured["version"] = version + captured["content_hash"] = content_hash + captured["updated_by"] = updated_by + return item + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + item = await svc.update_builtin_skill(None, "reporter", force=True, updated_by="root") + + assert item.version == "1.0.1" + assert item.content_hash == "hash-v2" + assert (target_dir / "prompt.md").read_text(encoding="utf-8") == "new builtin content" + assert captured["updated_by"] == "root" + + +@pytest.mark.asyncio +async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + target_dir = tmp_path / "skills" / "reporter" + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "SKILL.md").write_text( + "---\nname: reporter\ndescription: builtin\n---\n# Reporter\n", + encoding="utf-8", + ) + + builtin_item = Skill( + slug="reporter", + name="reporter", + description="builtin", + dir_path="skills/reporter", + is_builtin=True, + ) + + async def fake_get_skill_or_raise(_db, _slug: str): + return builtin_item + + monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise) + + with pytest.raises(ValueError, match="内置 skill 不允许直接修改文件"): + await svc.update_skill_file( + None, + slug="reporter", + relative_path="SKILL.md", + content="new content", + updated_by="root", + ) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 952fc2a4..f6233c20 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -43,6 +43,7 @@ - 新增 API Key 认证功能,支持外部系统通过 API Key 调用系统服务 - 新增 subagents 的支持,支持在 web 中添加 subagents,以及两个内置的子智能体 - 新增内置Skills reporter,并移除内置 Agent reporter,数据库报表将由 Skills 完成 +- 重构内置 Skills 安装机制:内置 skill 改为在管理页以“未安装”状态展示,支持按需安装、基于 `version + content_hash` 的更新提示与覆盖确认,并对已安装内置 skill 禁止在线文件编辑 - 新增知识库 PDF、图片的预览功能 @@ -122,6 +123,7 @@ - 为工作台新增 viewer-oriented filesystem service 与 `/api/viewer/filesystem/*` 接口,解耦 agent backend 语义,支持真实目录浏览、原始文件读取与下载 - 重写沙盒技术文档,明确 thread-local sandbox、viewer-oriented filesystem service、`/mnt` 命名空间、skills 可见性与当前实现边界,替换过时的 `/api/sandbox/*` 与 user-level 设计描述 - 收紧沙盒遗留代码:修复未注册 `sandbox_router` 中残留的 user/thread 参数错位,改进宿主机挂载路径映射逻辑,并为 remote sandbox provisioner 增加基础 URL 校验与销毁失败日志 +- 修复 builtin skill 内容哈希计算对单文件使用 `read_bytes()` 的无上限内存读取问题,改为分块计算并补充回归测试 ### 破坏性更新 diff --git a/web/src/apis/base.js b/web/src/apis/base.js index 08d258db..b4f369dd 100644 --- a/web/src/apis/base.js +++ b/web/src/apis/base.js @@ -73,6 +73,13 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons } // 特殊处理401和403错误 + const error = new Error(errorMessage) + error.response = { + status: response.status, + statusText: response.statusText, + data: errorData + } + if (response.status === 401) { // 如果是认证失败,可能需要重新登录 const userStore = useUserStore() @@ -97,14 +104,16 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons window.location.href = '/login' }, 1500) - throw new Error('未授权,请先登录') + throw error } else if (response.status === 403) { - throw new Error('没有权限执行此操作') + error.message = '没有权限执行此操作' + throw error } else if (response.status === 500) { - throw new Error('服务器内部错误,请使用 docker logs api-dev 查看详细日志') + error.message = '服务器内部错误,请使用 docker logs api-dev 查看详细日志' + throw error } - throw new Error(errorMessage) + throw error } // 根据responseType处理响应 diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index b5dbe9e0..e9d1fc36 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -22,6 +22,18 @@ export const getSkillDependencyOptions = async () => { return apiSuperAdminGet(`${BASE_URL}/dependency-options`) } +export const listBuiltinSkills = async () => { + return apiSuperAdminGet(`${BASE_URL}/builtin`) +} + +export const installBuiltinSkill = async (slug) => { + return apiSuperAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/install`) +} + +export const updateBuiltinSkill = async (slug, force = false) => { + return apiSuperAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/update`, { force }) +} + export const getSkillTree = async (slug) => { return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`) } @@ -62,6 +74,9 @@ export const skillApi = { listSkills, importSkillZip, getSkillDependencyOptions, + listBuiltinSkills, + installBuiltinSkill, + updateBuiltinSkill, getSkillTree, getSkillFile, createSkillFile, diff --git a/web/src/components/SkillsManagerComponent.vue b/web/src/components/SkillsManagerComponent.vue index db48989e..18ed0e02 100644 --- a/web/src/components/SkillsManagerComponent.vue +++ b/web/src/components/SkillsManagerComponent.vue @@ -18,13 +18,68 @@
-
+
+
内置 Skills
+ + +
已安装 Skills