diff --git a/PixPin_2026-05-26_21-54-34.png b/PixPin_2026-05-26_21-54-34.png new file mode 100644 index 00000000..ac6aa1de Binary files /dev/null and b/PixPin_2026-05-26_21-54-34.png differ diff --git a/REFACTOR.md b/REFACTOR.md index f192a855..91070364 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -35,4 +35,5 @@ - [x] 链接 Notion 和 feishu 目前来看,都是支持的 - [x] 知识库的权限调整,修改为三个等级,全局共享、部门共享、指定人可访问- [x] 当前的评估基准是最重要的是评估数据集和评估结果都是放在一个文件里面的,这个是绝对不可以的,应该是放在数据库里面,比如评估数据集是一个表,每一个评估的题目是一个表,评估的结果是一个表,每一个评估的 item 也是一个表,但是数据表太多要注意命名规范。现在第一步就是完成原本的评估的功能的重新梳理 - [ ] 考虑如何将知识库更好的挂载到沙盒,是不是可以使用一个别的后端,但是使用别的后端是否还能读取到数据?应该不能 -- [ ] 智能体体系改进。 +- [ ] 智能体体系改进。改进子智能体 +- [ ] RAG 中的文件的 metadata 包含那些内容?然后 Find 和 Read 的时候要支持展示 diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index 4aceb8b5..0306e5c9 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -155,8 +155,8 @@ class BaseContext: summary_threshold: int = field( default=100, metadata={ - "name": "上下文摘要触发阈值 (KB)", - "description": "当上下文大小超过该值时,启用摘要功能以优化上下文使用。单位为 KB,默认值为 100KB。", + "name": "上下文摘要触发阈值 (K)", + "description": "当上下文大小超过该值时,启用摘要功能以优化上下文使用。单位为 K,默认值为 100K。", "type": "number", "auth": "admin", }, @@ -294,9 +294,9 @@ async def resolve_agent_resource_options( if server.enabled and server.slug ] if "skills" in fields_to_load: - from yuxi.services.skill_service import list_skills + from yuxi.services.skill_service import list_accessible_skills - skills = await list_skills(db) + skills = await list_accessible_skills(db, user) options["skills"] = [ _resource_option(skill.slug, skill.name, skill.description) for skill in skills if skill.slug ] @@ -374,6 +374,8 @@ async def prepare_agent_runtime_context( setattr(context, "_visible_knowledge_bases", []) setattr(context, "_prompt_skills", []) setattr(context, "_readable_skills", []) + setattr(context, "_runtime_skill_metadata", {}) + setattr(context, "_runtime_skill_dependency_map", {}) return context raw_resources = { @@ -392,9 +394,11 @@ async def prepare_agent_runtime_context( setattr(context, field_name, normalized.get(field_name, [])) await resolve_visible_knowledge_bases_for_context(context) - skill_scope = await resolve_runtime_skills_for_context(context, db=db) + skill_scope = await resolve_runtime_skills_for_context(context, db=db, user=user) context.skills = skill_scope["context_skills"] setattr(context, "_prompt_skills", skill_scope["prompt_skills"]) setattr(context, "_readable_skills", skill_scope["readable_skills"]) + setattr(context, "_runtime_skill_metadata", skill_scope["runtime_skill_metadata"]) + setattr(context, "_runtime_skill_dependency_map", skill_scope["runtime_skill_dependency_map"]) return context diff --git a/backend/package/yuxi/agents/middlewares/skills_middleware.py b/backend/package/yuxi/agents/middlewares/skills_middleware.py index 2cc5f29f..98e8e91c 100644 --- a/backend/package/yuxi/agents/middlewares/skills_middleware.py +++ b/backend/package/yuxi/agents/middlewares/skills_middleware.py @@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.toolkits import get_all_tool_instances from yuxi.repositories.skill_repository import SkillRepository from yuxi.services.mcp_service import get_enabled_mcp_tools -from yuxi.services.skill_service import is_valid_skill_slug, normalize_string_list +from yuxi.services.skill_service import is_valid_skill_slug, list_accessible_skills, normalize_string_list from yuxi.storage.postgres.manager import pg_manager from yuxi.utils.logging_config import logger @@ -42,20 +42,22 @@ class SkillDependencyNode(TypedDict): # ============================================================================= -async def _list_skills_from_db(db: AsyncSession | None = None) -> list: +async def _list_skills_from_db(db: AsyncSession | None = None, user=None) -> list: """从数据库加载 skills 列表""" if db is not None: + if user is not None: + return await list_accessible_skills(db, user) repo = SkillRepository(db) - return await repo.list_all() + return await repo.list_enabled() async with pg_manager.get_async_session_context() as session: + if user is not None: + return await list_accessible_skills(session, user) repo = SkillRepository(session) - return await repo.list_all() + return await repo.list_enabled() -async def get_prompt_metadata(db: AsyncSession | None = None) -> dict[str, SkillPromptMetadata]: - """获取提示词元数据(直接从数据库加载)""" - skills = await _list_skills_from_db(db) +def build_prompt_metadata(skills: list) -> dict[str, SkillPromptMetadata]: return { item.slug: { "name": item.name, @@ -63,14 +65,15 @@ async def get_prompt_metadata(db: AsyncSession | None = None) -> dict[str, Skill "path": f"/home/gem/skills/{item.slug}/SKILL.md", } for item in skills + if item.slug } -async def get_dependency_map(db: AsyncSession | None = None) -> dict[str, SkillDependencyNode]: - """获取依赖关系映射(直接从数据库加载)""" - skills = await _list_skills_from_db(db) +def build_dependency_map(skills: list) -> dict[str, SkillDependencyNode]: result: dict[str, SkillDependencyNode] = {} for item in skills: + if not item.slug: + continue result[item.slug] = { "tools": normalize_string_list(item.tool_dependencies or []), "mcps": normalize_string_list(item.mcp_dependencies or []), @@ -79,6 +82,16 @@ async def get_dependency_map(db: AsyncSession | None = None) -> dict[str, SkillD return result +async def get_prompt_metadata(db: AsyncSession | None = None, user=None) -> dict[str, SkillPromptMetadata]: + """获取提示词元数据(直接从数据库加载)""" + return build_prompt_metadata(await _list_skills_from_db(db, user)) + + +async def get_dependency_map(db: AsyncSession | None = None, user=None) -> dict[str, SkillDependencyNode]: + """获取依赖关系映射(直接从数据库加载)""" + return build_dependency_map(await _list_skills_from_db(db, user)) + + def expand_skill_closure( slugs: list[str] | None, dependency_map: dict[str, SkillDependencyNode], @@ -115,16 +128,20 @@ def expand_skill_closure( return result -async def resolve_runtime_skills_for_context(context, *, db: AsyncSession | None = None) -> dict[str, list[str]]: - dependency_map = await get_dependency_map(db) - installed = set(dependency_map) +async def resolve_runtime_skills_for_context(context, *, db: AsyncSession | None = None, user=None) -> dict[str, Any]: + skill_items = await _list_skills_from_db(db, user) + dependency_map = build_dependency_map(skill_items) + prompt_metadata = build_prompt_metadata(skill_items) + available = set(dependency_map) selected = normalize_string_list(getattr(context, "skills", None)) - context_skills = [slug for slug in selected if slug in installed] + context_skills = [slug for slug in selected if slug in available] prompt_skills = expand_skill_closure(context_skills, dependency_map) return { "context_skills": context_skills, "prompt_skills": prompt_skills, "readable_skills": prompt_skills, + "runtime_skill_metadata": prompt_metadata, + "runtime_skill_dependency_map": dependency_map, } @@ -199,7 +216,7 @@ class SkillsMiddleware(AgentMiddleware): return None # 收集提示词元数据并构建提示段 - skills_meta = await self._collect_prompt_metadata(prompt_skills) + skills_meta = self._collect_prompt_metadata(prompt_skills, runtime_context) skills_section = self._build_skills_section(skills_meta) # 注入提示词 @@ -224,7 +241,7 @@ class SkillsMiddleware(AgentMiddleware): readable_skills = self._get_readable_skills(runtime_context) activated = [slug for slug in normalize_string_list(activated) if slug in readable_skills] - deps_bundle = await self._build_dependency_bundle(activated) + deps_bundle = self._build_dependency_bundle(activated, runtime_context) enabled_tools = [] @@ -251,9 +268,9 @@ class SkillsMiddleware(AgentMiddleware): return await handler(request) - async def _build_dependency_bundle(self, activated_skills: list[str]) -> dict[str, list[str]]: + def _build_dependency_bundle(self, activated_skills: list[str], runtime_context) -> dict[str, list[str]]: """根据直接激活的 skills 构建依赖包(不包含闭包展开的依赖)""" - dependency_map = await get_dependency_map() + dependency_map = self._get_runtime_dependency_map(runtime_context) tools: list[str] = [] mcps: list[str] = [] @@ -275,9 +292,9 @@ class SkillsMiddleware(AgentMiddleware): return {"tools": tools, "mcps": mcps, "skills": activated_skills} - async def _collect_prompt_metadata(self, slugs: list[str]) -> list[SkillPromptMetadata]: + def _collect_prompt_metadata(self, slugs: list[str], runtime_context) -> list[SkillPromptMetadata]: """收集指定 slugs 的提示词元数据""" - prompt_metadata = await get_prompt_metadata() + prompt_metadata = self._get_runtime_prompt_metadata(runtime_context) result: list[SkillPromptMetadata] = [] seen: set[str] = set() @@ -403,6 +420,14 @@ class SkillsMiddleware(AgentMiddleware): selected = getattr(runtime_context, "_readable_skills", []) return set(normalize_string_list(selected if isinstance(selected, list) else [])) + def _get_runtime_prompt_metadata(self, runtime_context) -> dict[str, SkillPromptMetadata]: + metadata = getattr(runtime_context, "_runtime_skill_metadata", {}) + return metadata if isinstance(metadata, dict) else {} + + def _get_runtime_dependency_map(self, runtime_context) -> dict[str, SkillDependencyNode]: + dependency_map = getattr(runtime_context, "_runtime_skill_dependency_map", {}) + return dependency_map if isinstance(dependency_map, dict) else {} + def _is_visible_skill_slug(self, request: ToolCallRequest, slug: str) -> bool: """检查 slug 是否可见""" return slug in self._get_readable_skills(request.runtime.context) diff --git a/backend/package/yuxi/repositories/skill_repository.py b/backend/package/yuxi/repositories/skill_repository.py index 645f3210..a887086e 100644 --- a/backend/package/yuxi/repositories/skill_repository.py +++ b/backend/package/yuxi/repositories/skill_repository.py @@ -15,6 +15,21 @@ class SkillRepository: result = await self.db.execute(select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc())) return list(result.scalars().all()) + async def list_enabled(self) -> list[Skill]: + result = await self.db.execute( + select(Skill).where(Skill.enabled.is_(True)).order_by(Skill.updated_at.desc(), Skill.id.desc()) + ) + return list(result.scalars().all()) + + async def list_by_slugs(self, slugs: list[str]) -> list[Skill]: + normalized = [slug for slug in dict.fromkeys(slugs) if isinstance(slug, str) and slug] + if not normalized: + return [] + result = await self.db.execute(select(Skill).where(Skill.slug.in_(normalized))) + items = list(result.scalars().all()) + item_map = {item.slug: item for item in items} + return [item_map[slug] for slug in normalized if slug in item_map] + async def get_by_slug(self, slug: str, *, for_update: bool = False) -> Skill | None: stmt = select(Skill).where(Skill.slug == slug) if for_update: @@ -31,12 +46,14 @@ class SkillRepository: slug: str, name: str, description: str, + source_type: str, tool_dependencies: list[str] | None, mcp_dependencies: list[str] | None, skill_dependencies: list[str] | None, dir_path: str, + share_config: dict, + enabled: bool = True, version: str | None = None, - is_builtin: bool = False, content_hash: str | None = None, created_by: str | None, ) -> Skill: @@ -45,13 +62,15 @@ class SkillRepository: slug=slug, name=name, description=description, + source_type=source_type, tool_dependencies=tool_dependencies or [], mcp_dependencies=mcp_dependencies or [], skill_dependencies=skill_dependencies or [], dir_path=dir_path, version=version, - is_builtin=is_builtin, content_hash=content_hash, + share_config=share_config, + enabled=enabled, created_by=created_by, updated_by=created_by, created_at=now, @@ -72,7 +91,8 @@ class SkillRepository: ) -> Skill: item.version = version item.content_hash = content_hash - item.is_builtin = True + item.source_type = "builtin" + item.share_config = {"access_level": "global", "department_ids": [], "user_uids": []} item.updated_by = updated_by item.updated_at = utc_now_naive() await self.db.commit() @@ -113,6 +133,22 @@ class SkillRepository: await self.db.refresh(item) return item + async def update_share_config(self, item: Skill, *, share_config: dict, updated_by: str | None) -> Skill: + item.share_config = share_config + 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_enabled(self, item: Skill, *, enabled: bool, updated_by: str | None) -> Skill: + item.enabled = enabled + item.updated_by = updated_by + item.updated_at = utc_now_naive() + await self.db.commit() + await self.db.refresh(item) + return item + async def delete(self, item: Skill) -> None: await self.db.delete(item) await self.db.commit() diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index 4ebe870d..78272eae 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -13,6 +13,7 @@ from sqlalchemy.exc import OperationalError from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository from yuxi.services.chat_service import stream_agent_chat from yuxi.services.mcp_service import ensure_builtin_mcp_servers_in_db +from yuxi.services.skill_service import init_builtin_skills from yuxi.services.run_queue_service import ( append_run_stream_event, clear_cancel_signal, @@ -369,6 +370,8 @@ async def _worker_startup(ctx): await pg_manager.create_business_tables() await pg_manager.ensure_business_schema() await ensure_builtin_mcp_servers_in_db() + async with pg_manager.get_async_session_context() as session: + await init_builtin_skills(session) async def _worker_shutdown(ctx): diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index b3513fc9..0c19c170 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -2,10 +2,12 @@ from __future__ import annotations import asyncio import hashlib +import json import re import shutil import tempfile import threading +import time import uuid import zipfile from pathlib import Path, PurePosixPath @@ -17,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from yuxi import config as sys_config from yuxi.repositories.skill_repository import SkillRepository from yuxi.services.mcp_service import get_enabled_mcp_server_slugs -from yuxi.storage.postgres.models_business import Skill +from yuxi.storage.postgres.models_business import Skill, User from yuxi.utils.logging_config import logger SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") @@ -54,16 +56,16 @@ TEXT_FILE_EXTENSIONS = { } BUILTIN_SKILL_OPERATOR = "builtin-system" +SKILL_SOURCE_TYPES = {"builtin", "upload", "remote"} +ACCESS_LEVELS = {"global", "department", "user"} +ADMIN_ROLES = {"admin", "superadmin"} +DEFAULT_SKILL_SHARE_CONFIG = {"access_level": "user", "department_ids": [], "user_uids": []} +BUILTIN_SKILL_SHARE_CONFIG = {"access_level": "global", "department_ids": [], "user_uids": []} +SKILL_DRAFT_TTL_SECONDS = 60 * 60 _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) @@ -95,12 +97,178 @@ def is_valid_skill_slug(slug: str) -> bool: return bool(SKILL_SLUG_PATTERN.match(slug.strip())) +def is_builtin_skill(item: Skill | dict) -> bool: + source_type = item.get("source_type") if isinstance(item, dict) else item.source_type + return source_type == "builtin" + + +def _normalize_department_ids(department_ids: list | None) -> list[int]: + normalized = [] + for department_id in department_ids or []: + normalized.append(int(department_id)) + return normalized + + +def _normalize_user_uids(user_uids: list | None) -> list[str]: + return [uid for uid in (str(uid).strip() for uid in user_uids or []) if uid] + + +def get_allowed_skill_access_levels(user: User) -> list[str]: + if user.role in ADMIN_ROLES: + return ["global", "department", "user"] + return ["user"] + + +def normalize_skill_share_config( + share_config: dict | None, + *, + operator_uid: str, + operator_department_id: int | str | None, + source_type: str = "upload", + allowed_access_levels: set[str] | None = None, +) -> dict: + if source_type == "builtin": + return BUILTIN_SKILL_SHARE_CONFIG.copy() + + config = share_config or DEFAULT_SKILL_SHARE_CONFIG + access_level = config.get("access_level") or "user" + if access_level not in ACCESS_LEVELS: + raise ValueError("无效的 Skill 权限等级") + if allowed_access_levels is not None and access_level not in allowed_access_levels: + raise ValueError("当前用户无权使用该 Skill 共享范围") + + if access_level == "global": + return {"access_level": "global", "department_ids": [], "user_uids": []} + + if access_level == "department": + department_ids = _normalize_department_ids(config.get("department_ids")) + if operator_department_id is not None: + department_ids.append(int(operator_department_id)) + department_ids = sorted(set(department_ids)) + if not department_ids: + raise ValueError("部门共享至少需要选择一个部门") + return {"access_level": "department", "department_ids": department_ids, "user_uids": []} + + user_uids = _normalize_user_uids(config.get("user_uids")) + if operator_uid: + user_uids.append(str(operator_uid)) + user_uids = sorted(set(user_uids)) + if not user_uids: + raise ValueError("指定人可访问至少需要选择一个用户") + return {"access_level": "user", "department_ids": [], "user_uids": user_uids} + + +def user_can_access_skill(user: User, skill: Skill, *, require_enabled: bool = True) -> bool: + if require_enabled and not skill.enabled: + return False + if user.role == "superadmin": + return True + + user_uid = str(user.uid or "") + if user_uid and skill.created_by == user_uid: + return True + + share_config = skill.share_config or DEFAULT_SKILL_SHARE_CONFIG.copy() + access_level = share_config.get("access_level") + if access_level == "global": + return True + if access_level == "department": + if user.department_id is None: + return False + try: + return int(user.department_id) in [int(value) for value in share_config.get("department_ids") or []] + except (TypeError, ValueError): + return False + if access_level == "user": + return bool(user_uid and user_uid in (share_config.get("user_uids") or [])) + return False + + +def user_can_manage_skill(user: User, skill: Skill) -> bool: + if is_builtin_skill(skill): + return user.role in ADMIN_ROLES + return user.role in ADMIN_ROLES or skill.created_by == str(user.uid or "") + + +def can_skill_depend_on(parent: Skill, dependency: Skill) -> bool: + if not dependency.enabled: + return False + if is_builtin_skill(dependency): + return True + + dep_config = dependency.share_config or DEFAULT_SKILL_SHARE_CONFIG.copy() + parent_config = parent.share_config or DEFAULT_SKILL_SHARE_CONFIG.copy() + dep_level = dep_config.get("access_level") + parent_level = parent_config.get("access_level") + + if dep_level == "global": + return True + if parent_level == "global": + return False + if parent_level == "department" and dep_level == "department": + parent_ids = {int(value) for value in parent_config.get("department_ids") or []} + dep_ids = {int(value) for value in dep_config.get("department_ids") or []} + return parent_ids.issubset(dep_ids) + if parent_level == "user" and dep_level == "user": + parent_uids = {str(value) for value in parent_config.get("user_uids") or []} + dep_uids = {str(value) for value in dep_config.get("user_uids") or []} + return parent_uids.issubset(dep_uids) + return False + + +def _ensure_non_builtin(item: Skill) -> None: + if is_builtin_skill(item): + raise ValueError("内置 skill 不允许执行该操作") + + def get_skills_root_dir() -> Path: root = Path(sys_config.save_dir) / "skills" root.mkdir(parents=True, exist_ok=True) return root +def get_skill_drafts_root_dir() -> Path: + root = Path(sys_config.save_dir) / "skill_import_drafts" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _cleanup_expired_skill_drafts() -> None: + root = get_skill_drafts_root_dir() + now = time.time() + for entry in root.iterdir(): + if not entry.is_dir(): + continue + metadata_path = entry / "metadata.json" + try: + if not metadata_path.exists() or now - entry.stat().st_mtime > SKILL_DRAFT_TTL_SECONDS: + shutil.rmtree(entry, ignore_errors=True) + continue + data = json.loads(metadata_path.read_text(encoding="utf-8")) + if data.get("expires_at", 0) < now: + shutil.rmtree(entry, ignore_errors=True) + except Exception: + shutil.rmtree(entry, ignore_errors=True) + + +def _load_skill_draft(draft_id: str) -> tuple[Path, dict]: + if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", str(draft_id or "")): + raise ValueError("无效的安装草稿") + draft_dir = (get_skill_drafts_root_dir() / draft_id).resolve() + try: + draft_dir.relative_to(get_skill_drafts_root_dir().resolve()) + except ValueError: + raise ValueError("无效的安装草稿") from None + metadata_path = draft_dir / "metadata.json" + if not metadata_path.exists(): + raise ValueError("安装草稿不存在或已过期") + data = json.loads(metadata_path.read_text(encoding="utf-8")) + if data.get("expires_at", 0) < time.time(): + shutil.rmtree(draft_dir, ignore_errors=True) + raise ValueError("安装草稿已过期") + return draft_dir, data + + def get_thread_skills_root_dir(thread_id: str) -> Path: safe_thread_id = str(thread_id or "").strip() if not safe_thread_id: @@ -170,25 +338,10 @@ 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() -def _is_builtin_managed(item: Skill, slug: str) -> bool: - expected_dir = _build_builtin_skill_dir_path(slug) - if item.dir_path != expected_dir: - return False - return (item.created_by or "") == BUILTIN_SKILL_OPERATOR - - def _dirs_equal(dir1: Path, dir2: Path) -> bool: """检查两个目录内容是否相同(通过文件列表比较)""" if not dir1.exists() or not dir2.exists(): @@ -212,17 +365,6 @@ def _compute_dir_hash(source_dir: Path) -> str: return hasher.hexdigest() -def _copy_skill_target(target_dir: Path, source_dir: Path) -> None: - if target_dir.is_symlink(): - target_dir.unlink() - elif target_dir.exists(): - if _dirs_equal(target_dir, source_dir): - return - raise ValueError(f"技能目录已存在且非内置链接,无法托管: {target_dir}") - - 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 @@ -245,25 +387,20 @@ def _replace_skill_target(target_dir: Path, source_dir: Path) -> None: 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 +async def list_accessible_skills( + db: AsyncSession, + user: User, + *, + require_enabled: bool = True, +) -> list[Skill]: + repo = SkillRepository(db) + items = await repo.list_enabled() if require_enabled else await repo.list_all() + return [item for item in items if user_can_access_skill(user, item, require_enabled=require_enabled)] - def get_tools(): - all_tools = get_tool_metadata() - return [{"slug": tool["slug"], "name": tool.get("name", tool["slug"])} for tool in all_tools] - skill_slugs, tool_list, mcp_names = await asyncio.gather( - list_skill_slugs(db), - asyncio.to_thread(get_tools), - get_enabled_mcp_server_slugs(db=db), - ) - - return { - "tools": tool_list, - "mcps": mcp_names, - "skills": skill_slugs, - } +async def list_manageable_skills(db: AsyncSession, user: User) -> list[Skill]: + repo = SkillRepository(db) + return [item for item in await repo.list_all() if user_can_manage_skill(user, item)] async def list_skills(db: AsyncSession) -> list[Skill]: @@ -271,11 +408,39 @@ async def list_skills(db: AsyncSession) -> list[Skill]: return await repo.list_all() -async def list_skill_slugs(db: AsyncSession) -> list[str]: - result = await db.execute(select(Skill.slug).order_by(Skill.updated_at.desc(), Skill.id.desc())) +async def list_skill_slugs(db: AsyncSession, *, user: User | None = None) -> list[str]: + if user is not None: + return [item.slug for item in await list_accessible_skills(db, user) if isinstance(item.slug, str)] + result = await db.execute( + select(Skill.slug).where(Skill.enabled.is_(True)).order_by(Skill.updated_at.desc(), Skill.id.desc()) + ) return [slug for slug in result.scalars().all() if isinstance(slug, str)] +async def get_skill_dependency_options( + db: AsyncSession, user: User, slug: str | None = None +) -> dict[str, list[str] | list[dict]]: + from yuxi.services.tool_service import get_tool_metadata + + def get_tools(): + all_tools = get_tool_metadata() + return [{"slug": tool["slug"], "name": tool.get("name", tool["slug"])} for tool in all_tools] + + skill_slugs, tool_list, mcp_names = await asyncio.gather( + list_skill_slugs(db, user=user), + asyncio.to_thread(get_tools), + get_enabled_mcp_server_slugs(db=db), + ) + if slug: + skill_slugs = [item for item in skill_slugs if item != slug] + + return { + "tools": tool_list, + "mcps": mcp_names, + "skills": skill_slugs, + } + + def _get_all_tool_names() -> list[str]: """获取所有工具名称(包括 buildin 和其他来源)""" from yuxi.services.tool_service import get_tool_metadata @@ -286,11 +451,11 @@ def _get_all_tool_names() -> list[str]: async def _validate_dependencies( *, - slug: str, + parent: Skill, tool_dependencies: list[str], mcp_dependencies: list[str], skill_dependencies: list[str], - available_skill_slugs: set[str], + available_skills: dict[str, Skill], ) -> tuple[list[str], list[str], list[str]]: tools = normalize_string_list(tool_dependencies) mcps = normalize_string_list(mcp_dependencies) @@ -307,13 +472,17 @@ async def _validate_dependencies( if invalid_mcps: raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}") - invalid_skills = [name for name in skills if name not in available_skill_slugs] + invalid_skills = [name for name in skills if name not in available_skills] if invalid_skills: raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}") - if slug in skills: + if parent.slug in skills: raise ValueError("skill_dependencies 不允许包含自身") + forbidden_skills = [name for name in skills if not can_skill_depend_on(parent, available_skills[name])] + if forbidden_skills: + raise ValueError(f"存在权限范围不匹配的 skill 依赖: {', '.join(forbidden_skills)}") + return tools, mcps, skills @@ -324,18 +493,19 @@ async def update_skill_dependencies( tool_dependencies: list[str], mcp_dependencies: list[str], skill_dependencies: list[str], - updated_by: str | None, + operator: User, ) -> Skill: - item = await get_skill_or_raise(db, slug) + item = await get_manageable_skill_or_raise(db, operator, slug) + _ensure_non_builtin(item) repo = SkillRepository(db) - skill_items = await repo.list_all() - available_skill_slugs = {skill.slug for skill in skill_items} + skill_items = await list_accessible_skills(db, operator) + available_skills = {skill.slug: skill for skill in skill_items} tools, mcps, skills = await _validate_dependencies( - slug=slug, + parent=item, tool_dependencies=tool_dependencies, mcp_dependencies=mcp_dependencies, skill_dependencies=skill_dependencies, - available_skill_slugs=available_skill_slugs, + available_skills=available_skills, ) return await repo.update_dependencies( @@ -343,7 +513,7 @@ async def update_skill_dependencies( tool_dependencies=tools, mcp_dependencies=mcps, skill_dependencies=skills, - updated_by=updated_by, + updated_by=operator.uid, ) @@ -431,33 +601,107 @@ 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() - +def _parse_skill_dir_metadata(source_skill_dir: Path) -> dict[str, Any]: 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) + parsed_name, parsed_desc, meta = _parse_skill_markdown(content) + return { + "name": parsed_name, + "description": parsed_desc, + "tool_dependencies": normalize_string_list(meta.get("tool_dependencies")), + "mcp_dependencies": normalize_string_list(meta.get("mcp_dependencies")), + "skill_dependencies": normalize_string_list(meta.get("skill_dependencies")), + } - final_slug = await _generate_available_slug(repo, parsed_name) - final_name = parsed_name + +async def _stage_skill_draft_item( + repo: SkillRepository, + *, + source_skill_dir: Path, + draft_items_dir: Path, +) -> dict[str, Any]: + item_id = uuid.uuid4().hex + item_dir = draft_items_dir / item_id + shutil.copytree(source_skill_dir, item_dir, symlinks=False) + parsed = _parse_skill_dir_metadata(item_dir) + final_slug = await _generate_available_slug(repo, parsed["name"]) + return { + "draft_item_id": item_id, + "source_dir": f"items/{item_id}", + "slug": final_slug, + "name": final_slug, + "original_name": parsed["name"], + "description": parsed["description"], + "tool_dependencies": parsed["tool_dependencies"], + "mcp_dependencies": parsed["mcp_dependencies"], + "skill_dependencies": parsed["skill_dependencies"], + "warnings": [f"原始名称 {parsed['name']} 已存在,将安装为 {final_slug}"] + if final_slug != parsed["name"] + else [], + "success": True, + } + + +def _build_default_share_payload(operator: User) -> dict[str, Any]: + default_share_config = normalize_skill_share_config( + None, + operator_uid=operator.uid, + operator_department_id=operator.department_id, + allowed_access_levels=set(get_allowed_skill_access_levels(operator)), + ) + return { + "default_share_config": default_share_config, + "allowed_access_levels": get_allowed_skill_access_levels(operator), + } + + +def _write_skill_draft( + *, + operator: User, + source_type: str, + source: str | None, + items: list[dict[str, Any]], +) -> dict[str, Any]: + _cleanup_expired_skill_drafts() + draft_id = str(uuid.uuid4()) + draft_dir = get_skill_drafts_root_dir() / draft_id + draft_dir.mkdir(parents=True, exist_ok=False) + data = { + "draft_id": draft_id, + "created_by": operator.uid, + "source_type": source_type, + "source": source, + "created_at": time.time(), + "expires_at": time.time() + SKILL_DRAFT_TTL_SECONDS, + "items": items, + **_build_default_share_payload(operator), + } + (draft_dir / "metadata.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return data + + +async def _import_skill_dir_impl( + db: AsyncSession, + *, + source_skill_dir: Path, + created_by: str | None, + source_type: str, + share_config: dict, +) -> Skill: + repo = SkillRepository(db) + skills_root = get_skills_root_dir() + parsed = _parse_skill_dir_metadata(source_skill_dir) + final_slug = await _generate_available_slug(repo, 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" + stage_dir = Path(temp_root) / "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") + if final_slug != parsed["name"]: + content = (stage_dir / "SKILL.md").read_text(encoding="utf-8") + (stage_dir / "SKILL.md").write_text(_rewrite_frontmatter_name(content, final_slug), encoding="utf-8") temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}" if temp_target.exists(): @@ -473,12 +717,15 @@ async def _import_skill_dir_impl( try: item = await repo.create( slug=final_slug, - name=final_name, - description=parsed_desc, - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=[], + name=final_slug, + description=parsed["description"], + source_type=source_type, + tool_dependencies=parsed["tool_dependencies"], + mcp_dependencies=parsed["mcp_dependencies"], + skill_dependencies=parsed["skill_dependencies"], dir_path=(Path("skills") / final_slug).as_posix(), + share_config=share_config, + enabled=True, created_by=created_by, ) except Exception: @@ -544,49 +791,214 @@ def _build_tree(path: Path, base_dir: Path) -> list[dict[str, Any]]: return children -async def import_skill_zip( +async def prepare_skill_upload( db: AsyncSession, *, filename: str, file_bytes: bytes, - created_by: str | None, -) -> Skill: + operator: User, +) -> dict[str, Any]: 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) + draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4()) + items_dir = draft_dir / "items" + draft_dir.mkdir(parents=True, exist_ok=False) + items_dir.mkdir(parents=True, exist_ok=True) + + try: + with tempfile.TemporaryDirectory(prefix=".skill-prepare-", dir=str(get_skills_root_dir().parent)) as temp_root: + extract_dir = Path(temp_root) / "extract" + extract_dir.mkdir(parents=True, exist_ok=True) + if is_zip_upload: + zip_path = Path(temp_root) / "upload.zip" + 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)") + source_skill_dir = skill_md_files[0].parent + else: + source_skill_dir = extract_dir + (source_skill_dir / "SKILL.md").write_bytes(file_bytes) + + item = await _stage_skill_draft_item(repo, source_skill_dir=source_skill_dir, draft_items_dir=items_dir) + + data = { + "draft_id": draft_dir.name, + "created_by": operator.uid, + "source_type": "upload", + "source": filename, + "created_at": time.time(), + "expires_at": time.time() + SKILL_DRAFT_TTL_SECONDS, + "items": [item], + **_build_default_share_payload(operator), + } + (draft_dir / "metadata.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return data + except Exception: + shutil.rmtree(draft_dir, ignore_errors=True) + raise + + +async def prepare_remote_skill_install( + db: AsyncSession, + *, + source: str, + skills: list[str], + operator: User, +) -> dict[str, Any]: + from yuxi.services.remote_skill_install_service import prepare_remote_skills_batch + + repo = SkillRepository(db) + draft_dir = get_skill_drafts_root_dir() / str(uuid.uuid4()) + items_dir = draft_dir / "items" + draft_dir.mkdir(parents=True, exist_ok=False) + items_dir.mkdir(parents=True, exist_ok=True) + + preparation = None + try: + preparation = await prepare_remote_skills_batch(source=source, skills=skills) + items: list[dict[str, Any]] = [] + for result in preparation.results: + if not result.get("success"): + items.append( + {"slug": result.get("slug", ""), "success": False, "error": result.get("error", "安装失败")} + ) + continue + item = await _stage_skill_draft_item( + repo, + source_skill_dir=Path(result["source_dir"]), + draft_items_dir=items_dir, + ) + items.append(item) + + data = { + "draft_id": draft_dir.name, + "created_by": operator.uid, + "source_type": "remote", + "source": source, + "created_at": time.time(), + "expires_at": time.time() + SKILL_DRAFT_TTL_SECONDS, + "items": items, + **_build_default_share_payload(operator), + } + (draft_dir / "metadata.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return data + except Exception: + shutil.rmtree(draft_dir, ignore_errors=True) + raise + finally: + if preparation is not None: + await preparation.cleanup() + + +async def confirm_skill_install_draft( + db: AsyncSession, + *, + draft_id: str, + share_config: dict | None, + operator: User, +) -> list[dict[str, Any]]: + draft_dir, data = _load_skill_draft(draft_id) + if data.get("created_by") != operator.uid and operator.role not in ADMIN_ROLES: + raise ValueError("无权确认该安装草稿") + + source_type = data.get("source_type") + if source_type not in {"upload", "remote"}: + raise ValueError("无效的安装草稿来源") + + normalized_share_config = normalize_skill_share_config( + share_config, + operator_uid=operator.uid, + operator_department_id=operator.department_id, + source_type=source_type, + allowed_access_levels=set(get_allowed_skill_access_levels(operator)), + ) + + repo = SkillRepository(db) skills_root = get_skills_root_dir() + results: list[dict[str, Any]] = [] - 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" - 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) + for draft_item in data.get("items") or []: + if not draft_item.get("success", True): + results.append( + {"slug": draft_item.get("slug", ""), "success": False, "error": draft_item.get("error", "安装失败")} + ) + continue - with zipfile.ZipFile(zip_path, "r") as zf: - _validate_zip_paths(zf) - zf.extractall(extract_dir) + slug = str(draft_item.get("slug") or "").strip() + if not is_valid_skill_slug(slug): + results.append({"slug": slug, "success": False, "error": "无效 skill slug"}) + continue + if await repo.exists_slug(slug) or (skills_root / slug).exists(): + results.append({"slug": slug, "success": False, "error": "Skill slug 已被占用,请重新解析安装"}) + continue - skill_md_files = list(extract_dir.rglob("SKILL.md")) - if len(skill_md_files) != 1: - raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)") + source_dir = (draft_dir / str(draft_item.get("source_dir", ""))).resolve() + try: + source_dir.relative_to(draft_dir.resolve()) + except ValueError: + results.append({"slug": slug, "success": False, "error": "安装草稿路径非法"}) + continue - 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) + try: + parsed = _parse_skill_dir_metadata(source_dir) + with tempfile.TemporaryDirectory(prefix=".skill-confirm-", dir=str(skills_root.parent)) as temp_root: + stage_dir = Path(temp_root) / "stage" + shutil.copytree(source_dir, stage_dir) + if parsed["name"] != slug: + content = (stage_dir / "SKILL.md").read_text(encoding="utf-8") + (stage_dir / "SKILL.md").write_text(_rewrite_frontmatter_name(content, slug), encoding="utf-8") - return await _import_skill_dir_impl( - db, - source_skill_dir=source_skill_dir, - created_by=created_by, - ) + temp_target = skills_root / f".{slug}.tmp-{uuid.uuid4().hex[:8]}" + shutil.move(str(stage_dir), str(temp_target)) + final_dir = skills_root / slug + if final_dir.exists(): + shutil.rmtree(temp_target, ignore_errors=True) + results.append({"slug": slug, "success": False, "error": "Skill slug 已被占用,请重新解析安装"}) + continue + temp_target.rename(final_dir) + + try: + item = await repo.create( + slug=slug, + name=slug, + description=parsed["description"], + source_type=source_type, + tool_dependencies=parsed["tool_dependencies"], + mcp_dependencies=parsed["mcp_dependencies"], + skill_dependencies=parsed["skill_dependencies"], + dir_path=(Path("skills") / slug).as_posix(), + share_config=normalized_share_config, + enabled=True, + created_by=operator.uid, + ) + results.append({"slug": item.slug, "success": True, "skill": item.to_dict()}) + except Exception: + shutil.rmtree(final_dir, ignore_errors=True) + raise + except Exception as e: + if hasattr(db, "rollback"): + await db.rollback() + results.append({"slug": slug, "success": False, "error": str(e)}) + + if any(item.get("success") for item in results): + shutil.rmtree(draft_dir, ignore_errors=True) + return results + + +async def discard_skill_install_draft(*, draft_id: str, operator: User) -> None: + draft_dir, data = _load_skill_draft(draft_id) + if data.get("created_by") != operator.uid and operator.role not in ADMIN_ROLES: + raise ValueError("无权删除该安装草稿") + shutil.rmtree(draft_dir, ignore_errors=True) async def import_skill_dir( @@ -594,9 +1006,10 @@ async def import_skill_dir( *, source_dir: Path | str, created_by: str | None, + source_type: str = "upload", + share_config: dict | None = 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("技能目录路径不合法") @@ -606,6 +1019,8 @@ async def import_skill_dir( db, source_skill_dir=source_skill_dir, created_by=created_by, + source_type=source_type, + share_config=share_config or DEFAULT_SKILL_SHARE_CONFIG.copy(), ) @@ -621,6 +1036,20 @@ async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill: return item +async def get_accessible_skill_or_raise(db: AsyncSession, user: User, slug: str) -> Skill: + item = await get_skill_or_raise(db, slug) + if not user_can_access_skill(user, item): + raise ValueError(f"技能 '{slug}' 不存在或无权访问") + return item + + +async def get_manageable_skill_or_raise(db: AsyncSession, user: User, slug: str) -> Skill: + item = await get_skill_or_raise(db, slug) + if not user_can_manage_skill(user, item): + raise ValueError(f"技能 '{slug}' 不存在或无权管理") + return item + + async def get_skill_tree(db: AsyncSession, slug: str) -> list[dict[str, Any]]: item = await get_skill_or_raise(db, slug) skill_dir = _resolve_skill_dir(item) @@ -655,7 +1084,7 @@ async def create_skill_node( updated_by: str | None, ) -> None: item = await get_skill_or_raise(db, slug) - if item.is_builtin: + if is_builtin_skill(item): raise ValueError("内置 skill 不允许直接修改文件") skill_dir = _resolve_skill_dir(item) target, _ = _resolve_relative_path(skill_dir, relative_path) @@ -686,7 +1115,7 @@ async def update_skill_file( updated_by: str | None, ) -> None: item = await get_skill_or_raise(db, slug) - if item.is_builtin: + if is_builtin_skill(item): raise ValueError("内置 skill 不允许直接修改文件") skill_dir = _resolve_skill_dir(item) target, _ = _resolve_relative_path(skill_dir, relative_path) @@ -719,7 +1148,7 @@ 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: + if is_builtin_skill(item): raise ValueError("内置 skill 不允许直接修改文件") skill_dir = _resolve_skill_dir(item) target, rel = _resolve_relative_path(skill_dir, relative_path, allow_root=False) @@ -760,6 +1189,7 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None: item = await repo.get_by_slug(slug, for_update=True) if not item: raise ValueError(f"技能 '{slug}' 不存在") + _ensure_non_builtin(item) skill_dir = _resolve_skill_dir(item) trash_dir: Path | None = None @@ -795,32 +1225,28 @@ async def delete_skills_batch(db: AsyncSession, *, slugs: list[str]) -> list[dic return results -async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> None: - """校验内置 skills 配置,不执行安装。""" - specs = get_builtin_skill_specs() +async def update_skill_share_config( + db: AsyncSession, + *, + slug: str, + share_config: dict | None, + operator: User, +) -> Skill: + item = await get_manageable_skill_or_raise(db, operator, slug) + _ensure_non_builtin(item) + normalized = normalize_skill_share_config( + share_config, + operator_uid=operator.uid, + operator_department_id=operator.department_id, + source_type=item.source_type, + allowed_access_levels=set(get_allowed_skill_access_levels(operator)), + ) + return await SkillRepository(db).update_share_config(item, share_config=normalized, updated_by=operator.uid) - for spec in specs: - slug = str(getattr(spec, "slug", "")).strip() - source_dir = Path(str(getattr(spec, "source_dir", ""))).resolve() - 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(): - raise ValueError(f"内置 skill 缺少 SKILL.md: {source_dir}") - - 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) +async def update_skill_enabled(db: AsyncSession, *, slug: str, enabled: bool, operator: User) -> Skill: + item = await get_manageable_skill_or_raise(db, operator, slug) + return await SkillRepository(db).update_enabled(item, enabled=enabled, updated_by=operator.uid) def list_builtin_skill_specs() -> list[dict[str, Any]]: @@ -866,85 +1292,65 @@ def list_builtin_skill_specs() -> list[dict[str, Any]]: return specs -async def install_builtin_skill(db: AsyncSession, slug: str, *, installed_by: str | None) -> Skill: - _get_builtin_skill_spec_or_raise(slug) +async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> list[Skill]: repo = SkillRepository(db) - spec = next(item for item in list_builtin_skill_specs() if item["slug"] == slug) + synced_items: list[Skill] = [] - existing = await repo.get_by_slug(slug) - if existing: - raise ValueError(f"内置 skill '{slug}' 已安装") + for spec in list_builtin_skill_specs(): + slug = spec["slug"] + existing = await repo.get_by_slug(slug) + if existing and not is_builtin_skill(existing): + raise ValueError(f"内置 skill '{slug}' 与已存在的非内置 skill 冲突") - target_dir = get_skills_root_dir() / slug - if target_dir.exists(): - raise ValueError(f"技能目录已存在: {slug}") + target_dir = get_skills_root_dir() / slug + _replace_skill_target(target_dir, Path(spec["source_dir"])) - 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: - await asyncio.to_thread(shutil.rmtree, target_dir, ignore_errors=True) - raise + if existing: + if existing.name != spec["name"] or existing.description != spec["description"]: + await repo.update_metadata( + existing, + name=spec["name"], + description=spec["description"], + updated_by=created_by, + ) + if ( + normalize_string_list(existing.tool_dependencies or []) != spec["tool_dependencies"] + or normalize_string_list(existing.mcp_dependencies or []) != spec["mcp_dependencies"] + or normalize_string_list(existing.skill_dependencies or []) != spec["skill_dependencies"] + ): + await repo.update_dependencies( + existing, + tool_dependencies=spec["tool_dependencies"], + mcp_dependencies=spec["mcp_dependencies"], + skill_dependencies=spec["skill_dependencies"], + updated_by=created_by, + ) + synced_items.append( + await repo.update_builtin_install( + existing, + version=spec["version"], + content_hash=spec["content_hash"], + updated_by=created_by, + ) + ) + continue - -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 and not _is_builtin_managed(item, slug): - 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, + synced_items.append( + await repo.create( + slug=slug, + name=spec["name"], + description=spec["description"], + source_type="builtin", + tool_dependencies=spec["tool_dependencies"], + mcp_dependencies=spec["mcp_dependencies"], + skill_dependencies=spec["skill_dependencies"], + dir_path=_build_builtin_skill_dir_path(slug), + share_config=BUILTIN_SKILL_SHARE_CONFIG.copy(), + enabled=True, + version=spec["version"], + content_hash=spec["content_hash"], + created_by=created_by or BUILTIN_SKILL_OPERATOR, + ) ) - 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, - ) + return synced_items diff --git a/backend/package/yuxi/storage/postgres/manager.py b/backend/package/yuxi/storage/postgres/manager.py index c2b4db28..f563a0f2 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -372,7 +372,12 @@ class PostgresManager(metaclass=SingletonMeta): "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 source_type VARCHAR(32) NOT NULL DEFAULT 'upload'", + ( + "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS share_config JSONB NOT NULL " + 'DEFAULT \'{"access_level": "user", "department_ids": [], "user_uids": []}\'::jsonb' + ), + "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS content_hash VARCHAR(128)", "ALTER TABLE IF EXISTS subagents ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE", "ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE", diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index 8555223d..1c9a2419 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -208,13 +208,17 @@ class Skill(Base): slug = Column(String(128), nullable=False, unique=True, index=True, comment="技能唯一标识(目录名)") name = Column(String(128), nullable=False, comment="技能名称(来自 SKILL.md frontmatter.name)") description = Column(Text, nullable=False, comment="技能描述(来自 SKILL.md frontmatter.description)") + source_type = Column( + String(32), nullable=False, default="upload", index=True, comment="来源: builtin/upload/remote" + ) tool_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的内置工具名列表") 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 安装时计算)") + share_config = Column(JSON, nullable=False, default=dict, comment="共享权限配置") + enabled = Column(Boolean, nullable=False, default=True, comment="是否启用") created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, default=utc_now_naive) @@ -226,13 +230,15 @@ class Skill(Base): "slug": self.slug, "name": self.name, "description": self.description, + "source_type": self.source_type, "tool_dependencies": self.tool_dependencies or [], "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, + "share_config": self.share_config or {}, + "enabled": bool(self.enabled), "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), diff --git a/backend/server/routers/__init__.py b/backend/server/routers/__init__.py index ccd96784..f4ecbde0 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -9,7 +9,7 @@ from server.routers.dashboard_router import dashboard from server.routers.auth_dept_router import department from server.routers.mcp_router import mcp from server.routers.model_provider_router import model_providers -from server.routers.skill_router import skills +from server.routers.skill_router import skills, user_skills from server.routers.subagent_router import subagents_router from server.routers.system_router import system from server.routers.system_task_router import tasks @@ -36,6 +36,7 @@ router.include_router(tasks) # /api/tasks/* 后台任务查询与管理 router.include_router(mcp) # /api/system/mcp-servers/* MCP 服务管理 router.include_router(model_providers) # /api/system/model-providers/* 独立模型配置 router.include_router(skills) # /api/system/skills/* Skills 管理 +router.include_router(user_skills) # /api/skills/* 用户可用 Skills router.include_router(subagents_router) # /api/system/subagents/* 子智能体管理 router.include_router(tools) # /api/system/tools/* 工具列表与配置 router.include_router(user_router) # /api/user/* 用户级配置与凭据 diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index ef3c5a20..382afae3 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -10,33 +10,44 @@ from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from server.utils.auth_middleware import get_admin_user, get_db, get_required_user -from yuxi.services.remote_skill_install_service import ( - install_remote_skill, - install_remote_skills_batch, - list_remote_skills, -) +from yuxi.services.remote_skill_install_service import list_remote_skills, search_remote_skills from yuxi.services.skill_service import ( - BuiltinSkillUpdateConflictError, + confirm_skill_install_draft, create_skill_node, delete_skill, delete_skill_node, delete_skills_batch, + discard_skill_install_draft, export_skill_zip, + get_allowed_skill_access_levels, + get_manageable_skill_or_raise, get_skill_dependency_options, get_skill_tree, - import_skill_zip, - install_builtin_skill, - list_builtin_skill_specs, + init_builtin_skills, + list_accessible_skills, + list_manageable_skills, list_skills, + prepare_remote_skill_install, + prepare_skill_upload, read_skill_file, - update_builtin_skill, update_skill_dependencies, + update_skill_enabled, update_skill_file, + update_skill_share_config, ) from yuxi.storage.postgres.models_business import User from yuxi.utils.logging_config import logger skills = APIRouter(prefix="/system/skills", tags=["skills"]) +user_skills = APIRouter(prefix="/skills", tags=["skills"]) + + +class ShareConfigPayload(BaseModel): + share_config: dict | None = Field(None, description="共享权限配置") + + +class SkillEnabledUpdateRequest(BaseModel): + enabled: bool = Field(..., description="是否启用") class SkillNodeCreateRequest(BaseModel): @@ -56,20 +67,12 @@ class SkillDependenciesUpdateRequest(BaseModel): skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表") -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 名称") - - -class RemoteSkillBatchInstallRequest(RemoteSkillSourceRequest): - skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)") +class RemoteSkillPrepareRequest(RemoteSkillSourceRequest): + skills: list[str] = Field(..., description="需要安装的 skill 名称列表") class RemoteSkillSearchRequest(BaseModel): @@ -80,9 +83,13 @@ class SkillBatchDeleteRequest(BaseModel): slugs: list[str] = Field(..., max_length=50, description="需要批量删除的 skill slug 列表,最多支持 50 个") +class SkillDraftConfirmRequest(BaseModel): + share_config: dict | None = Field(None, description="共享权限配置") + + def _raise_from_value_error(e: ValueError) -> None: message = str(e) - status_code = 404 if "不存在" in message else 400 + status_code = 404 if "不存在" in message or "无权" in message else 400 raise HTTPException(status_code=status_code, detail=message) @@ -93,44 +100,156 @@ def _cleanup_export_file(path: str) -> None: logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}") +def _summarize_results(results: list[dict]) -> dict[str, int]: + return { + "total": len(results), + "success": sum(1 for item in results if item.get("success")), + "failed": sum(1 for item in results if not item.get("success")), + } + + +@user_skills.get("/accessible") +async def list_accessible_skills_route( + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +): + try: + items = await list_accessible_skills(db, current_user) + return {"success": True, "data": [item.to_dict() for item in items]} + except Exception as e: + logger.error(f"Failed to list accessible skills: {e}") + raise HTTPException(status_code=500, detail="获取可访问 Skills 失败") + + +@user_skills.post("/import/prepare") +async def prepare_skill_upload_route( + file: UploadFile = File(...), + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +): + try: + data = await prepare_skill_upload( + db, + filename=file.filename or "", + file_bytes=await file.read(), + operator=current_user, + ) + return {"success": True, "data": data} + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + logger.error(f"Failed to prepare skill upload: {e}") + raise HTTPException(status_code=500, detail="解析上传 Skill 失败") + + +@user_skills.post("/remote/list") +async def list_remote_skills_route(payload: RemoteSkillSourceRequest, _current_user: User = Depends(get_required_user)): + try: + return {"success": True, "data": await list_remote_skills(payload.source)} + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + logger.error(f"Failed to list remote skills from '{payload.source}': {e}") + raise HTTPException(status_code=500, detail="获取远程 skills 列表失败") + + +@user_skills.post("/remote/search") +async def search_remote_skills_route( + payload: RemoteSkillSearchRequest, _current_user: User = Depends(get_required_user) +): + try: + return {"success": True, "data": await search_remote_skills(payload.query)} + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + logger.error(f"Failed to search remote skills with query '{payload.query}': {e}") + raise HTTPException(status_code=500, detail="搜索远程 skills 失败") + + +@user_skills.post("/remote/prepare") +async def prepare_remote_skills_route( + payload: RemoteSkillPrepareRequest, + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +): + try: + data = await prepare_remote_skill_install( + db, + source=payload.source, + skills=payload.skills, + operator=current_user, + ) + return {"success": True, "data": data} + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + logger.error(f"Failed to prepare remote skills from '{payload.source}': {e}") + raise HTTPException(status_code=500, detail="解析远程 Skills 失败") + + +@user_skills.post("/install-drafts/{draft_id}/confirm") +async def confirm_skill_install_draft_route( + draft_id: str, + payload: SkillDraftConfirmRequest, + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +): + try: + results = await confirm_skill_install_draft( + db, + draft_id=draft_id, + share_config=payload.share_config, + operator=current_user, + ) + return {"success": True, "data": results, "summary": _summarize_results(results)} + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + logger.error(f"Failed to confirm skill install draft '{draft_id}': {e}") + raise HTTPException(status_code=500, detail="确认安装 Skill 失败") + + +@user_skills.delete("/install-drafts/{draft_id}") +async def discard_skill_install_draft_route(draft_id: str, current_user: User = Depends(get_required_user)): + try: + await discard_skill_install_draft(draft_id=draft_id, operator=current_user) + return {"success": True} + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + logger.error(f"Failed to discard skill install draft '{draft_id}': {e}") + raise HTTPException(status_code=500, detail="取消安装 Skill 失败") + + @skills.get("") async def list_skills_route( current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """获取技能列表(普通用户仅获取白名单脱敏数据,管理员可读完整元数据)。""" try: - items = await list_skills(db) - - # NOTE: 针对管理员与常规登录用户分流返回,防止物理目录结构(dir_path)与系统审计信息越权暴露给常规用户 - if current_user.role in ["admin", "superadmin"]: - return {"success": True, "data": [item.to_dict() for item in items]} - - safe_data = [] - for item in items: - safe_data.append( - { - "slug": item.slug, - "name": item.name, - "description": item.description, - "version": item.version, - "is_builtin": item.is_builtin, - } - ) - return {"success": True, "data": safe_data} + items = await list_manageable_skills(db, current_user) + return { + "success": True, + "data": [item.to_dict() for item in items], + "allowed_access_levels": get_allowed_skill_access_levels(current_user), + } except Exception as e: - logger.error(f"Failed to list skills: {e}") + logger.error(f"Failed to list manageable skills: {e}") raise HTTPException(status_code=500, detail="获取技能列表失败") @skills.get("/dependency-options") async def get_skill_dependency_options_route( - _current_user: User = Depends(get_admin_user), + slug: str | None = Query(None, description="当前 Skill slug"), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """获取 skill 依赖项可选列表(管理员)。""" try: - return {"success": True, "data": await get_skill_dependency_options(db)} + if slug: + await get_manageable_skill_or_raise(db, current_user, slug) + return {"success": True, "data": await get_skill_dependency_options(db, current_user, slug)} + except ValueError as e: + _raise_from_value_error(e) except Exception as e: logger.error(f"Failed to get skill dependency options: {e}") raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败") @@ -142,208 +261,75 @@ async def list_builtin_skills_route( 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} + items = [item for item in await list_skills(db) if item.source_type == "builtin"] + return {"success": True, "data": [item.to_dict() for item in items]} 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( +@skills.post("/builtin/sync") +async def sync_builtin_skills_route( + current_user: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + try: + items = await init_builtin_skills(db, created_by=current_user.uid) + return {"success": True, "data": [item.to_dict() for item in items]} + except ValueError as e: + _raise_from_value_error(e) + except Exception as e: + logger.error(f"Failed to sync builtin skills: {e}") + raise HTTPException(status_code=500, detail="同步内置 skill 失败") + + +@skills.put("/{slug}/share-config") +async def update_skill_share_config_route( slug: str, - current_user: User = Depends(get_admin_user), + payload: ShareConfigPayload, + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): try: - item = await install_builtin_skill(db, slug, installed_by=current_user.username) + item = await update_skill_share_config(db, slug=slug, share_config=payload.share_config, operator=current_user) 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 失败") + logger.error(f"Failed to update skill share config '{slug}': {e}") + raise HTTPException(status_code=500, detail="更新 Skill 共享范围失败") -@skills.post("/builtin/{slug}/update") -async def update_builtin_skill_route( +@skills.put("/{slug}/enabled") +async def update_skill_enabled_route( slug: str, - payload: BuiltinSkillUpdateRequest, - current_user: User = Depends(get_admin_user), + payload: SkillEnabledUpdateRequest, + current_user: User = Depends(get_required_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(...), - current_user: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - """导入技能包(支持 ZIP 或单个 SKILL.md,管理员)。""" - try: - file_bytes = await file.read() - item = await import_skill_zip( - db, - filename=file.filename or "", - file_bytes=file_bytes, - created_by=current_user.username, - ) + item = await update_skill_enabled(db, slug=slug, enabled=payload.enabled, operator=current_user) 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 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_admin_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_admin_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.post("/remote/install-batch") -async def install_remote_skills_batch_route( - payload: RemoteSkillBatchInstallRequest, - current_user: User = Depends(get_admin_user), - db: AsyncSession = Depends(get_db), -): - """批量从同一远程仓库安装多个 skills(仅一次克隆,不存在的 skill 静默跳过)。""" - try: - results = await install_remote_skills_batch( - db, - source=payload.source, - skills=payload.skills, - created_by=current_user.username, - ) - success_count = sum(1 for r in results if r["success"]) - failed_count = sum(1 for r in results if not r["success"]) - return { - "success": True, - "data": results, - "summary": {"total": len(results), "success": success_count, "failed": failed_count}, - } - except ValueError as e: - _raise_from_value_error(e) - except HTTPException: - raise - except Exception as e: - logger.error(f"Failed to install remote skills batch from '{payload.source}': {e}") - raise HTTPException(status_code=500, detail="批量安装远程 skills 失败") - - -@skills.post("/remote/search") -async def search_remote_skills_route( - payload: RemoteSkillSearchRequest, - _current_user: User = Depends(get_admin_user), -): - """搜索远程公开的 skills(管理员)。""" - try: - data = await search_remote_skills(payload.query) - 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 search remote skills with query '{payload.query}': {e}") - raise HTTPException(status_code=500, detail="搜索远程 skills 失败") + logger.error(f"Failed to update skill enabled '{slug}': {e}") + raise HTTPException(status_code=500, detail="更新 Skill 启用状态失败") @skills.get("/{slug}/tree") async def get_skill_tree_route( slug: str, - _current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """获取技能目录树(管理员)。""" try: - tree = await get_skill_tree(db, slug) - return {"success": True, "data": tree} + await get_manageable_skill_or_raise(db, current_user, slug) + return {"success": True, "data": await get_skill_tree(db, slug)} except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to get skill tree '{slug}': {e}") raise HTTPException(status_code=500, detail="获取技能目录树失败") @@ -353,17 +339,14 @@ async def get_skill_tree_route( async def get_skill_file_route( slug: str, path: str = Query(..., description="相对 skill 根目录路径"), - _current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """读取技能文本文件(管理员)。""" try: - data = await read_skill_file(db, slug, path) - return {"success": True, "data": data} + await get_manageable_skill_or_raise(db, current_user, slug) + return {"success": True, "data": await read_skill_file(db, slug, path)} except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to read skill file '{slug}/{path}': {e}") raise HTTPException(status_code=500, detail="读取技能文件失败") @@ -373,24 +356,22 @@ async def get_skill_file_route( async def create_skill_file_route( slug: str, payload: SkillNodeCreateRequest, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """创建技能文件或目录(管理员)。""" try: + await get_manageable_skill_or_raise(db, current_user, slug) await create_skill_node( db, slug=slug, relative_path=payload.path, is_dir=payload.is_dir, content=payload.content, - updated_by=current_user.username, + updated_by=current_user.uid, ) return {"success": True} except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to create skill node '{slug}/{payload.path}': {e}") raise HTTPException(status_code=500, detail="创建技能文件失败") @@ -400,23 +381,21 @@ async def create_skill_file_route( async def update_skill_file_route( slug: str, payload: SkillFileUpdateRequest, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """更新技能文本文件(管理员)。""" try: + await get_manageable_skill_or_raise(db, current_user, slug) await update_skill_file( db, slug=slug, relative_path=payload.path, content=payload.content, - updated_by=current_user.username, + updated_by=current_user.uid, ) return {"success": True} except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to update skill file '{slug}/{payload.path}': {e}") raise HTTPException(status_code=500, detail="更新技能文件失败") @@ -426,10 +405,9 @@ async def update_skill_file_route( async def update_skill_dependencies_route( slug: str, payload: SkillDependenciesUpdateRequest, - current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """更新 skill 依赖(管理员)。""" try: item = await update_skill_dependencies( db, @@ -437,13 +415,11 @@ async def update_skill_dependencies_route( tool_dependencies=payload.tool_dependencies, mcp_dependencies=payload.mcp_dependencies, skill_dependencies=payload.skill_dependencies, - updated_by=current_user.username, + operator=current_user, ) 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 update skill dependencies '{slug}': {e}") raise HTTPException(status_code=500, detail="更新 skill 依赖失败") @@ -453,17 +429,15 @@ async def update_skill_dependencies_route( async def delete_skill_file_route( slug: str, path: str = Query(..., description="相对 skill 根目录路径"), - _current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """删除技能文件或目录(管理员)。""" try: + await get_manageable_skill_or_raise(db, current_user, slug) await delete_skill_node(db, slug=slug, relative_path=path) return {"success": True} except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to delete skill file '{slug}/{path}': {e}") raise HTTPException(status_code=500, detail="删除技能文件失败") @@ -473,22 +447,16 @@ async def delete_skill_file_route( async def export_skill_route( slug: str, background_tasks: BackgroundTasks, - _current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """导出技能压缩包(管理员)。""" try: + await get_manageable_skill_or_raise(db, current_user, slug) export_path, download_name = await export_skill_zip(db, slug) background_tasks.add_task(_cleanup_export_file, export_path) - return FileResponse( - path=export_path, - media_type="application/zip", - filename=download_name, - ) + return FileResponse(path=export_path, media_type="application/zip", filename=download_name) except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to export skill '{slug}': {e}") raise HTTPException(status_code=500, detail="导出技能失败") @@ -497,17 +465,15 @@ async def export_skill_route( @skills.delete("/{slug}") async def delete_skill_route( slug: str, - _current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """删除技能(目录 + 数据库记录,管理员)。""" try: + await get_manageable_skill_or_raise(db, current_user, slug) await delete_skill(db, slug=slug) return {"success": True} except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to delete skill '{slug}': {e}") raise HTTPException(status_code=500, detail="删除技能失败") @@ -516,23 +482,16 @@ async def delete_skill_route( @skills.post("/delete-batch") async def delete_skills_batch_route( payload: SkillBatchDeleteRequest, - _current_user: User = Depends(get_admin_user), + current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """批量删除技能(目录 + 数据库记录,管理员)。""" try: + for slug in payload.slugs: + await get_manageable_skill_or_raise(db, current_user, slug) results = await delete_skills_batch(db, slugs=payload.slugs) - success_count = sum(1 for r in results if r["success"]) - failed_count = sum(1 for r in results if not r["success"]) - return { - "success": True, - "data": results, - "summary": {"total": len(results), "success": success_count, "failed": failed_count}, - } + return {"success": True, "data": results, "summary": _summarize_results(results)} except ValueError as e: _raise_from_value_error(e) - except HTTPException: - raise except Exception as e: logger.error(f"Failed to delete skills batch: {e}") raise HTTPException(status_code=500, detail="批量删除技能失败") diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index 3b1ef5fd..40f044a6 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -34,6 +34,14 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to ensure builtin MCP servers during startup: {e}") + try: + from yuxi.services.skill_service import init_builtin_skills + + 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}") + try: from yuxi.repositories.agent_repository import AgentRepository diff --git a/backend/test/unit/agents/test_context_auth.py b/backend/test/unit/agents/test_context_auth.py index cc6c4ea8..9fb283fb 100644 --- a/backend/test/unit/agents/test_context_auth.py +++ b/backend/test/unit/agents/test_context_auth.py @@ -104,7 +104,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_ types.SimpleNamespace(slug="mcp-b", name="MCP B", description="", enabled=True), ] - async def fake_list_skills(_db): + async def fake_list_skills(_db, _user): return [ types.SimpleNamespace(slug="skill-a", name="Skill A", description=""), types.SimpleNamespace(slug="skill-b", name="Skill B", description=""), @@ -120,7 +120,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_ sys.modules, "yuxi.services.tool_service", types.SimpleNamespace( - get_tool_metadata=lambda: [ + get_tool_metadata=lambda category=None: [ {"slug": "ask_user_question", "name": "Ask User", "description": ""}, {"slug": "tavily_search", "name": "Tavily", "description": ""}, ] @@ -139,7 +139,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_ monkeypatch.setitem( sys.modules, "yuxi.services.skill_service", - types.SimpleNamespace(list_skills=fake_list_skills), + types.SimpleNamespace(list_accessible_skills=fake_list_skills), ) monkeypatch.setitem( sys.modules, @@ -176,7 +176,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti async def fake_get_all_mcp_servers(_db): return [types.SimpleNamespace(slug="mcp-a", name="MCP A", description="", enabled=True)] - async def fake_list_skills(_db): + async def fake_list_skills(_db, _user): return [ types.SimpleNamespace(slug="skill-a", name="Skill A", description=""), types.SimpleNamespace(slug="skill-b", name="Skill B", description=""), @@ -190,13 +190,16 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti context._visible_knowledge_bases = [{"slug": "kb-a", "name": "Docs A"}] return context._visible_knowledge_bases - async def fake_resolve_runtime_skills_for_context(context, *, db=None): + async def fake_resolve_runtime_skills_for_context(context, *, db=None, user=None): del db + assert user.uid == "u1" assert context.skills == ["skill-a"] return { "context_skills": ["skill-a"], "prompt_skills": ["skill-a", "skill-b"], "readable_skills": ["skill-a", "skill-b"], + "runtime_skill_metadata": {"skill-a": {"name": "Skill A"}}, + "runtime_skill_dependency_map": {"skill-a": {"skills": ["skill-b"]}}, } class FakeSessionContext: @@ -209,7 +212,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti class FakeUserRepository: async def get_by_uid_with_db(self, _db, uid): assert uid == "u1" - return types.SimpleNamespace(role="user", uid="u1") + return types.SimpleNamespace(role="user", uid="u1", department_id=None) monkeypatch.setitem( sys.modules, @@ -235,7 +238,9 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti sys.modules, "yuxi.services.tool_service", types.SimpleNamespace( - get_tool_metadata=lambda: [{"slug": "ask_user_question", "name": "Ask User", "description": ""}] + get_tool_metadata=lambda category=None: [ + {"slug": "ask_user_question", "name": "Ask User", "description": ""} + ] ), ) monkeypatch.setitem( @@ -251,7 +256,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti monkeypatch.setitem( sys.modules, "yuxi.services.skill_service", - types.SimpleNamespace(list_skills=fake_list_skills), + types.SimpleNamespace(list_accessible_skills=fake_list_skills), ) monkeypatch.setitem( sys.modules, @@ -278,6 +283,8 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti assert prepared._visible_knowledge_bases == [{"slug": "kb-a", "name": "Docs A"}] assert prepared._prompt_skills == ["skill-a", "skill-b"] assert prepared._readable_skills == ["skill-a", "skill-b"] + assert prepared._runtime_skill_metadata == {"skill-a": {"name": "Skill A"}} + assert prepared._runtime_skill_dependency_map == {"skill-a": {"skills": ["skill-b"]}} @pytest.mark.asyncio @@ -301,7 +308,7 @@ async def test_prepare_agent_runtime_context_clears_resources_for_missing_user(m monkeypatch.setitem( sys.modules, "yuxi.agents.middlewares.skills_middleware", - types.SimpleNamespace(resolve_runtime_skills_for_context=lambda _context, db=None: None), + types.SimpleNamespace(resolve_runtime_skills_for_context=lambda _context, db=None, user=None: None), ) monkeypatch.setitem( sys.modules, @@ -333,3 +340,5 @@ async def test_prepare_agent_runtime_context_clears_resources_for_missing_user(m assert prepared._visible_knowledge_bases == [] assert prepared._prompt_skills == [] assert prepared._readable_skills == [] + assert prepared._runtime_skill_metadata == {} + assert prepared._runtime_skill_dependency_map == {} diff --git a/backend/test/unit/middlewares/test_skills_middleware.py b/backend/test/unit/middlewares/test_skills_middleware.py index f7a454af..d36bb39a 100644 --- a/backend/test/unit/middlewares/test_skills_middleware.py +++ b/backend/test/unit/middlewares/test_skills_middleware.py @@ -12,31 +12,47 @@ from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware, resolve_ @pytest.mark.asyncio async def test_resolve_runtime_skills_derives_prompt_and_readable_closure(monkeypatch): - async def fake_get_dependency_map(db=None): - del db - return { - "alpha": {"tools": [], "mcps": [], "skills": ["beta"]}, - "beta": {"tools": [], "mcps": [], "skills": []}, - } + async def fake_list_skills_from_db(db=None, user=None): + del db, user + return [ + SimpleNamespace( + slug="alpha", + name="Alpha", + description="alpha desc", + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=["beta"], + ), + SimpleNamespace( + slug="beta", + name="Beta", + description="beta desc", + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + ), + ] - monkeypatch.setattr(skills_middleware, "get_dependency_map", fake_get_dependency_map) + monkeypatch.setattr(skills_middleware, "_list_skills_from_db", fake_list_skills_from_db) context = SimpleNamespace(skills=["alpha", "missing"]) scope = await resolve_runtime_skills_for_context(context) - assert scope == { - "context_skills": ["alpha"], - "prompt_skills": ["alpha", "beta"], - "readable_skills": ["alpha", "beta"], - } + assert scope["context_skills"] == ["alpha"] + assert scope["prompt_skills"] == ["alpha", "beta"] + assert scope["readable_skills"] == ["alpha", "beta"] + assert set(scope["runtime_skill_metadata"]) == {"alpha", "beta"} + assert scope["runtime_skill_dependency_map"]["alpha"]["skills"] == ["beta"] @pytest.mark.asyncio -async def test_skills_prompt_uses_prepared_prompt_skills(monkeypatch): - async def fake_get_prompt_metadata(db=None): - del db - return { +async def test_skills_prompt_uses_prepared_prompt_skills(): + context = SimpleNamespace( + system_prompt="base", + skills=["configured-only"], + _prompt_skills=["alpha"], + _runtime_skill_metadata={ "alpha": { "name": "Alpha", "description": "alpha desc", @@ -47,14 +63,7 @@ async def test_skills_prompt_uses_prepared_prompt_skills(monkeypatch): "description": "should not appear", "path": "/home/gem/skills/configured-only/SKILL.md", }, - } - - monkeypatch.setattr(skills_middleware, "get_prompt_metadata", fake_get_prompt_metadata) - - context = SimpleNamespace( - system_prompt="base", - skills=["configured-only"], - _prompt_skills=["alpha"], + }, ) await SkillsMiddleware().abefore_agent({}, SimpleNamespace(context=context)) @@ -68,14 +77,6 @@ async def test_skills_prompt_uses_prepared_prompt_skills(monkeypatch): @pytest.mark.asyncio async def test_awrap_model_call_mounts_dependencies_only_for_readable_activated_skills(monkeypatch): - async def fake_get_dependency_map(db=None): - del db - return { - "alpha": {"tools": ["tool-a"], "mcps": [], "skills": []}, - "beta": {"tools": ["tool-b"], "mcps": [], "skills": []}, - } - - monkeypatch.setattr(skills_middleware, "get_dependency_map", fake_get_dependency_map) monkeypatch.setattr( skills_middleware, "get_all_tool_instances", @@ -84,7 +85,16 @@ async def test_awrap_model_call_mounts_dependencies_only_for_readable_activated_ class FakeRequest: def __init__(self, tools=None): - self.runtime = SimpleNamespace(context=SimpleNamespace(_readable_skills=["alpha"], mcps=[])) + self.runtime = SimpleNamespace( + context=SimpleNamespace( + _readable_skills=["alpha"], + _runtime_skill_dependency_map={ + "alpha": {"tools": ["tool-a"], "mcps": [], "skills": []}, + "beta": {"tools": ["tool-b"], "mcps": [], "skills": []}, + }, + mcps=[], + ) + ) self.state = {"activated_skills": ["alpha", "beta"]} self.tools = tools or [] diff --git a/backend/test/unit/routers/test_skill_router.py b/backend/test/unit/routers/test_skill_router.py index 1cbf1d60..83abf5fb 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -3,159 +3,187 @@ from __future__ import annotations from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient -from server.routers.skill_router import skills +from server.routers.skill_router import skills, user_skills from server.utils.auth_middleware import get_admin_user, get_db, get_required_user from yuxi.storage.postgres.models_business import Skill, User -def _build_app(*, allow_admin: bool = True) -> FastAPI: +def _build_app(*, role: str = "admin") -> FastAPI: app = FastAPI() app.include_router(skills, prefix="/api") + app.include_router(user_skills, prefix="/api") async def fake_db(): return None - async def fake_admin_user(): - if not allow_admin: - raise HTTPException(status_code=403, detail="需要管理员权限") - return User( - username="admin", - uid="admin", - password_hash="x", - role="admin", - ) - async def fake_required_user(): return User( - username="admin" if allow_admin else "user", - user_id="admin" if allow_admin else "user", + username=role, + uid=role, password_hash="x", - role="admin" if allow_admin else "user", + role=role, + department_id=1, ) + async def fake_admin_user(): + if role not in {"admin", "superadmin"}: + raise HTTPException(status_code=403, detail="需要管理员权限") + return await fake_required_user() + app.dependency_overrides[get_db] = fake_db - app.dependency_overrides[get_admin_user] = fake_admin_user app.dependency_overrides[get_required_user] = fake_required_user + app.dependency_overrides[get_admin_user] = fake_admin_user return app -def test_list_skills_route_returns_data(monkeypatch): - async def fake_list_skills(_db): - return [ - Skill( - slug="demo", - name="demo", - description="demo skill", - dir_path="skills/demo", - ) - ] +def _skill(slug: str = "demo", *, source_type: str = "upload", created_by: str = "admin") -> Skill: + return Skill( + slug=slug, + name=slug, + description="demo skill", + source_type=source_type, + dir_path=f"skills/{slug}", + share_config={"access_level": "user", "department_ids": [], "user_uids": [created_by]}, + enabled=True, + created_by=created_by, + updated_by=created_by, + ) - monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills) - app = _build_app() - client = TestClient(app) +def test_list_manageable_skills_route_returns_allowed_levels(monkeypatch): + async def fake_list_manageable_skills(_db, user): + assert user.uid == "admin" + return [_skill()] + + monkeypatch.setattr("server.routers.skill_router.list_manageable_skills", fake_list_manageable_skills) + + client = TestClient(_build_app()) resp = client.get("/api/system/skills") + + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert payload["data"][0]["slug"] == "demo" + assert payload["allowed_access_levels"] == ["global", "department", "user"] + + +def test_list_accessible_skills_route(monkeypatch): + async def fake_list_accessible_skills(_db, user): + assert user.uid == "user" + return [_skill(created_by="user")] + + monkeypatch.setattr("server.routers.skill_router.list_accessible_skills", fake_list_accessible_skills) + + client = TestClient(_build_app(role="user")) + resp = client.get("/api/skills/accessible") + assert resp.status_code == 200, resp.text payload = resp.json() assert payload["success"] is True assert payload["data"][0]["slug"] == "demo" -def test_skill_management_requires_admin(): - app = _build_app(allow_admin=False) - client = TestClient(app) +def test_prepare_skill_upload_route(monkeypatch): + captured: dict[str, object] = {} - dependency_resp = client.get("/api/system/skills/dependency-options") - assert dependency_resp.status_code == 403 - - import_resp = client.post( - "/api/system/skills/import", - files={"file": ("demo.zip", b"not zip", "application/zip")}, - ) - assert import_resp.status_code == 403 - - delete_resp = client.delete("/api/system/skills/demo") - assert delete_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): + async def fake_prepare_skill_upload(_db, *, filename, file_bytes, operator): 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, - ) + captured["operator_uid"] = operator.uid + return {"draft_id": "draft-1", "items": [{"slug": "demo", "success": True}]} - monkeypatch.setattr("server.routers.skill_router.import_skill_zip", fake_import_skill_zip) - - app = _build_app() - client = TestClient(app) + monkeypatch.setattr("server.routers.skill_router.prepare_skill_upload", fake_prepare_skill_upload) + client = TestClient(_build_app(role="user")) resp = client.post( - "/api/system/skills/import", + "/api/skills/import/prepare", 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"] == "admin" + assert resp.json()["data"]["draft_id"] == "draft-1" + assert captured == { + "filename": "SKILL.md", + "file_bytes": "---\nname: demo\ndescription: demo skill\n---\n", + "operator_uid": "user", + } -def test_update_skill_file_passes_operator(monkeypatch): +def test_remote_skill_prepare_and_confirm_routes(monkeypatch): + captured: dict[str, object] = {} + + async def fake_prepare_remote_skill_install(_db, *, source, skills, operator): + captured["prepare"] = {"source": source, "skills": skills, "operator_uid": operator.uid} + return {"draft_id": "draft-remote", "items": [{"slug": "frontend-design", "success": True}]} + + async def fake_confirm_skill_install_draft(_db, *, draft_id, share_config, operator): + captured["confirm"] = {"draft_id": draft_id, "share_config": share_config, "operator_uid": operator.uid} + return [{"slug": "frontend-design", "success": True}] + + monkeypatch.setattr("server.routers.skill_router.prepare_remote_skill_install", fake_prepare_remote_skill_install) + monkeypatch.setattr("server.routers.skill_router.confirm_skill_install_draft", fake_confirm_skill_install_draft) + + client = TestClient(_build_app(role="user")) + prepare_resp = client.post( + "/api/skills/remote/prepare", + json={"source": "anthropics/skills", "skills": ["frontend-design"]}, + ) + confirm_resp = client.post( + "/api/skills/install-drafts/draft-remote/confirm", + json={"share_config": {"access_level": "user", "department_ids": [], "user_uids": ["user"]}}, + ) + + assert prepare_resp.status_code == 200, prepare_resp.text + assert confirm_resp.status_code == 200, confirm_resp.text + assert captured["prepare"] == { + "source": "anthropics/skills", + "skills": ["frontend-design"], + "operator_uid": "user", + } + assert captured["confirm"]["draft_id"] == "draft-remote" + assert captured["confirm"]["operator_uid"] == "user" + + +def test_discard_skill_draft_route(monkeypatch): captured: dict[str, str] = {} - async def fake_update_skill_file(_db, *, slug, relative_path, content, updated_by): - captured["slug"] = slug - captured["relative_path"] = relative_path - captured["content"] = content - captured["updated_by"] = updated_by + async def fake_discard_skill_install_draft(*, draft_id, operator): + captured["draft_id"] = draft_id + captured["operator_uid"] = operator.uid - monkeypatch.setattr("server.routers.skill_router.update_skill_file", fake_update_skill_file) + monkeypatch.setattr("server.routers.skill_router.discard_skill_install_draft", fake_discard_skill_install_draft) - app = _build_app() - client = TestClient(app) + client = TestClient(_build_app(role="user")) + resp = client.delete("/api/skills/install-drafts/draft-1") - resp = client.put( - "/api/system/skills/demo/file", - json={ - "path": "SKILL.md", - "content": "---\nname: demo\ndescription: demo\n---\n# Demo\n", - }, - ) assert resp.status_code == 200, resp.text - assert captured["slug"] == "demo" - assert captured["relative_path"] == "SKILL.md" - assert captured["updated_by"] == "admin" + assert captured == {"draft_id": "draft-1", "operator_uid": "user"} -def test_dependency_options_route(monkeypatch): - async def fake_get_skill_dependency_options(_db): - return { - "tools": ["calculator"], - "mcps": ["mcp-a"], - "skills": ["demo"], - } +def test_dependency_options_route_checks_manage_permission(monkeypatch): + captured: dict[str, object] = {} + async def fake_get_manageable_skill_or_raise(_db, user, slug): + captured["manageable"] = {"slug": slug, "operator_uid": user.uid} + return _skill(slug=slug) + + async def fake_get_skill_dependency_options(_db, user, slug=None): + captured["options"] = {"slug": slug, "operator_uid": user.uid} + return {"tools": [{"slug": "calculator", "name": "Calculator"}], "mcps": ["mcp-a"], "skills": ["other"]} + + monkeypatch.setattr("server.routers.skill_router.get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise) monkeypatch.setattr("server.routers.skill_router.get_skill_dependency_options", fake_get_skill_dependency_options) - app = _build_app() - client = TestClient(app) - resp = client.get("/api/system/skills/dependency-options") + client = TestClient(_build_app()) + resp = client.get("/api/system/skills/dependency-options?slug=demo") + assert resp.status_code == 200, resp.text - payload = resp.json() - assert payload["success"] is True - assert payload["data"]["tools"] == ["calculator"] + assert resp.json()["data"]["skills"] == ["other"] + assert captured["manageable"] == {"slug": "demo", "operator_uid": "admin"} + assert captured["options"] == {"slug": "demo", "operator_uid": "admin"} -def test_update_skill_dependencies_route(monkeypatch): +def test_update_skill_dependencies_route_passes_operator(monkeypatch): captured: dict[str, object] = {} async def fake_update_skill_dependencies( @@ -165,27 +193,18 @@ def test_update_skill_dependencies_route(monkeypatch): tool_dependencies, mcp_dependencies, skill_dependencies, - updated_by, + operator, ): captured["slug"] = slug captured["tool_dependencies"] = tool_dependencies captured["mcp_dependencies"] = mcp_dependencies captured["skill_dependencies"] = skill_dependencies - captured["updated_by"] = updated_by - return Skill( - slug=slug, - name=slug, - description="demo", - dir_path=f"skills/{slug}", - tool_dependencies=tool_dependencies, - mcp_dependencies=mcp_dependencies, - skill_dependencies=skill_dependencies, - ) + captured["operator_uid"] = operator.uid + return _skill(slug=slug) monkeypatch.setattr("server.routers.skill_router.update_skill_dependencies", fake_update_skill_dependencies) - app = _build_app() - client = TestClient(app) + client = TestClient(_build_app()) resp = client.put( "/api/system/skills/demo/dependencies", json={ @@ -194,88 +213,37 @@ def test_update_skill_dependencies_route(monkeypatch): "skill_dependencies": ["other-skill"], }, ) + assert resp.status_code == 200, resp.text - assert captured["slug"] == "demo" - assert captured["tool_dependencies"] == ["calculator"] - assert captured["mcp_dependencies"] == ["mcp-a"] - assert captured["skill_dependencies"] == ["other-skill"] - assert captured["updated_by"] == "admin" + assert captured == { + "slug": "demo", + "tool_dependencies": ["calculator"], + "mcp_dependencies": ["mcp-a"], + "skill_dependencies": ["other-skill"], + "operator_uid": "admin", + } -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"}] +def test_builtin_routes_require_admin(): + client = TestClient(_build_app(role="user")) - monkeypatch.setattr("server.routers.skill_router.list_remote_skills", fake_list_remote_skills) + resp = client.get("/api/system/skills/builtin") - app = _build_app() - 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"}] + assert resp.status_code == 403 -def test_install_remote_skill_route(monkeypatch): +def test_sync_builtin_skills_route(monkeypatch): captured: dict[str, str] = {} - async def fake_install_remote_skill(_db, *, source, skill, created_by): - captured["source"] = source - captured["skill"] = skill + async def fake_init_builtin_skills(_db, *, created_by): 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, - ) + return [_skill(slug="builtin-demo", source_type="builtin")] - monkeypatch.setattr("server.routers.skill_router.install_remote_skill", fake_install_remote_skill) + monkeypatch.setattr("server.routers.skill_router.init_builtin_skills", fake_init_builtin_skills) + + client = TestClient(_build_app()) + resp = client.post("/api/system/skills/builtin/sync") - app = _build_app() - 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"] == "admin" - - -def test_list_skills_route_normal_user_success(monkeypatch): - async def fake_list_skills(_db): - return [ - Skill( - slug="test-skill", - name="test-skill-name", - description="test skill description", - dir_path="skills/test-skill", - ) - ] - - monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills) - - # 普通用户应该也能成功获取列表,但返回的字段应被安全白名单投影过滤 - app = _build_app(allow_admin=False) - client = TestClient(app) - resp = client.get("/api/system/skills") - assert resp.status_code == 200, resp.text - payload = resp.json() - assert payload["success"] is True - skill_data = payload["data"][0] - assert skill_data["slug"] == "test-skill" - assert skill_data["name"] == "test-skill-name" - # NOTE: 验证敏感字段如 dir_path、created_by 以及其它元数据已全部被白名单机制过滤,不发生越权泄露 - assert "dir_path" not in skill_data - assert "created_by" not in skill_data - assert "updated_by" not in skill_data - assert "content_hash" not in skill_data + assert resp.json()["data"][0]["slug"] == "builtin-demo" + assert captured == {"created_by": "admin"} diff --git a/backend/test/unit/services/test_skill_service.py b/backend/test/unit/services/test_skill_service.py index 31788c7a..6e756c88 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/test_skill_service.py @@ -10,7 +10,7 @@ import pytest from yuxi.services import skill_service as svc from yuxi.services import tool_service -from yuxi.storage.postgres.models_business import Skill +from yuxi.storage.postgres.models_business import Skill, User def _build_zip(files: dict[str, str]) -> bytes: @@ -21,6 +21,10 @@ def _build_zip(files: dict[str, str]) -> bytes: return buf.getvalue() +def _user(uid: str = "root", role: str = "admin") -> User: + return User(username=uid, uid=uid, password_hash="x", role=role, department_id=1) + + def test_parse_skill_markdown_ok(): content = "---\nname: demo-skill\ndescription: demo description\n---\n# Demo\n" name, desc, meta = svc._parse_skill_markdown(content) @@ -93,12 +97,15 @@ async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc, "get_enabled_mcp_server_slugs", fake_get_enabled_mcp_server_slugs) - async def fake_list_skill_slugs(_db): + user = SimpleNamespace(uid="user") + + async def fake_list_skill_slugs(_db, *, user): + assert user.uid == "user" return ["alpha", "beta"] monkeypatch.setattr(svc, "list_skill_slugs", fake_list_skill_slugs) - result = await svc.get_skill_dependency_options(None) + result = await svc.get_skill_dependency_options(None, user) assert result["tools"] == [{"slug": "calculator", "name": "Calculator"}, {"slug": "search", "name": "Search"}] assert result["mcps"] == ["mcp-a", "mcp-b"] assert result["skills"] == ["alpha", "beta"] @@ -113,7 +120,7 @@ def test_resolve_relative_path_blocks_traversal(tmp_path: Path): @pytest.mark.asyncio -async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): +async def test_skill_upload_prepare_confirm_rewrites_conflicting_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) class FakeRepo: @@ -126,30 +133,9 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc async def exists_slug(self, slug: str) -> bool: return slug in self.__class__.existing_slugs - 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__.existing_slugs.add(slug) + async def create(self, **kwargs) -> Skill: + item = Skill(**kwargs, updated_by=kwargs["created_by"]) + self.__class__.existing_slugs.add(item.slug) self.__class__.created_item = item return item @@ -161,22 +147,30 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc "demo/prompts/system.md": "You are demo skill", } ) + operator = _user("root") - item = await svc.import_skill_zip( + draft = await svc.prepare_skill_upload( None, filename="demo.zip", file_bytes=zip_bytes, - created_by="root", + operator=operator, + ) + results = await svc.confirm_skill_install_draft( + None, + draft_id=draft["draft_id"], + share_config=draft["default_share_config"], + operator=operator, ) - assert item.slug == "demo-v2" - assert item.name == "demo-v2" + assert results[0]["slug"] == "demo-v2" + assert results[0]["success"] is True + assert FakeRepo.created_item.slug == "demo-v2" skill_md = (tmp_path / "skills" / "demo-v2" / "SKILL.md").read_text(encoding="utf-8") 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): +async def test_skill_md_prepare_confirm_creates_single_file_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) class FakeRepo: @@ -188,44 +182,31 @@ async def test_import_skill_md_creates_single_file_skill(tmp_path: Path, monkeyp 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, - ) + async def create(self, **kwargs) -> Skill: + item = Skill(**kwargs, updated_by=kwargs["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( + operator = _user("root") + draft = await svc.prepare_skill_upload( None, filename="SKILL.md", file_bytes=skill_md.encode("utf-8"), - created_by="root", + operator=operator, + ) + results = await svc.confirm_skill_install_draft( + None, + draft_id=draft["draft_id"], + share_config=draft["default_share_config"], + operator=operator, ) - assert item.slug == "demo" - assert item.name == "demo" + assert results[0]["slug"] == "demo" + assert results[0]["success"] is True + assert FakeRepo.created_item.name == "demo" assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md @@ -309,7 +290,22 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): slug="alpha", name="alpha", description="alpha", + source_type="upload", dir_path="skills/alpha", + share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, + enabled=True, + tool_dependencies=[], + mcp_dependencies=[], + skill_dependencies=[], + ) + dependency = Skill( + slug="beta", + name="beta", + description="beta", + source_type="upload", + dir_path="skills/beta", + share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, + enabled=True, tool_dependencies=[], mcp_dependencies=[], skill_dependencies=[], @@ -338,18 +334,7 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): pass async def list_all(self): - return [ - item, - Skill( - slug="beta", - name="beta", - description="beta", - dir_path="skills/beta", - tool_dependencies=[], - mcp_dependencies=[], - skill_dependencies=[], - ), - ] + return [item, dependency] async def update_dependencies( self, @@ -369,7 +354,11 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): _item.skill_dependencies = skill_dependencies return _item + async def fake_list_accessible_skills(_db, _operator): + return [item, dependency] + monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise) + monkeypatch.setattr(svc, "list_accessible_skills", fake_list_accessible_skills) monkeypatch.setattr(svc, "SkillRepository", FakeRepo) updated = await svc.update_skill_dependencies( @@ -378,7 +367,7 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch): tool_dependencies=["calculator", "calculator"], mcp_dependencies=["mcp-a", "mcp-a"], skill_dependencies=["beta", "beta"], - updated_by="root", + operator=_user("root"), ) assert captured["tool_dependencies"] == ["calculator"] assert captured["mcp_dependencies"] == ["mcp-a"] @@ -416,68 +405,53 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p ) class FakeRepo: - created: list[dict] = [] + 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, - *, - 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: - self.__class__.created.append( - { - "slug": slug, - "name": name, - "description": description, - "tool_dependencies": tool_dependencies, - "mcp_dependencies": mcp_dependencies, - "skill_dependencies": skill_dependencies, - "dir_path": dir_path, - "created_by": created_by, - } - ) - return Skill( - slug=slug, - name=name, - description=description, - dir_path=dir_path, - tool_dependencies=tool_dependencies or [], - mcp_dependencies=mcp_dependencies or [], - skill_dependencies=skill_dependencies or [], - created_by=created_by, - updated_by=created_by, - ) + async def create(self, **kwargs) -> Skill: + self.__class__.created_payload = kwargs + return Skill(**kwargs, updated_by=kwargs["created_by"]) monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - await svc.init_builtin_skills(None) + items = await svc.init_builtin_skills(None) - assert FakeRepo.created == [] - assert not (tmp_path / "skills" / "reporter").exists() + assert len(items) == 1 + assert items[0].slug == "reporter" + assert FakeRepo.created_payload["source_type"] == "builtin" + assert FakeRepo.created_payload["share_config"] == svc.BUILTIN_SKILL_SHARE_CONFIG + assert FakeRepo.created_payload["enabled"] is True + assert FakeRepo.created_payload["created_by"] == "system" + assert FakeRepo.created_payload["tool_dependencies"] == ["mysql_query"] + assert FakeRepo.created_payload["mcp_dependencies"] == ["charts"] + assert FakeRepo.created_payload["skill_dependencies"] == ["common-report"] + assert (tmp_path / "skills" / "reporter" / "SKILL.md").exists() + assert (tmp_path / "skills" / "reporter" / "prompts" / "system.md").read_text(encoding="utf-8") == "prompt" @pytest.mark.asyncio -async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): +async def test_init_builtin_skills_updates_existing_record_and_preserves_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) source_dir = tmp_path / "builtin-skills" / "reporter" source_dir.mkdir(parents=True, exist_ok=True) (source_dir / "SKILL.md").write_text( - "---\nname: reporter\ndescription: old\n---\n# SQL Reporter\n", + "---\nname: reporter\ndescription: new markdown description\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" + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "prompt.md").write_text("old content", encoding="utf-8") monkeypatch.setattr( svc, @@ -487,6 +461,7 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke slug="reporter", source_dir=source_dir, description="new description", + version="1.0.1", tool_dependencies=("mysql_query",), mcp_dependencies=("charts",), skill_dependencies=(), @@ -499,327 +474,18 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke name="reporter", description="old description", dir_path="skills/reporter", + source_type="builtin", tool_dependencies=[], mcp_dependencies=[], skill_dependencies=[], + share_config={"access_level": "global", "department_ids": [], "user_uids": []}, + enabled=False, + version="1.0.0", + content_hash="old-hash", created_by="system", updated_by="system", ) - captured: dict[str, list[str] | str | None] = {} - - class FakeRepo: - def __init__(self, _db): - pass - - async def get_by_slug(self, slug: str): - return existing_item - - async def update_metadata( - self, - item: Skill, - *, - name: str, - description: str, - updated_by: str | None, - ) -> Skill: - item.name = name - item.description = description - captured["name"] = name - captured["description"] = description - captured["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, - ) -> Skill: - item.tool_dependencies = tool_dependencies - item.mcp_dependencies = mcp_dependencies - item.skill_dependencies = skill_dependencies - captured["tool_dependencies"] = tool_dependencies - captured["mcp_dependencies"] = mcp_dependencies - captured["skill_dependencies"] = skill_dependencies - captured["updated_by_deps"] = updated_by - return item - - monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - - 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) - - -def test_builtin_skill_specs_include_deep_reporter(): - specs = svc.list_builtin_skill_specs() - deep_reporter = next(item for item in specs if item["slug"] == "deep-reporter") - - assert deep_reporter["name"] == "deep-reporter" - assert "深度" in deep_reporter["description"] - assert deep_reporter["source_dir"].is_dir() - assert (deep_reporter["source_dir"] / "SKILL.md").is_file() - - -@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" - 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: @@ -827,12 +493,13 @@ async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch pass async def get_by_slug(self, slug: str): - return installed + assert slug == "reporter" + return existing_item - async def update_metadata(self, item: Skill, *, name: str, description: str, updated_by: str | None): + async def update_metadata(self, item: Skill, *, name: str, description: str, updated_by: str | None) -> Skill: item.name = name item.description = description - captured["metadata_updated_by"] = updated_by + captured["metadata"] = {"name": name, "description": description, "updated_by": updated_by} return item async def update_dependencies( @@ -843,11 +510,16 @@ async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch mcp_dependencies: list[str], skill_dependencies: list[str], updated_by: str | None, - ): + ) -> Skill: item.tool_dependencies = tool_dependencies item.mcp_dependencies = mcp_dependencies item.skill_dependencies = skill_dependencies - captured["deps_updated_by"] = updated_by + captured["dependencies"] = { + "tool_dependencies": tool_dependencies, + "mcp_dependencies": mcp_dependencies, + "skill_dependencies": skill_dependencies, + "updated_by": updated_by, + } return item async def update_builtin_install( @@ -857,23 +529,111 @@ async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch version: str, content_hash: str, updated_by: str | None, - ): + ) -> Skill: item.version = version item.content_hash = content_hash + item.source_type = "builtin" + item.share_config = svc.BUILTIN_SKILL_SHARE_CONFIG.copy() item.updated_by = updated_by - captured["version"] = version - captured["content_hash"] = content_hash - captured["updated_by"] = updated_by + captured["install"] = {"version": version, "content_hash": content_hash, "updated_by": updated_by} return item monkeypatch.setattr(svc, "SkillRepository", FakeRepo) - item = await svc.update_builtin_skill(None, "reporter", force=True, updated_by="root") + items = await svc.init_builtin_skills(None, created_by="release-bot") - assert item.version == "1.0.1" - assert item.content_hash == "hash-v2" + assert len(items) == 1 + assert items[0].enabled is False + assert items[0].version == "1.0.1" assert (target_dir / "prompt.md").read_text(encoding="utf-8") == "new builtin content" - assert captured["updated_by"] == "root" + assert captured["metadata"] == { + "name": "reporter", + "description": "new description", + "updated_by": "release-bot", + } + assert captured["dependencies"] == { + "tool_dependencies": ["mysql_query"], + "mcp_dependencies": ["charts"], + "skill_dependencies": [], + "updated_by": "release-bot", + } + assert captured["install"]["updated_by"] == "release-bot" + + +@pytest.mark.asyncio +async def test_init_builtin_skills_rejects_non_builtin_conflict(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, + } + ], + ) + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + return Skill(slug=slug, name=slug, description="uploaded", dir_path=f"skills/{slug}", source_type="upload") + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + with pytest.raises(ValueError, match="非内置 skill 冲突"): + await svc.init_builtin_skills(None) + + +@pytest.mark.asyncio +async def test_update_skill_enabled_allows_builtin(monkeypatch: pytest.MonkeyPatch): + builtin_item = Skill( + slug="reporter", + name="reporter", + description="builtin", + dir_path="skills/reporter", + source_type="builtin", + enabled=True, + ) + + async def fake_get_manageable_skill_or_raise(_db, user, slug: str): + assert user.uid == "root" + assert slug == "reporter" + return builtin_item + + class FakeRepo: + def __init__(self, _db): + pass + + async def update_enabled(self, item: Skill, *, enabled: bool, updated_by: str | None): + item.enabled = enabled + item.updated_by = updated_by + return item + + monkeypatch.setattr(svc, "get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise) + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + updated = await svc.update_skill_enabled(None, slug="reporter", enabled=False, operator=_user("root")) + + assert updated.enabled is False + assert updated.updated_by == "root" @pytest.mark.asyncio @@ -892,7 +652,7 @@ async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pyte name="reporter", description="builtin", dir_path="skills/reporter", - is_builtin=True, + source_type="builtin", ) async def fake_get_skill_or_raise(_db, _slug: str): @@ -962,7 +722,9 @@ async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest. monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) (tmp_path / "skills" / "concurrent-skill").mkdir(parents=True, exist_ok=True) - item = Skill(slug="concurrent-skill", name="concurrent-skill", description="desc", dir_path="skills/concurrent-skill") + item = Skill( + slug="concurrent-skill", name="concurrent-skill", description="desc", dir_path="skills/concurrent-skill" + ) db_items = {"concurrent-skill": item} lock_active = asyncio.Lock() @@ -1006,4 +768,3 @@ async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest. assert success_count == 1 assert error_count == 1 assert not (tmp_path / "skills" / "concurrent-skill").exists() - diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 3b67bbc8..249bd220 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -29,6 +29,7 @@ - 智能体运行时语义收敛:用户可见的 `AgentConfig` 收敛为数据库持久化的一级 `Agent`,内置 Python Agent 改为智能体后端;聊天、运行任务、恢复审批和文件预览均从线程绑定的 Agent 解析运行时上下文,前端只提交 `agent_id`。 - 知识库能力边界收敛:移除 Upload 与 LightRAG 知识库/图谱能力,知识库类型收敛为 Milvus 与只读连接器;知识库 API 统一使用 `/{db_id}/xxx` 形式,并整合 mindmap / eval 等子接口。 - Agent 资源默认选择与权限过滤:未显式配置工具、知识库、MCP、Skills、SubAgent 时默认启用当前用户可访问/可用的全部资源,显式保存空列表仍表示不启用对应资源;Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生。 +- Skill 安装与权限模型收敛:Skill 元数据使用 `source_type/share_config/enabled` 表达来源、生效范围与启用状态;内置 Skill 启动或同步时自动写入数据库并默认全局启用,上传和远程添加统一改为解析草稿后确认安装,不保留旧直接安装兼容路径。 - 历史兼容层精简:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端旧字段回退。 - 用户身份命名收敛:原业务登录标识统一改为 `uid`,Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid`;`user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。 @@ -46,6 +47,7 @@ - 新增 Milvus 图谱检索链路:Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity,读取 Neo4j 2-hop 子图后用 igraph 执行 PPR,最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。 - 收紧用户管理部门隔离:普通管理员创建用户时固定归属本部门,用户列表、访问选项、详情、更新和删除接口均限制在本部门范围内。 - 调整 Agent 资源默认选择与运行时上下文:未显式配置工具、知识库、MCP、Skills、SubAgent 时默认启用当前用户可访问/可用的全部资源,显式保存空列表仍表示不启用对应资源;Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。 +- 重构 Skills 权限与安装流程:Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态,支持启停但不允许删除;上传和远程添加统一为解析草稿后确认生效范围,管理端支持编辑生效范围与启停;Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活。 - 精简历史兼容层:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端若干旧字段回退。 - 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。 - 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树,Agent 继续使用 `query_kb` 与 `open_kb_document` 访问知识库内容。 diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index 32bfd2f2..19313341 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -1,47 +1,55 @@ -import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' +import { apiGet, apiPost, apiDelete, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base' const BASE_URL = '/api/system/skills' +const USER_BASE_URL = '/api/skills' export const listSkills = async () => { return apiGet(BASE_URL) } -export const importSkillZip = async (file) => { +export const listAccessibleSkills = async () => { + return apiGet(`${USER_BASE_URL}/accessible`) +} + +export const prepareSkillUpload = async (file) => { const formData = new FormData() formData.append('file', file) - return apiAdminPost(`${BASE_URL}/import`, formData) + return apiPost(`${USER_BASE_URL}/import/prepare`, formData) } export const listRemoteSkills = async (source) => { - return apiAdminPost(`${BASE_URL}/remote/list`, { source }) + return apiPost(`${USER_BASE_URL}/remote/list`, { source }) } -export const installRemoteSkill = async (payload) => { - return apiAdminPost(`${BASE_URL}/remote/install`, payload) -} - -export const installRemoteSkillsBatch = async (payload) => { - return apiAdminPost(`${BASE_URL}/remote/install-batch`, payload) +export const prepareRemoteSkills = async (payload) => { + return apiPost(`${USER_BASE_URL}/remote/prepare`, payload) } export const searchRemoteSkills = async (query) => { - return apiAdminPost(`${BASE_URL}/remote/search`, { query }) + return apiPost(`${USER_BASE_URL}/remote/search`, { query }) } -export const getSkillDependencyOptions = async () => { - return apiAdminGet(`${BASE_URL}/dependency-options`) +export const confirmSkillInstallDraft = async (draftId, shareConfig) => { + return apiPost(`${USER_BASE_URL}/install-drafts/${encodeURIComponent(draftId)}/confirm`, { + share_config: shareConfig + }) +} + +export const discardSkillInstallDraft = async (draftId) => { + return apiDelete(`${USER_BASE_URL}/install-drafts/${encodeURIComponent(draftId)}`) +} + +export const getSkillDependencyOptions = async (slug) => { + const query = slug ? `?slug=${encodeURIComponent(slug)}` : '' + return apiAdminGet(`${BASE_URL}/dependency-options${query}`) } export const listBuiltinSkills = async () => { return apiAdminGet(`${BASE_URL}/builtin`) } -export const installBuiltinSkill = async (slug) => { - return apiAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/install`) -} - -export const updateBuiltinSkill = async (slug, force = false) => { - return apiAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/update`, { force }) +export const syncBuiltinSkills = async () => { + return apiAdminPost(`${BASE_URL}/builtin/sync`) } export const getSkillTree = async (slug) => { @@ -66,6 +74,16 @@ export const updateSkillDependencies = async (slug, payload) => { return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload) } +export const updateSkillShareConfig = async (slug, shareConfig) => { + return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/share-config`, { + share_config: shareConfig + }) +} + +export const updateSkillEnabled = async (slug, enabled) => { + return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/enabled`, { enabled }) +} + export const deleteSkillFile = async (slug, path) => { return apiAdminDelete( `${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}` @@ -86,20 +104,23 @@ export const deleteSkillsBatch = async (slugs) => { export const skillApi = { listSkills, - importSkillZip, + listAccessibleSkills, + prepareSkillUpload, listRemoteSkills, - installRemoteSkill, - installRemoteSkillsBatch, + prepareRemoteSkills, searchRemoteSkills, + confirmSkillInstallDraft, + discardSkillInstallDraft, getSkillDependencyOptions, listBuiltinSkills, - installBuiltinSkill, - updateBuiltinSkill, + syncBuiltinSkills, getSkillTree, getSkillFile, createSkillFile, updateSkillFile, updateSkillDependencies, + updateSkillShareConfig, + updateSkillEnabled, deleteSkillFile, exportSkill, deleteSkill, diff --git a/web/src/components/AgentFilePreview.vue b/web/src/components/AgentFilePreview.vue index 4249fd81..71137e58 100644 --- a/web/src/components/AgentFilePreview.vue +++ b/web/src/components/AgentFilePreview.vue @@ -90,7 +90,13 @@ -
+
修改未保存