diff --git a/packages/views/locales/en/skills.json b/packages/views/locales/en/skills.json index de6a32b0b2..430034a066 100644 --- a/packages/views/locales/en/skills.json +++ b/packages/views/locales/en/skills.json @@ -49,11 +49,16 @@ "source_runtime_unknown": "From a runtime", "source_clawhub": "From ClawHub", "source_skills_sh": "From Skills.sh", - "source_github": "From GitHub" + "source_github": "From GitHub", + "source_upload": "Uploaded" }, "detail": { "all_skills": "All skills", "read_only": "Read-only", + "download_aria": "Download skill as zip", + "download_tooltip": "Download as .zip", + "download_tooltip_dirty": "Save changes before downloading", + "download_collision_error": "Download blocked: some support files have conflicting paths. Please fix the file paths before exporting.", "delete_aria": "Delete skill", "delete_tooltip": "Delete skill", "supporting_data_warning": "Some workspace data failed to load. Creator attribution, runtime names, or edit permissions may appear incomplete until the next refresh.", @@ -107,6 +112,7 @@ "imported_clawhub": "Imported from ClawHub", "imported_skills_sh": "Imported from Skills.sh", "imported_github": "Imported from GitHub", + "imported_upload": "Uploaded from local file", "provider": "provider · {{provider}}" }, "not_found": { @@ -162,6 +168,10 @@ "runtime": { "title": "Copy from runtime", "desc": "Scan a local runtime and promote one of its on-disk skills into this workspace." + }, + "upload": { + "title": "Upload file", + "desc": "Import a skill from a local folder or zip archive containing SKILL.md." } }, "method_card": { @@ -170,7 +180,9 @@ "url_title": "Import from URL", "url_desc": "Pull a published skill from ClawHub or Skills.sh.", "runtime_title": "Copy from runtime", - "runtime_desc": "Promote a skill already installed on your local runtime." + "runtime_desc": "Promote a skill already installed on your local runtime.", + "upload_title": "Upload file", + "upload_desc": "Import a local skill folder or zip archive." }, "manual": { "name_label": "Name", @@ -197,6 +209,42 @@ "fallback_error": "Import failed", "cancel": "Cancel", "toast_imported": "Skill imported" + }, + "upload": { + "drop_zone": "Drop a .zip file here", + "drop_zone_active": "Drop here", + "or": "or", + "browse_zip": "Choose .zip file", + "browse_folder": "Choose folder", + "parsing": "Parsing…", + "parsed_name": "Name", + "parsed_description": "Description", + "parsed_files": "{{count}} supporting files", + "no_description": "No description", + "change_file": "Change file", + "error_skill_md_not_found_zip": "SKILL.md not found in the archive.", + "error_skill_md_not_found_folder": "SKILL.md not found in the selected folder.", + "error_skill_md_too_large": "SKILL.md exceeds 1 MiB size limit.", + "error_skill_md_ambiguous_folder": "Multiple SKILL.md files found at the same level. Please select a folder containing only one skill.", + "error_drop_zip_only": "Please drop a .zip file, or use the folder picker below.", + "truncated_warning": "Some files were skipped because they exceed size or count limits. Import is disabled for incomplete bundles.", + "binary_skipped_warning": "{{count}} binary file(s) were skipped (images, PDFs, etc.). Only text files are imported.", + "error_name_conflict": "A skill with this name already exists. Try a different name and submit again.", + "fallback_error": "Failed to create skill.", + "cancel": "Cancel", + "submit": "Create skill", + "submitting": "Creating…", + "toast_created": "Skill uploaded", + "select_all": "Select all ({{count}})", + "bulk_import_button": "Import {{count}} skills", + "bulk_progress": "Importing {{completed}} / {{total}}…", + "bulk_cancel": "Cancel", + "bulk_complete_hint": "Import complete.", + "bulk_cancelled_hint": "Import cancelled.", + "bulk_done": "Done", + "bulk_summary_imported": "Imported", + "bulk_summary_skipped": "Skipped", + "bulk_summary_failed": "Failed" } }, "runtime_import": { diff --git a/packages/views/locales/zh-Hans/skills.json b/packages/views/locales/zh-Hans/skills.json index 611a80cb41..970ff888de 100644 --- a/packages/views/locales/zh-Hans/skills.json +++ b/packages/views/locales/zh-Hans/skills.json @@ -61,11 +61,16 @@ "source_runtime_unknown": "来自某个运行时", "source_clawhub": "来自 ClawHub", "source_skills_sh": "来自 Skills.sh", - "source_github": "来自 GitHub" + "source_github": "来自 GitHub", + "source_upload": "本地上传" }, "detail": { "all_skills": "全部 skill", "read_only": "只读", + "download_aria": "下载 skill 为 zip", + "download_tooltip": "下载为 .zip", + "download_tooltip_dirty": "请先保存修改再下载", + "download_collision_error": "下载已阻止:部分附属文件路径冲突,请先修正文件路径再导出。", "delete_aria": "删除 skill", "delete_tooltip": "删除 skill", "supporting_data_warning": "部分工作区数据加载失败。创建者归属、运行时名称、编辑权限可能要等下次刷新才完整。", @@ -119,6 +124,7 @@ "imported_clawhub": "从 ClawHub 导入", "imported_skills_sh": "从 Skills.sh 导入", "imported_github": "从 GitHub 导入", + "imported_upload": "从本地文件上传", "provider": "provider · {{provider}}" }, "not_found": { @@ -174,6 +180,10 @@ "runtime": { "title": "从运行时复制", "desc": "扫描本地运行时,把它磁盘上的 skill 提升到工作区。" + }, + "upload": { + "title": "上传文件", + "desc": "从本地文件夹或 zip 压缩包导入包含 SKILL.md 的 skill。" } }, "method_card": { @@ -182,7 +192,9 @@ "url_title": "从 URL 导入", "url_desc": "从 ClawHub 或 Skills.sh 拉取已发布的 skill。", "runtime_title": "从运行时复制", - "runtime_desc": "把本地运行时里已经装好的 skill 提升过来。" + "runtime_desc": "把本地运行时里已经装好的 skill 提升过来。", + "upload_title": "上传文件", + "upload_desc": "导入本地 skill 文件夹或 zip 压缩包。" }, "manual": { "name_label": "名称", @@ -209,6 +221,42 @@ "fallback_error": "导入失败", "cancel": "取消", "toast_imported": "已导入 skill" + }, + "upload": { + "drop_zone": "拖放 .zip 文件到这里", + "drop_zone_active": "松手放置", + "or": "或", + "browse_zip": "选择 .zip 文件", + "browse_folder": "选择文件夹", + "parsing": "解析中…", + "parsed_name": "名称", + "parsed_description": "描述", + "parsed_files": "{{count}} 个附属文件", + "no_description": "无描述", + "change_file": "更换文件", + "error_skill_md_not_found_zip": "压缩包中未找到 SKILL.md。", + "error_skill_md_not_found_folder": "所选文件夹中未找到 SKILL.md。", + "error_skill_md_too_large": "SKILL.md 超过 1 MiB 大小限制。", + "error_skill_md_ambiguous_folder": "在同一层级发现多个 SKILL.md 文件,请选择只包含一个 skill 的文件夹。", + "error_drop_zip_only": "请拖放 .zip 文件,或使用下方按钮选择文件夹。", + "truncated_warning": "部分文件因超出大小或数量限制而被跳过,无法导入不完整的技能包。", + "binary_skipped_warning": "已跳过 {{count}} 个二进制文件(图片、PDF 等),仅导入文本文件。", + "error_name_conflict": "同名 skill 已存在,请换一个名字再提交。", + "fallback_error": "创建 skill 失败。", + "cancel": "取消", + "submit": "创建 skill", + "submitting": "创建中…", + "toast_created": "已上传 skill", + "select_all": "全选 ({{count}})", + "bulk_import_button": "导入 {{count}} 个 skill", + "bulk_progress": "正在导入 {{completed}} / {{total}}…", + "bulk_cancel": "取消", + "bulk_complete_hint": "导入完成。", + "bulk_cancelled_hint": "导入已取消。", + "bulk_done": "完成", + "bulk_summary_imported": "已导入", + "bulk_summary_skipped": "已跳过", + "bulk_summary_failed": "失败" } }, "runtime_import": { diff --git a/packages/views/package.json b/packages/views/package.json index acc53a0bc1..0df520e560 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -95,6 +95,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "catalog:", + "fflate": "catalog:", "sonner": "^2.0.7" }, "peerDependencies": { diff --git a/packages/views/skills/components/create-skill-dialog.tsx b/packages/views/skills/components/create-skill-dialog.tsx index e52908547e..7a8bb9097f 100644 --- a/packages/views/skills/components/create-skill-dialog.tsx +++ b/packages/views/skills/components/create-skill-dialog.tsx @@ -1,15 +1,20 @@ "use client"; -import { useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AlertCircle, ArrowLeft, + CheckCircle2, ChevronRight, Download, + FileArchive, + FolderOpen, HardDrive, Loader2, Pencil, Plus, + SkipForward, + Upload, X as XIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -33,8 +38,10 @@ import { TooltipTrigger, } from "@multica/ui/components/ui/tooltip"; import { Button } from "@multica/ui/components/ui/button"; +import { Checkbox } from "@multica/ui/components/ui/checkbox"; import { Input } from "@multica/ui/components/ui/input"; import { Label } from "@multica/ui/components/ui/label"; +import { Progress } from "@multica/ui/components/ui/progress"; import { Textarea } from "@multica/ui/components/ui/textarea"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { cn } from "@multica/ui/lib/utils"; @@ -42,8 +49,15 @@ import { openExternal } from "../../platform"; import { RuntimeLocalSkillImportPanel } from "./runtime-local-skill-import-panel"; import { useT } from "../../i18n"; import { isNameConflictError } from "../lib/utils"; +import { + parseZipBundles, + parseFolderBundle, + ParseError, + updateFrontmatter, + type ParsedSkillBundle, +} from "../lib/parse-skill-bundle"; -type Method = "chooser" | "manual" | "url" | "runtime"; +type Method = "chooser" | "manual" | "url" | "runtime" | "upload"; function seedAfterCreate( qc: ReturnType, @@ -64,10 +78,11 @@ function MethodChooser({ onChoose }: { onChoose: (m: Method) => void }) { const methods: { key: Method; icon: typeof Plus; - titleKey: "manual" | "url" | "runtime"; + titleKey: "manual" | "url" | "runtime" | "upload"; }[] = [ { key: "manual", icon: Plus, titleKey: "manual" }, { key: "url", icon: Download, titleKey: "url" }, + { key: "upload", icon: Upload, titleKey: "upload" }, { key: "runtime", icon: HardDrive, titleKey: "runtime" }, ]; return ( @@ -420,6 +435,728 @@ function UrlForm({ ); } +// --------------------------------------------------------------------------- +// Upload form — supports single skill and bulk (multi-skill zip) import +// --------------------------------------------------------------------------- + +type UploadBulkResult = { + name: string; + status: "success" | "skipped" | "failed"; + error?: string; + skill?: Skill; +}; + +type UploadBulkState = { + phase: "idle" | "importing" | "done" | "cancelled"; + total: number; + completed: number; + results: UploadBulkResult[]; +}; + +const INITIAL_UPLOAD_BULK: UploadBulkState = { + phase: "idle", + total: 0, + completed: 0, + results: [], +}; + +function UploadBulkSummary({ results }: { results: UploadBulkResult[] }) { + const { t } = useT("skills"); + const succeeded = results.filter((r) => r.status === "success"); + const skipped = results.filter((r) => r.status === "skipped"); + const failed = results.filter((r) => r.status === "failed"); + + return ( +
+
+
+
+ {succeeded.length} +
+
+ {t(($) => $.create.upload.bulk_summary_imported)} +
+
+
+
+ {skipped.length} +
+
+ {t(($) => $.create.upload.bulk_summary_skipped)} +
+
+
+
+ {failed.length} +
+
+ {t(($) => $.create.upload.bulk_summary_failed)} +
+
+
+ +
+ {results.map((r, i) => ( +
+ {r.status === "success" && ( + + )} + {r.status === "skipped" && ( + + )} + {r.status === "failed" && ( + + )} + {r.name} + {r.error && ( + + {r.error} + + )} +
+ ))} +
+
+ ); +} + +function UploadForm({ + onCreated, + onCancel, + onBulkDone, + onWideChange, +}: { + onCreated: (skill: Skill) => void; + onCancel: () => void; + onBulkDone?: () => void; + onWideChange?: (wide: boolean) => void; +}) { + const { t } = useT("skills"); + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + + // Single-skill state + const [bundle, setBundle] = useState(null); + // Multi-skill state + const [bundles, setBundles] = useState([]); + const [selectedIndices, setSelectedIndices] = useState>(new Set()); + const [bulkState, setBulkState] = useState(INITIAL_UPLOAD_BULK); + const cancelRef = useRef(false); + + useEffect(() => { + return () => { + cancelRef.current = true; + }; + }, []); + + const [parsing, setParsing] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [dragOver, setDragOver] = useState(false); + const zipInputRef = useRef(null); + const folderInputRef = useRef(null); + const scrollRef = useRef(null); + const fadeStyle = useScrollFade(scrollRef); + + const isBulk = bundles.length > 1; + + const localizeParseError = useCallback( + (err: unknown): string => { + if (err instanceof ParseError) { + const key = `error_${err.code}` as const; + return t(($) => $.create.upload[key]); + } + return t(($) => $.create.upload.fallback_error); + }, + [t], + ); + + const handleZipFile = useCallback( + async (file: File) => { + setParsing(true); + setError(""); + setBundle(null); + setBundles([]); + setSelectedIndices(new Set()); + setBulkState(INITIAL_UPLOAD_BULK); + try { + if (file.size > 50 << 20) { + throw new Error("Archive exceeds 50 MiB size limit"); + } + const buf = await file.arrayBuffer(); + const parsed = parseZipBundles(buf, file.name.replace(/\.zip$/i, "")); + if (parsed.length === 0) { + throw new ParseError("skill_md_not_found_zip"); + } + if (parsed.length === 1) { + setBundle(parsed[0]!); + onWideChange?.(false); + } else { + setBundles(parsed); + setSelectedIndices(new Set(parsed.map((_, i) => i))); + onWideChange?.(true); + } + } catch (err) { + setError(localizeParseError(err)); + } finally { + setParsing(false); + } + }, + [onWideChange, localizeParseError], + ); + + const handleFolderFiles = useCallback( + async (files: FileList) => { + setParsing(true); + setError(""); + setBundle(null); + setBundles([]); + setSelectedIndices(new Set()); + setBulkState(INITIAL_UPLOAD_BULK); + try { + const result = await parseFolderBundle(files); + setBundle(result); + onWideChange?.(false); + } catch (err) { + setError(localizeParseError(err)); + } finally { + setParsing(false); + } + }, + [onWideChange, localizeParseError], + ); + + const handleZipSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + handleZipFile(file); + e.target.value = ""; + }, + [handleZipFile], + ); + + const handleFolderSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files?.length) return; + handleFolderFiles(files); + e.target.value = ""; + }, + [handleFolderFiles], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const items = e.dataTransfer.items; + if (!items?.length) return; + + const firstItem = items[0] as DataTransferItem | undefined; + if (!firstItem || firstItem.kind !== "file") return; + const file = firstItem.getAsFile(); + if (!file) return; + + if (/\.zip$/i.test(file.name)) { + handleZipFile(file); + } else { + setError(t(($) => $.create.upload.error_drop_zip_only)); + } + }, + [handleZipFile, t], + ); + + const resetAll = useCallback(() => { + setBundle(null); + setBundles([]); + setSelectedIndices(new Set()); + setBulkState(INITIAL_UPLOAD_BULK); + setError(""); + onWideChange?.(false); + }, [onWideChange]); + + // --- Single-skill submit --- + const submitSingle = async () => { + if (!bundle) return; + setLoading(true); + setError(""); + try { + const content = updateFrontmatter( + bundle.content, + bundle.name, + bundle.description, + ); + const skill = await api.createSkill({ + name: bundle.name, + description: bundle.description, + content, + config: { origin: { type: "upload" } }, + files: bundle.files, + }); + seedAfterCreate(qc, wsId, skill); + toast.success(t(($) => $.create.upload.toast_created)); + onCreated(skill); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + setError( + isNameConflictError(msg) + ? t(($) => $.create.upload.error_name_conflict) + : msg || t(($) => $.create.upload.fallback_error), + ); + setLoading(false); + } + }; + + // --- Bulk import --- + const handleBulkImport = async () => { + const toImport = bundles.filter((_, i) => selectedIndices.has(i)); + if (toImport.length === 0) return; + + cancelRef.current = false; + setBulkState({ phase: "importing", total: toImport.length, completed: 0, results: [] }); + + const results: UploadBulkResult[] = []; + + for (const b of toImport) { + if (cancelRef.current) break; + try { + const skill = await api.createSkill({ + name: b.name, + description: b.description, + content: b.content, + config: { origin: { type: "upload" } }, + files: b.files, + }); + results.push({ name: b.name, status: "success", skill }); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + results.push({ + name: b.name, + status: isNameConflictError(msg) ? "skipped" : "failed", + error: msg || t(($) => $.create.upload.fallback_error), + }); + } + setBulkState((prev) => ({ + ...prev, + completed: prev.completed + 1, + results: [...results], + })); + } + + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }); + for (const r of results) { + if (r.status === "success" && r.skill) { + qc.setQueryData(skillDetailOptions(wsId, r.skill.id).queryKey, r.skill); + } + } + + setBulkState((prev) => ({ + ...prev, + phase: cancelRef.current ? "cancelled" : "done", + })); + }; + + // --- Selection helpers --- + const toggleIndex = (idx: number) => { + setSelectedIndices((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; + + const toggleAll = () => { + if (selectedIndices.size === bundles.length) { + setSelectedIndices(new Set()); + } else { + setSelectedIndices(new Set(bundles.map((_, i) => i))); + } + }; + + const allSelected = bundles.length > 0 && selectedIndices.size === bundles.length; + + // --- File picker (shared between single & bulk) --- + const filePickerContent = ( + <> + + +
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + className={cn( + "flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors", + dragOver + ? "border-primary bg-primary/5" + : "border-muted-foreground/25 hover:border-muted-foreground/40", + )} + > + +

+ {dragOver + ? t(($) => $.create.upload.drop_zone_active) + : t(($) => $.create.upload.drop_zone)} +

+

+ {t(($) => $.create.upload.or)} +

+
+ + +
+
+ + ); + + // --- Render: bulk progress / summary --- + if (bulkState.phase === "importing") { + const pct = + bulkState.total > 0 + ? Math.round((bulkState.completed / bulkState.total) * 100) + : 0; + return ( + <> +
+
+
+ +

+ {t(($) => $.create.upload.bulk_progress, { + completed: bulkState.completed, + total: bulkState.total, + })} +

+
+ +
+ {bulkState.results.map((r, i) => ( +
+ {r.status === "success" && ( + + )} + {r.status === "skipped" && ( + + )} + {r.status === "failed" && ( + + )} + {r.name} +
+ ))} +
+
+
+
+ +
+ + ); + } + + if (bulkState.phase === "done" || bulkState.phase === "cancelled") { + return ( + <> +
+

+ {bulkState.phase === "done" + ? t(($) => $.create.upload.bulk_complete_hint) + : t(($) => $.create.upload.bulk_cancelled_hint)} +

+ +
+
+ +
+ + ); + } + + // --- Render: main content (file picker / single edit / bulk list) --- + return ( + <> +
+ {/* File picker — shown when nothing is loaded yet */} + {!bundle && !isBulk && !parsing && filePickerContent} + + {parsing && ( +
+ +

{t(($) => $.create.upload.parsing)}

+
+ )} + + {/* Single skill editing */} + {bundle && !parsing && ( +
+
+ + + setBundle({ ...bundle, name: e.target.value }) + } + /> +
+
+ +