技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 authored and xuhd committed 2026-05-28 18:27:12 +08:00
1 parent e2b676caaa
commit 96b0bae6ca
8 files changed
+661 -4

No files matched your search

@@ -4,6 +4,7 @@ import {
CopyIcon,
EyeIcon,
FileCode2Icon,
FilesIcon,
FileTextIcon,
GlobeIcon,
Loader2,
@@ -25,12 +26,15 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import {
useSetSkillDetail,
useSkill,
useSkillContent,
useSkillFiles,
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
@@ -83,10 +87,11 @@ export function SkillDetailDialog({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
null,
);
const [activeTab, setActiveTab] = useState<
"detail" | "source" | "files" | null
>(null);
const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const canManage = useMemo(() => canManageSkill(skill), [skill]);
const {
@@ -95,10 +100,18 @@ export function SkillDetailDialog({
error: contentError,
} = useSkillContent(open && canManage ? skillName : null);
const filesEnabled = open && canManage && activeTab === "files";
const {
data: filesData,
isLoading: isFilesLoading,
error: filesError,
} = useSkillFiles(filesEnabled ? skillName : null);
useEffect(() => {
setEditing(false);
setActiveTab(null);
setShowRawSource(false);
setOpenedFilePath(null);
}, [open, skillName]);
const skillBody = useMemo(() => {
@@ -131,6 +144,7 @@ export function SkillDetailDialog({
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
@@ -193,6 +207,12 @@ export function SkillDetailDialog({
<FileTextIcon className="mr-1 size-3.5" />
{tx.detailSectionTitle}
</TabsTrigger>
{canManage && (
<TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" />
{tx.filesSectionTitle}
</TabsTrigger>
)}
{canManage && (
<TabsTrigger value="source" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" />
@@ -310,6 +330,34 @@ export function SkillDetailDialog({
)}
</TabsContent>
{canManage && (
<TabsContent
value="files"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{isFilesLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : filesError ? (
<div className="text-destructive py-6 text-sm">
{filesError instanceof Error
? filesError.message
: String(filesError)}
</div>
) : filesData ? (
<SkillFileTree
root={filesData.root}
selectedPath={openedFilePath}
onOpen={(path) => setOpenedFilePath(path)}
emptyText={tx.filesEmpty}
/>
) : null}
</TabsContent>
)}
{canManage && (
<TabsContent
value="source"
@@ -346,5 +394,15 @@ export function SkillDetailDialog({
)}
</DialogContent>
</Dialog>
<SkillFilePreviewDialog
skillName={skill?.name ?? null}
path={openedFilePath}
treeEditable={!!filesData?.editable}
open={!!openedFilePath}
onOpenChange={(next) => {
if (!next) setOpenedFilePath(null);
}}
/>
</>
);
}
@@ -501,6 +501,20 @@ export const enUS: Translations = {
detailAdminOnlyHint:
"Only an admin or the skill publisher can edit the details.",
contentSectionTitle: "SKILL.md source",
filesSectionTitle: "Files",
filesEmpty: "This skill has no files to display.",
filesLoadError: "Failed to load files",
filesPreviewBinary: "Binary file cannot be previewed inline — use Download.",
filesPreviewUnsupported: "This file type is not supported for inline preview.",
filesPreviewTooLarge: "File is too large for inline preview.",
filesEditAction: "Edit",
filesSaveAction: "Save",
filesCancelEditAction: "Cancel",
filesDownloadAction: "Download",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesReadOnlyHint: "Built-in skills are read-only.",
viewDetailAction: "View skill details",
stateSheet: {
archivedTitle: "Archived skills",
@@ -432,6 +432,20 @@ export interface Translations {
detailSaveSuccess: string;
detailAdminOnlyHint: string;
contentSectionTitle: string;
filesSectionTitle: string;
filesEmpty: string;
filesLoadError: string;
filesPreviewBinary: string;
filesPreviewUnsupported: string;
filesPreviewTooLarge: string;
filesEditAction: string;
filesSaveAction: string;
filesCancelEditAction: string;
filesDownloadAction: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesReadOnlyHint: string;
viewDetailAction: string;
stateSheet: {
archivedTitle: string;
@@ -479,6 +479,20 @@ export const zhCN: Translations = {
detailSaveSuccess: "详情已更新",
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
contentSectionTitle: "SKILL.md 原文",
filesSectionTitle: "文件",
filesEmpty: "该技能目录下没有可显示的文件。",
filesLoadError: "加载文件失败",
filesPreviewBinary: "二进制文件无法预览,可点击下载查看。",
filesPreviewUnsupported: "该文件类型暂不支持预览。",
filesPreviewTooLarge: "文件过大,无法在线预览。",
filesEditAction: "编辑",
filesSaveAction: "保存",
filesCancelEditAction: "取消",
filesDownloadAction: "下载",
filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存",
filesReadOnlyHint: "内置技能为只读,仅可查看。",
viewDetailAction: "查看技能详情",
stateSheet: {
archivedTitle: "归档的技能",
+86
View File
@@ -239,6 +239,92 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
}
}
export interface SkillFileNode {
name: string;
type: "file" | "dir";
path: string;
size?: number | null;
mtime?: number | null;
children?: SkillFileNode[] | null;
}
export interface SkillFilesResponse {
name: string;
category: "public" | "custom";
editable: boolean;
root: SkillFileNode;
}
export interface SkillFileContentResponse {
name: string;
category: "public" | "custom";
path: string;
mime: string;
size: number;
mtime: number;
is_text: boolean;
editable: boolean;
content?: string | null;
}
export interface SkillFileSaveResponse {
name: string;
path: string;
size: number;
mtime: number;
}
export async function getSkillFiles(skillName: string): Promise<SkillFilesResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill files");
}
return response.json() as Promise<SkillFilesResponse>;
}
export async function getSkillFileContent(
skillName: string,
path: string,
): Promise<SkillFileContentResponse> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to read skill file");
}
return response.json() as Promise<SkillFileContentResponse>;
}
/** URL suitable for direct `<img>` / `<iframe>` / `<a download>` embedding. */
export function getSkillFileDownloadURL(skillName: string, path: string): string {
return (
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/download` +
`?path=${encodeURIComponent(path)}`
);
}
export async function saveSkillFile(
skillName: string,
path: string,
content: string,
): Promise<SkillFileSaveResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to save skill file");
}
return response.json() as Promise<SkillFileSaveResponse>;
}
export interface SetSkillDetailResponse {
name: string;
detail: string | null;
+43
View File
@@ -10,8 +10,11 @@ import {
enableSkill,
getSkill,
getSkillContent,
getSkillFileContent,
getSkillFiles,
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
setSkillDetail,
setSkillPinned,
setSkillPublished,
@@ -153,3 +156,43 @@ export function useDeleteCustomSkill() {
},
});
}
export function useSkillFiles(skillName: string | null) {
return useQuery({
queryKey: ["skill-files", skillName],
queryFn: () => getSkillFiles(skillName!),
enabled: !!skillName,
});
}
export function useSkillFileContent(skillName: string | null, path: string | null) {
return useQuery({
queryKey: ["skill-file", skillName, path],
queryFn: () => getSkillFileContent(skillName!, path!),
enabled: !!skillName && !!path,
});
}
export function useSaveSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
content,
}: {
skillName: string;
path: string;
content: string;
}) => saveSkillFile(skillName, path, content),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-file", skillName, path] });
// SKILL.md edits also affect the source-tab view and skill metadata.
if (path === "SKILL.md") {
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
}
},
});
}
@@ -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,
+1 -1
View File
@@ -131,7 +131,7 @@ sandbox:
allow_host_bash: true
skills:
path: D:\project\bj\deerflow-server\skills
path: D:\cursor-work\offline-backend-20260512\backend\skills
security_scan:
enabled: false