feat(skills): add skill management API module

- Create new skill_manager.py with list and upload endpoints
- Add GET /api/v1/skill/list to retrieve official and user skills
- Add POST /api/v1/skill/upload for skill file upload
- Parse SKILL.md frontmatter to extract name and description
- Move skill upload endpoint from files.py to skill_manager.py
- Add SKILLS_DIR configuration to settings.py
- Register skill_manager router in fastapi_app.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
朱潮 2026-01-07 19:45:04 +08:00
parent 8a85e9025e
commit 342932030f
4 changed files with 275 additions and 77 deletions

View File

@ -17,7 +17,7 @@ from utils.log_util.logger import init_with_fastapi
# Import route modules
from routes import chat, files, projects, system
from routes import chat, files, projects, system, skill_manager
@asynccontextmanager
@ -83,6 +83,7 @@ app.include_router(chat.router)
app.include_router(files.router)
app.include_router(projects.router)
app.include_router(system.router)
app.include_router(skill_manager.router)
# 注册文件管理API路由
app.include_router(file_manager_router)

View File

@ -350,82 +350,6 @@ async def upload_file(file: UploadFile = File(...), folder: Optional[str] = Form
raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}")
@router.post("/api/v1/skills/upload")
async def upload_skills(file: UploadFile = File(...), bot_id: Optional[str] = Form(None)):
"""
Skill文件上传API接口上传zip文件到 ./projects/uploads/ 目录下并自动解压
如果folder参数包含"skills"且文件是.zip格式保存后会自动解压到同名目录
Args:
file: 上传的zip文件
folder: 可选的自定义文件夹名 "skills" "projects/uploads/xxx/skills"
Returns:
dict: 包含文件路径解压信息的响应
"""
try:
# 调试信息
logger.info(f"Skill upload - bot_id parameter: {bot_id}")
logger.info(f"File received: {file.filename if file else 'None'}")
# 验证文件名
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
# 验证是否为zip文件
original_filename = file.filename
name_without_ext, file_extension = os.path.splitext(original_filename)
if file_extension.lower() != '.zip':
raise HTTPException(status_code=400, detail="仅支持上传.zip格式的skill文件")
folder_name = name_without_ext
# 创建上传目录
upload_dir = os.path.join("projects", "uploads", bot_id, "skill_zip")
extract_target = os.path.join("projects", "uploads", bot_id, "skills", folder_name)
os.makedirs(extract_target, exist_ok=True)
os.makedirs(upload_dir, exist_ok=True)
try:
# 保存zip文件使用原始文件名
file_path = os.path.join(upload_dir, original_filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
logger.info(f"Saved zip file: {file_path}")
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(extract_target)
logger.info(f"Extracted to: {extract_target}")
return {
"success": True,
"message": f"Skill文件上传并解压成功",
"file_path": file_path,
"extract_path": extract_target,
"original_filename": original_filename,
"skill_name": folder_name
}
except zipfile.BadZipFile:
# 解压失败删除已保存的zip文件
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=400, detail="上传的文件不是有效的zip文件")
except Exception as e:
# 解压失败删除已保存的zip文件
if os.path.exists(file_path):
os.remove(file_path)
logger.error(f"解压失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"解压失败: {str(e)}")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error uploading skill file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Skill文件上传失败: {str(e)}")
# Task management routes that are related to file processing
@router.get("/api/v1/task/{task_id}/status")
async def get_task_status(task_id: str):

272
routes/skill_manager.py Normal file
View File

