From c877be3f22e708c0a8cc4636e2ff0b609e46c3db Mon Sep 17 00:00:00 2001 From: Jesse <15653378+squarezw@user.noreply.gitee.com> Date: Tue, 22 Sep 2026 11:47:34 +0800 Subject: [PATCH] feat(skills): defer asset loading until the section is expanded Collapse the skill asset files panel by default and skip draft and published asset fetches until the user opens it. Co-authored-by: Cursor --- app/skills/components/SkillAssetsPanel.tsx | 712 +++++++++++---------- hooks/useSkillAssets.ts | 13 +- 2 files changed, 380 insertions(+), 345 deletions(-) diff --git a/app/skills/components/SkillAssetsPanel.tsx b/app/skills/components/SkillAssetsPanel.tsx index b4c4bcd..e460b55 100644 --- a/app/skills/components/SkillAssetsPanel.tsx +++ b/app/skills/components/SkillAssetsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { type DragEvent, useEffect, useMemo, useRef, useState } from "react"; +import { type DragEvent, useEffect, useId, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import axios from "@/lib/axios"; import { toast } from "sonner"; @@ -30,6 +30,7 @@ import { } from "@/components/ui/select"; import { BookOpen, + ChevronDown, Download, Eye, FileCode2, @@ -128,6 +129,10 @@ export default function SkillAssetsPanel({ }: SkillAssetsPanelProps) { const t = useTranslations("skills"); const tc = useTranslations("common"); + const [assetsOpen, setAssetsOpen] = useState(false); + // Mount and fetch on first expansion; later collapses preserve uploads and inputs. + const [assetsActivated, setAssetsActivated] = useState(false); + const assetsContentId = useId(); const { items, @@ -147,7 +152,7 @@ export default function SkillAssetsPanel({ deleteAsset, saveExecConfig, deleteExecConfig, - } = useSkillAssets(skill.id, canEdit); + } = useSkillAssets(skill.id, canEdit, assetsActivated); const [staged, setStaged] = useState([]); const [exporting, setExporting] = useState(false); @@ -428,371 +433,398 @@ export default function SkillAssetsPanel({ {/* 参考文档与资产文件:与「是否可执行」解耦,知识型 skill 也能只用这一块 */} - - - {t("assetFilesSection")} + +

{t("assetFilesDesc")}

- - {willRevertToDraft(skill.status) && ( -

- {t("assetRevertNotice")} -

- )} - - {/* 资产清单(draft stage) */} -
-
-

- - {t("assetManifestSection")} -

-
-

- {t("assetsQuota", { - count: items.length, - size: formatBytes(totalBytes), - limit: formatBytes(ASSET_MAX_TOTAL_BYTES), - })} +

- {assetsLoading ? ( -
- -
- ) : items.length === 0 ? ( -

- {t("assetsEmpty")} -

- ) : ( -
- {groups.map((group) => ( -
-
- - {group.dir ? `${group.dir}/` : t("assetsRootGroup")} - - - {group.items.length} · {formatBytes(group.totalBytes)} - -
- {/* 行布局而非 table:窄屏下 table-layout:auto 会把路径列压成每字一行并撑出横向滚动 */} -
    - {group.items.map((item) => ( -
  • -
    - {item.path} - {/* 可读 = 已发布快照里的文本 reference(判据见 isModelReadableAsset) */} - {readablePaths.has(item.path) ? ( - - - {t("assetReadable")} - - ) : ( - isModelReadableAsset(item) && ( - - {t("assetReadableAfterPublish")} + {assetsLoading ? ( +
    + +
    + ) : items.length === 0 ? ( +

    + {t("assetsEmpty")} +

    + ) : ( +
    + {groups.map((group) => ( +
    +
    + + {group.dir ? `${group.dir}/` : t("assetsRootGroup")} + + + {group.items.length} · {formatBytes(group.totalBytes)} + +
    + {/* 行布局而非 table:窄屏下 table-layout:auto 会把路径列压成每字一行并撑出横向滚动 */} +
      + {group.items.map((item) => ( +
    • +
      + {item.path} + {/* 可读 = 已发布快照里的文本 reference(判据见 isModelReadableAsset) */} + {readablePaths.has(item.path) ? ( + + + {t("assetReadable")} + + ) : ( + isModelReadableAsset(item) && ( + + {t("assetReadableAfterPublish")} + + ) + )} +
      +
      + {assetKindLabel(t, item.kind)} + + {formatBytes(item.size_bytes)} + + + {shortSha(item.sha256)} - ) - )} -
      -
      - {assetKindLabel(t, item.kind)} - - {formatBytes(item.size_bytes)} - - - {shortSha(item.sha256)} - - {/* 能预览的才给按钮:.zip/.so/.pyc 点开只有一屏乱码, + {/* 能预览的才给按钮:.zip/.so/.pyc 点开只有一屏乱码, 那比没有按钮更让人困惑 */} - {isPreviewableAsset(item.path) ? ( - - ) : ( - - )} - - -
      -
    • - ))} -
    + {isPreviewableAsset(item.path) ? ( + + ) : ( + + )} + + +
    +
  • + ))} +
+
+ ))}
- ))} -
- )} -
- - {/* 上传区 */} -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: 拖放区只是鼠标增强,键盘用户走下方两个选择按钮 */} -
{ - e.preventDefault(); - setDragging(true); - }} - onDragLeave={() => setDragging(false)} - onDrop={handleDrop} - className={`rounded-md border border-dashed p-6 text-center space-y-2 ${ - dragging ? "border-primary bg-primary/5" : "" - }`} - > - -

{t("uploadDropzone")}

-
- - + )}
-

- {t("uploadHint", { max: formatBytes(ASSET_MAX_FILE_BYTES) })} -

- { - addFiles(Array.from(e.target.files || []).map((f) => toStaged(f))); - e.target.value = ""; - }} - /> - {/* 替换用的单文件选择器。挑完文件才过 guard —— 与"先备好再确认"的上传 - 流程一致:先弹退回草稿的确认、用户点了确认才弹文件框,会让人不知道 - 自己刚确认的是什么。 */} - { - const file = e.target.files?.[0] ?? null; - e.target.value = ""; - const target = replaceTarget.current; - replaceTarget.current = null; - if (file && target) guard(() => runReplace(target, file)); - }} - /> - { - addFolderFiles(Array.from(e.target.files || []).map((f) => toStaged(f))); - e.target.value = ""; - }} - /> -
- {staged.length > 0 && ( -
-
-

- {t("uploadPending", { count: staged.length })} -

-
-
+

+ {t("uploadHint", { max: formatBytes(ASSET_MAX_FILE_BYTES) })} +

+ { + addFiles(Array.from(e.target.files || []).map((f) => toStaged(f))); + e.target.value = ""; + }} + /> + {/* 替换用的单文件选择器。挑完文件才过 guard —— 与"先备好再确认"的上传 + 流程一致:先弹退回草稿的确认、用户点了确认才弹文件框,会让人不知道 + 自己刚确认的是什么。 */} + { + const file = e.target.files?.[0] ?? null; + e.target.value = ""; + const target = replaceTarget.current; + replaceTarget.current = null; + if (file && target) guard(() => runReplace(target, file)); + }} + /> + { + addFolderFiles(Array.from(e.target.files || []).map((f) => toStaged(f))); + e.target.value = ""; + }} + />
-

