ForcePilot/backend/server/routers/skill_router.py
Wenjie Zhang a005e0f3dc feat: 内置 Skills 按需安装机制
- Skill 模型新增 version, is_builtin, content_hash 字段
- 内置 skills 不再自动安装,改为按需安装
- 新增 GET/POST /system/skills/builtin 路由
- 安装时计算目录内容 hash,用于变更检测
- 更新时 hash 不一致返回 409 确认
- 前端显示内置 skills 未安装状态,支持安装/更新按钮
- 内置 skill 文件编辑入口已隐藏
- 修复 _compute_dir_hash 内存问题,改为 chunk 读取
- 19 个测试用例全部通过
2026-03-28 17:53:53 +08:00

385 lines
13 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 yuxi.services.skill_service import (
BuiltinSkillUpdateConflictError,
create_skill_node,
delete_skill,
delete_skill_node,
export_skill_zip,
get_skill_dependency_options,
get_skill_tree,
import_skill_zip,
install_builtin_skill,
list_builtin_skill_specs,
list_skills,
read_skill_file,
update_builtin_skill,
update_skill_dependencies,
update_skill_file,
)
from yuxi.storage.postgres.models_business import User
from yuxi.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 列表")
class BuiltinSkillUpdateRequest(BaseModel):
force: bool = Field(False, description="是否强制覆盖本地已安装内容")
def _raise_from_value_error(e: ValueError) -> None:
message = str(e)
status_code = 404 if "不存在" in message else 400
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.get("/builtin")
async def list_builtin_skills_route(
_current_user: User = Depends(get_superadmin_user),
db: AsyncSession = Depends(get_db),
):
try:
installed_map = {item.slug: item for item in await list_skills(db)}
data = []
for spec in list_builtin_skill_specs():
installed = installed_map.get(spec["slug"])
status = "not_installed"
if installed:
status = "installed"
if installed.version != spec["version"] or installed.content_hash != spec["content_hash"]:
status = "update_available"
data.append(
{
"slug": spec["slug"],
"name": spec["name"],
"description": spec["description"],
"version": spec["version"],
"status": status,
"installed_record": installed.to_dict() if installed else None,
}
)
return {"success": True, "data": data}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to list builtin skills: {e}")
raise HTTPException(status_code=500, detail="获取内置 skill 列表失败")
@skills.post("/builtin/{slug}/install")
async def install_builtin_skill_route(
slug: str,
current_user: User = Depends(get_superadmin_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await install_builtin_skill(db, slug, installed_by=current_user.username)
return {"success": True, "data": item.to_dict()}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to install builtin skill '{slug}': {e}")
raise HTTPException(status_code=500, detail="安装内置 skill 失败")
@skills.post("/builtin/{slug}/update")
async def update_builtin_skill_route(
slug: str,
payload: BuiltinSkillUpdateRequest,
current_user: User = Depends(get_superadmin_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await update_builtin_skill(
db,
slug,
force=payload.force,
updated_by=current_user.username,
)
return {"success": True, "data": item.to_dict()}
except BuiltinSkillUpdateConflictError as e:
raise HTTPException(
status_code=409,
detail={"needs_confirm": True, "message": str(e)},
)
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update builtin skill '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新内置 skill 失败")
@skills.post("/import")
async def import_skill_route(
file: UploadFile = File(...),
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="删除技能失败")