2026-02-21 02:48:20 +08:00
|
|
|
|
"""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
|
|
|
|
|
|
|
2026-05-14 13:13:41 +08:00
|
|
|
|
from server.utils.auth_middleware import get_admin_user, get_db
|
2026-05-24 01:20:45 +08:00
|
|
|
|
from yuxi.services.remote_skill_install_service import (
|
|
|
|
|
|
install_remote_skill,
|
|
|
|
|
|
install_remote_skills_batch,
|
|
|
|
|
|
list_remote_skills,
|
|
|
|
|
|
search_remote_skills,
|
|
|
|
|
|
)
|
2026-03-17 10:16:44 +08:00
|
|
|
|
from yuxi.services.skill_service import (
|
2026-03-28 17:53:53 +08:00
|
|
|
|
BuiltinSkillUpdateConflictError,
|
2026-02-21 02:48:20 +08:00
|
|
|
|
create_skill_node,
|
|
|
|
|
|
delete_skill,
|
|
|
|
|
|
delete_skill_node,
|
2026-05-24 01:20:45 +08:00
|
|
|
|
delete_skills_batch,
|
2026-02-21 02:48:20 +08:00
|
|
|
|
export_skill_zip,
|
2026-02-22 17:42:37 +08:00
|
|
|
|
get_skill_dependency_options,
|
2026-02-21 02:48:20 +08:00
|
|
|
|
get_skill_tree,
|
|
|
|
|
|
import_skill_zip,
|
2026-03-28 17:53:53 +08:00
|
|
|
|
install_builtin_skill,
|
|
|
|
|
|
list_builtin_skill_specs,
|
2026-02-21 02:48:20 +08:00
|
|
|
|
list_skills,
|
|
|
|
|
|
read_skill_file,
|
2026-03-28 17:53:53 +08:00
|
|
|
|
update_builtin_skill,
|
2026-02-22 17:42:37 +08:00
|
|
|
|
update_skill_dependencies,
|
2026-02-21 02:48:20 +08:00
|
|
|
|
update_skill_file,
|
|
|
|
|
|
)
|
2026-03-17 10:16:44 +08:00
|
|
|
|
from yuxi.storage.postgres.models_business import User
|
|
|
|
|
|
from yuxi.utils.logging_config import logger
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
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="文件内容")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 17:42:37 +08:00
|
|
|
|
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 列表")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 17:53:53 +08:00
|
|
|
|
class BuiltinSkillUpdateRequest(BaseModel):
|
|
|
|
|
|
force: bool = Field(False, description="是否强制覆盖本地已安装内容")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 14:44:33 +08:00
|
|
|
|
class RemoteSkillSourceRequest(BaseModel):
|
|
|
|
|
|
source: str = Field(..., description="skills 仓库来源,如 owner/repo 或 GitHub URL")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RemoteSkillInstallRequest(RemoteSkillSourceRequest):
|
|
|
|
|
|
skill: str = Field(..., description="需要安装的 skill 名称")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 17:10:52 +08:00
|
|
|
|
class RemoteSkillBatchInstallRequest(RemoteSkillSourceRequest):
|
|
|
|
|
|
skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 01:20:45 +08:00
|
|
|
|
class RemoteSkillSearchRequest(BaseModel):
|
|
|
|
|
|
query: str = Field(..., description="搜索关键字")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SkillBatchDeleteRequest(BaseModel):
|
|
|
|
|
|
slugs: list[str] = Field(..., description="需要批量删除的 skill slug 列表")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-23 20:52:26 +08:00
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
@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="获取技能列表失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 17:42:37 +08:00
|
|
|
|
@skills.get("/dependency-options")
|
|
|
|
|
|
async def get_skill_dependency_options_route(
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-02-26 18:31:55 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
2026-02-22 17:42:37 +08:00
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""获取 skill 依赖项可选列表(管理员)。"""
|
2026-02-22 17:42:37 +08:00
|
|
|
|
try:
|
2026-02-26 18:31:55 +08:00
|
|
|
|
return {"success": True, "data": await get_skill_dependency_options(db)}
|
2026-02-22 17:42:37 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to get skill dependency options: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 17:53:53 +08:00
|
|
|
|
@skills.get("/builtin")
|
|
|
|
|
|
async def list_builtin_skills_route(
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-03-28 17:53:53 +08:00
|
|
|
|
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,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-03-28 17:53:53 +08:00
|
|
|
|
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,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-03-28 17:53:53 +08:00
|
|
|
|
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 失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
@skills.post("/import")
|
|
|
|
|
|
async def import_skill_route(
|
|
|
|
|
|
file: UploadFile = File(...),
|
2026-05-14 13:13:41 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""导入技能包(支持 ZIP 或单个 SKILL.md,管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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:
|
2026-04-01 13:47:01 +08:00
|
|
|
|
logger.error(f"Failed to import skill package: {e}")
|
2026-02-21 02:48:20 +08:00
|
|
|
|
raise HTTPException(status_code=500, detail="导入技能失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 14:44:33 +08:00
|
|
|
|
@skills.post("/remote/list")
|
|
|
|
|
|
async def list_remote_skills_route(
|
|
|
|
|
|
payload: RemoteSkillSourceRequest,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-04-01 14:44:33 +08:00
|
|
|
|
):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return {"success": True, "data": await list_remote_skills(payload.source)}
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
_raise_from_value_error(e)
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to list remote skills from '{payload.source}': {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="获取远程 skills 列表失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@skills.post("/remote/install")
|
|
|
|
|
|
async def install_remote_skill_route(
|
|
|
|
|
|
payload: RemoteSkillInstallRequest,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-04-01 14:44:33 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
try:
|
|
|
|
|
|
item = await install_remote_skill(
|
|
|
|
|
|
db,
|
|
|
|
|
|
source=payload.source,
|
|
|
|
|
|
skill=payload.skill,
|
|
|
|
|
|
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 install remote skill '{payload.skill}' from '{payload.source}': {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="安装远程 skill 失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 17:10:52 +08:00
|
|
|
|
@skills.post("/remote/install-batch")
|
|
|
|
|
|
async def install_remote_skills_batch_route(
|
|
|
|
|
|
payload: RemoteSkillBatchInstallRequest,
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""批量从同一远程仓库安装多个 skills(仅一次克隆,不存在的 skill 静默跳过)。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
results = await install_remote_skills_batch(
|
|
|
|
|
|
db,
|
|
|
|
|
|
source=payload.source,
|
|
|
|
|
|
skills=payload.skills,
|
|
|
|
|
|
created_by=current_user.username,
|
|
|
|
|
|
)
|
|
|
|
|
|
success_count = sum(1 for r in results if r["success"])
|
|
|
|
|
|
failed_count = sum(1 for r in results if not r["success"])
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"data": results,
|
|
|
|
|
|
"summary": {"total": len(results), "success": success_count, "failed": failed_count},
|
|
|
|
|
|
}
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
_raise_from_value_error(e)
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
f"Failed to install remote skills batch from '{payload.source}': {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="批量安装远程 skills 失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 01:20:45 +08:00
|
|
|
|
@skills.post("/remote/search")
|
|
|
|
|
|
async def search_remote_skills_route(
|
|
|
|
|
|
payload: RemoteSkillSearchRequest,
|
|
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""搜索远程公开的 skills(管理员)。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = await search_remote_skills(payload.query)
|
|
|
|
|
|
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 search remote skills with query '{payload.query}': {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="搜索远程 skills 失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
@skills.get("/{slug}/tree")
|
|
|
|
|
|
async def get_skill_tree_route(
|
|
|
|
|
|
slug: str,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""获取技能目录树(管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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 根目录路径"),
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""读取技能文本文件(管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""创建技能文件或目录(管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""更新技能文本文件(管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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="更新技能文件失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 17:42:37 +08:00
|
|
|
|
@skills.put("/{slug}/dependencies")
|
|
|
|
|
|
async def update_skill_dependencies_route(
|
|
|
|
|
|
slug: str,
|
|
|
|
|
|
payload: SkillDependenciesUpdateRequest,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-02-22 17:42:37 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""更新 skill 依赖(管理员)。"""
|
2026-02-22 17:42:37 +08:00
|
|
|
|
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 依赖失败")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-21 02:48:20 +08:00
|
|
|
|
@skills.delete("/{slug}/file")
|
|
|
|
|
|
async def delete_skill_file_route(
|
|
|
|
|
|
slug: str,
|
|
|
|
|
|
path: str = Query(..., description="相对 skill 根目录路径"),
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""删除技能文件或目录(管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""导出技能压缩包(管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
try:
|
|
|
|
|
|
export_path, download_name = await export_skill_zip(db, slug)
|
2026-02-23 20:52:26 +08:00
|
|
|
|
background_tasks.add_task(_cleanup_export_file, export_path)
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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,
|
2026-05-14 13:13:41 +08:00
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
2026-02-21 02:48:20 +08:00
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 13:13:41 +08:00
|
|
|
|
"""删除技能(目录 + 数据库记录,管理员)。"""
|
2026-02-21 02:48:20 +08:00
|
|
|
|
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="删除技能失败")
|
2026-05-24 01:20:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@skills.post("/delete-batch")
|
|
|
|
|
|
async def delete_skills_batch_route(
|
|
|
|
|
|
payload: SkillBatchDeleteRequest,
|
|
|
|
|
|
_current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""批量删除技能(目录 + 数据库记录,管理员)。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
results = await delete_skills_batch(db, slugs=payload.slugs)
|
|
|
|
|
|
success_count = sum(1 for r in results if r["success"])
|
|
|
|
|
|
failed_count = sum(1 for r in results if not r["success"])
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"data": results,
|
|
|
|
|
|
"summary": {"total": len(results), "success": success_count, "failed": failed_count},
|
|
|
|
|
|
}
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
_raise_from_value_error(e)
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to delete skills batch: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="批量删除技能失败")
|