"""Skills 管理路由""" from __future__ import annotations from pathlib import Path from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user from src.services.skill_service import ( create_skill_node, delete_skill, delete_skill_node, export_skill_zip, get_skill_dependency_options, get_skill_tree, import_skill_zip, list_skills, read_skill_file, update_skill_dependencies, update_skill_file, ) from src.storage.postgres.models_business import User from src.utils.logging_config import logger skills = APIRouter(prefix="/system/skills", tags=["skills"]) class SkillNodeCreateRequest(BaseModel): path: str = Field(..., description="相对 skill 根目录的路径") is_dir: bool = Field(False, description="是否创建目录") content: str | None = Field("", description="文件内容(仅文件创建时生效)") class SkillFileUpdateRequest(BaseModel): path: str = Field(..., description="相对 skill 根目录的路径") content: str = Field(..., description="文件内容") class SkillDependenciesUpdateRequest(BaseModel): tool_dependencies: list[str] = Field(default_factory=list, description="依赖的内置工具列表") mcp_dependencies: list[str] = Field(default_factory=list, description="依赖的 MCP 服务列表") skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表") def _raise_from_value_error(e: ValueError) -> None: message = str(e) status_code = 404 if "不存在" in message else 400 raise HTTPException(status_code=status_code, detail=message) def _cleanup_export_file(path: str) -> None: try: Path(path).unlink(missing_ok=True) except Exception as e: logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}") @skills.get("") async def list_skills_route( _current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): """获取技能列表(管理员可读)。""" try: items = await list_skills(db) return {"success": True, "data": [item.to_dict() for item in items]} except Exception as e: logger.error(f"Failed to list skills: {e}") raise HTTPException(status_code=500, detail="获取技能列表失败") @skills.get("/dependency-options") async def get_skill_dependency_options_route( _current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """获取 skill 依赖项可选列表(仅超级管理员)。""" try: return {"success": True, "data": await get_skill_dependency_options(db)} except Exception as e: logger.error(f"Failed to get skill dependency options: {e}") raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败") @skills.post("/import") async def import_skill_route( file: UploadFile = File(...), current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """导入技能压缩包(仅超级管理员)。""" 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, ) 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 zip: {e}") raise HTTPException(status_code=500, detail="导入技能失败") @skills.get("/{slug}/tree") async def get_skill_tree_route( slug: str, _current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """获取技能目录树(仅超级管理员)。""" try: tree = await get_skill_tree(db, slug) return {"success": True, "data": tree} 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="获取技能目录树失败") @skills.get("/{slug}/file") async def get_skill_file_route( slug: str, path: str = Query(..., description="相对 skill 根目录路径"), _current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """读取技能文本文件(仅超级管理员)。""" try: data = await read_skill_file(db, slug, path) 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 read skill file '{slug}/{path}': {e}") raise HTTPException(status_code=500, detail="读取技能文件失败") @skills.post("/{slug}/file") async def create_skill_file_route( slug: str, payload: SkillNodeCreateRequest, current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """创建技能文件或目录(仅超级管理员)。""" try: await create_skill_node( db, slug=slug, relative_path=payload.path, is_dir=payload.is_dir, content=payload.content, updated_by=current_user.username, ) 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="创建技能文件失败") @skills.put("/{slug}/file") async def update_skill_file_route( slug: str, payload: SkillFileUpdateRequest, current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """更新技能文本文件(仅超级管理员)。""" try: await update_skill_file( db, slug=slug, relative_path=payload.path, content=payload.content, updated_by=current_user.username, ) 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="更新技能文件失败") @skills.put("/{slug}/dependencies") async def update_skill_dependencies_route( slug: str, payload: SkillDependenciesUpdateRequest, current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """更新 skill 依赖(仅超级管理员)。""" try: item = await update_skill_dependencies( db, slug=slug, tool_dependencies=payload.tool_dependencies, mcp_dependencies=payload.mcp_dependencies, skill_dependencies=payload.skill_dependencies, updated_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 update skill dependencies '{slug}': {e}") raise HTTPException(status_code=500, detail="更新 skill 依赖失败") @skills.delete("/{slug}/file") async def delete_skill_file_route( slug: str, path: str = Query(..., description="相对 skill 根目录路径"), _current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """删除技能文件或目录(仅超级管理员)。""" try: 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="删除技能文件失败") @skills.get("/{slug}/export") async def export_skill_route( slug: str, background_tasks: BackgroundTasks, _current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """导出技能压缩包(仅超级管理员)。""" try: 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, ) 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="导出技能失败") @skills.delete("/{slug}") async def delete_skill_route( slug: str, _current_user: User = Depends(get_superadmin_user), db: AsyncSession = Depends(get_db), ): """删除技能(目录 + 数据库记录,仅超级管理员)。""" try: 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="删除技能失败")