Update local frontend and backend changes

This commit is contained in:
吉鑫升 committed 2026-05-13 17:17:42 +08:00
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,