ForcePilot/server/routers/skill_router.py
肖泽涛 46b676db07 feat(skills): add skills management module
实现 Skills 管理功能,支持超级管理员导入、编辑、导出和删除技能包。

- 新增 Skill 数据模型(元数据存数据库,内容存文件系统)
- 新增 /api/system/skills/* 路由(权限分级:admin 可列表,superadmin 可管理)
- 新增 SelectedSkillsReadonlyBackend,运行时只读挂载到 /skills
- Agent 配置新增 skills 字段,运行时注入技能提示词
- 前端新增 Skills 管理页面(设置弹窗 + 独立路由)
- 新增测试覆盖:service/router/backend 单测

未实现功能:
 - 关联使用其他技能
 - 沙箱执行代码
 - 在技能中绑定工具
2026-02-21 02:48:20 +08:00

240 lines
7.7 KiB
Python

"""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_tree,
import_skill_zip,
list_skills,
read_skill_file,
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="文件内容")
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)
@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.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.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(lambda p: Path(p).unlink(missing_ok=True), 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="删除技能失败")