From 3eb676b6f80a8644078dab66e9ec4303771be4ba Mon Sep 17 00:00:00 2001 From: zjr Date: Fri, 24 Jul 2026 19:59:07 +0800 Subject: [PATCH 1/4] =?UTF-8?q?[agent]=20feat:=20Skills=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20-=20per-user=20MinIO=20=E6=87=92=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=20+=20GitHub=20skill=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skill 不落本地:zip 存 MinIO skills/{uid}/{skill_id}.zip,元数据存 installed_skills 表 - per-user:每个用户独立 skill 集合,agent 只用自己装的 - 懒加载:load_skill tool 从 MinIO 拉取 + LRU 缓存 - skill store:GitHub repo 搜索(list_repos + zipball 下载),repo 作为 1 个 skill 不拆分 - 目录浏览 get_contents + 已安装预览 preview_skill(inspect_zip 支持 zipball 前缀) - 两层缓存:后端 per-query TTL(5min) + 前端切 tab 不重载 - 端点(get_current_uid 用户自主):/skills/installed|install|{id}|{id}/preview + /skills/store/list|contents|install - 代码工具不执行(沙箱后续) - 配置 SKILL_STORE__TOPIC/API_KEY,.env.example + default.yaml 同步 - 69 测试 passed --- .env.example | 9 + app/agent/chat/graph.py | 15 +- app/agent/chat/prompts.py | 4 + app/config/default.yaml | 8 + app/harness/app.py | 8 + app/infra/config.py | 8 + app/infra/minio.py | 24 +++ app/main.py | 5 + app/models.py | 33 +++ app/routers/skills.py | 258 +++++++++++++++++++++++ app/skills/__init__.py | 13 ++ app/skills/manager.py | 211 +++++++++++++++++++ app/skills/repository.py | 87 ++++++++ app/skills/store/__init__.py | 5 + app/skills/store/client.py | 218 ++++++++++++++++++++ app/skills/zip_parser.py | 158 +++++++++++++++ app/test/test_skill_store.py | 255 +++++++++++++++++++++++ app/test/test_skills.py | 371 ++++++++++++++++++++++++++++++++++ app/tools/_deps.py | 1 + app/tools/skill/__init__.py | 5 + app/tools/skill/load_skill.py | 74 +++++++ 21 files changed, 1768 insertions(+), 2 deletions(-) create mode 100644 app/routers/skills.py create mode 100644 app/skills/__init__.py create mode 100644 app/skills/manager.py create mode 100644 app/skills/repository.py create mode 100644 app/skills/store/__init__.py create mode 100644 app/skills/store/client.py create mode 100644 app/skills/zip_parser.py create mode 100644 app/test/test_skill_store.py create mode 100644 app/test/test_skills.py create mode 100644 app/tools/skill/__init__.py create mode 100644 app/tools/skill/load_skill.py diff --git a/.env.example b/.env.example index 4807789..137fac0 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,15 @@ SECURITY__API_KEY_ENCRYPTION_KEY= # MINIO__ACCESS_KEY= # MINIO__SECRET_KEY= +# ------------------------------------------------------------ +# Skill store(GitHub topic 市场,默认 enabled=false) +# 命名规则: 段__键 -> config["skill_store"]["key"] +# topic 标识 skill 仓库(仓库打该 topic 即上架);api_key 为 GitHub token(可选,提升搜索 rate limit) +# ------------------------------------------------------------ +# SKILL_STORE__ENABLED=true +# SKILL_STORE__TOPIC=mindbase-skill +# SKILL_STORE__API_KEY=github_pat_xxx + # ------------------------------------------------------------ # RabbitMQ(默认 enabled=false,启用后填) # ------------------------------------------------------------ diff --git a/app/agent/chat/graph.py b/app/agent/chat/graph.py index abd3ab2..1efe783 100644 --- a/app/agent/chat/graph.py +++ b/app/agent/chat/graph.py @@ -47,7 +47,11 @@ def _has_tool_calls(msg: BaseMessage) -> bool: async def inject_context( - state: ChatAgentState, *, deps: Any, runtime: AgentRuntime + state: ChatAgentState, + *, + deps: Any, + runtime: AgentRuntime, + skill_manager: Any = None, ) -> dict[str, Any]: """1/4. Resolve data scope + inject system prompt with conversation context. @@ -86,6 +90,9 @@ async def inject_context( from langchain_core.messages import HumanMessage, SystemMessage + skills_section = ( + await skill_manager.index_text(state.uid) if skill_manager is not None else "" + ) system = SystemMessage( content=build_system_prompt( state.query, @@ -93,6 +100,7 @@ async def inject_context( cloud_has_data=cloud_has_data, conversation_context=conversation_context, has_context_tools=has_context_tools, + skills_section=skills_section, ) ) user = HumanMessage(content=state.query) @@ -314,6 +322,7 @@ def build_chat_agent( llm: Any, deps: Any, circuit_breaker: CircuitBreaker | None = None, + skill_manager: Any = None, ) -> object: """Build the ReAct Chat Agent graph. @@ -344,7 +353,9 @@ def build_chat_agent( async def inject_node(s: ChatAgentState) -> dict: if circuit_breaker and circuit_breaker.is_tripped: return {"result": "", "error": "circuit breaker open"} - return await _inject(s, deps=deps, runtime=runtime) + return await _inject( + s, deps=deps, runtime=runtime, skill_manager=skill_manager + ) async def agent_node(s: ChatAgentState) -> dict: return await _agent(s, llm_with_tools=llm_with_tools) diff --git a/app/agent/chat/prompts.py b/app/agent/chat/prompts.py index 2f55a9f..823454f 100644 --- a/app/agent/chat/prompts.py +++ b/app/agent/chat/prompts.py @@ -61,6 +61,8 @@ {context_tools_section} +{skills_section} + ## 决策流程(必须遵循) ``` @@ -174,6 +176,7 @@ def build_system_prompt( cloud_has_data: bool = False, conversation_context: str = "", has_context_tools: bool = False, + skills_section: str = "", ) -> str: """Build the system prompt for the Chat Agent.""" if has_data and cloud_has_data: @@ -195,4 +198,5 @@ def build_system_prompt( date_status=date_status, conversation_context=conversation_context or "(无历史对话上下文)", context_tools_section=context_tools_section, + skills_section=skills_section, ) diff --git a/app/config/default.yaml b/app/config/default.yaml index 4178ebf..c219c94 100644 --- a/app/config/default.yaml +++ b/app/config/default.yaml @@ -103,6 +103,14 @@ minio: # access_key / secret_key via env: MINIO__ACCESS_KEY, MINIO__SECRET_KEY +# ---- Skill store - external third-party skill registry ------------- +skill_store: + enabled: false + topic: mindbase-skill # GitHub topic identifying skill repos (marketplace) + timeout: 30 + # api_key (GitHub token, optional) via env: SKILL_STORE__API_KEY + + # ---- Message queue — Celery + Redis Streams ----------------------- mq: enabled: false diff --git a/app/harness/app.py b/app/harness/app.py index 83ce03f..f1a03e1 100644 --- a/app/harness/app.py +++ b/app/harness/app.py @@ -19,6 +19,7 @@ from app.harness.orchestrator import AgentOrchestrator from app.harness.runtime import AgentRuntime from app.harness.scheduling.agent import AgentConfig, AgentScheduler +from app.skills import SkillManager from app.tools import ToolDeps, ToolManager, ToolRegistry logger = logging.getLogger(__name__) @@ -83,6 +84,7 @@ def __init__( self._lifecycle = AgentLifecycleManager() self._orchestrator = AgentOrchestrator(llm=llm) self._scheduler = AgentScheduler(self._lifecycle) + self._skill_manager = SkillManager(session_factory) self._tool_manager: ToolManager | None = None self._cleanup_task: asyncio.Task | None = None self._started = False @@ -103,6 +105,10 @@ def runtime(self) -> AgentRuntime: def scheduler(self) -> AgentScheduler: return self._scheduler + @property + def skill_manager(self) -> SkillManager: + return self._skill_manager + @property def tool_registry(self) -> ToolRegistry: """Registered tool instances. Use ``runtime.execute()`` to invoke.""" @@ -342,6 +348,7 @@ def _register_tools(self) -> None: llm=self._llm, db_deps=db_deps, lifecycle=self._lifecycle, + skill_manager=self._skill_manager, ) manager = ToolManager(deps) @@ -398,6 +405,7 @@ def _register_agents(self) -> None: llm=self._llm, deps=deps, circuit_breaker=self._lifecycle.circuit, + skill_manager=self._skill_manager, ) self._orchestrator.register( "chat", diff --git a/app/infra/config.py b/app/infra/config.py index 16aa362..aa2cc21 100644 --- a/app/infra/config.py +++ b/app/infra/config.py @@ -226,6 +226,13 @@ class MinioSection(_Section): public_endpoint: str = "" # e.g. https://example.com/minio-proxy +class SkillStoreSection(_Section): + enabled: bool = False + topic: str = "mindbase-skill" # GitHub topic identifying skill repos + api_key: SecretStr = SecretStr("") # optional GitHub token (raises search rate limit when unset) + timeout: int = 30 + + class MqSection(_Section): enabled: bool = False broker: str = "redis://localhost:6379/1" @@ -373,6 +380,7 @@ class AppConfig(BaseSettings): mongo: MongoSection = Field(default_factory=MongoSection) redis: RedisSection = Field(default_factory=RedisSection) minio: MinioSection = Field(default_factory=MinioSection) + skill_store: SkillStoreSection = Field(default_factory=SkillStoreSection) mq: MqSection = Field(default_factory=MqSection) llm: LlmSection = Field(default_factory=LlmSection) embedding: EmbeddingSection = Field(default_factory=EmbeddingSection) diff --git a/app/infra/minio.py b/app/infra/minio.py index 65d8b24..d706377 100644 --- a/app/infra/minio.py +++ b/app/infra/minio.py @@ -324,6 +324,30 @@ async def get_object(self, object_key: str) -> bytes: raise FileNotFoundError(f"Object not found: {object_key}") from exc raise + async def put_object( + self, object_key: str, data: bytes, content_type: str = "application/octet-stream" + ) -> None: + """Upload a small object (in-memory bytes) to MinIO. + + Used for skill packs and other small artifacts. Large uploads should + use the multipart API instead. + """ + import io + + self._ensure_client() + stream = io.BytesIO(data) + await _run_async( + self._client.put_object, + self.bucket, + object_key, + stream, + len(data), + content_type, + ) + logger.info( + "[MINIO] put_object object_key={} size={}", object_key, len(data) + ) + # --------------------------------------------------------------------------- # module-level singleton diff --git a/app/main.py b/app/main.py index 7294b45..66c8f46 100644 --- a/app/main.py +++ b/app/main.py @@ -457,6 +457,11 @@ async def request_context_middleware(request: Request, call_next): app.include_router(agent_runtime_router) +# Skills - install/list/uninstall (MinIO-backed, never local disk) +from app.routers.skills import router as skills_router # noqa: E402 + +app.include_router(skills_router) + # Plan 0021: Cloud drive router (with graceful degradation) try: from app.routers.cloud import router as cloud_router diff --git a/app/models.py b/app/models.py index 4f2f8bb..92770a5 100644 --- a/app/models.py +++ b/app/models.py @@ -924,3 +924,36 @@ class NoteShare(Base): view_count = Column(Integer, default=0) is_revoked = Column(Boolean, default=False) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + +class InstalledSkill(Base): + """Installed Skill metadata (per-user) - the skill *content* (zip) lives + in MinIO, never on the local filesystem. Rows here are the index each + user's agent sees. + + Skills are downloaded from an external skill store (or uploaded) into + MinIO at ``minio_key`` (``skills/{uid}/{skill_id}.zip``) and lazily + loaded by ``SkillManager`` when the agent calls ``load_skill``. + """ + + __tablename__ = "installed_skills" + __table_args__ = ( + UniqueConstraint("uid", "skill_id", name="uq_installed_skills_uid_skill"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + uid = Column(BigInteger, ForeignKey("users.uid"), nullable=False, index=True) + skill_id = Column(String(128), nullable=False, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=True) + version = Column(String(64), nullable=True) + source_store = Column(String(128), nullable=True) # 'upload' | store base url + minio_key = Column(String(256), nullable=False) # skills/{uid}/{skill_id}.zip + manifest = Column(JSON, nullable=True) # {has_code_tools, resources, ...} + enabled = Column(Boolean, default=True, nullable=False) + installed_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/app/routers/skills.py b/app/routers/skills.py new file mode 100644 index 0000000..f5b071e --- /dev/null +++ b/app/routers/skills.py @@ -0,0 +1,258 @@ +"""Skills HTTP endpoints - per-user install / list / uninstall + store browse. + +Skills are stored in MinIO (never local) at ``skills/{uid}/{skill_id}.zip``; +each user manages their own installed skills. Any logged-in user can browse +the store, install, and uninstall - this is NOT admin-gated. + +The agent sees the user's installed skills via ``SkillManager.index_text`` +in its system prompt and loads them on demand with the ``load_skill`` tool. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File +from pydantic import BaseModel + +from app.routers.auth import get_current_uid +from app.skills.store import SkillStoreClient, StoreRepo, get_skill_store_client +from app.skills.zip_parser import read_manifest + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/skills", tags=["skills"]) + + +class InstalledSkillResponse(BaseModel): + skill_id: str + name: str + description: str | None = None + version: str | None = None + source_store: str | None = None + has_code_tools: bool = False + enabled: bool = True + + +class StoreInstallRequest(BaseModel): + repo: str # "owner/repo" to install from + branch: str = "main" + + +def _harness(request: Request) -> Any: + harness = getattr(request.app.state, "agent_harness", None) + if harness is None or not getattr(harness, "started", False): + raise HTTPException(status_code=503, detail="Agent 服务暂不可用") + return harness + + +# --------------------------------------------------------------------------- +# Installed skills (per-user) +# --------------------------------------------------------------------------- + + +@router.get("/installed", response_model=list[InstalledSkillResponse]) +async def list_installed( + request: Request, + uid: int = Depends(get_current_uid), +) -> list[InstalledSkillResponse]: + """List the caller's installed skills (drives the agent's skill index).""" + harness = _harness(request) + skills = await harness.skill_manager.list_installed(uid) + out: list[InstalledSkillResponse] = [] + for s in skills: + manifest = s.manifest or {} + out.append( + InstalledSkillResponse( + skill_id=s.skill_id, + name=s.name, + description=s.description, + version=s.version, + source_store=s.source_store, + has_code_tools=bool(manifest.get("has_code_tools", False)), + enabled=s.enabled, + ) + ) + return out + + +@router.post("/install", response_model=InstalledSkillResponse) +async def install_skill( + request: Request, + file: UploadFile = File(...), + uid: int = Depends(get_current_uid), +) -> InstalledSkillResponse: + """Install a skill zip for the caller (upload -> MinIO + installed_skills row).""" + harness = _harness(request) + zip_bytes = await file.read() + if not zip_bytes: + raise HTTPException(status_code=400, detail="空文件") + + manifest = read_manifest(zip_bytes) + skill_id = ( + manifest.get("skill_id") + or (file.filename.rsplit(".", 1)[0] if file.filename else "") + ) + if not skill_id: + raise HTTPException(status_code=400, detail="无法确定 skill_id(manifest 缺失且无文件名)") + + name = manifest.get("name") or skill_id + description = manifest.get("description") + version = manifest.get("version") + has_code_tools = bool(manifest.get("has_code_tools", False)) + + try: + await harness.skill_manager.install( + uid=uid, + skill_id=skill_id, + name=name, + description=description, + version=version, + source_store="upload", + zip_bytes=zip_bytes, + manifest=manifest, + ) + except Exception as exc: + logger.exception("[SKILLS] install failed uid=%s skill=%s", uid, skill_id) + raise HTTPException(status_code=502, detail=f"安装失败: {exc}") from exc + + return InstalledSkillResponse( + skill_id=skill_id, + name=name, + description=description, + version=version, + source_store="upload", + has_code_tools=has_code_tools, + enabled=True, + ) + + +@router.delete("/{skill_id}") +async def uninstall_skill( + skill_id: str, + request: Request, + uid: int = Depends(get_current_uid), +) -> dict: + """Uninstall one of the caller's skills (deletes MinIO object + MySQL row).""" + harness = _harness(request) + ok = await harness.skill_manager.uninstall(uid, skill_id) + if not ok: + raise HTTPException(status_code=404, detail=f"未安装的技能: {skill_id}") + return {"deleted": skill_id} + + +@router.get("/{skill_id}/preview") +async def preview_installed_skill( + skill_id: str, + request: Request, + uid: int = Depends(get_current_uid), +) -> dict: + """Preview an installed skill's content (SKILL.md body + manifest + file list).""" + harness = _harness(request) + result = await harness.skill_manager.preview_skill(uid, skill_id) + if result is None: + raise HTTPException(status_code=404, detail=f"未安装或不可访问: {skill_id}") + return result + + +# --------------------------------------------------------------------------- +# Skill store (external third-party registry) - browse + install from store +# --------------------------------------------------------------------------- + + +def _store_or_503() -> SkillStoreClient: + store = get_skill_store_client() + if not store.enabled: + raise HTTPException(status_code=503, detail="Skill store 未配置") + return store + + +@router.get("/store/list", response_model=list[StoreRepo]) +async def list_store_repos( + request: Request, + q: str | None = None, + uid: int = Depends(get_current_uid), +) -> list[StoreRepo]: + """Search GitHub for skill repos (marketplace). No query -> topic default.""" + store = _store_or_503() + try: + return await store.list_repos(q) + except Exception as exc: + logger.warning("[SKILLS] store list failed: %s", exc) + raise HTTPException(status_code=502, detail=f"store 访问失败: {exc}") from exc + + +@router.get("/store/contents") +async def get_store_contents( + request: Request, + repo: str = Query(..., description="owner/repo to browse"), + path: str = Query("", description="path within the repo; empty = root"), + branch: str = Query("main"), + _uid: int = Depends(get_current_uid), +) -> dict: + """Browse a repo's directory tree or read a file before installing.""" + store = _store_or_503() + try: + return await store.get_contents(repo, path, branch) + except Exception as exc: + logger.warning( + "[SKILLS] store contents failed repo=%s path=%s: %s", repo, path, exc + ) + raise HTTPException(status_code=502, detail=f"访问仓库内容失败: {exc}") from exc + + +@router.post("/store/install", response_model=InstalledSkillResponse) +async def install_from_store( + request: Request, + body: StoreInstallRequest, + uid: int = Depends(get_current_uid), +) -> InstalledSkillResponse: + """Download a repo as ONE skill pack (zipball) and install it for the caller. + + The whole repo becomes a single skill (skill_id = repo name, or + manifest.skill_id if present) - it is NOT split per subdirectory. + """ + harness = _harness(request) + store = _store_or_503() + try: + zip_bytes, manifest = await store.download_repo(body.repo, body.branch) + except Exception as exc: + logger.warning( + "[SKILLS] store download failed uid=%s repo=%s: %s", uid, body.repo, exc + ) + raise HTTPException(status_code=502, detail=f"下载失败: {exc}") from exc + + repo_name = body.repo.rsplit("/", 1)[-1] + skill_id = manifest.get("skill_id") or repo_name + name = manifest.get("name") or repo_name + description = manifest.get("description") + version = manifest.get("version") + has_code_tools = bool(manifest.get("has_code_tools", False)) + + try: + await harness.skill_manager.install( + uid=uid, + skill_id=skill_id, + name=name, + description=description, + version=version, + source_store=body.repo, + zip_bytes=zip_bytes, + manifest=manifest, + ) + except Exception: + logger.exception( + "[SKILLS] install from store failed uid=%s skill=%s", uid, skill_id + ) + raise HTTPException(status_code=502, detail=f"安装失败: {skill_id}") + + return InstalledSkillResponse( + skill_id=skill_id, + name=name, + description=description, + version=version, + source_store=body.repo, + has_code_tools=has_code_tools, + enabled=True, + ) diff --git a/app/skills/__init__.py b/app/skills/__init__.py new file mode 100644 index 0000000..a3899e5 --- /dev/null +++ b/app/skills/__init__.py @@ -0,0 +1,13 @@ +"""Skills - packaged instruction packs loaded from MinIO (never local disk). + +Skills are downloaded from an external skill store into MinIO as zips; their +metadata lives in the ``installed_skills`` MySQL table. ``SkillManager`` +lazily loads a skill's instructions from MinIO when the agent calls +``load_skill``. Code tools inside a skill are not executed yet (sandbox +pending). +""" + +from .manager import SkillManager +from .zip_parser import Skill + +__all__ = ["Skill", "SkillManager"] diff --git a/app/skills/manager.py b/app/skills/manager.py new file mode 100644 index 0000000..cef9df2 --- /dev/null +++ b/app/skills/manager.py @@ -0,0 +1,211 @@ +"""SkillManager - per-user lazy load of Skills from MinIO (never local disk). + +Each user manages their own installed skills. The skill *content* (zip) +lives in MinIO at ``skills/{uid}/{skill_id}.zip``; the per-user index lives +in the ``installed_skills`` MySQL table. When an agent calls +``load_skill(name)``, the manager fetches the zip from MinIO, parses it in +memory, caches it (LRU, per uid+skill_id), and returns the instructions. + +Skill code tools (``manifest.has_code_tools``) are **not executed** yet - +sandbox support is pending. +""" + +from __future__ import annotations + +import logging +from collections import OrderedDict +from typing import Any, Optional + +from app.skills.repository import SkillRepository +from app.skills.zip_parser import Skill, inspect_zip, parse_skill_zip + +logger = logging.getLogger(__name__) + + +class SkillManager: + """Per-user lazy load of installed skills from MinIO. + + Usage:: + + mgr = SkillManager(session_factory) + idx = await mgr.index_text(uid) # inject into prompt + skill = await mgr.load_skill(uid, "video-summary") + await mgr.install(uid=1, skill_id=..., zip_bytes=..., ...) + """ + + def __init__( + self, + session_factory: Any = None, + minio_client: Any = None, + *, + cache_max: int = 32, + ) -> None: + self._session_factory = session_factory + self._minio = minio_client # lazy via _get_minio() if None + self._cache: "OrderedDict[tuple[int, str], Skill]" = OrderedDict() + self._cache_max = cache_max + + # ── minio access ─────────────────────────────────────────────────── + + def _get_minio(self) -> Any: + if self._minio is None: + from app.infra.minio import get_minio_client + + self._minio = get_minio_client() + return self._minio + + # ── index (for system prompt) ────────────────────────────────────── + + async def list_installed(self, uid: int) -> list[Any]: + """Return a user's installed_skills rows (enabled only).""" + if self._session_factory is None: + return [] + async with self._session_factory() as db: + return await SkillRepository(db).list_all(uid) + + async def index_text(self, uid: int) -> str: + """Compact skill index for the user's system prompt (async - reads MySQL).""" + skills = await self.list_installed(uid) + if not skills: + return "" + lines = [ + "## 可用技能(Skills)", + "当用户任务匹配某技能时,调用 load_skill(name) 加载详细指令:", + ] + for s in skills: + desc = s.description or s.name + lines.append(f"- {s.skill_id}: {desc}") + return "\n".join(lines) + + # ── lazy load from MinIO ─────────────────────────────────────────── + + async def load_skill(self, uid: int, skill_id: str) -> Optional[Skill]: + """Fetch the user's skill zip from MinIO, parse in memory, cache, return.""" + cache_key = (uid, skill_id) + cached = self._cache.get(cache_key) + if cached is not None: + return cached + + if self._session_factory is None: + return None + async with self._session_factory() as db: + meta = await SkillRepository(db).get(uid, skill_id) + if meta is None: + return None + + try: + zip_bytes = await self._get_minio().get_object(meta.minio_key) + except Exception: + logger.exception( + "[SKILLS] minio get failed uid=%s skill=%s key=%s", uid, skill_id, meta.minio_key + ) + return None + + skill = parse_skill_zip( + zip_bytes, + skill_id=meta.skill_id, + name=meta.name, + description=meta.description or "", + ) + self._cache_put(cache_key, skill) + return skill + + async def preview_skill(self, uid: int, skill_id: str) -> Optional[dict]: + """Inspect an installed skill's zip from MinIO for the preview UI. + + Returns ``None`` if the skill is not installed or MinIO is unreachable. + """ + if self._session_factory is None: + return None + async with self._session_factory() as db: + meta = await SkillRepository(db).get(uid, skill_id) + if meta is None: + return None + try: + zip_bytes = await self._get_minio().get_object(meta.minio_key) + except Exception: + logger.exception( + "[SKILLS] minio get (preview) failed uid=%s skill=%s", uid, skill_id + ) + return None + info = inspect_zip(zip_bytes) + manifest = info["manifest"] or {} + return { + "skill_id": meta.skill_id, + "name": meta.name, + "description": meta.description, + "version": meta.version, + "has_code_tools": bool(manifest.get("has_code_tools", False)), + "body": info["body"], + "manifest": manifest, + "files": info["files"], + } + + # ── install / uninstall ──────────────────────────────────────────── + + async def install( + self, + *, + uid: int, + skill_id: str, + name: str, + description: str | None, + version: str | None, + source_store: str | None, + zip_bytes: bytes, + manifest: dict | None, + ) -> None: + """Upload the zip to MinIO (per-user key) and write the installed_skills row.""" + minio_key = f"skills/{uid}/{skill_id}.zip" + await self._get_minio().put_object(minio_key, zip_bytes, "application/zip") + async with self._session_factory() as db: + await SkillRepository(db).upsert( + uid=uid, + skill_id=skill_id, + name=name, + description=description, + version=version, + source_store=source_store, + minio_key=minio_key, + manifest=manifest, + ) + await db.commit() + self._cache.pop((uid, skill_id), None) + logger.info("[SKILLS] installed uid=%s skill=%s", uid, skill_id) + + async def uninstall(self, uid: int, skill_id: str) -> bool: + """Delete the user's zip from MinIO and the row from MySQL.""" + if self._session_factory is None: + return False + async with self._session_factory() as db: + meta = await SkillRepository(db).get(uid, skill_id) + if meta is None: + return False + try: + await self._get_minio().delete_object(meta.minio_key) + except Exception: + logger.exception("[SKILLS] minio delete failed uid=%s skill=%s", uid, skill_id) + async with self._session_factory() as db: + await SkillRepository(db).delete(uid, skill_id) + await db.commit() + self._cache.pop((uid, skill_id), None) + logger.info("[SKILLS] uninstalled uid=%s skill=%s", uid, skill_id) + return True + + # ── cache ────────────────────────────────────────────────────────── + + def _cache_put(self, key: tuple[int, str], skill: Skill) -> None: + self._cache[key] = skill + self._cache.move_to_end(key) + while len(self._cache) > self._cache_max: + self._cache.popitem(last=False) + + def invalidate(self, uid: int | None = None, skill_id: str | None = None) -> None: + """Drop cached skills (one user's, one skill, or all).""" + if uid is None and skill_id is None: + self._cache.clear() + elif uid is not None and skill_id is not None: + self._cache.pop((uid, skill_id), None) + elif uid is not None: + for k in [k for k in self._cache if k[0] == uid]: + self._cache.pop(k, None) diff --git a/app/skills/repository.py b/app/skills/repository.py new file mode 100644 index 0000000..ece7d66 --- /dev/null +++ b/app/skills/repository.py @@ -0,0 +1,87 @@ +"""SkillRepository - MySQL CRUD for installed_skills rows (per-user). + +The skill *content* lives in MinIO; this table is only the per-user index +(uid + skill_id / name / description / minio_key / manifest) that each +user's agent sees in its system prompt and that ``SkillManager.load_skill`` +uses to locate the zip in MinIO. +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import InstalledSkill + +logger = logging.getLogger(__name__) + + +class SkillRepository: + """Per-user CRUD access to ``installed_skills``.""" + + def __init__(self, db: AsyncSession) -> None: + self._db = db + + async def list_all(self, uid: int, *, enabled_only: bool = True) -> list[InstalledSkill]: + stmt = select(InstalledSkill).where(InstalledSkill.uid == uid) + if enabled_only: + stmt = stmt.where(InstalledSkill.enabled.is_(True)) + stmt = stmt.order_by(InstalledSkill.name) + result = await self._db.execute(stmt) + return list(result.scalars()) + + async def get(self, uid: int, skill_id: str) -> InstalledSkill | None: + result = await self._db.execute( + select(InstalledSkill).where( + InstalledSkill.uid == uid, + InstalledSkill.skill_id == skill_id, + ) + ) + return result.scalar_one_or_none() + + async def upsert( + self, + *, + uid: int, + skill_id: str, + name: str, + description: str | None, + version: str | None, + source_store: str | None, + minio_key: str, + manifest: dict | None, + ) -> InstalledSkill: + existing = await self.get(uid, skill_id) + if existing is not None: + existing.name = name + existing.description = description + existing.version = version + existing.source_store = source_store + existing.minio_key = minio_key + existing.manifest = manifest + await self._db.flush() + return existing + row = InstalledSkill( + uid=uid, + skill_id=skill_id, + name=name, + description=description, + version=version, + source_store=source_store, + minio_key=minio_key, + manifest=manifest, + ) + self._db.add(row) + await self._db.flush() + return row + + async def delete(self, uid: int, skill_id: str) -> bool: + result = await self._db.execute( + delete(InstalledSkill).where( + InstalledSkill.uid == uid, + InstalledSkill.skill_id == skill_id, + ) + ) + return (result.rowcount or 0) > 0 diff --git a/app/skills/store/__init__.py b/app/skills/store/__init__.py new file mode 100644 index 0000000..09c5894 --- /dev/null +++ b/app/skills/store/__init__.py @@ -0,0 +1,5 @@ +"""Skill store client - searches GitHub for skill repos.""" + +from .client import SkillStoreClient, StoreRepo, get_skill_store_client + +__all__ = ["SkillStoreClient", "StoreRepo", "get_skill_store_client"] diff --git a/app/skills/store/client.py b/app/skills/store/client.py new file mode 100644 index 0000000..26b4f95 --- /dev/null +++ b/app/skills/store/client.py @@ -0,0 +1,218 @@ +"""SkillStoreClient - search GitHub for skill repos, download their skills. + +Two-step marketplace: + +1. ``list_repos(query)`` - GitHub Search API for repositories matching the + query (falls back to ``topic:{topic}`` when no query). Returns repo + metadata so the UI can show GitHub search results. +2. ``download_repo_skills(repo, branch)`` - read the repo's ``skills/`` + directory; each subdir is one skill (``SKILL.md`` + ``manifest.json``). + Pack each into an in-memory zip for ``SkillManager.install`` to store + in MinIO. Nothing is written to local disk. + +Config (``config.skill_store``): + topic - GitHub topic used as the default search when no query (default + "mindbase-skill") + api_key - optional GitHub token (search API is tightly rate-limited when + unauthenticated: 10/min vs 30/min authenticated) +""" + +from __future__ import annotations + +import base64 +import logging +import time +from typing import Any, Optional + +import httpx +from pydantic import BaseModel, ConfigDict + +from app.infra.config import config +from app.skills.zip_parser import read_manifest + +logger = logging.getLogger(__name__) + + +class StoreRepo(BaseModel): + """A GitHub repository returned by the store search.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + full_name: str # "owner/repo" + description: str = "" + stargazers_count: int = 0 + default_branch: str = "main" + html_url: str = "" + + +class SkillStoreClient: + """GitHub-search-backed skill store client.""" + + API = "https://api.github.com" + + def __init__( + self, + topic: str = "mindbase-skill", + token: str = "", + timeout: int = 30, + transport: Optional[httpx.AsyncBaseTransport] = None, + cache_ttl: int = 300, + ) -> None: + self._topic = topic + self._token = token + self._timeout = timeout + self._transport = transport + # per-query result cache: key -> (fetched_at_monotonic, repos) + # keeps us within GitHub Search rate limits (10/min unauth, 30/min auth) + self._cache: dict[str, tuple[float, list[StoreRepo]]] = {} + self._cache_ttl = cache_ttl + + @property + def topic(self) -> str: + return self._topic + + @property + def enabled(self) -> bool: + return True # always can search GitHub (topic is just a default) + + def _headers(self) -> dict[str, str]: + h: dict[str, str] = {"Accept": "application/vnd.github+json"} + if self._token: + h["Authorization"] = f"Bearer {self._token}" + return h + + async def _get_json( + self, client: httpx.AsyncClient, repo: str, branch: str, path: str + ) -> Any: + resp = await client.get( + f"{self.API}/repos/{repo}/contents/{path}", + params={"ref": branch}, + headers=self._headers(), + ) + resp.raise_for_status() + return resp.json() + + async def _read_file( + self, client: httpx.AsyncClient, repo: str, branch: str, path: str + ) -> str: + data = await self._get_json(client, repo, branch, path) + if isinstance(data, list): + raise ValueError(f"expected file, got directory: {path}") + if data.get("encoding") == "base64" and data.get("content") is not None: + return base64.b64decode(data["content"]).decode("utf-8") + return data.get("content", "") + + # ── search ───────────────────────────────────────────────────────── + + async def list_repos(self, query: str | None = None) -> list[StoreRepo]: + """Search GitHub repos. Falls back to ``topic:{topic}`` when no query. + + Results are cached per-query for ``cache_ttl`` seconds (default 300s) + to stay within GitHub Search rate limits. + """ + key = (query or "").strip() or f"__topic__{self._topic}" + now = time.monotonic() + cached = self._cache.get(key) + if cached is not None and now - cached[0] < self._cache_ttl: + return cached[1] + + q = query.strip() if query and query.strip() else f"topic:{self._topic}" + async with httpx.AsyncClient(timeout=self._timeout, transport=self._transport) as client: + resp = await client.get( + f"{self.API}/search/repositories", + params={"q": q, "per_page": 30}, + headers=self._headers(), + ) + resp.raise_for_status() + items = resp.json().get("items", []) + repos = [ + StoreRepo( + full_name=it.get("full_name", ""), + description=it.get("description") or "", + stargazers_count=it.get("stargazers_count", 0), + default_branch=it.get("default_branch", "main"), + html_url=it.get("html_url", ""), + ) + for it in items + if it.get("full_name") + ] + self._cache[key] = (now, repos) + return repos + + def invalidate_cache(self) -> None: + """Drop all cached search results (e.g. after install/uninstall).""" + self._cache.clear() + + # ── download ─────────────────────────────────────────────────────── + + async def download_repo( + self, repo: str, branch: str = "main" + ) -> tuple[bytes, dict]: + """Download the entire repo as a zip (GitHub zipball). + + The repo is treated as ONE skill pack (skill_id = repo name). Returns + ``(zip_bytes, manifest)``. ``manifest.json`` is read from the zipball + (top-level prefix supported by ``read_manifest``). + """ + async with httpx.AsyncClient(timeout=self._timeout, transport=self._transport) as client: + resp = await client.get( + f"{self.API}/repos/{repo}/zipball/{branch}", + headers=self._headers(), + follow_redirects=True, + ) + resp.raise_for_status() + zip_bytes = resp.content + manifest = read_manifest(zip_bytes) + return zip_bytes, manifest + + async def get_contents( + self, repo: str, path: str = "", branch: str = "main" + ) -> dict: + """List a directory or read a file in a repo (GitHub contents API). + + Lets the UI browse a repo before installing. Returns + ``{"type": "dir", "entries": [{name, type, path, size}]}`` for a + directory, or ``{"type": "file", "name", "path", "content"}`` for a + file (content decoded to text). + """ + async with httpx.AsyncClient(timeout=self._timeout, transport=self._transport) as client: + data = await self._get_json(client, repo, branch, path or "") + if isinstance(data, list): + return { + "type": "dir", + "entries": [ + { + "name": e.get("name", ""), + "type": e.get("type", "file"), + "path": e.get("path", ""), + "size": e.get("size", 0), + } + for e in data + ], + } + content = "" + if data.get("encoding") == "base64" and data.get("content") is not None: + content = base64.b64decode(data["content"]).decode("utf-8") + else: + content = data.get("content", "") or "" + return { + "type": "file", + "name": data.get("name", ""), + "path": data.get("path", path), + "content": content, + } + + +# ── module-level singleton (lazy, config-driven) ────────────────────────── + +_client: Optional[SkillStoreClient] = None + + +def get_skill_store_client() -> SkillStoreClient: + """Return the process-wide SkillStoreClient built from config.""" + global _client + if _client is None: + sec = config.skill_store + token = sec.api_key.get_secret_value() if sec.api_key else "" + _client = SkillStoreClient(sec.topic, token, sec.timeout) + return _client diff --git a/app/skills/zip_parser.py b/app/skills/zip_parser.py new file mode 100644 index 0000000..2490c13 --- /dev/null +++ b/app/skills/zip_parser.py @@ -0,0 +1,158 @@ +"""Parse a skill zip (from MinIO) into a Skill in-memory. + +A skill zip contains: + manifest.json - {name, description, version, has_code_tools, resources[]} + SKILL.md - instruction body (frontmatter optional; manifest wins) + resources/ - optional reference resources + tools/ - optional code tools (NOT executed yet - sandbox pending) + +Nothing is written to disk. The zip is read entirely in memory. +""" + +from __future__ import annotations + +import io +import json +import logging +import zipfile +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Skill: + """One parsed skill - instructions + metadata, no code execution.""" + + skill_id: str + name: str + description: str + body: str # SKILL.md instruction body (frontmatter stripped) + has_code_tools: bool + resources: list[str] = field(default_factory=list) + manifest: dict = field(default_factory=dict) + + +def parse_skill_zip( + zip_bytes: bytes, *, skill_id: str, name: str, description: str = "" +) -> Skill: + """Parse a skill zip archive into a :class:`Skill`. + + ``skill_id`` / ``name`` / ``description`` come from the installed_skills + row (manifest is source of truth for metadata); the zip's own + ``manifest.json`` provides ``has_code_tools`` / ``resources``. + """ + has_code_tools = False + resources: list[str] = [] + manifest: dict = {} + body = "" + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + names = zf.namelist() + m_entry = _find_entry(names, "manifest.json") + if m_entry is not None: + try: + manifest = json.loads(zf.read(m_entry).decode("utf-8")) + has_code_tools = bool(manifest.get("has_code_tools", False)) + resources = list(manifest.get("resources", [])) + except Exception: + logger.exception("[SKILLS] bad manifest.json in %s", skill_id) + s_entry = _find_entry(names, "SKILL.md") + if s_entry is not None: + text = zf.read(s_entry).decode("utf-8") + body = _strip_frontmatter(text) + + return Skill( + skill_id=skill_id, + name=name, + description=description, + body=body, + has_code_tools=has_code_tools, + resources=resources, + manifest=manifest, + ) + + +def _find_entry(names: list[str], target: str) -> str | None: + """Find the zip entry closest to the root matching *target* filename. + + Supports both flat zips (``SKILL.md`` at root) and GitHub zipballs + (``owner-repo-sha/SKILL.md`` under a top-level prefix dir). When several + entries match, the shortest path wins (root preferred over nested). + """ + matches = [n for n in names if n == target or n.endswith(f"/{target}")] + if not matches: + return None + return min(matches, key=len) + + +def _strip_frontmatter(text: str) -> str: + """Drop a leading ``---\\n...\\n---`` YAML frontmatter block.""" + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + return parts[2].lstrip("\n") + return text + + +def read_manifest(zip_bytes: bytes) -> dict: + """Read just ``manifest.json`` from a skill zip (for the install endpoint). + + Returns ``{}`` when the archive has no manifest. Used to extract + ``skill_id`` / ``name`` / ``description`` / ``version`` / ``has_code_tools`` + before persisting the row. Supports GitHub zipball layout (manifest under + a top-level prefix dir). + """ + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + m_entry = _find_entry(zf.namelist(), "manifest.json") + if m_entry is not None: + try: + return json.loads(zf.read(m_entry).decode("utf-8")) + except Exception: + logger.exception("[SKILLS] bad manifest.json") + return {} + return {} + + +def inspect_zip(zip_bytes: bytes) -> dict: + """Inspect an installed skill zip without writing to disk. + + Returns ``{"files": [{name, path, size}], "manifest": dict, "body": str}`` + where ``body`` is the SKILL.md content (frontmatter stripped) and + ``files`` lists every file in the archive. Used by the preview endpoint. + """ + files: list[dict] = [] + manifest: dict = {} + body = "" + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + names = zf.namelist() + for n in names: + if n.endswith("/"): + continue # directory entry + info = zf.getinfo(n) + files.append( + {"name": n.rsplit("/", 1)[-1], "path": n, "size": info.file_size} + ) + m_entry = _find_entry(names, "manifest.json") + if m_entry is not None: + try: + manifest = json.loads(zf.read(m_entry).decode("utf-8")) + except Exception: + logger.exception("[SKILLS] bad manifest.json in inspect") + s_entry = _find_entry(names, "SKILL.md") + if s_entry is not None: + body = _strip_frontmatter(zf.read(s_entry).decode("utf-8")) + return {"files": files, "manifest": manifest, "body": body} + + +def build_skill_zip( + *, skill_md: str, manifest: dict, resources: dict[str, bytes] | None = None +) -> bytes: + """Build a skill zip in memory (used by tests and the upload endpoint).""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("SKILL.md", skill_md) + zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False)) + for fname, data in (resources or {}).items(): + zf.writestr(f"resources/{fname}", data) + return buf.getvalue() diff --git a/app/test/test_skill_store.py b/app/test/test_skill_store.py new file mode 100644 index 0000000..33465b6 --- /dev/null +++ b/app/test/test_skill_store.py @@ -0,0 +1,255 @@ +"""Tests for SkillStoreClient - GitHub repo search + skill download. + +Uses httpx.MockTransport so no real network is hit. The mock imitates: + - /search/repositories -> {items: [{full_name, description, ...}]} + - /repos/{repo}/contents/{path} -> directory listing or base64 file +""" + +from __future__ import annotations + +import base64 +import io +import json +import zipfile + +import httpx +import pytest + +from app.skills.store.client import SkillStoreClient, StoreRepo +from app.skills.zip_parser import build_skill_zip + +REPO1 = "owner1/skills-repo" + + +def _file_response(content: str) -> httpx.Response: + return httpx.Response( + 200, + json={ + "type": "file", + "encoding": "base64", + "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), + "path": "x", + }, + ) + + +def _dir_response(items: list[dict]) -> httpx.Response: + return httpx.Response(200, json=items) + + +def _search_response(repos: list[dict]) -> httpx.Response: + return httpx.Response(200, json={"items": repos}) + + +class TestSkillStoreClient: + @pytest.mark.asyncio + async def test_list_repos_with_query(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/search/repositories"): + assert request.url.params["q"] == "apple" + return _search_response( + [ + { + "full_name": REPO1, + "description": "apple skill repo", + "stargazers_count": 42, + "default_branch": "main", + "html_url": "https://github.com/owner1/skills-repo", + } + ] + ) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + repos = await client.list_repos("apple") + assert len(repos) == 1 + assert repos[0].full_name == REPO1 + assert repos[0].stargazers_count == 42 + assert repos[0].html_url == "https://github.com/owner1/skills-repo" + + @pytest.mark.asyncio + async def test_list_repos_no_query_falls_back_to_topic(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/search/repositories"): + assert request.url.params["q"] == "topic:mindbase-skill" + return _search_response([]) + return httpx.Response(404) + + client = SkillStoreClient(topic="mindbase-skill", transport=httpx.MockTransport(handler)) + assert await client.list_repos() == [] + + @pytest.mark.asyncio + async def test_list_repos_ignores_items_without_full_name(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/search/repositories"): + return _search_response( + [{"full_name": REPO1}, {"description": "no name"}] + ) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + repos = await client.list_repos("x") + assert len(repos) == 1 + assert repos[0].full_name == REPO1 + + @pytest.mark.asyncio + async def test_list_repos_caches_within_ttl(self) -> None: + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + if request.url.path.endswith("/search/repositories"): + call_count += 1 + return _search_response([{"full_name": REPO1, "default_branch": "main"}]) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler), cache_ttl=300) + r1 = await client.list_repos("apple") + r2 = await client.list_repos("apple") + assert call_count == 1 # second served from cache + assert r1 == r2 + + @pytest.mark.asyncio + async def test_list_repos_cache_separate_per_query(self) -> None: + queries: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/search/repositories"): + queries.append(request.url.params["q"]) + return _search_response([{"full_name": REPO1}]) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler), cache_ttl=300) + await client.list_repos("apple") + await client.list_repos("banana") + assert queries == ["apple", "banana"] # separate cache keys + + @pytest.mark.asyncio + async def test_list_repos_invalidate_cache_refetches(self) -> None: + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + if request.url.path.endswith("/search/repositories"): + call_count += 1 + return _search_response([{"full_name": REPO1}]) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler), cache_ttl=300) + await client.list_repos("apple") + client.invalidate_cache() + await client.list_repos("apple") + assert call_count == 2 # cache cleared -> re-fetched + + @pytest.mark.asyncio + async def test_download_repo_returns_zip_and_manifest(self) -> None: + zip_bytes = build_skill_zip( + skill_md="# Apple Skills\n24 sub-packs", + manifest={"skill_id": "apple-skills", "name": "Apple Skills", "has_code_tools": False}, + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == f"/repos/{REPO1}/zipball/main": + return httpx.Response(200, content=zip_bytes) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + data, manifest = await client.download_repo(REPO1, "main") + assert data == zip_bytes + assert manifest["name"] == "Apple Skills" + + @pytest.mark.asyncio + async def test_download_repo_finds_manifest_under_zipball_prefix(self) -> None: + """GitHub zipball wraps files under a top-level owner-repo-sha/ dir.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr( + "owner1-skills-repo-abcd123/manifest.json", + json.dumps({"skill_id": "apple-skills", "name": "Apple"}), + ) + zf.writestr("owner1-skills-repo-abcd123/SKILL.md", "# Apple\nbody") + zip_bytes = buf.getvalue() + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == f"/repos/{REPO1}/zipball/main": + return httpx.Response(200, content=zip_bytes) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + _, manifest = await client.download_repo(REPO1, "main") + assert manifest["name"] == "Apple" + + @pytest.mark.asyncio + async def test_download_repo_raises_on_404(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + with pytest.raises(httpx.HTTPStatusError): + await client.download_repo(REPO1, "main") + + @pytest.mark.asyncio + async def test_get_contents_returns_dir_entries(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == f"/repos/{REPO1}/contents/skills": + return _dir_response( + [ + {"name": "video-summary", "type": "dir", "path": "skills/video-summary", "size": 0}, + {"name": "SKILL.md", "type": "file", "path": "skills/SKILL.md", "size": 100}, + ] + ) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + result = await client.get_contents(REPO1, "skills", "main") + assert result["type"] == "dir" + assert len(result["entries"]) == 2 + assert result["entries"][0]["name"] == "video-summary" + assert result["entries"][0]["type"] == "dir" + + @pytest.mark.asyncio + async def test_get_contents_returns_file_content(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == f"/repos/{REPO1}/contents/skills/SKILL.md": + return _file_response("# Title\nbody") + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + result = await client.get_contents(REPO1, "skills/SKILL.md", "main") + assert result["type"] == "file" + assert "body" in result["content"] + + @pytest.mark.asyncio + async def test_get_contents_root_when_path_empty(self) -> None: + def handler(request: httpx.Request) -> httpx.Response: + # path="" -> /contents/ (root); accept either form + p = request.url.path + if p in (f"/repos/{REPO1}/contents/", f"/repos/{REPO1}/contents"): + return _dir_response( + [{"name": "skills", "type": "dir", "path": "skills", "size": 0}] + ) + return httpx.Response(404) + + client = SkillStoreClient(transport=httpx.MockTransport(handler)) + result = await client.get_contents(REPO1, "", "main") + assert result["type"] == "dir" + assert len(result["entries"]) == 1 + + def test_enabled_always_true(self) -> None: + assert SkillStoreClient().enabled is True + + def test_token_header(self) -> None: + client = SkillStoreClient(token="ghp_x") + assert client._headers()["Authorization"] == "Bearer ghp_x" + + def test_store_repo_parses_fields(self) -> None: + r = StoreRepo( + full_name="o/r", + description="d", + stargazers_count=5, + default_branch="main", + html_url="u", + extra="ignored", # type: ignore[call-arg] + ) + assert r.full_name == "o/r" + assert r.stargazers_count == 5 diff --git a/app/test/test_skills.py b/app/test/test_skills.py new file mode 100644 index 0000000..df055a3 --- /dev/null +++ b/app/test/test_skills.py @@ -0,0 +1,371 @@ +"""Tests for Skills - per-user MinIO-backed lazy load + MySQL index. + +Covers SkillManager (install/load/uninstall/cache, per uid), zip_parser, +and the LoadSkillTool. No local filesystem - skills live in MinIO (mocked +here) and their metadata in MySQL (in-memory SQLite). Each user manages +their own installed skills. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.models import Base +from app.skills.manager import SkillManager +from app.skills.zip_parser import ( + Skill, + build_skill_zip, + parse_skill_zip, + read_manifest, +) +from app.tools import ToolDeps +from app.tools.skill.load_skill import LoadSkillTool + +UID = 1 + + +# --------------------------------------------------------------------------- +# fixtures +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def session_factory(): + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + yield factory + await engine.dispose() + + +@pytest_asyncio.fixture +async def mock_minio(): + """In-memory MinIO stand-in: put/get/delete backed by a dict.""" + m = AsyncMock() + store: dict[str, bytes] = {} + + async def put(key, data, ct="application/octet-stream"): + store[key] = data + + async def get(key): + if key not in store: + raise FileNotFoundError(key) + return store[key] + + async def delete(key): + store.pop(key, None) + + m.put_object = AsyncMock(side_effect=put) + m.get_object = AsyncMock(side_effect=get) + m.delete_object = AsyncMock(side_effect=delete) + m._store = store + return m + + +def _make_zip(*, body: str = "# Skill\n步骤1\n", has_code_tools: bool = False, name: str = "demo") -> bytes: + return build_skill_zip( + skill_md=body, + manifest={ + "skill_id": name, + "name": name, + "description": f"{name} description", + "version": "1.0", + "has_code_tools": has_code_tools, + }, + ) + + +# --------------------------------------------------------------------------- +# zip_parser +# --------------------------------------------------------------------------- + + +class TestZipParser: + def test_parse_skill_zip_extracts_body_and_flags(self) -> None: + zip_bytes = build_skill_zip( + skill_md="# Title\n正文内容", + manifest={"has_code_tools": True, "resources": ["a.md", "b.md"]}, + ) + skill = parse_skill_zip(zip_bytes, skill_id="x", name="X", description="d") + assert isinstance(skill, Skill) + assert "正文内容" in skill.body + assert skill.has_code_tools is True + assert skill.resources == ["a.md", "b.md"] + + def test_parse_skill_zip_strips_frontmatter(self) -> None: + zip_bytes = build_skill_zip( + skill_md="---\nname: ignored\n---\n正文", + manifest={}, + ) + skill = parse_skill_zip(zip_bytes, skill_id="x", name="X") + assert skill.body == "正文" + + def test_read_manifest_returns_dict(self) -> None: + zip_bytes = _make_zip(name="abc") + m = read_manifest(zip_bytes) + assert m["name"] == "abc" + assert m["has_code_tools"] is False + + def test_read_manifest_empty_when_missing(self) -> None: + zip_bytes = build_skill_zip(skill_md="body", manifest={}) + assert read_manifest(zip_bytes) == {} + + +# --------------------------------------------------------------------------- +# SkillManager (per-user) +# --------------------------------------------------------------------------- + + +class TestSkillManager: + @pytest.mark.asyncio + async def test_install_then_load_roundtrip(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + zip_bytes = _make_zip(body="# 视频总结\n步骤1 检索\n") + + await mgr.install( + uid=UID, + skill_id="video-summary", + name="video-summary", + description="深度总结视频", + version="1.0", + source_store="upload", + zip_bytes=zip_bytes, + manifest={"has_code_tools": False}, + ) + + skill = await mgr.load_skill(UID, "video-summary") + assert skill is not None + assert skill.name == "video-summary" + assert "步骤1" in skill.body + assert f"skills/{UID}/video-summary.zip" in mock_minio._store + + @pytest.mark.asyncio + async def test_load_unknown_returns_none(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + assert await mgr.load_skill(UID, "nope") is None + + @pytest.mark.asyncio + async def test_skills_are_per_user(self, session_factory, mock_minio) -> None: + """User 1's installed skill is invisible to user 2.""" + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=1, skill_id="a", name="a", description="A", version=None, + source_store="upload", zip_bytes=_make_zip(name="a"), manifest={}, + ) + assert await mgr.load_skill(1, "a") is not None + assert await mgr.load_skill(2, "a") is None # different user + assert await mgr.index_text(2) == "" + + @pytest.mark.asyncio + async def test_index_text_lists_installed(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="a", name="a", description="A 技能", version=None, + source_store="upload", zip_bytes=_make_zip(name="a"), manifest={}, + ) + await mgr.install( + uid=UID, skill_id="b", name="b", description="B 技能", version=None, + source_store="upload", zip_bytes=_make_zip(name="b"), manifest={}, + ) + + idx = await mgr.index_text(UID) + assert "a" in idx and "b" in idx + assert "load_skill" in idx + + @pytest.mark.asyncio + async def test_index_text_empty_when_none(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + assert await mgr.index_text(UID) == "" + + @pytest.mark.asyncio + async def test_uninstall_removes_minio_and_row(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="x", name="x", description="", version=None, + source_store="upload", zip_bytes=_make_zip(), manifest={}, + ) + assert await mgr.uninstall(UID, "x") is True + assert f"skills/{UID}/x.zip" not in mock_minio._store + assert await mgr.load_skill(UID, "x") is None + + @pytest.mark.asyncio + async def test_uninstall_unknown_returns_false(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + assert await mgr.uninstall(UID, "missing") is False + + @pytest.mark.asyncio + async def test_load_uses_cache_after_first_fetch(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="x", name="x", description="", version=None, + source_store="upload", zip_bytes=_make_zip(), manifest={}, + ) + s1 = await mgr.load_skill(UID, "x") + s2 = await mgr.load_skill(UID, "x") + assert s1 is s2 + assert mock_minio.get_object.call_count == 1 # second load from cache + + @pytest.mark.asyncio + async def test_install_upsert_overwrites(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="x", name="old", description="old", version="1", + source_store="upload", zip_bytes=_make_zip(body="old body"), manifest={}, + ) + await mgr.install( + uid=UID, skill_id="x", name="new", description="new", version="2", + source_store="upload", zip_bytes=_make_zip(body="new body"), manifest={}, + ) + mgr.invalidate(UID, "x") + skill = await mgr.load_skill(UID, "x") + assert skill is not None + assert "new body" in skill.body + skills = await mgr.list_installed(UID) + assert len(skills) == 1 # upsert, not duplicate + + @pytest.mark.asyncio + async def test_has_code_tools_flag_propagates(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="code", name="code", description="", version=None, + source_store="upload", + zip_bytes=_make_zip(body="body", has_code_tools=True), + manifest={"has_code_tools": True}, + ) + skill = await mgr.load_skill(UID, "code") + assert skill is not None + assert skill.has_code_tools is True + + @pytest.mark.asyncio + async def test_no_session_factory_degrades_gracefully(self, mock_minio) -> None: + mgr = SkillManager(None, mock_minio) + assert await mgr.list_installed(UID) == [] + assert await mgr.index_text(UID) == "" + assert await mgr.load_skill(UID, "x") is None + + @pytest.mark.asyncio + async def test_preview_skill_returns_body_and_files(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + zip_bytes = build_skill_zip( + skill_md="# 视频总结\n步骤1 检索\n", + manifest={"skill_id": "video-summary", "name": "Video", "has_code_tools": False}, + ) + await mgr.install( + uid=UID, skill_id="video-summary", name="Video", description="d", + version="1", source_store="upload", zip_bytes=zip_bytes, + manifest={"has_code_tools": False}, + ) + result = await mgr.preview_skill(UID, "video-summary") + assert result is not None + assert "步骤1" in result["body"] + assert any(f["name"] == "SKILL.md" for f in result["files"]) + assert result["name"] == "Video" + assert result["has_code_tools"] is False + + @pytest.mark.asyncio + async def test_preview_skill_unknown_returns_none(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + assert await mgr.preview_skill(UID, "nope") is None + + @pytest.mark.asyncio + async def test_preview_skill_handles_zipball_prefix(self, session_factory, mock_minio) -> None: + """Preview a zipball-style zip (files under owner-repo-sha/ prefix).""" + import io as _io + import zipfile as _zip + import json as _json + buf = _io.BytesIO() + with _zip.ZipFile(buf, "w", _zip.ZIP_DEFLATED) as zf: + zf.writestr("owner-repo-abc/SKILL.md", "# Title\nbody") + zf.writestr("owner-repo-abc/manifest.json", _json.dumps({"has_code_tools": True})) + zip_bytes = buf.getvalue() + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="prefixed", name="P", description="", + version=None, source_store="upload", zip_bytes=zip_bytes, + manifest={"has_code_tools": True}, + ) + result = await mgr.preview_skill(UID, "prefixed") + assert result is not None + assert "body" in result["body"] + assert result["has_code_tools"] is True + + +# --------------------------------------------------------------------------- +# LoadSkillTool +# --------------------------------------------------------------------------- + + +class TestLoadSkillTool: + @pytest.mark.asyncio + async def test_load_known_skill_returns_body(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="video-summary", name="video-summary", description="d", + version="1", source_store="upload", + zip_bytes=_make_zip(body="# 视频总结\n步骤1\n"), manifest={}, + ) + tool = LoadSkillTool(mgr) + result = await tool.run(name="video-summary", _uid=UID) + assert "步骤1" in result + assert "视频总结" in result + + @pytest.mark.asyncio + async def test_load_without_uid_returns_error(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + tool = LoadSkillTool(mgr) + result = await tool.run(name="x") # no _uid + assert "_uid" in result or "用户上下文" in result + + @pytest.mark.asyncio + async def test_load_unknown_lists_available(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="video-summary", name="video-summary", description="d", + version="1", source_store="upload", zip_bytes=_make_zip(), manifest={}, + ) + tool = LoadSkillTool(mgr) + result = await tool.run(name="nope", _uid=UID) + assert "nope" in result or "未知" in result + assert "video-summary" in result + + @pytest.mark.asyncio + async def test_code_tools_warning(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + await mgr.install( + uid=UID, skill_id="code", name="code", description="", version=None, + source_store="upload", + zip_bytes=_make_zip(body="body", has_code_tools=True), + manifest={"has_code_tools": True}, + ) + tool = LoadSkillTool(mgr) + result = await tool.run(name="code", _uid=UID) + assert "代码工具" in result + assert "沙箱" in result + + def test_from_deps_returns_none_when_no_skill_manager(self) -> None: + assert LoadSkillTool.from_deps(ToolDeps()) is None + + def test_from_deps_returns_tool_when_skill_manager_present( + self, session_factory, mock_minio + ) -> None: + mgr = SkillManager(session_factory, mock_minio) + assert LoadSkillTool.from_deps(ToolDeps(skill_manager=mgr)) is not None + + def test_name_and_parameters(self, session_factory, mock_minio) -> None: + mgr = SkillManager(session_factory, mock_minio) + tool = LoadSkillTool(mgr) + assert tool.name == "load_skill" + params = tool.parameters() + assert params["type"] == "object" + assert "name" in params["properties"] + assert "name" in params["required"] diff --git a/app/tools/_deps.py b/app/tools/_deps.py index 02cac69..3d620b8 100644 --- a/app/tools/_deps.py +++ b/app/tools/_deps.py @@ -29,3 +29,4 @@ class ToolDeps: llm: Any = None db_deps: Any = None lifecycle: Any = None + skill_manager: Any = None diff --git a/app/tools/skill/__init__.py b/app/tools/skill/__init__.py new file mode 100644 index 0000000..f71f7d7 --- /dev/null +++ b/app/tools/skill/__init__.py @@ -0,0 +1,5 @@ +"""Skill tools - tools that serve the Skills system (e.g. load_skill).""" + +from .load_skill import LoadSkillTool + +__all__ = ["LoadSkillTool"] diff --git a/app/tools/skill/load_skill.py b/app/tools/skill/load_skill.py new file mode 100644 index 0000000..1cd0692 --- /dev/null +++ b/app/tools/skill/load_skill.py @@ -0,0 +1,74 @@ +"""load_skill tool - lets an agent pull a Skill's full instructions on demand. + +The agent sees a skill *index* in its system prompt (built by +``SkillManager.index_text()``). When a user task matches a skill, the agent +calls ``load_skill(name)`` to retrieve the full SKILL.md body, then follows +those instructions (and uses the skill's dedicated tools if any). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.skills.manager import SkillManager +from app.tools import ToolDeps, register_tool + +logger = logging.getLogger(__name__) + + +@register_tool +class LoadSkillTool: + """Load a named skill's detailed instructions into the conversation.""" + + def __init__(self, skill_manager: SkillManager) -> None: + self._skill_manager = skill_manager + + @classmethod + def from_deps(cls, deps: ToolDeps) -> "LoadSkillTool | None": + if deps.skill_manager is None: + return None + return cls(deps.skill_manager) + + @property + def name(self) -> str: + return "load_skill" + + @property + def description(self) -> str: + return ( + "加载指定技能的详细指令。可用技能见 system prompt 的技能索引。" + "当用户任务匹配某技能时调用,获取该技能的执行步骤与指引。" + ) + + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "技能名称(见技能索引)", + } + }, + "required": ["name"], + } + + async def run(self, name: str, **kwargs: Any) -> str: + uid = kwargs.get("_uid") + if uid is None: + return "无法加载技能:缺少用户上下文(_uid)。" + skill = await self._skill_manager.load_skill(uid, name) + if skill is None: + installed = await self._skill_manager.list_installed(uid) + available = ( + ", ".join(s.skill_id for s in installed) or "无" + ) + return f"未知技能 '{name}'。可用技能: {available}" + logger.info("[LOAD_SKILL] uid=%s loaded skill='%s'", uid, name) + msg = f"# 技能: {skill.name}\n\n{skill.body}" + if skill.has_code_tools: + msg += ( + "\n\n⚠️ 该技能含代码工具,需沙箱支持,当前暂不可执行。" + "请仅按上述指令使用已有工具完成任务。" + ) + return msg From 05aa6711e8a55082d795d900e2fc6cbbed3afc2f Mon Sep 17 00:00:00 2001 From: zjr Date: Fri, 24 Jul 2026 19:59:21 +0800 Subject: [PATCH 2/4] =?UTF-8?q?[frontend]=20feat:=20=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=95=86=E5=BA=97=E9=9D=A2=E6=9D=BF=EF=BC=88Google=20=C3=97=20?= =?UTF-8?q?Apple=20=E9=A3=8E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技能工坊 dock 面板:已安装 + 商店 tab - 商店:GitHub repo 搜索 + 卡片列表 + 目录浏览(RepoBrowser) + 一键安装 - 已安装:skill 卡片 + 预览(SKILL.md/manifest/文件列表) + 卸载 - Google × Apple 设计:Geist display + Roboto body + Google 蓝 accent(#4285F4) + 大圆角卡片(16px) + elevation 阴影 + spring 动效;实色 surface 无玻璃拟态 - 两层缓存(前端切 tab 不重载 + 后端 TTL) - lib/api.ts: skillsApi(listInstalled/uploadInstall/uninstall/storeList/storeInstall/storeContents/preview) --- frontend/components/dock-modules/index.ts | 10 +- frontend/components/dock-modules/skills.tsx | 1086 +++++++++++++++++++ frontend/lib/api.ts | 101 ++ 3 files changed, 1196 insertions(+), 1 deletion(-) create mode 100644 frontend/components/dock-modules/skills.tsx diff --git a/frontend/components/dock-modules/index.ts b/frontend/components/dock-modules/index.ts index 2862d3a..ed6a1b3 100644 --- a/frontend/components/dock-modules/index.ts +++ b/frontend/components/dock-modules/index.ts @@ -1,4 +1,4 @@ -import { BarChart3, BookOpen, Cloud, FolderHeart, MessageCircle, MessageSquareText, NotebookPen, Settings, User, Activity } from "lucide-react"; +import { BarChart3, BookOpen, Cloud, FolderHeart, MessageCircle, MessageSquareText, NotebookPen, Settings, Sparkles, User, Activity } from "lucide-react"; import { DockModule } from "@/lib/dock-registry"; import ChatDockPanel from "@/components/chat/ChatDockPanel"; import FavoritesPanel from "./favorites"; @@ -10,6 +10,7 @@ import BillingPanel from "./billing"; import QuizPanel from "./quiz"; import CloudDrivePanel from "./cloud-drive"; import NotesPanel from "./notes"; +import SkillsPanel from "./skills"; export const dockModules: DockModule[] = [ { @@ -82,4 +83,11 @@ export const dockModules: DockModule[] = [ panel: BillingPanel, defaultSize: { width: 1156, height: 672 }, }, + { + id: "skills", + icon: Sparkles, + title: "技能商店", + panel: SkillsPanel, + defaultSize: { width: 720, height: 640 }, + }, ]; diff --git a/frontend/components/dock-modules/skills.tsx b/frontend/components/dock-modules/skills.tsx new file mode 100644 index 0000000..387b66a --- /dev/null +++ b/frontend/components/dock-modules/skills.tsx @@ -0,0 +1,1086 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { skillsApi, type InstalledSkill, type RepoContents, type SkillPreview, type StoreRepo } from "@/lib/api"; +import type { DockPanelProps } from "@/lib/dock-registry"; + +/* ────────── Google × Apple design tokens ────────── + * Google: Material color system (gemini-primary blue), elevation shadows, + * Roboto body. Apple: large radii, generous whitespace, refined + * spring motion, Geist (SF Pro / Google Sans geometry) display. + * Real surfaces only - no glassmorphism. */ +const DISPLAY = "var(--font-sans), var(--font-body), -apple-system, BlinkMacSystemFont, sans-serif"; +const SANS = "var(--font-roboto), var(--font-body), -apple-system, sans-serif"; +const MONO = "ui-monospace, 'SF Mono', var(--font-roboto), monospace"; +const ACCENT = "var(--gemini-primary, #4285F4)"; +const ACCENT_HOVER = "var(--gemini-primary-hover, #3367D6)"; +const ACCENT_SOFT = "rgba(66,133,244,0.10)"; +const R_CARD = 16; +const R_BTN = 12; +const SHADOW_1 = "0 1px 2px rgba(0,0,0,0.10), 0 1px 3px rgba(0,0,0,0.12)"; +const SHADOW_2 = "0 8px 24px rgba(0,0,0,0.24)"; +const SPRING = { type: "spring", stiffness: 380, damping: 30 } as const; + +type Tab = "installed" | "store"; + +export default function SkillsPanel({ isOpen }: DockPanelProps) { + const [tab, setTab] = useState("installed"); + const [installed, setInstalled] = useState([]); + const [store, setStore] = useState([]); + const [storeLoaded, setStoreLoaded] = useState(false); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const fileRef = useRef(null); + + // store browser state + const [browsingRepo, setBrowsingRepo] = useState(null); + const [browsingBranch, setBrowsingBranch] = useState("main"); + const [browsePath, setBrowsePath] = useState(""); + const [browseContents, setBrowseContents] = useState(null); + const [browseLoading, setBrowseLoading] = useState(false); + const [browseError, setBrowseError] = useState(null); + + // installed preview state + const [previewingSkill, setPreviewingSkill] = useState(null); + const [previewData, setPreviewData] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + + const loadInstalled = useCallback(async () => { + setLoading(true); + setError(null); + try { + setInstalled(await skillsApi.listInstalled()); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "加载失败"); + } finally { + setLoading(false); + } + }, []); + + const loadStore = useCallback(async () => { + setLoading(true); + setError(null); + try { + setStore(await skillsApi.storeList(query || undefined)); + setStoreLoaded(true); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "商店访问失败"); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { + if (isOpen && tab === "installed") loadInstalled(); + }, [isOpen, tab, loadInstalled]); + useEffect(() => { + if (isOpen && tab === "store" && !storeLoaded) loadStore(); + }, [isOpen, tab, storeLoaded, loadStore]); + + const handleUninstall = async (skillId: string) => { + setBusy(skillId); + setError(null); + try { + await skillsApi.uninstall(skillId); + await loadInstalled(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "卸载失败"); + } finally { + setBusy(null); + } + }; + + const handleStoreInstall = async (repo: string, branch: string) => { + setBusy(repo); + setError(null); + try { + await skillsApi.storeInstall(repo, branch); + await loadInstalled(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "安装失败"); + } finally { + setBusy(null); + } + }; + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setBusy("__upload__"); + setError(null); + try { + await skillsApi.uploadInstall(file); + await loadInstalled(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "上传安装失败"); + } finally { + setBusy(null); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const loadContents = useCallback( + async (repo: string, path: string, branch: string) => { + setBrowseLoading(true); + setBrowseError(null); + try { + setBrowseContents(await skillsApi.storeContents(repo, path, branch)); + } catch (e: unknown) { + setBrowseError(e instanceof Error ? e.message : "加载失败"); + setBrowseContents(null); + } finally { + setBrowseLoading(false); + } + }, + [], + ); + + const openRepo = (repo: string, branch: string) => { + setBrowsingRepo(repo); + setBrowsingBranch(branch); + setBrowsePath(""); + loadContents(repo, "", branch); + }; + + const enterDir = (path: string) => { + if (!browsingRepo) return; + setBrowsePath(path); + loadContents(browsingRepo, path, browsingBranch); + }; + + const backToRepos = () => { + setBrowsingRepo(null); + setBrowseContents(null); + setBrowsePath(""); + setBrowseError(null); + }; + + const loadPreview = useCallback(async (skillId: string) => { + setPreviewLoading(true); + setPreviewError(null); + try { + setPreviewData(await skillsApi.preview(skillId)); + } catch (e: unknown) { + setPreviewError(e instanceof Error ? e.message : "预览失败"); + setPreviewData(null); + } finally { + setPreviewLoading(false); + } + }, []); + + const openPreview = (skillId: string) => { + setPreviewingSkill(skillId); + loadPreview(skillId); + }; + + const closePreview = () => { + setPreviewingSkill(null); + setPreviewData(null); + setPreviewError(null); + }; + + if (!isOpen) return null; + + return ( +
+ {/* ────────── masthead ────────── */} +
+
+
+

