技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e2b676caaa
commit
96b0bae6ca
8 files changed
+661
-4
No files matched your search
@@ -4,6 +4,7 @@ import {
|
|||||||
CopyIcon,
|
CopyIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileCode2Icon,
|
FileCode2Icon,
|
||||||
|
FilesIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -25,12 +26,15 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
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 { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
useSetSkillDetail,
|
useSetSkillDetail,
|
||||||
useSkill,
|
useSkill,
|
||||||
useSkillContent,
|
useSkillContent,
|
||||||
|
useSkillFiles,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
import { reasoningPlugins } from "@/core/streamdown";
|
||||||
@@ -83,10 +87,11 @@ export function SkillDetailDialog({
|
|||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
|
const [activeTab, setActiveTab] = useState<
|
||||||
null,
|
"detail" | "source" | "files" | null
|
||||||
);
|
>(null);
|
||||||
const [showRawSource, setShowRawSource] = useState(false);
|
const [showRawSource, setShowRawSource] = useState(false);
|
||||||
|
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||||
|
|
||||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||||
const {
|
const {
|
||||||
@@ -95,10 +100,18 @@ export function SkillDetailDialog({
|
|||||||
error: contentError,
|
error: contentError,
|
||||||
} = useSkillContent(open && canManage ? skillName : null);
|
} = useSkillContent(open && canManage ? skillName : null);
|
||||||
|
|
||||||
|
const filesEnabled = open && canManage && activeTab === "files";
|
||||||
|
const {
|
||||||
|
data: filesData,
|
||||||
|
isLoading: isFilesLoading,
|
||||||
|
error: filesError,
|
||||||
|
} = useSkillFiles(filesEnabled ? skillName : null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setActiveTab(null);
|
setActiveTab(null);
|
||||||
setShowRawSource(false);
|
setShowRawSource(false);
|
||||||
|
setOpenedFilePath(null);
|
||||||
}, [open, skillName]);
|
}, [open, skillName]);
|
||||||
|
|
||||||
const skillBody = useMemo(() => {
|
const skillBody = useMemo(() => {
|
||||||
@@ -131,6 +144,7 @@ export function SkillDetailDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||||
@@ -193,6 +207,12 @@ export function SkillDetailDialog({
|
|||||||
<FileTextIcon className="mr-1 size-3.5" />
|
<FileTextIcon className="mr-1 size-3.5" />
|
||||||
{tx.detailSectionTitle}
|
{tx.detailSectionTitle}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{canManage && (
|
||||||
|
<TabsTrigger value="files" className="text-xs">
|
||||||
|
<FilesIcon className="mr-1 size-3.5" />
|
||||||
|
{tx.filesSectionTitle}
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<TabsTrigger value="source" className="text-xs">
|
<TabsTrigger value="source" className="text-xs">
|
||||||
<FileCode2Icon className="mr-1 size-3.5" />
|
<FileCode2Icon className="mr-1 size-3.5" />
|
||||||
@@ -310,6 +330,34 @@ export function SkillDetailDialog({
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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 && (
|
{canManage && (
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="source"
|
value="source"
|
||||||
@@ -346,5 +394,15 @@ export function SkillDetailDialog({
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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:
|
detailAdminOnlyHint:
|
||||||
"Only an admin or the skill publisher can edit the details.",
|
"Only an admin or the skill publisher can edit the details.",
|
||||||
contentSectionTitle: "SKILL.md source",
|
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",
|
viewDetailAction: "View skill details",
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: "Archived skills",
|
archivedTitle: "Archived skills",
|
||||||
|
|||||||
@@ -432,6 +432,20 @@ export interface Translations {
|
|||||||
detailSaveSuccess: string;
|
detailSaveSuccess: string;
|
||||||
detailAdminOnlyHint: string;
|
detailAdminOnlyHint: string;
|
||||||
contentSectionTitle: 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;
|
viewDetailAction: string;
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: string;
|
archivedTitle: string;
|
||||||
|
|||||||
@@ -479,6 +479,20 @@ export const zhCN: Translations = {
|
|||||||
detailSaveSuccess: "详情已更新",
|
detailSaveSuccess: "详情已更新",
|
||||||
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
|
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
|
||||||
contentSectionTitle: "SKILL.md 原文",
|
contentSectionTitle: "SKILL.md 原文",
|
||||||
|
filesSectionTitle: "文件",
|
||||||
|
filesEmpty: "该技能目录下没有可显示的文件。",
|
||||||
|
filesLoadError: "加载文件失败",
|
||||||
|
filesPreviewBinary: "二进制文件无法预览,可点击下载查看。",
|
||||||
|
filesPreviewUnsupported: "该文件类型暂不支持预览。",
|
||||||
|
filesPreviewTooLarge: "文件过大,无法在线预览。",
|
||||||
|
filesEditAction: "编辑",
|
||||||
|
filesSaveAction: "保存",
|
||||||
|
filesCancelEditAction: "取消",
|
||||||
|
filesDownloadAction: "下载",
|
||||||
|
filesCopyPathAction: "复制路径",
|
||||||
|
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||||
|
filesSaveSuccess: "文件已保存",
|
||||||
|
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
||||||
viewDetailAction: "查看技能详情",
|
viewDetailAction: "查看技能详情",
|
||||||
stateSheet: {
|
stateSheet: {
|
||||||
archivedTitle: "归档的技能",
|
archivedTitle: "归档的技能",
|
||||||
|
|||||||
@@ -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 {
|
export interface SetSkillDetailResponse {
|
||||||
name: string;
|
name: string;
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
enableSkill,
|
enableSkill,
|
||||||
getSkill,
|
getSkill,
|
||||||
getSkillContent,
|
getSkillContent,
|
||||||
|
getSkillFileContent,
|
||||||
|
getSkillFiles,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
reconcileCustomSkills,
|
reconcileCustomSkills,
|
||||||
|
saveSkillFile,
|
||||||
setSkillDetail,
|
setSkillDetail,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillPublished,
|
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 json
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
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)}")
|
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(
|
@router.put(
|
||||||
"/skills/{skill_name}",
|
"/skills/{skill_name}",
|
||||||
response_model=SkillResponse,
|
response_model=SkillResponse,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ sandbox:
|
|||||||
allow_host_bash: true
|
allow_host_bash: true
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
path: D:\project\bj\deerflow-server\skills
|
path: D:\cursor-work\offline-backend-20260512\backend\skills
|
||||||
security_scan:
|
security_scan:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user