From f883bc04b985b580fefc5ffe0616e389da9e0548 Mon Sep 17 00:00:00 2001 From: Jesse <15653378+squarezw@user.noreply.gitee.com> Date: Tue, 18 Aug 2026 15:48:34 +0800 Subject: [PATCH] feat(skills): import a skill folder or zip, with the tree shown before importing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a skill meant typing everything by hand — there was no way to bring in a directory exported from another platform. The new-skill page has no assets panel either, because every asset endpoint needs a skill id that does not exist yet, so even after saving you had to upload files one at a time. ## One file tree, two entry points Dropping a folder, dropping a zip, and picking either from a file dialog all normalize to the same `BundleFile[]` in `lib/skillBundle.ts` before anything is uploaded. The backend therefore accepts one shape. Keeping them separate would eventually grow bugs that exist on only one side — "zip imports fine but the folder doesn't" — with nothing in the code to suggest why. ## Validate, look, then import The dialog calls `/import/validate` first and renders the whole tree with each file marked: red blocks the import, grey is skipped by rule, and green shows the detected kind. Only then does the confirm button call `/import`. Skipped files are still listed rather than filtered out. Silently dropping `.git/`, `__pycache__` or a real `.env` looks identical to losing them in upload, and the user has no way to tell which happened. Directory status rolls up from its descendants (`skillImportTree.ts`), because the tree is collapsible: without roll-up a red file three levels down is hidden behind a folder that looks fine. ## Details that only fail on real input - **`toBase64` chunks the array.** `String.fromCharCode(...bytes)` on a whole file throws "Maximum call stack size exceeded" — but only past a few hundred KB, so it passes every small test and breaks on the first real skill. - **`readEntries` is called in a loop.** It returns at most 100 entries per call; reading once silently drops files from any directory larger than that, and drops exactly the ones sorted last. - **`webkitRelativePath` over `file.name`.** The latter is just a basename, so `scripts/run.py` and `references/run.py` would collapse into one key. - **`__MACOSX/` is discarded.** macOS puts resource forks in every zip it makes; they are not skill content and would litter the tree with `._x` files. - **Proxy body limit raised to 160mb.** Next's 1mb default would 413 before the request ever reached the backend, so the backend's own error would never be seen. Both proxies use the same limit — differing limits would produce "validated fine, import 413s" on large bundles only. ## Proxy coverage had a gap `skillsProxyCoverage.test.ts` (added after the `/tenant` 404) did not catch a deleted `import/index.ts`: `/api/v1/skills/import` is a single segment, so any `[id].ts`-shaped proxy "matches" it. That proxy forwards `/skills/{id}` though, so POSTing an id of "import" only produces a confusing error. Added an explicit existence assertion for both import proxies rather than relying on the wildcard coincidence — verified by deleting each file in turn. ## Tests 16 new (8 bundle normalization, 8 tree building), 285 passing, tsc non-TS5097 errors unchanged at 112. Rendering is not verified in a browser: the skills pages need an authenticated session. Co-Authored-By: Claude Opus 5 (1M context) --- app/skills/components/SkillImportDialog.tsx | 310 ++++++++++++++++++++ app/skills/page.tsx | 11 +- lib/skillBundle.ts | 160 ++++++++++ lib/skillImportTree.ts | 118 ++++++++ messages/en/skills.json | 23 +- messages/zh-CN/skills.json | 23 +- pages/api/v1/skills/import/index.ts | 17 ++ pages/api/v1/skills/import/validate.ts | 18 ++ test/skillBundle.test.ts | 63 ++++ test/skillImportTree.test.ts | 72 +++++ test/skillsProxyCoverage.test.ts | 16 + 11 files changed, 826 insertions(+), 5 deletions(-) create mode 100644 app/skills/components/SkillImportDialog.tsx create mode 100644 lib/skillBundle.ts create mode 100644 lib/skillImportTree.ts create mode 100644 pages/api/v1/skills/import/index.ts create mode 100644 pages/api/v1/skills/import/validate.ts create mode 100644 test/skillBundle.test.ts create mode 100644 test/skillImportTree.test.ts diff --git a/app/skills/components/SkillImportDialog.tsx b/app/skills/components/SkillImportDialog.tsx new file mode 100644 index 0000000..6258808 --- /dev/null +++ b/app/skills/components/SkillImportDialog.tsx @@ -0,0 +1,310 @@ +"use client"; + +import React, { useCallback, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + AlertCircle, ChevronDown, ChevronRight, File as FileIcon, Folder, + FolderOpen, Loader2, Upload, +} from "lucide-react"; +import { toast } from "sonner"; +import axios from "@/lib/axios"; +import { + isZipFile, precheck, readDataTransfer, readFileList, readZip, toPayload, + type BundleFile, +} from "@/lib/skillBundle"; +import { + buildTree, countByStatus, formatSize, + type FileStatus, type ImportFileVerdict, type TreeNode, +} from "@/lib/skillImportTree"; + +interface ValidateResult { + ok: boolean; + name: string; + description: string; + stripped_root: string | null; + errors: string[]; + warnings: string[]; + files: ImportFileVerdict[]; + total_files: number; + total_bytes: number; +} + +const STATUS_STYLE: Record = { + error: "text-destructive", + warning: "text-warning", + ok: "text-foreground", + // 跳过的文件压暗但**仍然列出来** —— 不显示会让用户以为文件传丢了 + skipped: "text-muted-foreground/60 line-through", +}; + +export default function SkillImportDialog({ + open, onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const t = useTranslations("skills"); + const tc = useTranslations("common"); + const router = useRouter(); + + const [busy, setBusy] = useState(null); + const [files, setFiles] = useState([]); + const [result, setResult] = useState(null); + const [dragging, setDragging] = useState(false); + const [collapsed, setCollapsed] = useState>(new Set()); + + const folderInput = useRef(null); + const zipInput = useRef(null); + + const reset = () => { + setFiles([]); setResult(null); setBusy(null); setCollapsed(new Set()); + }; + + const validate = useCallback(async (bundle: BundleFile[]) => { + const localError = precheck(bundle); + if (localError) { toast.error(localError); return; } + + setFiles(bundle); + setBusy("validating"); + try { + const { data } = await axios.post( + "/api/v1/skills/import/validate", toPayload(bundle)); + setResult(data); + } catch (e: any) { + toast.error(e?.response?.data?.detail || t("importFailed")); + setResult(null); + } finally { + setBusy(null); + } + }, [t]); + + const ingest = useCallback(async (read: () => Promise) => { + setBusy("reading"); + try { + await validate(await read()); + } catch (e: any) { + toast.error(e?.message || t("importFailed")); + setBusy(null); + } + }, [validate, t]); + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + void ingest(() => readDataTransfer(e.dataTransfer)); + }; + + const doImport = async () => { + if (!result?.ok || files.length === 0) return; + setBusy("importing"); + try { + const { data } = await axios.post("/api/v1/skills/import", toPayload(files)); + toast.success(t("importSuccess")); + onOpenChange(false); + reset(); + // 直接进编辑页:显示名留空,用户下一步就是填它 + if (data?.id) router.push(`/skills/${data.id}`); + } catch (e: any) { + const detail = e?.response?.data?.detail; + toast.error(typeof detail === "string" ? detail + : detail?.message || t("importFailed")); + // 失败时重新校验一次:可能是这两次请求之间有人占了同名 + if (files.length) void validate(files); + } finally { + setBusy(null); + } + }; + + const counts = result ? countByStatus(result.files) : null; + const errorCount = (result?.errors.length ?? 0) + (counts?.error ?? 0); + + const toggle = (path: string) => setCollapsed((prev) => { + const next = new Set(prev); + next.has(path) ? next.delete(path) : next.add(path); + return next; + }); + + const renderNode = (node: TreeNode, depth = 0): React.ReactNode => { + const isCollapsed = collapsed.has(node.path); + return ( +
+
+ {node.isDir ? ( + + ) : ( + + )} + +
+
+ {node.name} + {!node.isDir && node.kind && ( + {node.kind} + )} + + {formatSize(node.size)} + +
+ {/* 原因紧跟在文件下方——把它收进 tooltip 的话,用户得逐个悬停才知道哪里错 */} + {!node.isDir && node.reason && ( +
+ {node.reason} +
+ )} +
+
+ {node.isDir && !isCollapsed && node.children.map((c) => renderNode(c, depth + 1))} +
+ ); + }; + + return ( + { onOpenChange(v); if (!v) reset(); }}> + + + {t("importTitle")} + {t("importHint")} + + + {!result && ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + className={`border-2 border-dashed rounded-lg p-10 text-center transition-colors ${ + dragging ? "border-primary bg-primary/5" : "border-border"}`} + > + {busy ? ( +
+ + {busy === "reading" ? t("importReading") : t("importValidating")} +
+ ) : ( + <> + +

{t("importHint")}

+
+ + +
+ + )} +
+ )} + + {/* webkitdirectory 不是标准属性,React 需要用 ref 之外的方式注入 */} + { + const fl = e.target.files; + if (fl?.length) void ingest(() => readFileList(fl)); + e.target.value = ""; + }} + /> + { + const f = e.target.files?.[0]; + if (f) { + if (!isZipFile(f)) { toast.error(t("importPickZip")); return; } + void ingest(() => readZip(f)); + } + e.target.value = ""; + }} + /> + + {result && ( +
+ {result.stripped_root && ( +
+ {t("importStrippedRoot", { root: result.stripped_root })} +
+ )} + + {result.name && ( +
+
name:{" "} + {result.name}
+
+ {t("importDisplayNameTip")} +
+
+ )} + + {errorCount > 0 && ( +
+
+ + {t("importHasErrors", { n: errorCount })} +
+ {result.errors.length > 0 && ( +
    + {result.errors.map((e, i) =>
  • {e}
  • )} +
+ )} +
+ )} + + {result.warnings.map((w, i) => ( +
⚠️ {w}
+ ))} + + {counts && ( +
+ {t("importSummary", { ok: counts.ok + counts.warning, skipped: counts.skipped })} +
+ )} + +
+ {buildTree(result.files).map((n) => renderNode(n))} +
+
+ )} + + + + {result && ( + <> + + + + )} + +
+
+ ); +} diff --git a/app/skills/page.tsx b/app/skills/page.tsx index e7e97b3..05f53c0 100644 --- a/app/skills/page.tsx +++ b/app/skills/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import SkillImportDialog from "./components/SkillImportDialog"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { @@ -23,7 +24,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Loader2, Plus, Search, Sparkles } from "lucide-react"; +import { Loader2, Plus, Search, Sparkles, Upload } from "lucide-react"; import { useDebounce } from "use-debounce"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useSkills } from "@/hooks/useSkills"; @@ -58,6 +59,7 @@ export default function SkillsPage() { // 删除被引用时(409)弹引用应用清单 const [referencedApps, setReferencedApps] = useState(null); const [deletingSkill, setDeletingSkill] = useState(null); + const [importOpen, setImportOpen] = useState(false); const handleDelete = async (skill: Skill) => { if (!confirm(t("deleteConfirm", { name: skill.name }))) return; @@ -89,6 +91,11 @@ export default function SkillsPage() { className="pl-8 w-56" /> + {/* 导入放在新建左边:从别处搬一个现成 skill 进来,比从空白开始更常见 */} +