@ -0,0 +1,272 @@
import os
import re
import shutil
import zipfile
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Form
from pydantic import BaseModel
from utils.settings import SKILLS_DIR
logger = logging.getLogger('app')
router = APIRouter()
class SkillItem(BaseModel):
name: str
description: str
user_skill: bool = False
class SkillListResponse(BaseModel):
skills: List[SkillItem]
total: int
def parse_skill_frontmatter(skill_md_path: str) -> Optional[dict]:
"""Parse the YAML frontmatter from SKILL.md file
Args:
skill_md_path: Path to the SKILL.md file
Returns:
dict with 'name' and 'description' if found, None otherwise
"""
try:
with open(skill_md_path, 'r', encoding='utf-8') as f:
content = f.read()
# Match YAML frontmatter between --- delimiters
frontmatter_match = re.match(r'^---\s*\n(.*?)\n---', content, re.DOTALL)
if not frontmatter_match:
logger.warning(f"No frontmatter found in {skill_md_path}")
return None
frontmatter = frontmatter_match.group(1)
metadata = {}
# Parse key: value pairs from frontmatter
for line in frontmatter.split('\n'):
line = line.strip()
if ':' in line:
key, value = line.split(':', 1)
metadata[key.strip()] = value.strip()
# Return name and description if both exist
if 'name' in metadata and 'description' in metadata:
return {
'name': metadata['name'],
'description': metadata['description']
}
logger.warning(f"Missing name or description in {skill_md_path}")
return None
except Exception as e:
logger.error(f"Error parsing {skill_md_path}: {e}")
return None
def get_official_skills(base_dir: str) -> List[SkillItem]:
"""Get all official skills from the skills directory
Args:
base_dir: Base directory of the project
Returns:
List of SkillItem objects
"""
skills = []
# Use SKILLS_DIR from settings, relative to base_dir
if os.path.isabs(SKILLS_DIR):
official_skills_dir = SKILLS_DIR
else:
official_skills_dir = os.path.join(base_dir, SKILLS_DIR)
if not os.path.exists(official_skills_dir):
logger.warning(f"Official skills directory not found: {official_skills_dir}")
return skills
for skill_name in os.listdir(official_skills_dir):
skill_path = os.path.join(official_skills_dir, skill_name)
if os.path.isdir(skill_path):
skill_md_path = os.path.join(skill_path, 'SKILL.md')
if os.path.exists(skill_md_path):
metadata = parse_skill_frontmatter(skill_md_path)
if metadata:
skills.append(SkillItem(
name=metadata['name'],
description=metadata['description'],
user_skill=False
))
logger.debug(f"Found official skill: {metadata['name']}")
return skills
def get_user_skills(base_dir: str, bot_id: str) -> List[SkillItem]:
"""Get all user uploaded skills for a specific bot
Args:
base_dir: Base directory of the project
bot_id: Bot ID to look up user skills for
Returns:
List of SkillItem objects
"""
skills = []
user_skills_dir = os.path.join(base_dir, 'projects', 'uploads', bot_id, 'skills')
if not os.path.exists(user_skills_dir):
logger.info(f"No user skills directory found for bot {bot_id}: {user_skills_dir}")
return skills
for skill_name in os.listdir(user_skills_dir):
skill_path = os.path.join(user_skills_dir, skill_name)
if os.path.isdir(skill_path):
skill_md_path = os.path.join(skill_path, 'SKILL.md')
if os.path.exists(skill_md_path):
metadata = parse_skill_frontmatter(skill_md_path)
if metadata:
skills.append(SkillItem(
name=metadata['name'],
description=metadata['description'],
user_skill=True
))
logger.debug(f"Found user skill: {metadata['name']}")
return skills
@router.get("/api/v1/skill/list", response_model=SkillListResponse)
async def list_skills(
bot_id: str = Query(..., description="Bot ID to fetch user skills for")
):
"""
Get list of all available skills (official + user uploaded)
Args:
bot_id: Bot ID to fetch user uploaded skills for
Returns:
SkillListResponse containing all skills
Notes:
- Official skills are read from the /skills directory
- User skills are read from /projects/uploads/{bot_id}/skills directory
- User skills are marked with user_skill: true
"""
try:
# Get the project base directory
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Get official skills
official_skills = get_official_skills(base_dir)
# Get user skills for the specific bot
user_skills = get_user_skills(base_dir, bot_id)
# Combine both lists (user skills first)
all_skills = user_skills + official_skills
logger.info(f"Found {len(official_skills)} official skills and {len(user_skills)} user skills for bot {bot_id}")
return SkillListResponse(
skills=all_skills,
total=len(all_skills)
)
except Exception as e:
import traceback
error_details = traceback.format_exc()
logger.error(f"Error in list_skills: {str(e)}")
logger.error(f"Full traceback: {error_details}")
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post("/api/v1/skill/upload")
async def upload_skill(file: UploadFile = File(...), bot_id: Optional[str] = Form(None)):
"""
Skill文件上传API接口上传zip文件到 ./projects/uploads/ 目录下并自动解压
Args:
file: 上传的zip文件
bot_id: Bot ID用于创建用户专属的skills目录
Returns:
dict: 包含文件路径解压信息的响应
Notes:
- 仅支持.zip格式的skill文件
- 上传后会自动解压到 projects/uploads/{bot_id}/skills/{skill_name}/ 目录
"""
try:
logger.info(f"Skill upload - bot_id parameter: {bot_id}")
logger.info(f"File received: {file.filename if file else 'None'}")
# 验证bot_id
if not bot_id:
raise HTTPException(status_code=400, detail="bot_id is required")
# 验证文件名
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
# 验证是否为zip文件
original_filename = file.filename
name_without_ext, file_extension = os.path.splitext(original_filename)
if file_extension.lower() != '.zip':
raise HTTPException(status_code=400, detail="仅支持上传.zip格式的skill文件")
folder_name = name_without_ext
# 创建上传目录
upload_dir = os.path.join("projects", "uploads", bot_id, "skill_zip")
extract_target = os.path.join("projects", "uploads", bot_id, "skills", folder_name)
os.makedirs(extract_target, exist_ok=True)
os.makedirs(upload_dir, exist_ok=True)
try:
# 保存zip文件使用原始文件名
file_path = os.path.join(upload_dir, original_filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
logger.info(f"Saved zip file: {file_path}")
# 解压zip文件
with zipfile.ZipFile(file_path, 'r') as zip_ref:
zip_ref.extractall(extract_target)
logger.info(f"Extracted to: {extract_target}")
return {
"success": True,
"message": f"Skill文件上传并解压成功",
"file_path": file_path,
"extract_path": extract_target,
"original_filename": original_filename,
"skill_name": folder_name
}
except zipfile.BadZipFile:
# 解压失败删除已保存的zip文件
if os.path.exists(file_path):
os.remove(file_path)
raise HTTPException(status_code=400, detail="上传的文件不是有效的zip文件")
except Exception as e:
# 解压失败删除已保存的zip文件
if os.path.exists(file_path):
os.remove(file_path)
logger.error(f"解压失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"解压失败: {str(e)}")
except HTTPException:
raise
except Exception as e:
import traceback
error_details = traceback.format_exc()
logger.error(f"Error uploading skill file: {str(e)}")
logger.error(f"Full traceback: {error_details}")
raise HTTPException(status_code=500, detail=f"Skill文件上传失败: {str(e)}")

View File

@ -22,6 +22,7 @@ TOOL_CACHE_AUTO_RENEW = os.getenv("TOOL_CACHE_AUTO_RENEW", "true") == "true"
# Project Settings
PROJECT_DATA_DIR = os.getenv("PROJECT_DATA_DIR", "./projects/data")
SKILLS_DIR = os.getenv("SKILLS_DIR", "./skills")
# Tokenizer Settings
TOKENIZERS_PARALLELISM = os.getenv("TOKENIZERS_PARALLELISM", "true")