技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
67d5e43ec4
commit
9a63a4b7ca
8 files changed
+661
-4
No files matched your search
@@ -1,11 +1,13 @@
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
||||
@@ -974,6 +976,432 @@ async def get_skill_content(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill files browser (tree + per-file read / write)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SKILL_FILE_TEXT_EXTS = {
|
||||
".md", ".markdown", ".txt", ".rst",
|
||||
".py", ".pyi",
|
||||
".json", ".jsonl",
|
||||
".yaml", ".yml",
|
||||
".toml", ".ini", ".cfg", ".env",
|
||||
".sh", ".bash", ".zsh", ".ps1",
|
||||
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||
".html", ".htm", ".css", ".scss", ".sass", ".xml", ".svg",
|
||||
".csv", ".tsv",
|
||||
".sql",
|
||||
}
|
||||
_SKILL_FILE_TREE_SKIP_NAMES = {
|
||||
"__pycache__", "node_modules", ".git", ".DS_Store", ".idea", ".vscode",
|
||||
}
|
||||
_SKILL_FILE_MAX_NODES = 2000
|
||||
_SKILL_FILE_MAX_DEPTH = 8
|
||||
_SKILL_FILE_READ_BYTES = 2 * 1024 * 1024 # 2 MiB cap on JSON content payload
|
||||
_SKILL_FILE_WRITE_BYTES = 1 * 1024 * 1024 # 1 MiB cap on edits
|
||||
|
||||
|
||||
class SkillFileNode(BaseModel):
|
||||
"""One entry in a skill's file tree.
|
||||
|
||||
``children`` is only set for directories. ``path`` is POSIX-style and is
|
||||
always relative to the skill's own directory (never absolute).
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="Base name (no slashes)")
|
||||
type: str = Field(..., description="'file' or 'dir'")
|
||||
path: str = Field(..., description="Path relative to the skill directory, POSIX-separated")
|
||||
size: int | None = Field(default=None, description="File size in bytes (files only)")
|
||||
mtime: float | None = Field(default=None, description="Modification time as POSIX seconds (files only)")
|
||||
children: list["SkillFileNode"] | None = Field(default=None, description="Child entries (directories only)")
|
||||
|
||||
|
||||
SkillFileNode.model_rebuild()
|
||||
|
||||
|
||||
class SkillFilesResponse(BaseModel):
|
||||
"""Tree of files under a skill directory."""
|
||||
|
||||
name: str
|
||||
category: SkillCategory
|
||||
editable: bool = Field(..., description="True when the caller may PUT new content into any file under this tree")
|
||||
root: SkillFileNode
|
||||
|
||||
|
||||
class SkillFileContentResponse(BaseModel):
|
||||
"""JSON payload for reading a single file inside a skill."""
|
||||
|
||||
name: str
|
||||
category: SkillCategory
|
||||
path: str
|
||||
mime: str
|
||||
size: int
|
||||
mtime: float
|
||||
is_text: bool = Field(..., description="True when ``content`` carries the decoded text; otherwise download via the binary endpoint")
|
||||
editable: bool = Field(..., description="True when the caller may PUT new content for this file")
|
||||
content: str | None = Field(default=None, description="Decoded text content when ``is_text`` is True")
|
||||
|
||||
|
||||
class SkillFileSaveRequest(BaseModel):
|
||||
"""Request body for writing a text file inside a skill."""
|
||||
|
||||
path: str = Field(..., description="File path relative to the skill directory, POSIX-separated")
|
||||
content: str = Field(..., description="Replacement text content")
|
||||
|
||||
|
||||
class SkillFileSaveResponse(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
size: int
|
||||
mtime: float
|
||||
|
||||
|
||||
def _normalize_skill_name_param(skill_name: str) -> str:
|
||||
"""Strip CR/LF the same way other skill routes do before lookup."""
|
||||
return skill_name.replace("\r\n", "").replace("\n", "")
|
||||
|
||||
|
||||
def _load_skill_or_404(skill_name: str, config: AppConfig) -> Skill:
|
||||
skills = get_or_new_skill_storage(app_config=config).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
|
||||
|
||||
|
||||
async def _ensure_files_view_allowed(
|
||||
skill: Skill,
|
||||
request: Request,
|
||||
skill_store: SkillStore,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Mirror _can_view_skill_source: admins or the publisher (custom owner)."""
|
||||
record = await skill_store.get_any(skill.name)
|
||||
owner_user_id = record.get("owner_user_id") if record else None
|
||||
user_id = _current_user_id(request)
|
||||
if not _can_view_skill_source(skill.category, owner_user_id, user_id, is_admin=_is_admin_user(request)):
|
||||
raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can browse skill files")
|
||||
return record
|
||||
|
||||
|
||||
def _caller_can_edit_skill_files(
|
||||
skill: Skill,
|
||||
record: dict[str, Any] | None,
|
||||
request: Request,
|
||||
) -> bool:
|
||||
"""Editing is limited to custom skills, and to either owner or admin."""
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
return False
|
||||
if _is_admin_user(request):
|
||||
return True
|
||||
if record is None:
|
||||
return False
|
||||
return record.get("owner_user_id") == _current_user_id(request)
|
||||
|
||||
|
||||
def _resolve_skill_relative_path(skill: Skill, relative_path: str) -> Path:
|
||||
"""Validate *relative_path* against the skill directory and return the absolute Path.
|
||||
|
||||
Rejects empty, absolute, parent-traversing, or out-of-tree paths so a
|
||||
malicious caller can never escape the skill's own directory.
|
||||
"""
|
||||
if not relative_path or relative_path.strip() == "":
|
||||
raise HTTPException(status_code=400, detail="path must not be empty")
|
||||
candidate = Path(relative_path)
|
||||
if candidate.is_absolute() or any(part in {"..", ""} for part in candidate.parts):
|
||||
raise HTTPException(status_code=400, detail="path must be a relative path without '..' segments")
|
||||
skill_dir = skill.skill_file.parent.resolve()
|
||||
target = (skill_dir / candidate).resolve()
|
||||
try:
|
||||
target.relative_to(skill_dir)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="path must stay inside the skill directory")
|
||||
return target
|
||||
|
||||
|
||||
def _file_mime(path: Path) -> str:
|
||||
"""Best-effort MIME guess; defaults to ``application/octet-stream``."""
|
||||
guessed, _ = mimetypes.guess_type(path.name)
|
||||
if guessed:
|
||||
return guessed
|
||||
if path.suffix.lower() in _SKILL_FILE_TEXT_EXTS:
|
||||
return "text/plain"
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def _is_text_file(path: Path) -> bool:
|
||||
"""Treat known text extensions as text; everything else as binary."""
|
||||
return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS
|
||||
|
||||
|
||||
def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode:
|
||||
"""Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
|
||||
|
||||
Hidden files (``.foo``) and a small block-list of noise directories
|
||||
(``__pycache__`` etc.) are skipped so the UI does not drown in housekeeping
|
||||
artefacts.
|
||||
"""
|
||||
counter = {"n": 0}
|
||||
|
||||
def build(current_dir: Path, depth: int) -> SkillFileNode:
|
||||
rel = current_dir.relative_to(skill_dir).as_posix()
|
||||
node = SkillFileNode(
|
||||
name=current_dir.name if rel != "." else skill_dir.name,
|
||||
type="dir",
|
||||
path="" if rel == "." else rel,
|
||||
children=[],
|
||||
)
|
||||
if depth >= _SKILL_FILE_MAX_DEPTH:
|
||||
return node
|
||||
try:
|
||||
entries = sorted(current_dir.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
except OSError:
|
||||
return node
|
||||
for entry in entries:
|
||||
if counter["n"] >= _SKILL_FILE_MAX_NODES:
|
||||
break
|
||||
if entry.name.startswith(".") or entry.name in _SKILL_FILE_TREE_SKIP_NAMES:
|
||||
continue
|
||||
counter["n"] += 1
|
||||
if entry.is_dir():
|
||||
node.children.append(build(entry, depth + 1))
|
||||
continue
|
||||
try:
|
||||
stat = entry.stat()
|
||||
size = stat.st_size
|
||||
mtime = stat.st_mtime
|
||||
except OSError:
|
||||
size = 0
|
||||
mtime = 0.0
|
||||
node.children.append(
|
||||
SkillFileNode(
|
||||
name=entry.name,
|
||||
type="file",
|
||||
path=entry.relative_to(skill_dir).as_posix(),
|
||||
size=size,
|
||||
mtime=mtime,
|
||||
)
|
||||
)
|
||||
return node
|
||||
|
||||
return build(skill_dir, 0)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_name}/files",
|
||||
response_model=SkillFilesResponse,
|
||||
summary="List Skill Files (Tree)",
|
||||
description=(
|
||||
"Return the full file tree under a skill directory. Only an admin or the "
|
||||
"skill's publisher (owner of a custom skill) may browse files; other users "
|
||||
"get 403."
|
||||
),
|
||||
)
|
||||
async def list_skill_files(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillFilesResponse:
|
||||
try:
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
skill = _load_skill_or_404(skill_name, config)
|
||||
record = await _ensure_files_view_allowed(skill, request, skill_store)
|
||||
skill_dir = skill.skill_file.parent
|
||||
root = _build_skill_file_tree(skill_dir)
|
||||
return SkillFilesResponse(
|
||||
name=skill.name,
|
||||
category=skill.category,
|
||||
editable=_caller_can_edit_skill_files(skill, record, request),
|
||||
root=root,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to list skill files for %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list skill files: {e}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_name}/files/content",
|
||||
response_model=SkillFileContentResponse,
|
||||
summary="Read Skill File (Text)",
|
||||
description=(
|
||||
"Return JSON metadata for a single file under a skill. Text files include "
|
||||
"their decoded ``content``; binary files omit ``content`` and should be "
|
||||
"fetched from the ``files/download`` endpoint for inline display or "
|
||||
"download. Same view authorisation as the file tree endpoint."
|
||||
),
|
||||
)
|
||||
async def get_skill_file_content(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
path: str = Query(..., description="File path relative to the skill directory"),
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillFileContentResponse:
|
||||
try:
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
skill = _load_skill_or_404(skill_name, config)
|
||||
record = await _ensure_files_view_allowed(skill, request, skill_store)
|
||||
target = _resolve_skill_relative_path(skill, path)
|
||||
if not target.exists() or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
|
||||
stat = target.stat()
|
||||
mime = _file_mime(target)
|
||||
is_text = _is_text_file(target)
|
||||
content: str | None = None
|
||||
if is_text:
|
||||
if stat.st_size > _SKILL_FILE_READ_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"File exceeds the {_SKILL_FILE_READ_BYTES // 1024} KiB preview limit")
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# File has a text-y extension but is not UTF-8 — treat as binary.
|
||||
is_text = False
|
||||
return SkillFileContentResponse(
|
||||
name=skill.name,
|
||||
category=skill.category,
|
||||
path=target.relative_to(skill.skill_file.parent.resolve()).as_posix(),
|
||||
mime=mime,
|
||||
size=stat.st_size,
|
||||
mtime=stat.st_mtime,
|
||||
is_text=is_text,
|
||||
editable=is_text and _caller_can_edit_skill_files(skill, record, request),
|
||||
content=content,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to read skill file %s/%s: %s", skill_name, path, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read skill file: {e}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_name}/files/download",
|
||||
summary="Download Skill File (Raw Bytes)",
|
||||
description=(
|
||||
"Stream the raw bytes of a single file under a skill, suitable for "
|
||||
"embedding in an ``<img>`` tag or for direct download. Same view "
|
||||
"authorisation as the file tree endpoint."
|
||||
),
|
||||
)
|
||||
async def download_skill_file(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
path: str = Query(..., description="File path relative to the skill directory"),
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
):
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
skill = _load_skill_or_404(skill_name, config)
|
||||
await _ensure_files_view_allowed(skill, request, skill_store)
|
||||
target = _resolve_skill_relative_path(skill, path)
|
||||
if not target.exists() or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
|
||||
return FileResponse(
|
||||
path=target,
|
||||
media_type=_file_mime(target),
|
||||
filename=target.name,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}/files/content",
|
||||
response_model=SkillFileSaveResponse,
|
||||
summary="Write Skill File (Text)",
|
||||
description=(
|
||||
"Replace the contents of a text file under a custom skill. Only the "
|
||||
"skill's owner or an admin may write; built-in (public) skills are "
|
||||
"read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
|
||||
"refresh the system-prompt cache."
|
||||
),
|
||||
)
|
||||
async def save_skill_file(
|
||||
skill_name: str,
|
||||
body: SkillFileSaveRequest,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillFileSaveResponse:
|
||||
try:
|
||||
skill_name = _normalize_skill_name_param(skill_name)
|
||||
skill = _load_skill_or_404(skill_name, config)
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
raise HTTPException(status_code=403, detail="Built-in skills are read-only")
|
||||
record = await skill_store.get_any(skill.name)
|
||||
if not _caller_can_edit_skill_files(skill, record, request):
|
||||
raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill")
|
||||
|
||||
target = _resolve_skill_relative_path(skill, body.path)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File '{body.path}' not found in skill '{skill_name}'")
|
||||
if target.is_dir():
|
||||
raise HTTPException(status_code=400, detail="path points to a directory")
|
||||
if not _is_text_file(target):
|
||||
raise HTTPException(status_code=400, detail=f"File type '{target.suffix or target.name}' is not editable")
|
||||
|
||||
if len(body.content.encode("utf-8")) > _SKILL_FILE_WRITE_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"Content exceeds the {_SKILL_FILE_WRITE_BYTES // 1024} KiB edit limit")
|
||||
|
||||
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
|
||||
is_skill_md = relative_posix == SKILL_MD_FILE
|
||||
prev_content: str | None = None
|
||||
if target.exists():
|
||||
try:
|
||||
prev_content = target.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
prev_content = None
|
||||
|
||||
if is_skill_md:
|
||||
# SKILL.md edits must keep their frontmatter valid (name must match).
|
||||
storage.validate_skill_markdown_content(skill.name, body.content)
|
||||
|
||||
storage.write_custom_skill(skill.name, relative_posix, body.content)
|
||||
|
||||
try:
|
||||
storage.append_history(
|
||||
skill.name,
|
||||
{
|
||||
"action": "human_edit_file",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": relative_posix,
|
||||
"prev_content": prev_content,
|
||||
"new_content": body.content,
|
||||
},
|
||||
)
|
||||
except Exception as history_err: # noqa: BLE001 — history is best-effort
|
||||
logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err)
|
||||
|
||||
if is_skill_md:
|
||||
try:
|
||||
from deerflow.skills.usage import bump_patch
|
||||
bump_patch(skill.name)
|
||||
except Exception:
|
||||
pass
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
|
||||
stat = target.stat()
|
||||
return SkillFileSaveResponse(
|
||||
name=skill.name,
|
||||
path=relative_posix,
|
||||
size=stat.st_size,
|
||||
mtime=stat.st_mtime,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
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 Exception as e:
|
||||
logger.error("Failed to save skill file %s/%s: %s", skill_name, body.path, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}",
|
||||
response_model=SkillResponse,
|
||||
|
||||
Reference in new issue
Block a user