mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 06:12:14 +08:00
feat: add skill installation API endpoint
Add POST /api/skills/install endpoint to install .skill files from thread's user-data directory. The endpoint extracts the ZIP archive, validates SKILL.md frontmatter, and installs to skills/custom/. Frontend Install buttons now call the API instead of downloading. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||
from src.skills import Skill, load_skills
|
||||
from src.skills.loader import get_skills_root_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["skills"])
|
||||
@@ -34,6 +41,141 @@ class SkillUpdateRequest(BaseModel):
|
||||
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")
|
||||
|
||||
|
||||
# Base directory for thread data (relative to backend/)
|
||||
THREAD_DATA_BASE_DIR = ".deer-flow/threads"
|
||||
|
||||
# Virtual path prefix used in sandbox environments (without leading slash for URL path matching)
|
||||
VIRTUAL_PATH_PREFIX = "mnt/user-data"
|
||||
|
||||
# Allowed properties in SKILL.md frontmatter
|
||||
ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata"}
|
||||
|
||||
|
||||
def _resolve_skill_file_path(thread_id: str, virtual_path: str) -> Path:
|
||||
"""Resolve a virtual skill file path to the actual filesystem path.
|
||||
|
||||
Args:
|
||||
thread_id: The thread ID.
|
||||
virtual_path: The virtual path (e.g., mnt/user-data/outputs/my-skill.skill).
|
||||
|
||||
Returns:
|
||||
The resolved filesystem path.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the path is invalid or outside allowed directories.
|
||||
"""
|
||||
# Remove leading slash if present
|
||||
virtual_path = virtual_path.lstrip("/")
|
||||
|
||||
# Validate and remove virtual path prefix
|
||||
if not virtual_path.startswith(VIRTUAL_PATH_PREFIX):
|
||||
raise HTTPException(status_code=400, detail=f"Path must start with /{VIRTUAL_PATH_PREFIX}")
|
||||
relative_path = virtual_path[len(VIRTUAL_PATH_PREFIX) :].lstrip("/")
|
||||
|
||||
# Build the actual path
|
||||
base_dir = Path(os.getcwd()) / THREAD_DATA_BASE_DIR / thread_id / "user-data"
|
||||
actual_path = base_dir / relative_path
|
||||
|
||||
# Security check: ensure the path is within the thread's user-data directory
|
||||
try:
|
||||
actual_path = actual_path.resolve()
|
||||
base_dir_resolved = base_dir.resolve()
|
||||
if not str(actual_path).startswith(str(base_dir_resolved)):
|
||||
raise HTTPException(status_code=403, detail="Access denied: path traversal detected")
|
||||
except (ValueError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
return actual_path
|
||||
|
||||
|
||||
def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]:
|
||||
"""Validate a skill directory's SKILL.md frontmatter.
|
||||
|
||||
Args:
|
||||
skill_dir: Path to the skill directory containing SKILL.md.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, message, skill_name).
|
||||
"""
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found", None
|
||||
|
||||
content = skill_md.read_text()
|
||||
if not content.startswith("---"):
|
||||
return False, "No YAML frontmatter found", None
|
||||
|
||||
# Extract frontmatter
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
return False, "Invalid frontmatter format", None
|
||||
|
||||
frontmatter_text = match.group(1)
|
||||
|
||||
# Parse YAML frontmatter
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_text)
|
||||
if not isinstance(frontmatter, dict):
|
||||
return False, "Frontmatter must be a YAML dictionary", None
|
||||
except yaml.YAMLError as e:
|
||||
return False, f"Invalid YAML in frontmatter: {e}", None
|
||||
|
||||
# Check for unexpected properties
|
||||
unexpected_keys = set(frontmatter.keys()) - ALLOWED_FRONTMATTER_PROPERTIES
|
||||
if unexpected_keys:
|
||||
return False, f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}", None
|
||||
|
||||
# Check required fields
|
||||
if "name" not in frontmatter:
|
||||
return False, "Missing 'name' in frontmatter", None
|
||||
if "description" not in frontmatter:
|
||||
return False, "Missing 'description' in frontmatter", None
|
||||
|
||||
# Validate name
|
||||
name = frontmatter.get("name", "")
|
||||
if not isinstance(name, str):
|
||||
return False, f"Name must be a string, got {type(name).__name__}", None
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return False, "Name cannot be empty", None
|
||||
|
||||
# Check naming convention (hyphen-case: lowercase with hyphens)
|
||||
if not re.match(r"^[a-z0-9-]+$", name):
|
||||
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", None
|
||||
if name.startswith("-") or name.endswith("-") or "--" in name:
|
||||
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", None
|
||||
if len(name) > 64:
|
||||
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters.", None
|
||||
|
||||
# Validate description
|
||||
description = frontmatter.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
return False, f"Description must be a string, got {type(description).__name__}", None
|
||||
description = description.strip()
|
||||
if description:
|
||||
if "<" in description or ">" in description:
|
||||
return False, "Description cannot contain angle brackets (< or >)", None
|
||||
if len(description) > 1024:
|
||||
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", None
|
||||
|
||||
return True, "Skill is valid!", name
|
||||
|
||||
|
||||
def _skill_to_response(skill: Skill) -> SkillResponse:
|
||||
"""Convert a Skill object to a SkillResponse."""
|
||||
return SkillResponse(
|
||||
@@ -225,3 +367,119 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillRes
|
||||
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:
|
||||
"""Install a skill from a .skill file.
|
||||
|
||||
The .skill file is a ZIP archive containing a skill directory with SKILL.md
|
||||
and optional resources (scripts, references, assets).
|
||||
|
||||
Args:
|
||||
request: The install request containing thread_id and virtual path to .skill file.
|
||||
|
||||
Returns:
|
||||
Installation result with skill name and status message.
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- 400 if path is invalid or file is not a valid .skill file
|
||||
- 403 if access denied (path traversal detected)
|
||||
- 404 if file not found
|
||||
- 409 if skill already exists
|
||||
- 500 if installation fails
|
||||
|
||||
Example Request:
|
||||
```json
|
||||
{
|
||||
"thread_id": "abc123-def456",
|
||||
"path": "/mnt/user-data/outputs/my-skill.skill"
|
||||
}
|
||||
```
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"skill_name": "my-skill",
|
||||
"message": "Skill 'my-skill' installed successfully"
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Resolve the virtual path to actual file path
|
||||
skill_file_path = _resolve_skill_file_path(request.thread_id, request.path)
|
||||
|
||||
# Check if file exists
|
||||
if not skill_file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Skill file not found: {request.path}")
|
||||
|
||||
# Check if it's a file
|
||||
if not skill_file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail=f"Path is not a file: {request.path}")
|
||||
|
||||
# Check file extension
|
||||
if not skill_file_path.suffix == ".skill":
|
||||
raise HTTPException(status_code=400, detail="File must have .skill extension")
|
||||
|
||||
# Verify it's a valid ZIP file
|
||||
if not zipfile.is_zipfile(skill_file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a valid ZIP archive")
|
||||
|
||||
# Get the custom skills directory
|
||||
skills_root = get_skills_root_path()
|
||||
custom_skills_dir = skills_root / "custom"
|
||||
|
||||
# Create custom directory if it doesn't exist
|
||||
custom_skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Extract to a temporary directory first for validation
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Extract the .skill file
|
||||
with zipfile.ZipFile(skill_file_path, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_path)
|
||||
|
||||
# Find the skill directory (should be the only top-level directory)
|
||||
extracted_items = list(temp_path.iterdir())
|
||||
if len(extracted_items) == 0:
|
||||
raise HTTPException(status_code=400, detail="Skill archive is empty")
|
||||
|
||||
# Handle both cases: single directory or files directly in root
|
||||
if len(extracted_items) == 1 and extracted_items[0].is_dir():
|
||||
skill_dir = extracted_items[0]
|
||||
else:
|
||||
# Files are directly in the archive root
|
||||
skill_dir = temp_path
|
||||
|
||||
# Validate the skill
|
||||
is_valid, message, skill_name = _validate_skill_frontmatter(skill_dir)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid skill: {message}")
|
||||
|
||||
if not skill_name:
|
||||
raise HTTPException(status_code=400, detail="Could not determine skill name")
|
||||
|
||||
# Check if skill already exists
|
||||
target_dir = custom_skills_dir / skill_name
|
||||
if target_dir.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Skill '{skill_name}' already exists. Please remove it first or use a different name.")
|
||||
|
||||
# Move the skill directory to the custom skills directory
|
||||
shutil.copytree(skill_dir, target_dir)
|
||||
|
||||
logger.info(f"Skill '{skill_name}' installed successfully to {target_dir}")
|
||||
return SkillInstallResponse(success=True, skill_name=skill_name, message=f"Skill '{skill_name}' installed successfully")
|
||||
|
||||
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)}")
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
type Citation,
|
||||
} from "@/core/citations";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -99,6 +101,8 @@ export function ArtifactFileDetail({
|
||||
}, [content, language]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewable) {
|
||||
setViewMode("preview");
|
||||
@@ -106,6 +110,28 @@ export function ArtifactFileDetail({
|
||||
setViewMode("code");
|
||||
}
|
||||
}, [previewable]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
try {
|
||||
const result = await installSkill({
|
||||
thread_id: threadId,
|
||||
path: filepath,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message || "Failed to install skill");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install skill:", error);
|
||||
toast.error("Failed to install skill");
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
<ArtifactHeader className="px-2">
|
||||
@@ -155,13 +181,13 @@ export function ArtifactFileDetail({
|
||||
<div className="flex items-center gap-2">
|
||||
<ArtifactActions>
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
|
||||
<ArtifactAction
|
||||
icon={PackageIcon}
|
||||
label={t.common.install}
|
||||
tooltip={t.common.openInNewWindow}
|
||||
/>
|
||||
</a>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
label={t.common.install}
|
||||
tooltip={t.common.install}
|
||||
disabled={isInstalling}
|
||||
onClick={handleInstallSkill}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && (
|
||||
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DownloadIcon, PackageIcon } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { getFileExtensionDisplayName, getFileName } from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -27,6 +29,8 @@ export function ArtifactFileList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(filepath: string) => {
|
||||
selectArtifact(filepath);
|
||||
@@ -34,6 +38,35 @@ export function ArtifactFileList({
|
||||
},
|
||||
[selectArtifact, setOpen],
|
||||
);
|
||||
|
||||
const handleInstallSkill = useCallback(
|
||||
async (e: React.MouseEvent, filepath: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (installingFile) return;
|
||||
|
||||
setInstallingFile(filepath);
|
||||
try {
|
||||
const result = await installSkill({
|
||||
thread_id: threadId,
|
||||
path: filepath,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message || "Failed to install skill");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to install skill:", error);
|
||||
toast.error("Failed to install skill");
|
||||
} finally {
|
||||
setInstallingFile(null);
|
||||
}
|
||||
},
|
||||
[threadId, installingFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
||||
{files.map((file) => (
|
||||
@@ -49,20 +82,18 @@ export function ArtifactFileList({
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
{file.endsWith(".skill") && (
|
||||
<a
|
||||
href={urlOfArtifact({
|
||||
filepath: file,
|
||||
threadId: threadId,
|
||||
download: true,
|
||||
})}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={installingFile === file}
|
||||
onClick={(e) => handleInstallSkill(e, file)}
|
||||
>
|
||||
<Button variant="ghost">
|
||||
{installingFile === file ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PackageIcon className="size-4" />
|
||||
{t.common.install}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{t.common.install}
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
href={urlOfArtifact({
|
||||
|
||||
@@ -24,12 +24,39 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function installSkill(skillName: string) {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/skills/${skillName}/install`,
|
||||
{
|
||||
method: "POST",
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface InstallSkillResponse {
|
||||
success: boolean;
|
||||
skill_name: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
): Promise<InstallSkillResponse> {
|
||||
const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
);
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle HTTP error responses (4xx, 5xx)
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage =
|
||||
errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||
return {
|
||||
success: false,
|
||||
skill_name: "",
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user