+ 技能商店 + + Store + +

+
+ fileRef.current?.click()} + busy={busy === "__upload__"} + small + > + {busy === "__upload__" ? "安装中…" : "上传 zip"} + + +
+ + +
+ + + {error && ( + + {error} + + )} + + + {/* ────────── list ────────── */} +
+ {loading ? ( + + ) : tab === "installed" ? ( + previewingSkill ? ( + + ) : installed.length === 0 ? ( + + ) : ( +
+ {installed.map((s, i) => ( + handleUninstall(s.skill_id)} + onPreview={() => openPreview(s.skill_id)} + /> + ))} +
+ ) + ) : browsingRepo ? ( + handleStoreInstall(browsingRepo, browsingBranch)} + installBusy={busy === browsingRepo} + /> + ) : store.length === 0 ? ( + + ) : ( +
+ {store.map((r, i) => ( + handleStoreInstall(r.full_name, r.default_branch)} + onView={() => openRepo(r.full_name, r.default_branch)} + /> + ))} +
+ )} +
+ + {/* ────────── footer ────────── */} +
+ {tab === "installed" ? `${installed.length} installed` : `${store.length} in store`} + MinIO · per-user +
+
+ ); +} + +/* ────────── masthead pieces ────────── */ + +function TabLink({ active, onClick, label, count }: { active: boolean; onClick: () => void; label: string; count?: number }) { + return ( + + ); +} + +function SearchInput({ value, onChange, onSubmit }: { value: string; onChange: (v: string) => void; onSubmit: () => void }) { + return ( + onChange(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSubmit()} + style={{ + width: 240, + padding: "8px 14px", + background: "var(--bg-input, rgba(255,255,255,0.04))", + border: "1px solid var(--border)", + borderRadius: R_BTN, + color: "var(--foreground)", + fontFamily: SANS, + fontSize: 13, + outline: "none", + transition: "border-color 0.2s, box-shadow 0.2s", + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = ACCENT; + e.currentTarget.style.boxShadow = `0 0 0 3px ${ACCENT_SOFT}`; + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = "var(--border)"; + e.currentTarget.style.boxShadow = "none"; + }} + /> + ); +} + +/* ────────── buttons ────────── */ + +function PrimaryButton({ + children, + disabled, + onClick, + busy, + small, +}: { + children: React.ReactNode; + disabled?: boolean; + onClick: () => void; + busy?: boolean; + small?: boolean; +}) { + return ( + !disabled && (e.currentTarget.style.background = ACCENT_HOVER)} + onMouseLeave={(e) => !disabled && (e.currentTarget.style.background = ACCENT)} + > + {busy ? "…" : children} + + ); +} + +function SecondaryButton({ + children, + disabled, + onClick, + small, + title, +}: { + children: React.ReactNode; + disabled?: boolean; + onClick: () => void; + small?: boolean; + title?: string; +}) { + return ( + !disabled && (e.currentTarget.style.borderColor = ACCENT)} + onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border)")} + > + {children} + + ); +} + +/* ────────── cards ────────── */ + +function CardShell({ index, children }: { index: number; children: React.ReactNode }) { + return ( + { + e.currentTarget.style.boxShadow = SHADOW_2; + e.currentTarget.style.borderColor = "var(--border-hover, var(--border))"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.boxShadow = SHADOW_1; + e.currentTarget.style.borderColor = "var(--border)"; + }} + > + {children} + + ); +} + +function SkillCard({ + index, + title, + skillId, + description, + version, + hasCodeTools, + actionLabel, + actionBusy, + onAction, + onPreview, +}: { + index: number; + title: string; + skillId: string; + description: string | null; + version: string | null; + hasCodeTools: boolean; + actionLabel: string; + actionBusy: boolean; + onAction: () => void; + onPreview?: () => void; +}) { + return ( + +
+
+
+

