314 lines
13 KiB
Python
314 lines
13 KiB
Python
import json
|
|
import logging
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.config import get_gateway_config
|
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
|
from app.gateway.skill_yaml_importer import materialize_skill_tree, parse_skill_yaml_spec
|
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
|
from deerflow.skills import Skill, load_skills
|
|
from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["skills"])
|
|
|
|
|
|
class SkillResponse(BaseModel):
|
|
"""Response model for skill information."""
|
|
|
|
name: str = Field(..., description="Name of the skill")
|
|
description: str = Field(..., description="Description of what the skill does")
|
|
license: str | None = Field(None, description="License information")
|
|
category: str = Field(..., description="Category of the skill (public or custom)")
|
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
|
|
|
|
|
class SkillsListResponse(BaseModel):
|
|
"""Response model for listing all skills."""
|
|
|
|
skills: list[SkillResponse]
|
|
|
|
|
|
class SkillUpdateRequest(BaseModel):
|
|
"""Request model for updating a skill."""
|
|
|
|
enabled: bool = Field(..., description="Whether to enable or disable the skill")
|
|
|
|
|
|
class SkillInstallRequest(BaseModel):
|
|
"""Request model for installing a skill from a .skill file."""
|
|
|
|
thread_id: str = Field(..., description="The thread ID where the .skill file is located")
|
|
path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
|
|
|
|
|
|
class SkillInstallResponse(BaseModel):
|
|
"""Response model for skill installation."""
|
|
|
|
success: bool = Field(..., description="Whether the installation was successful")
|
|
skill_name: str = Field(..., description="Name of the installed skill")
|
|
message: str = Field(..., description="Installation result message")
|
|
|
|
|
|
class RemoteSkillBootstrapRequest(BaseModel):
|
|
"""Request model for bootstrapping skill files from remote content API."""
|
|
|
|
thread_id: str = Field(..., description="Thread ID used for user-data path binding")
|
|
content_ids: list[int] = Field(
|
|
...,
|
|
min_length=1,
|
|
description="Remote content ID sequence (maps from frontend query param skill_id)",
|
|
)
|
|
language_type: int = Field(default=0, description="Language type for remote API request body")
|
|
target_dir: str = Field(
|
|
default="/mnt/user-data/uploads/skill",
|
|
description="Virtual base directory where each skill-{id} subdirectory is created",
|
|
)
|
|
clear_target: bool = Field(
|
|
default=True,
|
|
description="Whether to clear target directory before writing parsed files",
|
|
)
|
|
|
|
|
|
class RemoteSkillBootstrapResponse(BaseModel):
|
|
"""Response model for remote bootstrap endpoint."""
|
|
|
|
success: bool = Field(..., description="Whether bootstrap succeeded")
|
|
target_dir: str = Field(..., description="Virtual base target directory")
|
|
content_ids: list[int] = Field(..., description="Bootstrapped content IDs")
|
|
created_directories: int = Field(..., description="Number of created directories")
|
|
created_files: int = Field(..., description="Number of created files")
|
|
sandbox_id: str | None = Field(default=None, description="Acquired sandbox ID (null when sandbox is not acquired)")
|
|
message: str = Field(..., description="Operation result message")
|
|
|
|
|
|
def _skill_to_response(skill: Skill) -> SkillResponse:
|
|
"""Convert a Skill object to a SkillResponse."""
|
|
return SkillResponse(
|
|
name=skill.name,
|
|
description=skill.description,
|
|
license=skill.license,
|
|
category=skill.category,
|
|
enabled=skill.enabled,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/skills",
|
|
response_model=SkillsListResponse,
|
|
summary="List All Skills",
|
|
description="Retrieve a list of all available skills from both public and custom directories.",
|
|
)
|
|
async def list_skills() -> SkillsListResponse:
|
|
try:
|
|
skills = load_skills(enabled_only=False)
|
|
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
|
except Exception as e:
|
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
|
|
|
|
|
@router.get(
|
|
"/skills/{skill_name}",
|
|
response_model=SkillResponse,
|
|
summary="Get Skill Details",
|
|
description="Retrieve detailed information about a specific skill by its name.",
|
|
)
|
|
async def get_skill(skill_name: str) -> SkillResponse:
|
|
try:
|
|
skills = load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
return _skill_to_response(skill)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
|
|
|
|
|
@router.put(
|
|
"/skills/{skill_name}",
|
|
response_model=SkillResponse,
|
|
summary="Update Skill",
|
|
description="Update a skill's enabled status by modifying the extensions_config.json file.",
|
|
)
|
|
async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse:
|
|
try:
|
|
skills = load_skills(enabled_only=False)
|
|
skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
|
|
|
config_path = ExtensionsConfig.resolve_config_path()
|
|
if config_path is None:
|
|
config_path = Path.cwd().parent / "extensions_config.json"
|
|
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
|
|
|
extensions_config = get_extensions_config()
|
|
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
|
|
|
config_data = {
|
|
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
|
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
|
|
}
|
|
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
json.dump(config_data, f, indent=2)
|
|
|
|
logger.info(f"Skills configuration updated and saved to: {config_path}")
|
|
reload_extensions_config()
|
|
|
|
skills = load_skills(enabled_only=False)
|
|
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
|
|
|
if updated_skill is None:
|
|
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
|
|
|
logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
|
|
return _skill_to_response(updated_skill)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/skills/install",
|
|
response_model=SkillInstallResponse,
|
|
summary="Install Skill",
|
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
|
)
|
|
async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
|
|
try:
|
|
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
|
result = install_skill_from_archive(skill_file_path)
|
|
return SkillInstallResponse(**result)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except SkillAlreadyExistsError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to install skill: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/skills/bootstrap-remote",
|
|
response_model=RemoteSkillBootstrapResponse,
|
|
summary="Bootstrap Skill Files From Remote API",
|
|
description=(
|
|
"Fetch YAML text from configured remote API by content_ids/language_type and "
|
|
"materialize files into skill-{id} directories under /mnt/user-data/uploads/skill "
|
|
"before first thread submit."
|
|
),
|
|
)
|
|
async def bootstrap_skill_from_remote(request: RemoteSkillBootstrapRequest) -> RemoteSkillBootstrapResponse:
|
|
"""Initialize thread skill directory from remote YAML content service."""
|
|
try:
|
|
cfg = get_gateway_config()
|
|
api_url = cfg.skill_content_api_url
|
|
created_directories_total = 0
|
|
created_files_total = 0
|
|
|
|
base_target_path = resolve_thread_virtual_path(request.thread_id, request.target_dir)
|
|
if request.clear_target and base_target_path.exists():
|
|
if base_target_path.is_dir():
|
|
shutil.rmtree(base_target_path)
|
|
else:
|
|
base_target_path.unlink()
|
|
base_target_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
for content_id in request.content_ids:
|
|
payload = {
|
|
"contentId": content_id,
|
|
"languageType": request.language_type,
|
|
}
|
|
|
|
response = await client.post(api_url, json=payload)
|
|
if response.status_code >= 400:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=(
|
|
"Remote skill content API failed with HTTP "
|
|
f"{response.status_code} for content_id={content_id}"
|
|
),
|
|
)
|
|
|
|
try:
|
|
response_json = response.json()
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=502, detail=f"Remote API did not return valid JSON: {e}") from e
|
|
|
|
status = response_json.get("status")
|
|
if status != 1000:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=(
|
|
"Remote API returned non-success status: "
|
|
f"{status}, message: {response_json.get('message')}, content_id={content_id}"
|
|
),
|
|
)
|
|
|
|
yaml_text = response_json.get("data")
|
|
if not isinstance(yaml_text, str) or not yaml_text.strip():
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Remote API returned empty or invalid YAML content for content_id={content_id}",
|
|
)
|
|
|
|
target_dir = f"{request.target_dir.rstrip('/')}/skill-{content_id}"
|
|
target_path = resolve_thread_virtual_path(request.thread_id, target_dir)
|
|
parsed = parse_skill_yaml_spec(yaml_text)
|
|
materialize_skill_tree(parsed, target_path, clear_target=False)
|
|
|
|
created_directories_total += len(parsed.directories)
|
|
created_files_total += len(parsed.files)
|
|
|
|
logger.info(
|
|
"Bootstrapped remote skill YAML for thread %s (content_id=%s, language_type=%s) to %s: dirs=%d files=%d",
|
|
request.thread_id,
|
|
content_id,
|
|
request.language_type,
|
|
target_dir,
|
|
len(parsed.directories),
|
|
len(parsed.files),
|
|
)
|
|
|
|
return RemoteSkillBootstrapResponse(
|
|
success=True,
|
|
target_dir=request.target_dir,
|
|
content_ids=request.content_ids,
|
|
created_directories=created_directories_total,
|
|
created_files=created_files_total,
|
|
sandbox_id=None,
|
|
message=(
|
|
f"Bootstrapped {created_files_total} files and {created_directories_total} directories "
|
|
f"for {len(request.content_ids)} skills under '{request.target_dir}'"
|
|
),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Failed to bootstrap skill from remote API: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to bootstrap skill from remote API: {str(e)}")
|