feat: 管理员可删除内置技能并管理技能内文件

新增内置技能删除与技能文件删除 API,允许管理员编辑/删除内置技能文件;技能设置页与文件预览弹窗同步支持删除操作。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-12 15:05:29 +08:00
1 parent c9c2dc7ea8
commit bf023d746a
9 files changed
+361 -73

No files matched your search

@@ -71,8 +71,9 @@ import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
useDuplicateSkill,
useGenerateMissingSkillNameZh,
useGenerateSkillNameZh,
@@ -616,9 +617,10 @@ export function SkillRow({
const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const deleteSkill = useDeleteCustomSkill();
const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings();
const deleteCustom = useDeleteCustomSkill();
const deleteBuiltin = useDeleteBuiltinSkill();
const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
@@ -634,8 +636,9 @@ export function SkillRow({
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
// Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned;
@@ -686,11 +689,15 @@ export function SkillRow({
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
async function handleDelete() {
try {
if (isBuiltin) {
await deleteBuiltin.mutateAsync(skill.name);
} else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -918,7 +925,7 @@ export function SkillRow({
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
{canManage && (
<Button
size="icon"
variant="ghost"
@@ -939,12 +946,12 @@ export function SkillRow({
/>
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
@@ -1011,11 +1018,11 @@ export function SkillRow({
>
<CopyIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
@@ -1109,16 +1116,18 @@ export function SkillRow({
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
>
{deleteCustom.isPending || deleteBuiltin.isPending
? t.common.loading
: t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,6 +5,7 @@ import {
DownloadIcon,
Loader2,
PencilIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -22,6 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getSkillFileDownloadURL } from "@/core/skills/api";
import {
useDeleteSkillFile,
useSaveSkillFile,
useSkillFileContent,
} from "@/core/skills/hooks";
@@ -66,6 +68,7 @@ export function SkillFilePreviewDialog({
open ? path : null,
);
const save = useSaveSkillFile();
const remove = useDeleteSkillFile();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@@ -77,6 +80,8 @@ export function SkillFilePreviewDialog({
}, [path, open]);
const fileEditable = !!data?.editable && treeEditable;
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
const isMarkdown = useMemo(() => {
if (!path) return false;
const lower = path.toLowerCase();
@@ -115,6 +120,18 @@ export function SkillFilePreviewDialog({
}
}
async function handleDelete() {
if (!skillName || !path) return;
if (!window.confirm(tx.filesDeleteConfirm.replace("{path}", path))) return;
try {
await remove.mutateAsync({ skillName, path });
toast.success(tx.filesDeleteSuccess);
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleCopyPath() {
if (!path) return;
await navigator.clipboard.writeText(path);
@@ -158,6 +175,18 @@ export function SkillFilePreviewDialog({
{tx.filesEditAction}
</Button>
)}
{fileDeletable && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => void handleDelete()}
disabled={remove.isPending}
>
{remove.isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
{tx.filesDeleteAction}
</Button>
)}
{editing && (
<>
<Button
+7 -4
View File
@@ -545,10 +545,13 @@ export const enUS: Translations = {
filesSaveAction: "Save",
filesCancelEditAction: "Cancel",
filesDownloadAction: "Download",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesReadOnlyHint: "Built-in skills are read-only.",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
viewDetailAction: "View skill details",
stateSheet: {
archivedTitle: "Archived skills",
+7 -4
View File
@@ -475,10 +475,13 @@ export interface Translations {
filesSaveAction: string;
filesCancelEditAction: string;
filesDownloadAction: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesReadOnlyHint: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
viewDetailAction: string;
stateSheet: {
archivedTitle: string;
+5 -2
View File
@@ -524,8 +524,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载",
filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存",
filesReadOnlyHint: "内置技能为只读,仅可查看。",
filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情",
stateSheet: {
archivedTitle: "归档的技能",
+31 -7
View File
@@ -402,15 +402,26 @@ export async function getSkillContent(
return response.json() as Promise<SkillContentResponse>;
}
export async function deleteCustomSkill(skillName: string): Promise<void> {
export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
}
}
/** Delete a built-in skill. The backend permits this only for administrators. */
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete built-in skill");
}
}
export interface SkillFileNode {
name: string;
@@ -479,7 +490,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
);
}
export async function saveSkillFile(
export async function saveSkillFile(
skillName: string,
path: string,
content: string,
@@ -494,9 +505,22 @@ export async function saveSkillFile(
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to save skill file");
}
return response.json() as Promise<SkillFileSaveResponse>;
}
}
return response.json() as Promise<SkillFileSaveResponse>;
}
export async function deleteSkillFile(
skillName: string,
path: string,
): Promise<void> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url, { method: "DELETE" });
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill file");
}
}
export interface SkillNameZhResponse {
name: string;
+35 -5
View File
@@ -5,8 +5,10 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import {
deleteCustomSkill,
import {
deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
duplicateSkill,
enableSkill,
generateMissingSkillNameZh,
@@ -318,7 +320,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
});
}
export function useSaveSkillFile() {
export function useSaveSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
@@ -339,5 +341,33 @@ export function useSaveSkillFile() {
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
}
},
});
}
});
}
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
@@ -1622,6 +1622,102 @@ async def delete_custom_skill(
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
def _remove_skill_state_config(skill_name: str) -> None:
"""Remove a deleted skill's enabled/display override from extensions config."""
extensions_config = get_extensions_config()
if skill_name not in extensions_config.skills:
return
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
config_path = Path.cwd().parent / "extensions_config.json"
extensions_config.skills.pop(skill_name, None)
config_data = _dump_extensions_config(extensions_config)
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=str(config_path.parent),
) as tmp_file:
json.dump(config_data, tmp_file, indent=2)
tmp_path = Path(tmp_file.name)
os.replace(tmp_path, config_path)
finally:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
reload_extensions_config()
@router.delete(
"/skills/{skill_name}",
summary="Delete Built-in Skill",
description=(
"Administrators may permanently delete a built-in skill. The skill is "
"removed from enabled configuration and detached from agents that used it."
),
)
async def delete_builtin_skill(
skill_name: str,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
agent_store=Depends(get_agent_store),
) -> dict[str, bool]:
try:
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only administrators can delete built-in skills")
skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config)
if skill.category != SkillCategory.PUBLIC:
raise HTTPException(status_code=400, detail="Use the custom skill deletion endpoint for a custom skill")
storage = get_or_new_skill_storage(app_config=config)
public_root = (storage.get_skills_root_path() / SkillCategory.PUBLIC.value).resolve()
skill_dir = skill.skill_file.parent.resolve()
if skill_dir == public_root or public_root not in skill_dir.parents:
raise ValueError(f"Refusing to delete built-in skill outside the public skills directory: {skill.name}")
_remove_skill_state_config(skill.name)
shutil.rmtree(skill_dir)
try:
from deerflow.skills.usage import forget
forget(skill.name)
except Exception:
pass
await skill_store.delete_admin(skill.name)
try:
await get_tag_store(request).unassign_all("skill", skill.name)
except Exception: # noqa: BLE001 -- tag cleanup must not block deletion
pass
await cascade_skill_unpublished_or_deleted(
skill.name,
actor_user_id=_current_user_id(request),
operation="delete",
skill_owner_user_id=None,
agent_store=agent_store,
)
await refresh_skills_system_prompt_cache_async()
logger.info("Administrator deleted built-in skill %s", skill.name)
return {"success": True}
except HTTPException:
raise
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to delete built-in skill %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete built-in skill: {str(e)}")
class SkillPublishRequest(BaseModel):
"""Request body for toggling a custom skill's published flag."""
@@ -2080,11 +2176,16 @@ def _caller_can_edit_skill_files(
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
"""Whether the caller can edit or delete a file within *skill*.
Built-in skills are deployment-owned, so only administrators may change
their files. Custom skills remain editable by their owner or by an
administrator.
"""
if _is_admin_user(request):
return True
if skill.category != SkillCategory.CUSTOM:
return False
if record is None:
return False
return record.get("owner_user_id") == _current_user_id(request)
@@ -2125,6 +2226,24 @@ def _is_text_file(path: Path) -> bool:
return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS
def _write_skill_text_file_atomically(target: Path, content: str) -> None:
"""Replace an already-validated skill file without exposing partial text."""
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=str(target.parent),
) as tmp_file:
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
os.replace(tmp_path, target)
finally:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode:
"""Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
@@ -2302,9 +2421,9 @@ async def download_skill_file(
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 "
"Replace the contents of a text file under a skill. Administrators may "
"write built-in and custom skills; custom-skill owners may write their "
"own skills. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
"refresh the system-prompt cache."
),
)
@@ -2318,11 +2437,9 @@ async def save_skill_file(
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")
raise HTTPException(status_code=403, detail="Only an administrator or the custom skill owner can edit files in this skill")
target = _resolve_skill_relative_path(skill, body.path)
if not target.exists():
@@ -2338,7 +2455,7 @@ async def save_skill_file(
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
is_skill_md = relative_posix.casefold() == SKILL_MD_FILE.casefold()
prev_content: str | None = None
if target.exists():
try:
@@ -2350,20 +2467,21 @@ async def save_skill_file(
# 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)
_write_skill_text_file_atomically(target, 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,
},
)
if skill.category == SkillCategory.CUSTOM:
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)
@@ -2393,6 +2511,75 @@ async def save_skill_file(
raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}")
@router.delete(
"/skills/{skill_name}/files/content",
status_code=204,
summary="Delete Skill File",
description=(
"Delete one non-SKILL.md file under a skill. Administrators may delete "
"files from built-in and custom skills; custom-skill owners may delete "
"files from their own skills. The root ``SKILL.md`` manifest cannot be "
"deleted because it defines the skill."
),
)
async def delete_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),
) -> None:
try:
skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config)
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 an administrator or the custom skill owner can delete files in this skill")
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}'")
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
if relative_posix.casefold() == SKILL_MD_FILE.casefold():
raise HTTPException(status_code=400, detail="SKILL.md defines the skill and cannot be deleted")
prev_content: str | None = None
if _is_text_file(target):
try:
prev_content = target.read_text(encoding="utf-8")
except UnicodeDecodeError:
pass
target.unlink()
if skill.category == SkillCategory.CUSTOM:
storage = get_or_new_skill_storage(app_config=config)
try:
storage.append_history(
skill.name,
{
"action": "human_delete_file",
"author": "human",
"thread_id": None,
"file_path": relative_posix,
"prev_content": prev_content,
},
)
except Exception as history_err: # noqa: BLE001
logger.warning("Failed to append deletion history for %s/%s: %s", skill.name, relative_posix, history_err)
else:
logger.info("Administrator deleted built-in skill file %s/%s", skill.name, relative_posix)
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 delete skill file %s/%s: %s", skill_name, path, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete skill file: {e}")
@router.put(
"/skills/{skill_name}",
response_model=SkillResponse,
@@ -8,7 +8,7 @@ SKILL_MD_FILE = "SKILL.md"
class SkillCategory(StrEnum):
"""Source category for a skill.
- ``PUBLIC``: built-in skill bundled with the platform, read-only.
- ``PUBLIC``: built-in skill bundled with the platform; administrator-managed.
- ``CUSTOM``: user-authored skill that can be edited or deleted.
"""