feat(admin-web): skill zip import and folder-grouped role picker

- parse skill package zips client-side, mirroring backend ingestion limits (ADR-0018)
- add zip upload flow on skills page and zip replace in SkillEditor
- group role skill bindings by the shared management folder tree (ADR-0028)
- add fflate dependency
This commit is contained in:
2026-07-31 15:41:42 +08:00
parent 6ea8e33148
commit ff990b4caf
7 changed files with 334 additions and 46 deletions
@@ -3,6 +3,7 @@
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { parseSkillZip } from '$lib/skillZip';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
@@ -30,6 +31,11 @@
let newSkillDescription = $state('');
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 folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
@@ -151,6 +157,37 @@
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 }) {
load();
}
@@ -252,15 +289,51 @@
</div>
<div>
<div class="saas-card-pad mb-6">
<div class="flex items-center justify-between">
<div class="saas-card-pad mb-6 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="saas-section-title">新建技能</h2>
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
{showNewSkill ? '取消' : '+ 新建'}
</button>
<div class="flex flex-wrap gap-3">
<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>
{#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}
<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
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
@@ -280,7 +353,7 @@
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="mt-2 text-xs text-surface-600">
<p class="text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
</p>
{/if}