技能详情对话框新增文件浏览面板:支持预览、编辑、下载、复制路径
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,
|
||||
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: "归档的技能",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user