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
+97
View File
@@ -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);
}