+ {title} +

+ {version && ( + v{version} + )} + {hasCodeTools && } +
+
+ #{skillId} +
+ {description && ( +

+ {description} +

+ )} +
+
+ {onPreview && ( + + 预览 + + )} + + {actionBusy ? "…" : actionLabel} + +
+
+
+ ); +} + +function RepoCard({ + index, + fullName, + description, + stars, + branch, + htmlUrl, + actionLabel, + actionBusy, + onAction, + onView, +}: { + index: number; + fullName: string; + description: string; + stars: number; + branch: string; + htmlUrl: string; + actionLabel: string; + actionBusy: boolean; + onAction: () => void; + onView: () => void; +}) { + return ( + +
+
+
+

+ {fullName} +

+
+
+ ★ {stars} + · + {branch} + {htmlUrl && ( + + ↗ + + )} +
+ {description && ( +

+ {description} +

+ )} +
+
+ + 查看 + + + {actionLabel} + +
+
+
+ ); +} + +function CodeToolsBadge() { + return ( + + + 代码·待沙箱 + + ); +} + +/* ────────── repo browser ────────── */ + +function RepoBrowser({ + repo, + branch, + path, + contents, + loading, + error, + onEnterDir, + onBack, + onInstall, + installBusy, +}: { + repo: string; + branch: string; + path: string; + contents: RepoContents | null; + loading: boolean; + error: string | null; + onEnterDir: (path: string) => void; + onBack: () => void; + onInstall: () => void; + installBusy: boolean; +}) { + const segments = path ? path.split("/").filter(Boolean) : []; + return ( +
+
+ + ← 返回 + +
+ onEnterDir("")} /> + {segments.map((seg, i) => { + const segPath = segments.slice(0, i + 1).join("/"); + return ( + + / + onEnterDir(segPath)} + active={i === segments.length - 1} + /> + + ); + })} +
+
+ + {installBusy ? "安装中…" : "安装此仓库"} + +
+
+ + {loading ? ( + + ) : error ? ( +
{error}
+ ) : contents?.type === "dir" ? ( +
+ {contents.entries?.length === 0 ? ( + + ) : ( + contents.entries?.map((e) => ( + + )) + )} +
+ ) : contents?.type === "file" ? ( +
+
+ {contents.path} +
+
+                        {contents.content}
+                    
+
+ ) : null} +
+ ); +} + +function BreadcrumbLink({ label, onClick, active }: { label: string; onClick: () => void; active?: boolean }) { + return ( + + ); +} + +/* ────────── skill preview ────────── */ + +function SkillPreviewView({ + skillId, + data, + loading, + error, + onBack, +}: { + skillId: string; + data: SkillPreview | null; + loading: boolean; + error: string | null; + onBack: () => void; +}) { + return ( +
+
+ + ← 返回 + +
+ {data?.name || skillId} +
+ {data?.has_code_tools && } +
+ {loading ? ( + + ) : error ? ( +
{error}
+ ) : data ? ( +
+ {data.description && ( +

+ {data.description} +

+ )} + SKILL.md +
+                        {data.body || "(无 SKILL.md)"}
+                    
+ 文件列表({data.files.length}) +
+ {data.files.map((f) => ( +
+ {f.path} + {f.size}b +
+ ))} +
+
+ ) : null} +
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/* ────────── states ────────── */ + +function LoadingState() { + return ( +
+ + 加载中… + +
+ ); +} + +function EmptyState({ quote, hint }: { quote: string; hint: string }) { + return ( + +
+ {quote} +
+ {hint && ( +
{hint}
+ )} +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index ae7a003..ce995c2 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -2135,3 +2135,104 @@ export const notesApi = { getShared: (token: string) => requestCamel(`/notes/shared/${token}`), }; + +// ==================== Skills (per-user, client-side store) ==================== + +export interface InstalledSkill { + skill_id: string; + name: string; + description: string | null; + version: string | null; + source_store: string | null; + has_code_tools: boolean; + enabled: boolean; +} + +export interface StoreRepo { + full_name: string; + description: string; + stargazers_count: number; + default_branch: string; + html_url: string; +} + +export interface RepoEntry { + name: string; + type: string; // "dir" | "file" + path: string; + size: number; +} + +export interface RepoContents { + type: "dir" | "file"; + entries?: RepoEntry[]; + name?: string; + path?: string; + content?: string; +} + +export interface SkillPreviewFile { + name: string; + path: string; + size: number; +} + +export interface SkillPreview { + skill_id: string; + name: string; + description: string | null; + version: string | null; + has_code_tools: boolean; + body: string; + manifest: Record; + files: SkillPreviewFile[]; +} + +export const skillsApi = { + listInstalled: () => + request("/skills/installed", { + headers: getAuthHeaders(), + }), + uploadInstall: async (file: File): Promise => { + const form = new FormData(); + form.append("file", file); + const resp = await fetch(`${API_BASE_URL}/skills/install`, { + method: "POST", + headers: getAuthHeaders(), + body: form, + }); + if (!resp.ok) { + let detail = ""; + try { + detail = (await resp.json()).detail ?? ""; + } catch {} + throw new Error(sanitizeError({ status: resp.status, detail })); + } + return resp.json(); + }, + uninstall: (skillId: string) => + request<{ deleted: string }>(`/skills/${encodeURIComponent(skillId)}`, { + method: "DELETE", + headers: getAuthHeaders(), + }), + storeList: (q?: string) => + request( + `/skills/store/list${q ? `?q=${encodeURIComponent(q)}` : ""}`, + { headers: getAuthHeaders() }, + ), + storeInstall: (repo: string, branch: string = "main") => + request("/skills/store/install", { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ repo, branch }), + }), + storeContents: (repo: string, path: string = "", branch: string = "main") => + request( + `/skills/store/contents?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}&branch=${encodeURIComponent(branch)}`, + { headers: getAuthHeaders() }, + ), + preview: (skillId: string) => + request(`/skills/${encodeURIComponent(skillId)}/preview`, { + headers: getAuthHeaders(), + }), +}; From 9355596f26118b22a35725bf28635fb0b27621cb Mon Sep 17 00:00:00 2001 From: zjr Date: Fri, 24 Jul 2026 20:09:36 +0800 Subject: [PATCH 3/4] =?UTF-8?q?[chore]=20fix:=20ruff/eslint=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend: 移除 RepoBrowser 未用 branch prop(eslint no-unused-vars warning) - backend: security.py / eval_ragas.py 既有 E402 加 noqa(非本次引入,ruff app/ 清零) --- app/services/auth/security.py | 2 +- app/test/rag/eval_ragas.py | 2 +- frontend/components/dock-modules/skills.tsx | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/services/auth/security.py b/app/services/auth/security.py index 1be8bb7..953f162 100644 --- a/app/services/auth/security.py +++ b/app/services/auth/security.py @@ -111,7 +111,7 @@ def decrypt(ciphertext_b64: str) -> str: # ── Password hashing ────────────────────────────────────────────── -import bcrypt +import bcrypt # noqa: E402 (grouped under password hashing) def hash_password(password: str) -> str: diff --git a/app/test/rag/eval_ragas.py b/app/test/rag/eval_ragas.py index c6a0d58..1d68684 100644 --- a/app/test/rag/eval_ragas.py +++ b/app/test/rag/eval_ragas.py @@ -35,7 +35,7 @@ logger.remove() logger.add(sys.stderr, level="WARNING") -from app.config import settings +from app.config import settings # noqa: E402 (after logger config) COLLECTION = "uid_eval" JUDGE_MODEL = "qwen-plus" # 同源不同 model, 减偏倚 (生成用 settings.llm_model = qwen3-max) diff --git a/frontend/components/dock-modules/skills.tsx b/frontend/components/dock-modules/skills.tsx index 387b66a..bae282f 100644 --- a/frontend/components/dock-modules/skills.tsx +++ b/frontend/components/dock-modules/skills.tsx @@ -321,7 +321,6 @@ export default function SkillsPanel({ isOpen }: DockPanelProps) { ) : browsingRepo ? ( Date: Fri, 24 Jul 2026 20:15:42 +0800 Subject: [PATCH 4/4] =?UTF-8?q?[ci]=20fix:=20=E5=8A=A0=20ruff.toml=20?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=20lint=20=E8=A7=84=E5=88=99=E9=9B=86?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20CI=20ruff=20action=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 pip install ruff 装最新版,默认规则集变严(含 BLE001/I001/UP045/SIM), 既有代码 ~1800 违规导致 ruff action 失败。加 ruff.toml 固定 select E4/E7/E9/F + ignore E501/E402,与项目历史 lint 表面一致,跨 ruff 版本稳定。 验证:ruff check app/ / ruff check . / CI 命令 均通过。 broader rules (B/I/UP/SIM) 后续逐步启用。 --- ruff.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 ruff.toml diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..1397c6f --- /dev/null +++ b/ruff.toml @@ -0,0 +1,18 @@ +# Ruff configuration - pin the lint rule set so CI is stable across ruff +# versions. Only pycodestyle errors (E4/E7/E9) + pyflakes (F) are enabled; +# broader rules (B/I/UP/SIM) are deliberately OFF because the existing +# codebase has many violations under them - enable incrementally later. +# +# CI runs: ruff check app/ --ignore E501,E402 (this config makes the +# --ignore redundant but harmless, and pins select so a newer ruff with a +# wider default rule set does not suddenly fail CI). + +target-version = "py310" + +[lint] +select = ["E4", "E7", "E9", "F"] +ignore = ["E501", "E402"] + +[lint.per-file-ignores] +"app/test/**" = ["F401", "E402"] +"app/test/eval/**" = ["F401", "E402", "F811"]