From 9a63a4b7ca7fe757cc4d3d76a1ab33d7a7412d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 25 May 2026 14:17:39 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E6=8A=80=E8=83=BD=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E9=9D=A2=E6=9D=BF:=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E3=80=81=E7=BC=96=E8=BE=91=E3=80=81=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E3=80=81=E5=A4=8D=E5=88=B6=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspace/skills/skill-detail-dialog.tsx | 64 ++- frontend-web/src/core/i18n/locales/en-US.ts | 14 + frontend-web/src/core/i18n/locales/types.ts | 14 + frontend-web/src/core/i18n/locales/zh-CN.ts | 14 + frontend-web/src/core/skills/api.ts | 86 ++++ frontend-web/src/core/skills/hooks.ts | 43 ++ .../backend/app/gateway/routers/skills.py | 428 ++++++++++++++++++ offline-backend-20260512/backend/config.yaml | 2 +- 8 files changed, 661 insertions(+), 4 deletions(-) diff --git a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx index 29d6bb9d..ca401519 100644 --- a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx +++ b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx @@ -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"; @@ -82,10 +86,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(null); const canManage = useMemo(() => canManageSkill(skill), [skill]); const { @@ -94,10 +99,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(() => { @@ -130,6 +143,7 @@ export function SkillDetailDialog({ } return ( + <> {tx.detailSectionTitle} + {canManage && ( + + + {tx.filesSectionTitle} + + )} {canManage && ( @@ -309,6 +329,34 @@ export function SkillDetailDialog({ )} + {canManage && ( + + {isFilesLoading ? ( +
+ + {t.common.loading} +
+ ) : filesError ? ( +
+ {filesError instanceof Error + ? filesError.message + : String(filesError)} +
+ ) : filesData ? ( + setOpenedFilePath(path)} + emptyText={tx.filesEmpty} + /> + ) : null} +
+ )} + {canManage && (
+ { + if (!next) setOpenedFilePath(null); + }} + /> + ); } diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 210dd65d..8b602073 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -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", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 532692d4..5051292c 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 5e1e0688..b58d38b8 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -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: "归档的技能", diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 25428dcb..3b31cde1 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -239,6 +239,92 @@ export async function deleteCustomSkill(skillName: string): Promise { } } +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 { + 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; +} + +export async function getSkillFileContent( + skillName: string, + path: string, +): Promise { + 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; +} + +/** URL suitable for direct `` / `