From fe14574965261f20b5ad194a18590136e8406df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=BA?= Date: Sat, 23 May 2026 07:19:52 -0400 Subject: [PATCH] feat: local skill upload from folder or zip Add a fourth creation method to the Add Skill dialog: "Upload folder or zip". Users can select local skill directories via folder picker, zip files, or drag-and-drop, preview detected skills with editable names and descriptions, and import up to 16 skills per batch. Browser-side parsing (packages/views/skills/utils/local-skill-upload.ts): - Path normalization rejecting absolute, traversal, hidden, and metadata paths - SKILL.md discovery and multi-skill grouping by nearest skill root - UTF-8 validation with fatal TextDecoder, null byte detection - Per-file (1 MiB), per-skill (128 files, 8 MiB), per-batch (16 skills) limits - Zip entry pre-inflate size check to avoid decompression bombs - Zip entries scoped to detected skill roots only Upload panel (packages/views/skills/components/local-skill-upload-panel.tsx): - Dashed drop zone with folder picker and zip picker buttons - Single-skill and multi-skill preview with editable fields - Batch selection with dynamic enable/disable at the 16-skill cap - Import progress and structured result summary (created/skipped/failed) - File input value reset for re-selection of same path - Full en + zh-Hans localization Backend API (server/internal/handler/skill_import_local.go): - POST /api/skills/import-local with per-item structured results - Server-side validation: path rules, per-file and bundle size caps, hidden/metadata file rejection, null byte sanitization - MaxBytesReader at 32 MiB to bound JSON decode memory - Partial success: duplicate names reported as skipped, not failed API client (packages/core/api/): - importLocalSkills method with Zod response schema and parseWithFallback - Malformed response graceful degradation to empty result Co-Authored-By: Claude Opus 4.7 --- .../2026/05/12/local-skill-upload-plan.md | 144 +++++ .../2026/05/12/local-skill-upload-design.md | 264 +++++++++ packages/core/api/client.test.ts | 104 ++++ packages/core/api/client.ts | 14 + packages/core/api/schemas.ts | 45 ++ packages/core/types/agent.ts | 31 ++ packages/core/types/index.ts | 5 + packages/views/locales/en/skills.json | 59 +- packages/views/locales/zh-Hans/skills.json | 59 +- packages/views/package.json | 1 + .../components/create-skill-dialog.test.tsx | 56 ++ .../skills/components/create-skill-dialog.tsx | 12 +- .../local-skill-upload-panel.test.tsx | 217 ++++++++ .../components/local-skill-upload-panel.tsx | 484 ++++++++++++++++ .../views/skills/components/skill-columns.tsx | 5 + .../skills/components/skill-detail-page.tsx | 14 +- packages/views/skills/lib/origin.test.ts | 21 + packages/views/skills/lib/origin.ts | 10 +- .../skills/utils/local-skill-upload.test.ts | 525 ++++++++++++++++++ .../views/skills/utils/local-skill-upload.ts | 506 +++++++++++++++++ pnpm-lock.yaml | 88 ++- pnpm-workspace.yaml | 1 + server/cmd/server/router.go | 1 + server/internal/handler/skill_import_local.go | 207 +++++++ .../handler/skill_import_local_test.go | 439 +++++++++++++++ 25 files changed, 3300 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026/05/12/local-skill-upload-plan.md create mode 100644 docs/superpowers/specs/2026/05/12/local-skill-upload-design.md create mode 100644 packages/views/skills/components/create-skill-dialog.test.tsx create mode 100644 packages/views/skills/components/local-skill-upload-panel.test.tsx create mode 100644 packages/views/skills/components/local-skill-upload-panel.tsx create mode 100644 packages/views/skills/lib/origin.test.ts create mode 100644 packages/views/skills/utils/local-skill-upload.test.ts create mode 100644 packages/views/skills/utils/local-skill-upload.ts create mode 100644 server/internal/handler/skill_import_local.go create mode 100644 server/internal/handler/skill_import_local_test.go diff --git a/docs/superpowers/plans/2026/05/12/local-skill-upload-plan.md b/docs/superpowers/plans/2026/05/12/local-skill-upload-plan.md new file mode 100644 index 0000000000..102dfab13b --- /dev/null +++ b/docs/superpowers/plans/2026/05/12/local-skill-upload-plan.md @@ -0,0 +1,144 @@ +# Local Skill Upload Implementation Plan + +> Source design: `docs/superpowers/specs/2026/05/12/local-skill-upload-design.md` + +## Goal + +Add a fourth creation method to the Add Skill dialog — "Upload folder or zip" — so users can select one or more local skill directories or zip bundles, preview detected skills in the browser, and submit them to a dedicated batch-import API. + +## Architecture + +``` +Browser Server +┌─────────────────────────────┐ ┌──────────────────────────────┐ +│ local-skill-upload.ts │ │ skill_import_local.go │ +│ · path normalization │ │ · path/size/count validation │ +│ · SKILL.md discovery │ POST │ · null byte sanitization │ +│ · group by skill root │ ───────> │ · createSkillWithFiles │ +│ · file read + UTF-8 check │ /import- │ · duplicate name → skipped │ +│ · skip policy + preview │ local │ · structured per-item result │ +│ · candidateToImportRequest │ └──────────────────────────────┘ +│ │ +│ local-skill-upload-panel.tsx │ +│ · drag-drop / folder / zip │ +│ · single / multi preview │ +│ · editable name & desc │ +│ · batch select (max 16) │ +│ · import progress & summary │ +└─────────────────────────────┘ +``` + +## Module Boundaries + +| Package | File | Responsibility | +|---|---|---| +| `packages/views/skills/utils/` | `local-skill-upload.ts` | Pure functions: path normalization, SKILL.md discovery, file grouping, limit checks, zip parsing, drag-drop reading | +| `packages/views/skills/components/` | `local-skill-upload-panel.tsx` | UI component: drop zone, preview, editing, import, result summary | +| `packages/views/skills/components/` | `create-skill-dialog.tsx` | Entry point: add `upload` method card | +| `packages/views/skills/lib/` | `origin.ts` | Add `uploaded_bundle` origin type | +| `packages/views/skills/components/` | `skill-columns.tsx`, `skill-detail-page.tsx` | Display uploaded_bundle source info | +| `packages/views/locales/` | `en/skills.json`, `zh-Hans/skills.json` | i18n strings | +| `packages/core/types/` | `agent.ts`, `index.ts` | Shared TS types | +| `packages/core/api/` | `client.ts`, `schemas.ts` | API method + Zod response schema | +| `server/internal/handler/` | `skill_import_local.go` | Handler: decode, validate, create, publish events | +| `server/cmd/server/` | `router.go` | Register `POST /api/skills/import-local` | + +## Limit Alignment + +| Parameter | Client Constant | Server Constant | Notes | +|---|---|---|---| +| Per-file size | `MAX_SKILL_FILE_BYTES` = 1 MiB | `maxLocalUploadedSkillFileBytes` = 1 MiB | Includes SKILL.md itself | +| Files per skill | `MAX_SKILL_FILES` = 128 | `maxLocalUploadedSkillFiles` = 128 | Excludes SKILL.md | +| Text per skill | `MAX_SKILL_ACCEPTED_BYTES` = 8 MiB | `maxLocalUploadedSkillTotalBytes` = 8 MiB | UTF-8 bytes | +| Skills per import | `MAX_LOCAL_SKILL_IMPORT_BATCH` = 16 | `maxLocalUploadedSkills` = 16 | | +| HTTP request body | — | `maxLocalUploadedSkillRequestBytes` = 32 MiB | Covers JSON escaping overhead | + +## Security Boundaries + +- **Path validation**: Client `normalizeUploadPath` rejects absolute paths and `..` traversal; server `normalizeLocalUploadedSkillFilePath` additionally rejects hidden files (`.` prefix), metadata files (Thumbs.db, desktop.ini), null bytes, Windows drive paths, and SKILL.md overwrites +- **UTF-8 validation**: Client uses `TextDecoder("utf-8", { fatal: true })`; non-UTF-8 files are skipped as `binary_file` +- **Null bytes**: Client detects and skips files containing `\0`; accepted text has null bytes stripped before submission; server calls `sanitizeNullBytes` on all fields +- **Zip safety**: Checks `uncompressedSize` before inflating; only inflates entries under detected skill roots; skips oversized entries instead of rejecting the entire zip +- **Request body limit**: `http.MaxBytesReader` truncates before JSON decode to bound memory allocation +- **File input reset**: Clears `` value after selection so the same path can be re-selected + +## Test Strategy + +### Client Unit Tests (Vitest) + +**`local-skill-upload.test.ts`** — 27 tests covering: +- Frontmatter parsing +- Single/multi skill root grouping +- Root-level skill bundle supporting file retention +- Source label composition +- Nested SKILL.md ownership +- Hidden/binary/oversized/traversal file skipping +- Non-UTF-8 file skipping +- Oversized SKILL.md surfaced as `unreadable_skill_md` +- UTF-8 byte counting (CJK multi-byte) +- File count/byte budget short-circuit (stops reading excess files) +- Zip path traversal rejection +- Zip oversized entry marking (no inflate) +- Zip oversized SKILL.md marker preservation +- Zip per-skill independent file counting +- Zip hidden files not consuming budget +- Zip entries outside detected roots ignored + +**`local-skill-upload-panel.test.tsx`** — 7 tests covering: +- Missing SKILL.md error display +- Single skill preview and import +- Zip drag-and-drop import +- Batch selection UX beyond 16 skills +- Partial success dialog retention with result summary +- zh-Hans localization verification +- File input reset + +**`create-skill-dialog.test.tsx`** — 2 tests covering: +- Upload method card presence +- Chinese title rendering + +**`origin.test.ts`** — 1 test covering uploaded_bundle origin + +**`client.test.ts`** — 3 tests covering: +- API endpoint path and method +- Response schema parsing +- Malformed response fallback + +### Server Unit Tests (Go test) + +**`skill_import_local_test.go`** — 14 tests covering: +- Valid skill creation with files +- Duplicate name skip + continue +- Path traversal rejection +- Single file size limit rejection +- SKILL.md size limit rejection +- SKILL.md path cleaning rejection +- Request body size rejection +- Large payload rejection before decode +- Batch size (>16 skills) rejection +- JSON-escaped content within decoded bundle limit +- Multi-skill JSON-escaped batch acceptance +- Hidden file path rejection +- Metadata file path rejection +- Backslash path traversal rejection + +## Risks + +1. **Drag-and-drop compatibility**: `webkitGetAsEntry` is a non-standard API; some browsers may not support directory traversal. The code includes a plain-file fallback path. +2. **Zip decompression memory**: Zips with many small files may allocate significant browser memory. Mitigated by pre-inflate size checks and per-root file count limits. +3. **JSON escaping overhead**: Files with many special characters expand significantly under `JSON.stringify`. The server request cap (32 MiB) provides sufficient headroom for the decoded limits (8 MiB × up to 16 skills). + +## Acceptance Criteria + +- [x] Add Skill dialog shows four creation methods (manual, URL, runtime, upload) +- [x] Folder picker imports a single skill +- [x] Zip file imports a single skill +- [x] Folder/zip imports multiple skills with select/deselect +- [x] Preview shows skipped files with reasons +- [x] Duplicate names reported as skipped without affecting other skills +- [x] Partial success keeps dialog open with result summary +- [x] Hidden, binary, and oversized files correctly skipped +- [x] Server-side validation on paths, sizes, and counts +- [x] API responses validated through Zod schema; malformed responses degrade safely +- [x] en and zh-Hans localization complete +- [x] All unit tests pass diff --git a/docs/superpowers/specs/2026/05/12/local-skill-upload-design.md b/docs/superpowers/specs/2026/05/12/local-skill-upload-design.md new file mode 100644 index 0000000000..f069413fba --- /dev/null +++ b/docs/superpowers/specs/2026/05/12/local-skill-upload-design.md @@ -0,0 +1,264 @@ +# Local Skill Upload Design + +## Context + +The Skills page already supports three creation paths in +`packages/views/skills/components/create-skill-dialog.tsx`: + +- manual skill creation +- URL import from ClawHub, Skills.sh, or GitHub +- copying an installed skill from an online local runtime + +Upstream issue `multica-ai/multica#478` asks for importing a local skill +directory or zip, and `#702` clarifies the same workflow in Chinese: users may +receive or create a skill bundle locally and want to upload it into the +workspace. PR `#913` explores CLI and backend support for local directory +imports, while PR `#460` explores a browser-side folder picker. The product gap +left between those approaches is a first-class Web entry for user-selected local +folders or zip bundles that fits the current dialog architecture. + +## Product Goal + +Users can import one or more local skill bundles into the current workspace from +the Add Skill dialog without publishing the skill to a remote registry and +without relying on a local runtime scan. + +The feature should make the source choices clear: + +- `Import from URL` is for ClawHub, Skills.sh, and GitHub links. +- `Copy from runtime` is for skills already installed in a local runtime. +- `Upload folder or zip` is for files selected from the user's computer. + +## Entry Point + +Add a fourth method card to the Add Skill chooser: + +`Upload folder or zip` + +The card uses a familiar upload or folder-upload icon and a short description: +`Import a local skill folder or zipped skill bundle.` + +Choosing this card opens a wider dialog body, matching the interaction density +of `RuntimeLocalSkillImportPanel` rather than the narrow URL form. + +## Upload Panel + +The upload panel has three stable regions. + +The top region contains the file input surface: + +- a dashed drop zone labelled `Drop a skill folder or .zip here` +- `Choose Folder` and `Choose Zip` buttons +- a compact hint that a valid skill must contain `SKILL.md` + +The middle region is a preview and validation surface. After selection, the +client scans the selected files and displays either a single-skill preview or a +multi-skill list. + +For a single skill, show: + +- editable skill name +- editable description +- detected root folder or zip name +- file count +- `SKILL.md` as the main file +- supporting file list +- skipped file list with reasons + +For multiple detected skills, show a checklist: + +- one row per detected skill root +- checkbox for valid rows +- editable name and description per row +- file count per row +- disabled rows for invalid groups, with the validation reason + +The bottom region contains the import summary and action: + +- single skill: `Ready to import "" into workspace` +- multiple skills: `Ready to import skills into workspace` +- primary button: `Import` or `Import skills` +- during import: `Importing / ` + +## Skill Detection + +The client normalizes all selected paths to forward-slash relative paths and +groups files by the nearest directory containing `SKILL.md`. + +Detection rules: + +- If the selected root itself contains `SKILL.md`, treat it as a single skill. +- If child directories contain their own `SKILL.md`, treat each child root as a + separate skill candidate. +- Ignore directories without `SKILL.md` unless they are supporting files under a + detected skill root. +- Use `SKILL.md` YAML frontmatter for default `name` and `description`. +- Fall back to the skill root folder name when frontmatter is absent. + +The first version should support selecting: + +- a folder through `webkitdirectory` +- a zip file unpacked in the browser +- drag-and-drop of files or folders where the browser exposes directory entries + +## File Policy + +The workspace skill storage model stores supporting file content in text fields. +Until binary asset storage exists, the uploader should avoid pretending binary +assets are fully supported. + +Apply the same limits across folder and zip imports: + +- require `SKILL.md` +- reject absolute paths and path traversal +- skip hidden files and ignored metadata files +- skip files above 1 MiB +- cap each skill at 128 supporting files +- cap each skill bundle at 8 MiB of accepted text files +- skip binary or non-UTF-8 supporting files +- strip null bytes from accepted text content before submission + +Skipped files are visible in the preview before import. If a skill has skipped +files, the user can still import it, but the preview makes the partial import +explicit. + +## API Shape + +Prefer a dedicated local-import API over calling generic `createSkill` directly +from the upload panel. + +Add a workspace-scoped endpoint: + +`POST /api/skills/import-local` + +Request: + +```json +{ + "skills": [ + { + "name": "Code Review", + "description": "Reviews pull requests", + "content": "...SKILL.md content...", + "files": [ + { "path": "templates/review.md", "content": "..." } + ], + "source": { + "type": "uploaded_bundle", + "label": "team-skills.zip/code-review" + } + } + ] +} +``` + +Response: + +```json +{ + "created": [ + { "skill": { "...": "..." }, "source_label": "team-skills.zip/code-review" } + ], + "skipped": [ + { "name": "Existing Skill", "reason": "already_exists" } + ], + "failed": [ + { "name": "Broken Skill", "reason": "missing_skill_md" } + ] +} +``` + +The backend performs the authoritative validation even when the client already +validated the preview. This keeps the browser UI helpful without trusting it as +a security boundary. + +## Backend Behavior + +The backend should create each valid skill in its own transaction so a batch +import can partially succeed. A duplicate skill name should be reported as a +skipped item instead of failing the entire batch. + +For each skill: + +- validate the workspace and owner/admin permission +- validate name and `SKILL.md` content +- validate every file path with the existing skill-file path rules +- sanitize null bytes from text fields +- enforce file count and accepted-size limits +- create the skill and supporting files through the same create-with-files path +- publish normal skill-created realtime events for created skills + +The endpoint should return structured per-item results so the UI can show a +summary without parsing human-readable error strings. + +## Frontend Data Flow + +Add a new panel beside the existing forms: + +- extend `Method` with `upload` +- add the fourth `MethodChooser` card +- create `LocalSkillUploadPanel` +- add a typed API client method for `importLocalSkills` + +After successful imports: + +- seed each created skill detail cache +- invalidate `workspaceKeys.skills(wsId)` +- invalidate `workspaceKeys.agents(wsId)` +- select the first created skill if the dialog caller provides selection +- close the dialog only when at least one skill is created and there are no + unresolved blocking failures + +For partial success, keep the dialog open and show the result summary so the +user can see what was imported, skipped, or failed. + +## Error Handling + +Preview-time errors stay local and immediate: + +- empty selection +- no `SKILL.md` found +- unreadable zip +- zip path traversal +- all files skipped + +Import-time errors come from the API: + +- permission failure +- duplicate names +- invalid paths missed by preview +- request size exceeded +- server transaction failure + +The UI should prefer structured messages such as `already exists`, `missing +SKILL.md`, `unsupported binary file skipped`, and `bundle is too large`. + +## Out of Scope + +- Persistent binary asset storage for skill bundles. +- A public skill registry or package-version system. +- Runtime multi-select import. That belongs to `Copy from runtime`. +- Automatic upload from arbitrary filesystem paths without user selection. +- Editing imported files inside the upload preview before creation. + +## Testing + +Add focused coverage for: + +- grouping selected files into one or many skill candidates +- parsing frontmatter defaults from `SKILL.md` +- skipping binary, hidden, oversized, and traversal-path files +- zip import path normalization and traversal rejection +- upload panel states for empty, invalid, single-skill, and multi-skill inputs +- API client response parsing for created, skipped, and failed results +- backend validation and partial-success behavior +- duplicate-name handling as a skipped result +- cache invalidation after successful import + +Manual verification should cover: + +- folder picker import of a single skill +- zip import of a single skill +- folder or zip import containing multiple skills +- partial import with one duplicate skill +- partial import with binary supporting files diff --git a/packages/core/api/client.test.ts b/packages/core/api/client.test.ts index d710fa198f..85f0c99ba6 100644 --- a/packages/core/api/client.test.ts +++ b/packages/core/api/client.test.ts @@ -173,6 +173,110 @@ describe("ApiClient", () => { expect(headers["X-Client-OS"]).toBeUndefined(); }); + it("imports local skills through the dedicated endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + created: [ + { + skill: { + id: "skill-1", + workspace_id: "ws-1", + name: "Review Helper", + description: "Reviews PRs", + content: "# Review Helper", + config: {}, + created_by: "user-1", + created_at: "2026-05-12T00:00:00Z", + updated_at: "2026-05-12T00:00:00Z", + files: [], + }, + source_label: "team.zip/review-helper", + }, + ], + skipped: [], + failed: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new ApiClient("https://api.example.test"); + const result = await client.importLocalSkills({ + skills: [ + { + name: "Review Helper", + description: "Reviews PRs", + content: "# Review Helper", + files: [{ path: "templates/review.md", content: "body" }], + source: { type: "uploaded_bundle", label: "team.zip/review-helper" }, + }, + ], + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.test/api/skills/import-local", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + skills: [ + { + name: "Review Helper", + description: "Reviews PRs", + content: "# Review Helper", + files: [{ path: "templates/review.md", content: "body" }], + source: { type: "uploaded_bundle", label: "team.zip/review-helper" }, + }, + ], + }), + }), + ); + expect(result.created[0]?.skill.name).toBe("Review Helper"); + }); + + it("falls back for malformed local skill import responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ created: null, skipped: "bad", failed: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const client = new ApiClient("https://api.example.test"); + const result = await client.importLocalSkills({ + skills: [{ name: "Review Helper", content: "# Review Helper", source: { type: "uploaded_bundle", label: "review" } }], + }); + + expect(result).toEqual({ created: [], skipped: [], failed: [] }); + }); + + it("falls back when imported local skill entries are missing required skill fields", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + created: [{ skill: { id: "skill-1" }, source_label: "team.zip/review-helper" }], + skipped: [], + failed: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const client = new ApiClient("https://api.example.test"); + const result = await client.importLocalSkills({ + skills: [{ name: "Review Helper", content: "# Review Helper", source: { type: "uploaded_bundle", label: "review" } }], + }); + + expect(result).toEqual({ created: [], skipped: [], failed: [] }); + }); + it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => { const node = { id: "node-1", diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index a08e6264c3..c174f333c6 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -34,6 +34,8 @@ import type { Skill, SkillSummary, CreateSkillRequest, + ImportLocalSkillsRequest, + ImportLocalSkillsResponse, UpdateSkillRequest, SetAgentSkillsRequest, PersonalAccessToken, @@ -130,6 +132,7 @@ import { EMPTY_CLOUD_RUNTIME_NODE_LIST, EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE, EMPTY_GROUPED_ISSUES_RESPONSE, + EMPTY_IMPORT_LOCAL_SKILLS_RESPONSE, EMPTY_LIST_ISSUES_RESPONSE, EMPTY_SQUAD_MEMBER_STATUS_LIST, EMPTY_TIMELINE_ENTRIES, @@ -137,6 +140,7 @@ import { EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE, EMPTY_WEBHOOK_DELIVERY, GroupedIssuesResponseSchema, + ImportLocalSkillsResponseSchema, ListIssuesResponseSchema, ListWebhookDeliveriesResponseSchema, RuntimeHourlyActivityListSchema, @@ -1275,6 +1279,16 @@ export class ApiClient { }); } + async importLocalSkills(data: ImportLocalSkillsRequest): Promise { + const raw = await this.fetch("/api/skills/import-local", { + method: "POST", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, ImportLocalSkillsResponseSchema, EMPTY_IMPORT_LOCAL_SKILLS_RESPONSE, { + endpoint: "POST /api/skills/import-local", + }); + } + async listAgentSkills(agentId: string): Promise { return this.fetch(`/api/agents/${agentId}/skills`); } diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 735b49847d..1e9b80601e 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -6,6 +6,7 @@ import type { Attachment, CreateAgentFromTemplateResponse, GroupedIssuesResponse, + ImportLocalSkillsResponse, ListIssuesResponse, ListWebhookDeliveriesResponse, TimelineEntry, @@ -206,6 +207,20 @@ export const ChildIssuesResponseSchema = z.object({ issues: z.array(IssueSchema).default([]), }).loose(); +const ImportLocalSkillResultSchema = z.object({ + name: z.string(), + reason: z.string(), +}).loose(); + +const SkillFileSchema = z.object({ + id: z.string(), + skill_id: z.string(), + path: z.string(), + content: z.string(), + created_at: z.string(), + updated_at: z.string(), +}).loose(); + export const CloudRuntimeNodeSchema = z.object({ id: z.string(), owner_id: z.string(), @@ -222,6 +237,36 @@ export const CloudRuntimeNodeSchema = z.object({ updated_at: z.string(), }).loose(); +const SkillSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + name: z.string(), + description: z.string(), + config: z.record(z.string(), z.unknown()), + created_by: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + content: z.string(), + files: z.array(SkillFileSchema), +}).loose(); + +const ImportLocalSkillCreatedSchema = z.object({ + skill: SkillSchema, + source_label: z.string(), +}).loose(); + +export const ImportLocalSkillsResponseSchema = z.object({ + created: z.array(ImportLocalSkillCreatedSchema).default([]), + skipped: z.array(ImportLocalSkillResultSchema).default([]), + failed: z.array(ImportLocalSkillResultSchema).default([]), +}).loose(); + +export const EMPTY_IMPORT_LOCAL_SKILLS_RESPONSE: ImportLocalSkillsResponse = { + created: [], + skipped: [], + failed: [], +}; + export const CloudRuntimeNodeListSchema = z.array(CloudRuntimeNodeSchema); export const EMPTY_CLOUD_RUNTIME_NODE_LIST: CloudRuntimeNode[] = []; diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index c8d636195d..2321b7e62e 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -319,6 +319,37 @@ export interface CreateSkillRequest { files?: { path: string; content: string }[]; } +export interface ImportLocalSkillRequest { + name: string; + description?: string; + content: string; + files?: { path: string; content: string }[]; + source: { + type: "uploaded_bundle"; + label: string; + }; +} + +export interface ImportLocalSkillsRequest { + skills: ImportLocalSkillRequest[]; +} + +export interface ImportLocalSkillCreated { + skill: Skill; + source_label: string; +} + +export interface ImportLocalSkillResult { + name: string; + reason: string; +} + +export interface ImportLocalSkillsResponse { + created: ImportLocalSkillCreated[]; + skipped: ImportLocalSkillResult[]; + failed: ImportLocalSkillResult[]; +} + export interface UpdateSkillRequest { name?: string; description?: string; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 2612ffefbf..346a11699e 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -23,6 +23,11 @@ export type { AgentSkillSummary, SkillFile, CreateSkillRequest, + ImportLocalSkillRequest, + ImportLocalSkillsRequest, + ImportLocalSkillCreated, + ImportLocalSkillResult, + ImportLocalSkillsResponse, UpdateSkillRequest, SetAgentSkillsRequest, RuntimeUsage, diff --git a/packages/views/locales/en/skills.json b/packages/views/locales/en/skills.json index de6a32b0b2..c9bfbfb38d 100644 --- a/packages/views/locales/en/skills.json +++ b/packages/views/locales/en/skills.json @@ -49,7 +49,9 @@ "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_uploaded_bundle": "From uploaded bundle", + "source_uploaded_bundle_named": "From {{label}}" }, "detail": { "all_skills": "All skills", @@ -72,6 +74,8 @@ "origin_clawhub": "Imported · ClawHub", "origin_skills_sh": "Imported · Skills.sh", "origin_github": "Imported · GitHub", + "origin_uploaded_bundle": "Uploaded bundle", + "origin_uploaded_bundle_named": "Uploaded · {{label}}", "origin_workspace": "Workspace", "updated_label": "Updated {{when}}", "by_creator": "by {{name}}" @@ -107,6 +111,7 @@ "imported_clawhub": "Imported from ClawHub", "imported_skills_sh": "Imported from Skills.sh", "imported_github": "Imported from GitHub", + "imported_uploaded_bundle": "Imported from uploaded bundle", "provider": "provider · {{provider}}" }, "not_found": { @@ -162,6 +167,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 folder or zip", + "desc": "Import a local skill folder or zipped skill bundle." } }, "method_card": { @@ -170,7 +179,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 folder or zip", + "upload_desc": "Import a local skill folder or zipped skill bundle." }, "manual": { "name_label": "Name", @@ -199,6 +210,50 @@ "toast_imported": "Skill imported" } }, + "upload_import": { + "drop_title": "Drop a skill folder or .zip here", + "drop_label": "Dropped files", + "hint": "A valid skill must contain SKILL.md.", + "choose_folder": "Choose Folder", + "choose_zip": "Choose Zip", + "name_label": "Skill name", + "description_label": "Description", + "source_label": "Source", + "primary_file_badge": "SKILL.md", + "files_count_one": "{{count}} file", + "files_count_other": "{{count}} files", + "skipped_count_one": "{{count}} skipped", + "skipped_count_other": "{{count}} skipped", + "ready_single": "Ready to import \"{{name}}\" into workspace", + "ready_multiple": "Ready to import {{count}} skills into workspace", + "select_skill": "Select a valid skill to import.", + "import": "Import", + "import_many": "Import {{count}} skills", + "importing": "Importing {{done}} / {{total}}", + "toast_imported": "Skill imported", + "result_reasons": { + "already_exists": "already exists", + "missing_skill_md": "missing SKILL.md", + "invalid_file_path": "invalid file path", + "hidden_file": "hidden file skipped", + "metadata_file": "metadata file skipped", + "absolute_path": "absolute path skipped", + "path_traversal": "path traversal skipped", + "file_too_large": "file too large", + "binary_file": "binary file skipped", + "too_many_files": "too many files", + "bundle_too_large": "bundle is too large", + "imported": "imported" + }, + "errors": { + "empty_selection": "Select a folder or zip to continue.", + "no_skill_md": "No SKILL.md found", + "unreadable_skill_md": "SKILL.md is unreadable (binary or too large)", + "unreadable_zip": "Unable to read zip file.", + "import_failed": "Import failed", + "too_many_skills": "Select up to {{count}} skills. Import them, then repeat for the rest." + } + }, "runtime_import": { "runtime_label": "Runtime", "runtime_placeholder": "Select a local runtime", diff --git a/packages/views/locales/zh-Hans/skills.json b/packages/views/locales/zh-Hans/skills.json index 611a80cb41..3972409648 100644 --- a/packages/views/locales/zh-Hans/skills.json +++ b/packages/views/locales/zh-Hans/skills.json @@ -61,7 +61,9 @@ "source_runtime_unknown": "来自某个运行时", "source_clawhub": "来自 ClawHub", "source_skills_sh": "来自 Skills.sh", - "source_github": "来自 GitHub" + "source_github": "来自 GitHub", + "source_uploaded_bundle": "来自上传包", + "source_uploaded_bundle_named": "来自 {{label}}" }, "detail": { "all_skills": "全部 skill", @@ -84,6 +86,8 @@ "origin_clawhub": "导入自 · ClawHub", "origin_skills_sh": "导入自 · Skills.sh", "origin_github": "导入自 · GitHub", + "origin_uploaded_bundle": "上传包", + "origin_uploaded_bundle_named": "上传自 · {{label}}", "origin_workspace": "工作区", "updated_label": "{{when}}更新", "by_creator": "由 {{name}}" @@ -119,6 +123,7 @@ "imported_clawhub": "从 ClawHub 导入", "imported_skills_sh": "从 Skills.sh 导入", "imported_github": "从 GitHub 导入", + "imported_uploaded_bundle": "从上传包导入", "provider": "provider · {{provider}}" }, "not_found": { @@ -174,6 +179,10 @@ "runtime": { "title": "从运行时复制", "desc": "扫描本地运行时,把它磁盘上的 skill 提升到工作区。" + }, + "upload": { + "title": "上传 文件夹 或 zip压缩包", + "desc": "导入本地 skill 文件夹或打包后的 skill。" } }, "method_card": { @@ -182,7 +191,9 @@ "url_title": "从 URL 导入", "url_desc": "从 ClawHub 或 Skills.sh 拉取已发布的 skill。", "runtime_title": "从运行时复制", - "runtime_desc": "把本地运行时里已经装好的 skill 提升过来。" + "runtime_desc": "把本地运行时里已经装好的 skill 提升过来。", + "upload_title": "上传 文件夹 或 zip压缩包", + "upload_desc": "导入本地 skill 文件夹或打包后的 skill。" }, "manual": { "name_label": "名称", @@ -211,6 +222,50 @@ "toast_imported": "已导入 skill" } }, + "upload_import": { + "drop_title": "将 skill 文件夹或 .zip 拖到这里", + "drop_label": "拖入的文件", + "hint": "有效 skill 必须包含 SKILL.md。", + "choose_folder": "选择文件夹", + "choose_zip": "选择 Zip", + "name_label": "Skill 名称", + "description_label": "描述", + "source_label": "来源", + "primary_file_badge": "SKILL.md", + "files_count_one": "{{count}} 个文件", + "files_count_other": "{{count}} 个文件", + "skipped_count_one": "{{count}} 个已跳过", + "skipped_count_other": "{{count}} 个已跳过", + "ready_single": "准备将\"{{name}}\"导入工作区", + "ready_multiple": "准备将 {{count}} 个 skill 导入工作区", + "select_skill": "请选择一个有效 skill 导入。", + "import": "导入", + "import_many": "导入 {{count}} 个 skill", + "importing": "导入中 {{done}} / {{total}}", + "toast_imported": "已导入 skill", + "result_reasons": { + "already_exists": "已存在", + "missing_skill_md": "缺少 SKILL.md", + "invalid_file_path": "文件路径无效", + "hidden_file": "已跳过隐藏文件", + "metadata_file": "已跳过元数据文件", + "absolute_path": "已跳过绝对路径", + "path_traversal": "已跳过路径穿越文件", + "file_too_large": "文件过大", + "binary_file": "已跳过二进制文件", + "too_many_files": "文件数量过多", + "bundle_too_large": "文件包过大", + "imported": "已导入" + }, + "errors": { + "empty_selection": "请选择文件夹或 zip 后继续。", + "no_skill_md": "未找到 SKILL.md", + "unreadable_skill_md": "SKILL.md 无法读取(二进制文件或文件过大)", + "unreadable_zip": "无法读取 zip 文件。", + "import_failed": "导入失败", + "too_many_skills": "最多选择 {{count}} 个 skill。先导入它们,再导入剩余 skill。" + } + }, "runtime_import": { "runtime_label": "运行时", "runtime_placeholder": "选择一个本地运行时", diff --git a/packages/views/package.json b/packages/views/package.json index acc53a0bc1..7cc67a83db 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -80,6 +80,7 @@ "@tiptap/suggestion": "^3.22.1", "cmdk": "^1.1.1", "hast-util-to-html": "^9.0.5", + "jszip": "catalog:", "katex": "catalog:", "lowlight": "^3.3.0", "mermaid": "catalog:", diff --git a/packages/views/skills/components/create-skill-dialog.test.tsx b/packages/views/skills/components/create-skill-dialog.test.tsx new file mode 100644 index 0000000000..bd7eb76d95 --- /dev/null +++ b/packages/views/skills/components/create-skill-dialog.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enSkills from "../../locales/en/skills.json"; +import zhHansCommon from "../../locales/zh-Hans/common.json"; +import zhHansSkills from "../../locales/zh-Hans/skills.json"; +import { CreateSkillDialog } from "./create-skill-dialog"; + +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "ws-1", +})); + +vi.mock("@multica/core/api", () => ({ + api: { + createSkill: vi.fn(), + importSkill: vi.fn(), + importLocalSkills: vi.fn(), + }, +})); + +function wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +function zhHansWrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +describe("CreateSkillDialog", () => { + it("offers upload folder or zip as a creation method", () => { + render(, { wrapper }); + expect(screen.getByRole("button", { name: /Upload folder or zip/i })).toBeInTheDocument(); + }); + + it("uses the requested Chinese title for local upload", () => { + render(, { wrapper: zhHansWrapper }); + expect(screen.getByRole("button", { name: /上传 文件夹 或 zip压缩包/ })).toBeInTheDocument(); + }); +}); diff --git a/packages/views/skills/components/create-skill-dialog.tsx b/packages/views/skills/components/create-skill-dialog.tsx index e52908547e..aa3985325c 100644 --- a/packages/views/skills/components/create-skill-dialog.tsx +++ b/packages/views/skills/components/create-skill-dialog.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, ChevronRight, Download, + FolderUp, HardDrive, Loader2, Pencil, @@ -39,11 +40,12 @@ import { Textarea } from "@multica/ui/components/ui/textarea"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { cn } from "@multica/ui/lib/utils"; import { openExternal } from "../../platform"; +import { LocalSkillUploadPanel } from "./local-skill-upload-panel"; import { RuntimeLocalSkillImportPanel } from "./runtime-local-skill-import-panel"; import { useT } from "../../i18n"; import { isNameConflictError } from "../lib/utils"; -type Method = "chooser" | "manual" | "url" | "runtime"; +type Method = "chooser" | "manual" | "url" | "runtime" | "upload"; function seedAfterCreate( qc: ReturnType, @@ -64,10 +66,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: FolderUp, titleKey: "upload" }, { key: "runtime", icon: HardDrive, titleKey: "runtime" }, ]; return ( @@ -439,7 +442,7 @@ export function CreateSkillDialog({ onClose(); }; - const wide = method === "runtime"; + const wide = method === "runtime" || method === "upload"; return ( !v && onClose()}> @@ -519,6 +522,9 @@ export function CreateSkillDialog({ onBulkDone={onClose} /> )} + {method === "upload" && ( + + )} ); diff --git a/packages/views/skills/components/local-skill-upload-panel.test.tsx b/packages/views/skills/components/local-skill-upload-panel.test.tsx new file mode 100644 index 0000000000..bb0ed2aba1 --- /dev/null +++ b/packages/views/skills/components/local-skill-upload-panel.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import JSZip from "jszip"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enSkills from "../../locales/en/skills.json"; +import zhHansCommon from "../../locales/zh-Hans/common.json"; +import zhHansSkills from "../../locales/zh-Hans/skills.json"; + +const mockImportLocalSkills = vi.hoisted(() => vi.fn()); + +vi.mock("@multica/core/api", () => ({ + api: { importLocalSkills: (...args: unknown[]) => mockImportLocalSkills(...args) }, +})); + +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "ws-1", +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { LocalSkillUploadPanel } from "./local-skill-upload-panel"; + +function wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +function zhHansWrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +describe("LocalSkillUploadPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockImportLocalSkills.mockResolvedValue({ + created: [{ skill: skill("skill-1", "Review Helper"), source_label: "review" }], + skipped: [], + failed: [], + }); + }); + + it("shows an invalid state when no SKILL.md is found", async () => { + render(, { wrapper }); + const input = screen.getByTestId("local-skill-folder-input"); + fireEvent.change(input, { target: { files: [file("notes.txt", "hello")] } }); + expect(await screen.findByText(/No SKILL\.md found/i)).toBeInTheDocument(); + }); + + it("previews and imports a single skill", async () => { + const onImported = vi.fn(); + render(, { wrapper }); + + fireEvent.change(screen.getByTestId("local-skill-folder-input"), { + target: { files: [file("review/SKILL.md", "---\nname: Review Helper\n---\nBody")] }, + }); + + expect(await screen.findByDisplayValue("Review Helper")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^Import$/i })); + + await waitFor(() => { + expect(mockImportLocalSkills).toHaveBeenCalledWith({ + skills: [expect.objectContaining({ name: "Review Helper", content: expect.stringContaining("Body") })], + }); + expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ name: "Review Helper" })); + }); + }); + + it("previews a dropped zip skill", async () => { + const zip = new JSZip(); + zip.file("review/SKILL.md", "---\nname: Review Helper\n---\nBody"); + const blob = await zip.generateAsync({ type: "blob" }); + const archive = new File([blob], "review.zip", { type: "application/zip" }); + + render(, { wrapper }); + + fireEvent.drop(screen.getByText(/Drop a skill folder or \.zip here/i).parentElement!, { + dataTransfer: { + items: [dataTransferFileItem(archive)], + files: [archive], + }, + }); + + expect(await screen.findByDisplayValue("Review Helper")).toBeInTheDocument(); + expect(screen.queryByText(/No SKILL\.md found/i)).not.toBeInTheDocument(); + }); + + it("lets users pick any local skill batch under the server limit", async () => { + render(, { wrapper }); + + fireEvent.change(screen.getByTestId("local-skill-folder-input"), { + target: { + files: Array.from({ length: 17 }, (_, index) => + file(`team/skill-${String(index).padStart(2, "0")}/SKILL.md`, `# Skill ${index}`), + ), + }, + }); + + expect(await screen.findByText(/Select up to 16 skills/i)).toBeInTheDocument(); + expect(screen.getByText(/Ready to import 16 skills/i)).toBeInTheDocument(); + + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(17); + expect(checkboxes.slice(0, 16).every((checkbox) => checkbox.getAttribute("aria-checked") === "true")).toBe(true); + expect(checkboxes[16]).toHaveAttribute("aria-checked", "false"); + expect(checkboxes[16]).toHaveAttribute("aria-disabled", "true"); + + fireEvent.click(checkboxes[0]!); + expect(checkboxes[16]).not.toHaveAttribute("aria-disabled"); + fireEvent.click(checkboxes[16]!); + expect(checkboxes[16]).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(screen.getByRole("button", { name: /^Import$/i })); + + await waitFor(() => { + expect(mockImportLocalSkills).toHaveBeenCalledWith({ + skills: expect.arrayContaining([ + expect.objectContaining({ name: "skill-01" }), + expect.objectContaining({ name: "skill-16" }), + ]), + }); + }); + expect(mockImportLocalSkills.mock.calls[0]?.[0].skills).toHaveLength(16); + expect(mockImportLocalSkills.mock.calls[0]?.[0].skills).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "skill-00" })]), + ); + }); + + it("keeps the dialog open on partial success summary", async () => { + mockImportLocalSkills.mockResolvedValue({ + created: [{ skill: skill("skill-1", "Review Helper"), source_label: "review" }], + skipped: [{ name: "Existing", reason: "already_exists" }], + failed: [{ name: "Broken", reason: "missing_skill_md" }], + }); + const onImported = vi.fn(); + render(, { wrapper }); + + fireEvent.change(screen.getByTestId("local-skill-folder-input"), { + target: { files: [file("review/SKILL.md", "# Review")] }, + }); + expect(await screen.findByText(/Ready to import "review"/i)).toBeInTheDocument(); + const importButton = await screen.findByRole("button", { name: /^Import$/i }); + await waitFor(() => expect(importButton).not.toBeDisabled()); + fireEvent.click(importButton); + + await waitFor(() => expect(mockImportLocalSkills).toHaveBeenCalled()); + expect(await screen.findByText(/Existing.*already exists/i)).toBeInTheDocument(); + expect(screen.getByText(/Broken.*missing SKILL\.md/i)).toBeInTheDocument(); + expect(onImported).not.toHaveBeenCalled(); + }); + + it("localizes upload result reasons", async () => { + mockImportLocalSkills.mockResolvedValue({ + created: [{ skill: skill("skill-1", "Review Helper"), source_label: "review" }], + skipped: [{ name: "Existing", reason: "already_exists" }], + failed: [{ name: "Broken", reason: "missing_skill_md" }], + }); + render(, { wrapper: zhHansWrapper }); + + fireEvent.change(screen.getByTestId("local-skill-folder-input"), { + target: { files: [file("review/SKILL.md", "# Review")] }, + }); + const importButton = await screen.findByRole("button", { name: /^导入$/i }); + await waitFor(() => expect(importButton).not.toBeDisabled()); + fireEvent.click(importButton); + + await waitFor(() => expect(mockImportLocalSkills).toHaveBeenCalled()); + expect(await screen.findByText(/Existing.*已存在/i)).toBeInTheDocument(); + expect(screen.getByText(/Broken.*缺少 SKILL\.md/i)).toBeInTheDocument(); + }); +}); + +function file(path: string, content: string): File & { webkitRelativePath: string } { + const value = new File([content], path.split("/").at(-1) ?? "file.txt", { type: "text/plain" }); + Object.defineProperty(value, "webkitRelativePath", { value: path }); + return value as File & { webkitRelativePath: string }; +} + +function dataTransferFileItem(value: File): DataTransferItem { + return { + kind: "file", + type: value.type, + getAsFile: () => value, + getAsString: vi.fn(), + webkitGetAsEntry: () => null, + } as unknown as DataTransferItem; +} + +function skill(id: string, name: string) { + return { + id, + workspace_id: "ws-1", + name, + description: "", + content: "# Skill", + config: {}, + files: [], + created_by: "user-1", + created_at: "2026-05-12T00:00:00Z", + updated_at: "2026-05-12T00:00:00Z", + }; +} diff --git a/packages/views/skills/components/local-skill-upload-panel.tsx b/packages/views/skills/components/local-skill-upload-panel.tsx new file mode 100644 index 0000000000..b53523b402 --- /dev/null +++ b/packages/views/skills/components/local-skill-upload-panel.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { AlertCircle, CheckCircle2, FileArchive, FolderUp, Loader2, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "@multica/core/api"; +import type { ImportLocalSkillResult, Skill } from "@multica/core/types"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { + skillDetailOptions, + workspaceKeys, +} from "@multica/core/workspace/queries"; +import { Badge } from "@multica/ui/components/ui/badge"; +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 { Textarea } from "@multica/ui/components/ui/textarea"; +import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; +import { useT } from "../../i18n"; +import { + buildLocalSkillCandidates, + candidateToImportRequest, + filesFromDataTransfer, + MAX_LOCAL_SKILL_IMPORT_BATCH, + readZipFile, + type LocalSkillCandidate, + type LocalSkillInputFile, +} from "../utils/local-skill-upload"; + +interface ImportSummary { + created: { skill: Skill; source_label: string }[]; + skipped: ImportLocalSkillResult[]; + failed: ImportLocalSkillResult[]; +} + +interface ResultReasonLabels { + already_exists: string; + missing_skill_md: string; + invalid_file_path: string; + hidden_file: string; + metadata_file: string; + absolute_path: string; + path_traversal: string; + file_too_large: string; + binary_file: string; + too_many_files: string; + bundle_too_large: string; + imported: string; +} + +export function LocalSkillUploadPanel({ + onImported, +}: { + onImported?: (skill: Skill) => void; +}) { + const { t } = useT("skills"); + const wsId = useWorkspaceId(); + const qc = useQueryClient(); + const folderInputRef = useRef(null); + const zipInputRef = useRef(null); + const scrollRef = useRef(null); + const fadeStyle = useScrollFade(scrollRef); + + const [candidates, setCandidates] = useState([]); + const [error, setError] = useState(""); + const [importing, setImporting] = useState(false); + const [doneCount, setDoneCount] = useState(0); + const [summary, setSummary] = useState(null); + + const selectedCandidates = useMemo( + () => candidates.filter((candidate) => candidate.valid && candidate.selected && candidate.name.trim()), + [candidates], + ); + const validCandidates = candidates.filter((candidate) => candidate.valid); + const invalidCandidate = candidates.find((candidate) => !candidate.valid); + const overBatchLimit = validCandidates.length > MAX_LOCAL_SKILL_IMPORT_BATCH; + + const setCandidate = (id: string, patch: Partial) => { + setCandidates((prev) => + prev.map((candidate) => + candidate.id === id ? { ...candidate, ...patch } : candidate, + ), + ); + }; + + const handleFiles = async (files: File[] | LocalSkillInputFile[], label: string) => { + setError(""); + setSummary(null); + try { + const next = await buildLocalSkillCandidates(files, label); + setCandidates(limitSelectedCandidates(next)); + } catch (err) { + setCandidates([]); + setError(err instanceof Error ? err.message : t(($) => $.upload_import.errors.unreadable_zip)); + } + }; + + const handleFolderChange = async (files: FileList | null) => { + if (!files || files.length === 0) { + setError(t(($) => $.upload_import.errors.empty_selection)); + return; + } + const list = Array.from(files); + await handleFiles(list, rootLabel(list[0]?.webkitRelativePath || list[0]?.name || "upload")); + }; + + const handleZipChange = async (files: FileList | null) => { + const file = files?.[0]; + if (!file) { + setError(t(($) => $.upload_import.errors.empty_selection)); + return; + } + await handleZipFile(file); + }; + + const handleZipFile = async (file: File) => { + try { + const entries = await readZipFile(file); + await handleFiles(entries, file.name); + } catch { + setCandidates([]); + setError(t(($) => $.upload_import.errors.unreadable_zip)); + } + }; + + const handleDrop = async (event: React.DragEvent) => { + event.preventDefault(); + const droppedFiles = Array.from(event.dataTransfer.files); + const droppedZip = droppedFiles.length === 1 ? droppedFiles[0] : null; + if (droppedZip && isZipFile(droppedZip)) { + await handleZipFile(droppedZip); + return; + } + if (event.dataTransfer.items.length > 0) { + const files = await filesFromDataTransfer(event.dataTransfer.items); + await handleFiles(files, t(($) => $.upload_import.drop_label)); + return; + } + await handleFolderChange(event.dataTransfer.files); + }; + + const handleImport = async () => { + if (selectedCandidates.length === 0) return; + setImporting(true); + setError(""); + setSummary(null); + setDoneCount(0); + try { + const payload = { + skills: selectedCandidates.map(candidateToImportRequest), + }; + const result = await api.importLocalSkills(payload); + for (const item of result.created) { + qc.setQueryData(skillDetailOptions(wsId, item.skill.id).queryKey, item.skill); + } + await Promise.all([ + qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }), + qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }), + ]); + setDoneCount(result.created.length); + setSummary(result); + if (result.created.length > 0 && result.skipped.length === 0 && result.failed.length === 0) { + toast.success(t(($) => $.upload_import.toast_imported)); + onImported?.(result.created[0]!.skill); + } + } catch (err) { + setError(err instanceof Error ? err.message : t(($) => $.upload_import.errors.import_failed)); + } finally { + setImporting(false); + } + }; + + const readyLabel = + selectedCandidates.length === 1 + ? t(($) => $.upload_import.ready_single, { name: selectedCandidates[0]?.name ?? "" }) + : t(($) => $.upload_import.ready_multiple, { count: selectedCandidates.length }); + + return ( +
+
+
event.preventDefault()} + onDrop={handleDrop} + className="flex flex-col items-center justify-center rounded-lg border border-dashed bg-muted/20 px-4 py-5 text-center" + > + +

