forked from EduCraft/curriculum-project-hub
Merge pull request 'feat(admin-web): skill zip import and folder-grouped role picker' (#41) from feat/upload-skill-zip into main
Reviewed-on: EduCraft/curriculum-project-hub#41
This commit is contained in:
Generated
+19
-24
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "admin-web",
|
"name": "admin-web",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@skeletonlabs/skeleton": "^4.15.2",
|
"@skeletonlabs/skeleton": "^4.15.2",
|
||||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||||
@@ -25,29 +28,6 @@
|
|||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
|
||||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.2",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
|
||||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
@@ -582,6 +562,7 @@
|
|||||||
"integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==",
|
"integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
"@sveltejs/acorn-typescript": "^1.0.9",
|
||||||
@@ -634,6 +615,7 @@
|
|||||||
"integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==",
|
"integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
@@ -1579,6 +1561,7 @@
|
|||||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1768,6 +1751,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2181,6 +2170,7 @@
|
|||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2223,6 +2213,7 @@
|
|||||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -2388,6 +2379,7 @@
|
|||||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -2468,7 +2460,8 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
@@ -2524,6 +2517,7 @@
|
|||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2538,6 +2532,7 @@
|
|||||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.5",
|
"picomatch": "^4.0.5",
|
||||||
|
|||||||
@@ -29,5 +29,8 @@
|
|||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Checkbox, Label } from 'bits-ui';
|
import { Checkbox, Label } from 'bits-ui';
|
||||||
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
import type { AgentConfigFolderRow, AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate } from '$lib/format';
|
||||||
import { TOOL_OPTIONS } from '$lib/constants';
|
import { TOOL_OPTIONS } from '$lib/constants';
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
models,
|
models,
|
||||||
skills,
|
skills,
|
||||||
slug,
|
slug,
|
||||||
|
folders,
|
||||||
folderItems,
|
folderItems,
|
||||||
onupdated,
|
onupdated,
|
||||||
onskillschanged,
|
onskillschanged,
|
||||||
@@ -24,6 +25,8 @@
|
|||||||
models: AgentModelRow[];
|
models: AgentModelRow[];
|
||||||
skills: AgentSkillRow[];
|
skills: AgentSkillRow[];
|
||||||
slug: string;
|
slug: string;
|
||||||
|
/** ADR-0028 shared management tree — skill picker groups only; not binding identity */
|
||||||
|
folders: AgentConfigFolderRow[];
|
||||||
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
|
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
|
||||||
folderItems: { value: string; label: string }[];
|
folderItems: { value: string; label: string }[];
|
||||||
onupdated: (updated: AgentRoleRow) => void;
|
onupdated: (updated: AgentRoleRow) => void;
|
||||||
@@ -71,7 +74,59 @@
|
|||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
|
function folderPathLabel(folderId: string): string {
|
||||||
|
const folder = folders.find((f) => f.id === folderId);
|
||||||
|
if (!folder) return '(未知文件夹)';
|
||||||
|
const parts: string[] = [folder.name];
|
||||||
|
let cur: AgentConfigFolderRow | undefined = folder;
|
||||||
|
while (cur?.parentId) {
|
||||||
|
const parent = folders.find((x) => x.id === cur!.parentId);
|
||||||
|
if (!parent) break;
|
||||||
|
parts.unshift(parent.name);
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
return parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group picker by management folder; bindings remain skill name (ADR-0028). */
|
||||||
|
const skillGroups = $derived.by(() => {
|
||||||
|
type Group = { key: string; label: string; skills: AgentSkillRow[] };
|
||||||
|
const byFolder = new Map<string | null, AgentSkillRow[]>();
|
||||||
|
for (const s of skills) {
|
||||||
|
const key = s.folderId;
|
||||||
|
const list = byFolder.get(key) ?? [];
|
||||||
|
list.push(s);
|
||||||
|
byFolder.set(key, list);
|
||||||
|
}
|
||||||
|
for (const list of byFolder.values()) {
|
||||||
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
const filed = [...byFolder.entries()]
|
||||||
|
.filter((e): e is [string, AgentSkillRow[]] => e[0] !== null)
|
||||||
|
.map(([id, list]) => ({ key: id, label: folderPathLabel(id), skills: list }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
const unfiled = byFolder.get(null);
|
||||||
|
const groups: Group[] = [...filed];
|
||||||
|
if (unfiled && unfiled.length > 0) {
|
||||||
|
groups.push({ key: 'unfiled', label: '未分类', skills: unfiled });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
function groupSelectedCount(groupSkills: AgentSkillRow[]): number {
|
||||||
|
return groupSkills.filter((s) => selectedSkills.includes(s.name)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(groupSkills: AgentSkillRow[], checked: boolean) {
|
||||||
|
const names = new Set(groupSkills.map((s) => s.name));
|
||||||
|
if (checked) {
|
||||||
|
const next = new Set(selectedSkills);
|
||||||
|
for (const n of names) next.add(n);
|
||||||
|
selectedSkills = [...next];
|
||||||
|
} else {
|
||||||
|
selectedSkills = selectedSkills.filter((n) => !names.has(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function skillsDirty(): boolean {
|
function skillsDirty(): boolean {
|
||||||
const a = [...selectedSkills].sort();
|
const a = [...selectedSkills].sort();
|
||||||
@@ -209,19 +264,40 @@
|
|||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<span class="saas-label">技能绑定</span>
|
<span class="saas-label">技能绑定</span>
|
||||||
{#if skills.length === 0}
|
{#if skills.length === 0}
|
||||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
|
<p class="text-sm text-surface-600">组织内暂无已安装技能。请在技能页上传 zip 或新建空白模板(ADR-0018)。</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
<div class="space-y-3">
|
||||||
{#each skillItems as s}
|
{#each skillGroups as group (group.key)}
|
||||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
<div class="border border-surface-300">
|
||||||
<CheckboxControl
|
<label class="flex cursor-pointer items-center gap-2 border-b border-surface-200 bg-surface-100 px-2 py-1.5 text-sm">
|
||||||
checked={selectedSkills.includes(s.value)}
|
<CheckboxControl
|
||||||
onchange={(checked) => {
|
checked={groupSelectedCount(group.skills) === group.skills.length}
|
||||||
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
|
onchange={(checked) => toggleGroup(group.skills, checked)}
|
||||||
}}
|
/>
|
||||||
/>
|
<span class="text-xs font-semibold text-surface-700">{group.label}</span>
|
||||||
<span class="font-mono text-xs">{s.label}</span>
|
<span class="text-[10px] text-surface-500">
|
||||||
</label>
|
{groupSelectedCount(group.skills)}/{group.skills.length}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-1 gap-0.5 p-1 sm:grid-cols-2">
|
||||||
|
{#each group.skills as s (s.name)}
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||||
|
<CheckboxControl
|
||||||
|
checked={selectedSkills.includes(s.name)}
|
||||||
|
onchange={(checked) => {
|
||||||
|
selectedSkills = checked
|
||||||
|
? [...selectedSkills, s.name]
|
||||||
|
: selectedSkills.filter((x) => x !== s.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-xs">{s.name}</span>
|
||||||
|
{#if s.version}
|
||||||
|
<span class="text-[10px] text-surface-500">v{s.version}</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate } from '$lib/format';
|
||||||
|
import { parseSkillZip } from '$lib/skillZip';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import SelectField from '$lib/components/SelectField.svelte';
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
@@ -36,6 +37,8 @@
|
|||||||
let showNewFile = $state(false);
|
let showNewFile = $state(false);
|
||||||
let folderValue = $state(skill.folderId ?? '');
|
let folderValue = $state(skill.folderId ?? '');
|
||||||
let savingFolder = $state(false);
|
let savingFolder = $state(false);
|
||||||
|
let zipInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let zipImporting = $state(false);
|
||||||
|
|
||||||
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||||||
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||||||
@@ -178,6 +181,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importZip(fileList: FileList | null) {
|
||||||
|
const file = fileList?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const trimmedVersion = version.trim();
|
||||||
|
if (trimmedVersion === '') {
|
||||||
|
toastError('请先填写版本号再导入 zip');
|
||||||
|
if (zipInputEl) zipInputEl.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zipImporting = true;
|
||||||
|
try {
|
||||||
|
const buf = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const parsed = parseSkillZip(buf);
|
||||||
|
if (parsed.name !== skill.name) {
|
||||||
|
throw new Error(`zip 内技能名 "${parsed.name}" 与当前技能 "${skill.name}" 不一致`);
|
||||||
|
}
|
||||||
|
files = parsed.files.map((f) => ({ path: f.path, content: f.content }));
|
||||||
|
selectedPath = files.find((f) => f.path === 'SKILL.md')?.path ?? files[0]?.path ?? null;
|
||||||
|
if (parsed.description !== null) description = parsed.description;
|
||||||
|
dirty = true;
|
||||||
|
toastSuccess(`已载入 zip(${files.length} 个文件),请保存以写入`);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
zipImporting = false;
|
||||||
|
if (zipInputEl) zipInputEl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateFrontmatter(content: string, key: string, value: string): string {
|
function updateFrontmatter(content: string, key: string, value: string): string {
|
||||||
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||||||
if (regex.test(content)) {
|
if (regex.test(content)) {
|
||||||
@@ -324,9 +356,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty || zipImporting}>
|
||||||
{saving ? '保存中…' : '保存'}
|
{saving ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
|
<label class="saas-btn-ghost cursor-pointer {zipImporting ? 'pointer-events-none opacity-50' : ''}">
|
||||||
|
{zipImporting ? '导入中…' : '从 zip 替换'}
|
||||||
|
<input
|
||||||
|
bind:this={zipInputEl}
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
|
class="hidden"
|
||||||
|
disabled={zipImporting || saving}
|
||||||
|
onchange={(e) => importZip(e.currentTarget.files)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{#if !skill.disabledAt}
|
{#if !skill.disabledAt}
|
||||||
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
||||||
禁用
|
禁用
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
import type { SkillFileEntry } from '$lib/api';
|
||||||
|
|
||||||
|
/** Mirror hub/src/agent/skillStore.ts limits (ADR-0018). */
|
||||||
|
const MAX_SKILL_FILES = 512;
|
||||||
|
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
|
||||||
|
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
|
||||||
|
export interface ParsedSkillZip {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string | null;
|
||||||
|
readonly files: readonly SkillFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a skill package zip. Root of the archive IS the skill directory
|
||||||
|
* (must contain SKILL.md directly — no wrapping folder).
|
||||||
|
*/
|
||||||
|
export function parseSkillZip(data: Uint8Array): ParsedSkillZip {
|
||||||
|
let entries: Record<string, Uint8Array>;
|
||||||
|
try {
|
||||||
|
entries = unzipSync(data, {
|
||||||
|
filter: (file) => !file.name.endsWith('/'),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('无法解析 zip 文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: SkillFileEntry[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
for (const [rawPath, bytes] of Object.entries(entries)) {
|
||||||
|
const path = normalizeZipPath(rawPath);
|
||||||
|
if (path === null) continue;
|
||||||
|
totalBytes += bytes.byteLength;
|
||||||
|
if (totalBytes > MAX_SKILL_BYTES) {
|
||||||
|
throw new Error(`技能总大小超过 ${MAX_SKILL_BYTES / (1024 * 1024)} MiB 上限`);
|
||||||
|
}
|
||||||
|
if (files.length >= MAX_SKILL_FILES) {
|
||||||
|
throw new Error(`技能文件数超过 ${MAX_SKILL_FILES} 上限`);
|
||||||
|
}
|
||||||
|
// Text-only alpha path — API encodes content as UTF-8 strings.
|
||||||
|
try {
|
||||||
|
files.push({ path, content: decodeUtf8Strict(bytes) });
|
||||||
|
} catch {
|
||||||
|
throw new Error(`文件不是合法 UTF-8 文本:${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error('zip 为空或不含可用文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = files.find((f) => f.path === 'SKILL.md');
|
||||||
|
if (!manifest) {
|
||||||
|
throw new Error('zip 根目录必须包含 SKILL.md(根即 skill 目录,不要多包一层文件夹)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parseFrontmatterField(manifest.content, 'name');
|
||||||
|
if (name === null || name === '') {
|
||||||
|
throw new Error('SKILL.md frontmatter 缺少 name');
|
||||||
|
}
|
||||||
|
if (!SKILL_NAME_PATTERN.test(name)) {
|
||||||
|
throw new Error('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = parseFrontmatterField(manifest.content, 'description');
|
||||||
|
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
return { name, description, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeZipPath(raw: string): string | null {
|
||||||
|
let path = raw.replace(/\\/g, '/');
|
||||||
|
// Drop zip noise and absolute/parent escapes before other checks.
|
||||||
|
if (path.startsWith('__MACOSX/') || path.includes('/__MACOSX/')) return null;
|
||||||
|
const base = path.split('/').pop() ?? '';
|
||||||
|
if (base === '.DS_Store' || base.startsWith('._')) return null;
|
||||||
|
if (path.startsWith('/') || path.includes('\0')) {
|
||||||
|
throw new Error(`非法文件路径:${raw}`);
|
||||||
|
}
|
||||||
|
// Strip a single leading "./"
|
||||||
|
if (path.startsWith('./')) path = path.slice(2);
|
||||||
|
const parts = path.split('/').filter((p) => p !== '' && p !== '.');
|
||||||
|
if (parts.length === 0 || parts.some((p) => p === '..')) {
|
||||||
|
throw new Error(`非法文件路径:${raw}`);
|
||||||
|
}
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatterField(manifest: string, key: string): string | null {
|
||||||
|
const match = new RegExp(`^${key}:\\s*['"]?([^'"\\r\\n]+)['"]?\\s*$`, 'm').exec(manifest);
|
||||||
|
return match ? match[1]!.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeUtf8Strict(bytes: Uint8Array): string {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||||
|
}
|
||||||
@@ -292,6 +292,7 @@
|
|||||||
{models}
|
{models}
|
||||||
{skills}
|
{skills}
|
||||||
{slug}
|
{slug}
|
||||||
|
{folders}
|
||||||
{folderItems}
|
{folderItems}
|
||||||
onupdated={onRoleUpdated}
|
onupdated={onRoleUpdated}
|
||||||
onskillschanged={onRoleSkillsChanged}
|
onskillschanged={onRoleSkillsChanged}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
|
||||||
import { session } from '$lib/session';
|
import { session } from '$lib/session';
|
||||||
import { resolveOrg } from '$lib/org';
|
import { resolveOrg } from '$lib/org';
|
||||||
|
import { parseSkillZip } from '$lib/skillZip';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
@@ -30,6 +31,11 @@
|
|||||||
let newSkillDescription = $state('');
|
let newSkillDescription = $state('');
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
|
|
||||||
|
let showZipUpload = $state(false);
|
||||||
|
let zipVersion = $state('0.1.0');
|
||||||
|
let zipUploading = $state(false);
|
||||||
|
let zipInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
let showFolderModal = $state(false);
|
let showFolderModal = $state(false);
|
||||||
let folderModalMode = $state<'create' | 'rename'>('create');
|
let folderModalMode = $state<'create' | 'rename'>('create');
|
||||||
let folderModalId = $state<string | null>(null);
|
let folderModalId = $state<string | null>(null);
|
||||||
@@ -151,6 +157,37 @@
|
|||||||
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadZip(fileList: FileList | null) {
|
||||||
|
const file = fileList?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const version = zipVersion.trim();
|
||||||
|
if (version === '') {
|
||||||
|
toastError('版本号不能为空');
|
||||||
|
if (zipInputEl) zipInputEl.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zipUploading = true;
|
||||||
|
try {
|
||||||
|
const buf = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const parsed = parseSkillZip(buf);
|
||||||
|
const result = await api.installAgentSkill(slug, parsed.name, {
|
||||||
|
version,
|
||||||
|
files: parsed.files,
|
||||||
|
});
|
||||||
|
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
|
||||||
|
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
|
||||||
|
}
|
||||||
|
toastSuccess(`技能 ${result.name} 已从 zip 安装(${parsed.files.length} 个文件)`);
|
||||||
|
showZipUpload = false;
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
zipUploading = false;
|
||||||
|
if (zipInputEl) zipInputEl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
@@ -252,15 +289,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="saas-card-pad mb-6">
|
<div class="saas-card-pad mb-6 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 class="saas-section-title">新建技能</h2>
|
<h2 class="saas-section-title">新建技能</h2>
|
||||||
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
|
<div class="flex flex-wrap gap-3">
|
||||||
{showNewSkill ? '取消' : '+ 新建'}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
class="text-sm text-primary-700 hover:text-primary-900"
|
||||||
|
onclick={() => {
|
||||||
|
showZipUpload = !showZipUpload;
|
||||||
|
if (showZipUpload) showNewSkill = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showZipUpload ? '取消上传' : '上传 zip'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm text-primary-700 hover:text-primary-900"
|
||||||
|
onclick={() => {
|
||||||
|
showNewSkill = !showNewSkill;
|
||||||
|
if (showNewSkill) showZipUpload = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showNewSkill ? '取消' : '+ 空白模板'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if showZipUpload}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[8rem_1fr_auto]">
|
||||||
|
<input class="saas-input text-sm" placeholder="版本号" bind:value={zipVersion} disabled={zipUploading} />
|
||||||
|
<input
|
||||||
|
bind:this={zipInputEl}
|
||||||
|
class="saas-input text-sm file:mr-3 file:border-0 file:bg-transparent file:text-sm file:font-medium"
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
|
disabled={zipUploading}
|
||||||
|
onchange={(e) => uploadZip(e.currentTarget.files)}
|
||||||
|
/>
|
||||||
|
<span class="self-center text-xs text-surface-600">{zipUploading ? '安装中…' : '选择 .zip'}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-surface-600">
|
||||||
|
zip 根目录即为 skill(须直接含 SKILL.md)。名称取自 manifest;仅 UTF-8 文本;当前选中管理文件夹时会自动归入。覆盖同 name 会更新内容(可能使绑定角色会话不可安全恢复)。
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if showNewSkill}
|
{#if showNewSkill}
|
||||||
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
<div class="grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
|
||||||
<input
|
<input
|
||||||
class="saas-input font-mono text-sm"
|
class="saas-input font-mono text-sm"
|
||||||
placeholder="技能名(如 typst-help)"
|
placeholder="技能名(如 typst-help)"
|
||||||
@@ -280,7 +353,7 @@
|
|||||||
{creating ? '创建中…' : '创建'}
|
{creating ? '创建中…' : '创建'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-surface-600">
|
<p class="text-xs text-surface-600">
|
||||||
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
|
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user