/** * 技能目录规范与共用小工具。 * * 「插件与技能 → 技能」的**查看详情**与**新增/编辑**两个弹框必须保持同一套口径 * (同样的目录规范、同样的 frontmatter 拆解、同样的体积展示),所以集中在这里, * 避免两边各写一份后悄悄漂移。 */ import type { SkillFile } from '../api/types'; /** 可选规范子目录(顺序即页面展示顺序) */ export const SKILL_STD_DIRS = [ { key: 'scripts', desc: '自动化脚本或工具调用逻辑' }, { key: 'references', desc: '长文档、规范、API 说明' }, ] as const; /** 规范目录名集合:用来判断某个附属文件是否落在规范目录内 */ export const SKILL_STD_DIR_KEYS: readonly string[] = SKILL_STD_DIRS.map((d) => d.key); /** 技能名合法字符(与后端 SkillUtil.isValidSkillName 对齐:小写字母/数字/短横线,1~64) */ export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; /** 文件大小展示 */ export function formatFileSize(size?: number): string { if (size == null) return ''; if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1024 / 1024).toFixed(1)} MB`; } /** * 拆 SKILL.md 的 frontmatter:返回描述与正文;没有 frontmatter 时整段当正文。 * * 后端 buildSkillMd 会把 description 用双引号包裹并转义,这里要还原,保证编辑往返一致。 */ export function splitSkillMd(raw: string): { description: string; body: string } { const text = raw.replace(/\r\n/g, '\n'); const m = text.match(/^---\n([\s\S]*?)\n---\n?/); if (!m) return { description: '', body: text.trim() }; let description = ''; const dm = m[1].match(/^description:\s*(.*)$/m); if (dm) { description = dm[1] .trim() .replace(/^["']|["']$/g, '') .replace(/\\"/g, '"'); } const body = text.slice(m[0].length).replace(/^\n+/, '').replace(/\s+$/, ''); return { description, body }; } /** 文件所属的第一层目录;根目录散文件返回空串 */ export function topDirOf(path: string): string { const idx = path.indexOf('/'); return idx > 0 ? path.slice(0, idx) : ''; } /** 去掉规范目录前缀后的目录内相对路径(文件行展示用) */ export function leafPath(path: string, dirKey: string): string { return path.startsWith(`${dirKey}/`) ? path.slice(dirKey.length + 1) : path; } /** 把附属文件按第一层目录分组,规范目录打头(空的也占位),其余目录按字典序排在后面 */ export function groupSkillFiles(files: SkillFile[]) { const byDir = new Map(); for (const file of files) { const dir = topDirOf(file.path); const bucket = byDir.get(dir); if (bucket) bucket.push(file); else byDir.set(dir, [file]); } const rootFiles = byDir.get('') ?? []; byDir.delete(''); const dirs = SKILL_STD_DIRS.map((d) => ({ key: d.key as string, desc: d.desc as string, files: byDir.get(d.key) ?? [], })); for (const d of SKILL_STD_DIRS) byDir.delete(d.key); for (const [key, list] of [...byDir.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { dirs.push({ key, desc: '', files: list }); } return { dirs, rootFiles }; } /** 图片后缀 → mime。只列位图:svg 是文本、后端会当 content 返回,走不到 base64 分支 */ const IMAGE_MIME: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', }; function extOf(path: string) { const idx = path.lastIndexOf('.'); return idx > 0 ? path.slice(idx + 1).toLowerCase() : ''; } /** 是否可用 预览的图片文件 */ export function isImagePath(path: string): boolean { return !!IMAGE_MIME[extOf(path)]; } /** 图片文件的 mime(非图片回落到 octet-stream) */ export function imageMimeOf(path: string): string { return IMAGE_MIME[extOf(path)] || 'application/octet-stream'; }