{t(($) => $.upload_import.drop_title)}

+

{t(($) => $.upload_import.hint)}

+
+ + +
+ { + handleFolderChange(event.currentTarget.files); + event.currentTarget.value = ""; + }} + {...{ webkitdirectory: "" }} + /> + { + handleZipChange(event.currentTarget.files); + event.currentTarget.value = ""; + }} + /> +
+
+ +
+ {error && {error}} + {overBatchLimit && ( + + {t(($) => $.upload_import.errors.too_many_skills, { count: MAX_LOCAL_SKILL_IMPORT_BATCH })} + + )} + {invalidCandidate?.reason === "missing_skill_md" && ( + + {t(($) => $.upload_import.errors.no_skill_md)} + + )} + {invalidCandidate?.reason === "unreadable_skill_md" && ( + + {t(($) => $.upload_import.errors.unreadable_skill_md)} + + )} + + {validCandidates.length === 1 && ( + setCandidate(validCandidates[0]!.id, patch)} + /> + )} + {validCandidates.length > 1 && ( + + )} + + {summary && } +
+ +
+
+ {selectedCandidates.length > 0 + ? readyLabel + : t(($) => $.upload_import.select_skill)} +
+ +
+
+ ); +} + +function SingleSkillPreview({ + candidate, + onChange, +}: { + candidate: LocalSkillCandidate; + onChange: (patch: Partial) => void; +}) { + const { t } = useT("skills"); + return ( +
+
+
+
+ + onChange({ name: event.target.value })} /> +
+
+ + +
+
+
+ +