Update local frontend and backend changes
This commit is contained in:
1 parent
faca2c9a2d
commit
a27206c543
35 files changed
+1756
-334
No files matched your search
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_config
|
||||
@@ -56,6 +57,20 @@ class SkillInstallResponse(BaseModel):
|
||||
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")
|
||||
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
|
||||
|
||||
|
||||
class SkillUploadValidateResponse(BaseModel):
|
||||
"""Response model for the pre-install validation endpoint."""
|
||||
|
||||
ok: bool = Field(..., description="Whether the archive passed cheap validation")
|
||||
skill_name: str = Field(..., description="Skill name parsed from frontmatter")
|
||||
description: str = Field(default="", description="Skill description parsed from frontmatter")
|
||||
license: str | None = Field(default=None, description="Skill license, if declared")
|
||||
allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
|
||||
conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
|
||||
conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
|
||||
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
|
||||
|
||||
|
||||
class CustomSkillContentResponse(SkillResponse):
|
||||
@@ -100,6 +115,110 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
||||
|
||||
|
||||
_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
|
||||
_ZIP_MAGIC = b"PK\x03\x04"
|
||||
|
||||
|
||||
async def _persist_uploaded_skill(file: UploadFile) -> Path:
|
||||
"""Stream an uploaded skill archive to a temp file. Caller must unlink it.
|
||||
|
||||
Validates the leading bytes look like a ZIP archive and enforces the same
|
||||
512 MB cap that the extractor uses, so we reject obvious garbage early.
|
||||
"""
|
||||
if file.filename is None:
|
||||
raise HTTPException(status_code=400, detail="No file provided")
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in {".zip", ".skill"}:
|
||||
raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file")
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-")
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
head: bytes = b""
|
||||
try:
|
||||
with open(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
if not head:
|
||||
head = chunk[: len(_ZIP_MAGIC)]
|
||||
total += len(chunk)
|
||||
if total > _MAX_SKILL_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit")
|
||||
out.write(chunk)
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
if not head.startswith(_ZIP_MAGIC):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/validate",
|
||||
response_model=SkillUploadValidateResponse,
|
||||
summary="Validate Skill Archive (No Install)",
|
||||
description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
|
||||
)
|
||||
async def validate_skill_upload(
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillUploadValidateResponse:
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
|
||||
return SkillUploadValidateResponse(**result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate uploaded skill: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}")
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/install-upload",
|
||||
response_model=SkillInstallResponse,
|
||||
summary="Install Skill from Upload",
|
||||
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
|
||||
)
|
||||
async def install_skill_upload(
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
||||
overwrite: bool = False,
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillInstallResponse:
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
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("Failed to install uploaded skill: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}")
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/install",
|
||||
response_model=SkillInstallResponse,
|
||||
|
||||
Reference in new issue
Block a user