forked from bai/curriculum-project-hub
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user