Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

508 lines
18 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_required_user
from yuxi.agents.skills.service import (
confirm_skill_install_draft,
create_skill_node,
delete_skill,
delete_skill_node,
delete_skills_batch,
discard_skill_install_draft,
export_skill_zip,
get_allowed_skill_access_levels,
get_manageable_skill_or_raise,
get_management_readable_skill_or_raise,
get_skill_dependency_options,
get_skill_tree,
init_builtin_skills,
is_builtin_skill,
list_accessible_skills,
list_skills,
list_visible_skills_for_management,
prepare_remote_skill_install,
prepare_skill_upload,
read_skill_file,
update_skill_dependencies,
update_skill_enabled,
update_skill_file,
update_skill_share_config,
user_can_manage_skill,
)
from yuxi.agents.skills.remote_install import list_remote_skills, search_remote_skills
from yuxi.storage.postgres.models_business import User
from yuxi.utils.logging_config import logger
skills = APIRouter(prefix="/system/skills", tags=["skills"])
user_skills = APIRouter(prefix="/skills", tags=["skills"])
class ShareConfigPayload(BaseModel):
share_config: dict | None = Field(None, description="共享权限配置")
class SkillEnabledUpdateRequest(BaseModel):
enabled: bool = Field(..., description="是否启用")
class SkillNodeCreateRequest(BaseModel):
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 RemoteSkillSourceRequest(BaseModel):
source: str = Field(..., description="skills 仓库来源,如 owner/repo 或 GitHub URL")
class RemoteSkillPrepareRequest(RemoteSkillSourceRequest):
skills: list[str] = Field(..., description="需要安装的 skill 名称列表")
class RemoteSkillSearchRequest(BaseModel):
query: str = Field(..., description="搜索关键字")
class SkillBatchDeleteRequest(BaseModel):
slugs: list[str] = Field(..., max_length=50, description="需要批量删除的 skill slug 列表,最多支持 50 个")
class SkillDraftConfirmRequest(BaseModel):
share_config: dict | None = Field(None, description="共享权限配置")
def _raise_from_value_error(e: ValueError) -> None:
message = str(e)
status_code = 404 if "不存在" in message or "无权" 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}")
def _summarize_results(results: list[dict]) -> dict[str, int]:
return {
"total": len(results),
"success": sum(1 for item in results if item.get("success")),
"failed": sum(1 for item in results if not item.get("success")),
}
def _serialize_skill_for_user(item, user: User) -> dict:
data = item.to_dict()
data["can_manage"] = user_can_manage_skill(user, item)
data["is_builtin"] = is_builtin_skill(item)
return data
@user_skills.get("/accessible")
async def list_accessible_skills_route(
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
items = await list_accessible_skills(db, current_user)
return {"success": True, "data": [_serialize_skill_for_user(item, current_user) for item in items]}
except Exception as e:
logger.error(f"Failed to list accessible skills: {e}")
raise HTTPException(status_code=500, detail="获取可访问 Skills 失败")
@user_skills.post("/import/prepare")
async def prepare_skill_upload_route(
file: UploadFile = File(...),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
data = await prepare_skill_upload(
db,
filename=file.filename or "",
file_bytes=await file.read(),
operator=current_user,
)
return {"success": True, "data": data}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to prepare skill upload: {e}")
raise HTTPException(status_code=500, detail="解析上传 Skill 失败")
@user_skills.post("/remote/list")
async def list_remote_skills_route(payload: RemoteSkillSourceRequest, _current_user: User = Depends(get_required_user)):
try:
return {"success": True, "data": await list_remote_skills(payload.source)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to list remote skills from '{payload.source}': {e}")
raise HTTPException(status_code=500, detail="获取远程 skills 列表失败")
@user_skills.post("/remote/search")
async def search_remote_skills_route(
payload: RemoteSkillSearchRequest, _current_user: User = Depends(get_required_user)
):
try:
return {"success": True, "data": await search_remote_skills(payload.query)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to search remote skills with query '{payload.query}': {e}")
raise HTTPException(status_code=500, detail="搜索远程 skills 失败")
@user_skills.post("/remote/prepare")
async def prepare_remote_skills_route(
payload: RemoteSkillPrepareRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
data = await prepare_remote_skill_install(
db,
source=payload.source,
skills=payload.skills,
operator=current_user,
)
return {"success": True, "data": data}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to prepare remote skills from '{payload.source}': {e}")
raise HTTPException(status_code=500, detail="解析远程 Skills 失败")
@user_skills.post("/install-drafts/{draft_id}/confirm")
async def confirm_skill_install_draft_route(
draft_id: str,
payload: SkillDraftConfirmRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
results = await confirm_skill_install_draft(
db,
draft_id=draft_id,
share_config=payload.share_config,
operator=current_user,
)
return {"success": True, "data": results, "summary": _summarize_results(results)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to confirm skill install draft '{draft_id}': {e}")
raise HTTPException(status_code=500, detail="确认安装 Skill 失败")
@user_skills.delete("/install-drafts/{draft_id}")
async def discard_skill_install_draft_route(draft_id: str, current_user: User = Depends(get_required_user)):
try:
await discard_skill_install_draft(draft_id=draft_id, operator=current_user)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to discard skill install draft '{draft_id}': {e}")
raise HTTPException(status_code=500, detail="取消安装 Skill 失败")
@skills.get("")
async def list_skills_route(
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
items = await list_visible_skills_for_management(db, current_user)
return {
"success": True,
"data": [_serialize_skill_for_user(item, current_user) for item in items],
"allowed_access_levels": get_allowed_skill_access_levels(current_user),
}
except Exception as e:
logger.error(f"Failed to list manageable skills: {e}")
raise HTTPException(status_code=500, detail="获取技能列表失败")
@skills.get("/dependency-options")
async def get_skill_dependency_options_route(
slug: str | None = Query(None, description="当前 Skill slug"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
if slug:
await get_manageable_skill_or_raise(db, current_user, slug)
return {"success": True, "data": await get_skill_dependency_options(db, current_user, slug)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to get skill dependency options: {e}")
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
@skills.get("/builtin")
async def list_builtin_skills_route(
_current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
items = [item for item in await list_skills(db) if item.source_type == "builtin"]
return {"success": True, "data": [item.to_dict() for item in items]}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to list builtin skills: {e}")
raise HTTPException(status_code=500, detail="获取内置 skill 列表失败")
@skills.post("/builtin/sync")
async def sync_builtin_skills_route(
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
items = await init_builtin_skills(db, created_by=current_user.uid)
return {"success": True, "data": [item.to_dict() for item in items]}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to sync builtin skills: {e}")
raise HTTPException(status_code=500, detail="同步内置 skill 失败")
@skills.put("/{slug}/share-config")
async def update_skill_share_config_route(
slug: str,
payload: ShareConfigPayload,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await update_skill_share_config(db, slug=slug, share_config=payload.share_config, operator=current_user)
return {"success": True, "data": _serialize_skill_for_user(item, current_user)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to update skill share config '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新 Skill 共享范围失败")
@skills.put("/{slug}/enabled")
async def update_skill_enabled_route(
slug: str,
payload: SkillEnabledUpdateRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await update_skill_enabled(db, slug=slug, enabled=payload.enabled, operator=current_user)
return {"success": True, "data": _serialize_skill_for_user(item, current_user)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to update skill enabled '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新 Skill 启用状态失败")
@skills.get("/{slug}/tree")
async def get_skill_tree_route(
slug: str,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
await get_management_readable_skill_or_raise(db, current_user, slug)
return {"success": True, "data": await get_skill_tree(db, slug)}
except ValueError as e:
_raise_from_value_error(e)
except 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_required_user),
db: AsyncSession = Depends(get_db),
):
try:
await get_management_readable_skill_or_raise(db, current_user, slug)
return {"success": True, "data": await read_skill_file(db, slug, path)}
except ValueError as e:
_raise_from_value_error(e)
except 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_required_user),
db: AsyncSession = Depends(get_db),
):
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await create_skill_node(
db,
slug=slug,
relative_path=payload.path,
is_dir=payload.is_dir,
content=payload.content,
updated_by=current_user.uid,
)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
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_required_user),
db: AsyncSession = Depends(get_db),
):
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await update_skill_file(
db,
slug=slug,
relative_path=payload.path,
content=payload.content,
updated_by=current_user.uid,
)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
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_required_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await update_skill_dependencies(
db,
slug=slug,
tool_dependencies=payload.tool_dependencies,
mcp_dependencies=payload.mcp_dependencies,
skill_dependencies=payload.skill_dependencies,
operator=current_user,
)
return {"success": True, "data": _serialize_skill_for_user(item, current_user)}
except ValueError as e:
_raise_from_value_error(e)
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_required_user),
db: AsyncSession = Depends(get_db),
):
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await delete_skill_node(db, slug=slug, relative_path=path)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except 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_required_user),
db: AsyncSession = Depends(get_db),
):
try:
await get_manageable_skill_or_raise(db, current_user, slug)
export_path, download_name = await export_skill_zip(db, slug)
background_tasks.add_task(_cleanup_export_file, export_path)
return FileResponse(path=export_path, media_type="application/zip", filename=download_name)
except ValueError as e:
_raise_from_value_error(e)
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_required_user),
db: AsyncSession = Depends(get_db),
):
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await delete_skill(db, slug=slug)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to delete skill '{slug}': {e}")
raise HTTPException(status_code=500, detail="删除技能失败")
@skills.post("/delete-batch")
async def delete_skills_batch_route(
payload: SkillBatchDeleteRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
for slug in payload.slugs:
await get_manageable_skill_or_raise(db, current_user, slug)
results = await delete_skills_batch(db, slugs=payload.slugs)
return {"success": True, "data": results, "summary": _summarize_results(results)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to delete skills batch: {e}")
raise HTTPException(status_code=500, detail="批量删除技能失败")