- {t("assetKindHint")} -

- {/* 剥掉一层目录是对用户输入的改写,必须看得见。下面那列路径本身已经是 + + {staged.length > 0 && ( +
+
+

+ {t("uploadPending", { count: staged.length })} +

+
+ + +
+
+

+ {t("assetKindHint")} +

+ {/* 剥掉一层目录是对用户输入的改写,必须看得见。下面那列路径本身已经是 改写后的结果,但不说一句,用户会以为自己选错了文件夹。 */} - {strippedRoot && ( -

- {t("uploadStrippedRoot", { root: strippedRoot })} -

- )} -
    - {staged.map((item) => { - const entry = entryByStagedId.get(item.id); - // 正文那一行没有 entry(它不是资产),也就没有类型可选 - const isBody = entry === undefined; - const kindWarning = entry ? assetKindWarning(entry.path, entry.kind) : null; - return ( -
  • -
    - - setStaged((prev) => - prev.map((s) => - s.id === item.id ? { ...s, path: e.target.value } : s - ) - ) - } - className={`h-7 text-xs font-mono ${ - entry?.error ? "border-destructive" : "" - }`} - aria-label={t("uploadTargetPath")} - /> - {entry?.error && ( -

    - {uploadErrorMessage(t, entry.error)} -

    - )} - {/* 正文行:切分失败要当场看见。成功也要说清 frontmatter 不应用 —— - 默默只更新一半是最容易误导人的做法。 */} - {isBody && bodySplit?.error && ( -

    {bodySplit.error}

    - )} - {isBody && bodySplit && !bodySplit.error && ( -

    - {t("uploadSkillMdFrontmatterIgnored")} -

    - )} - {/* 二进制标成 reference 会进注入块 footer 却读不出来,只提示不拦 */} - {kindWarning === "binaryAsReference" && ( -

    - {t("assetKindWarnBinaryAsReference")} -

    - )} -
    -
    - {isBody ? ( - - {t("uploadSkillMdBody")} - - ) : ( - + setStaged((prev) => + prev.map((s) => + s.id === item.id ? { ...s, path: e.target.value } : s + ) ) - ) - } - > - + {entry?.error && ( +

    + {uploadErrorMessage(t, entry.error)} +

    + )} + {/* 正文行:切分失败要当场看见。成功也要说清 frontmatter 不应用 —— + 默默只更新一半是最容易误导人的做法。 */} + {isBody && bodySplit?.error && ( +

    + {bodySplit.error} +

    + )} + {isBody && bodySplit && !bodySplit.error && ( +

    + {t("uploadSkillMdFrontmatterIgnored")} +

    + )} + {/* 二进制标成 reference 会进注入块 footer 却读不出来,只提示不拦 */} + {kindWarning === "binaryAsReference" && ( +

    + {t("assetKindWarnBinaryAsReference")} +

    + )} +
    +
    + {isBody ? ( + + {t("uploadSkillMdBody")} + + ) : ( + + )} + + {formatBytes(item.file.size)} + + -
    -
  • - ); - })} -
+ + +
+ + ); + })} + +
+ )}
- )} -
-
+ + )} +
{/* 可执行资产的运行配置:仅可执行 skill(或点了转换)才出现,知识型 skill 不必碰 */} diff --git a/hooks/useSkillAssets.ts b/hooks/useSkillAssets.ts index d6fd283..cc44bd4 100644 --- a/hooks/useSkillAssets.ts +++ b/hooks/useSkillAssets.ts @@ -78,15 +78,18 @@ const imagesFetcher = async (url: string): Promise => { * 四条独立请求:draft 清单 / published 清单(算模型可读集)/ exec 配置(404=非可执行)/ * 镜像白名单(失败=降级手输)。 */ -export function useSkillAssets(skillId: number | null, enabled: boolean) { +export function useSkillAssets(skillId: number | null, enabled: boolean, assetsEnabled = true) { const key = enabled && skillId ? skillId : null; + const assetsKey = assetsEnabled ? key : null; - const assets = useSWR(key ? `/api/v1/skills/${key}/assets?stage=draft` : null, listFetcher, { - revalidateOnFocus: false, - }); + const assets = useSWR( + assetsKey ? `/api/v1/skills/${assetsKey}/assets?stage=draft` : null, + listFetcher, + { revalidateOnFocus: false } + ); // 可读性只由已发布快照决定(draft 未过审,skill_view 读不到),故单独取一份 const publishedAssets = useSWR( - key ? `/api/v1/skills/${key}/assets?stage=published` : null, + assetsKey ? `/api/v1/skills/${assetsKey}/assets?stage=published` : null, publishedListFetcher, { revalidateOnFocus: false, shouldRetryOnError: false } );