skill.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. /**
  2. * 技能目录规范与共用小工具。
  3. *
  4. * 「插件与技能 → 技能」的**查看详情**与**新增/编辑**两个弹框必须保持同一套口径
  5. * (同样的目录规范、同样的 frontmatter 拆解、同样的体积展示),所以集中在这里,
  6. * 避免两边各写一份后悄悄漂移。
  7. */
  8. import type { SkillFile } from '../api/types';
  9. /** 可选规范子目录(顺序即页面展示顺序) */
  10. export const SKILL_STD_DIRS = [
  11. { key: 'scripts', desc: '自动化脚本或工具调用逻辑' },
  12. { key: 'references', desc: '长文档、规范、API 说明' },
  13. ] as const;
  14. /** 规范目录名集合:用来判断某个附属文件是否落在规范目录内 */
  15. export const SKILL_STD_DIR_KEYS: readonly string[] = SKILL_STD_DIRS.map((d) => d.key);
  16. /** 技能名合法字符(与后端 SkillUtil.isValidSkillName 对齐:小写字母/数字/短横线,1~64) */
  17. export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
  18. /** 文件大小展示 */
  19. export function formatFileSize(size?: number): string {
  20. if (size == null) return '';
  21. if (size < 1024) return `${size} B`;
  22. if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
  23. return `${(size / 1024 / 1024).toFixed(1)} MB`;
  24. }
  25. /**
  26. * 拆 SKILL.md 的 frontmatter:返回描述与正文;没有 frontmatter 时整段当正文。
  27. *
  28. * 后端 buildSkillMd 会把 description 用双引号包裹并转义,这里要还原,保证编辑往返一致。
  29. */
  30. export function splitSkillMd(raw: string): { description: string; body: string } {
  31. const text = raw.replace(/\r\n/g, '\n');
  32. const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
  33. if (!m) return { description: '', body: text.trim() };
  34. let description = '';
  35. const dm = m[1].match(/^description:\s*(.*)$/m);
  36. if (dm) {
  37. description = dm[1]
  38. .trim()
  39. .replace(/^["']|["']$/g, '')
  40. .replace(/\\"/g, '"');
  41. }
  42. const body = text.slice(m[0].length).replace(/^\n+/, '').replace(/\s+$/, '');
  43. return { description, body };
  44. }
  45. /** 文件所属的第一层目录;根目录散文件返回空串 */
  46. export function topDirOf(path: string): string {
  47. const idx = path.indexOf('/');
  48. return idx > 0 ? path.slice(0, idx) : '';
  49. }
  50. /** 去掉规范目录前缀后的目录内相对路径(文件行展示用) */
  51. export function leafPath(path: string, dirKey: string): string {
  52. return path.startsWith(`${dirKey}/`) ? path.slice(dirKey.length + 1) : path;
  53. }
  54. /** 把附属文件按第一层目录分组,规范目录打头(空的也占位),其余目录按字典序排在后面 */
  55. export function groupSkillFiles(files: SkillFile[]) {
  56. const byDir = new Map<string, SkillFile[]>();
  57. for (const file of files) {
  58. const dir = topDirOf(file.path);
  59. const bucket = byDir.get(dir);
  60. if (bucket) bucket.push(file);
  61. else byDir.set(dir, [file]);
  62. }
  63. const rootFiles = byDir.get('') ?? [];
  64. byDir.delete('');
  65. const dirs = SKILL_STD_DIRS.map((d) => ({
  66. key: d.key as string,
  67. desc: d.desc as string,
  68. files: byDir.get(d.key) ?? [],
  69. }));
  70. for (const d of SKILL_STD_DIRS) byDir.delete(d.key);
  71. for (const [key, list] of [...byDir.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
  72. dirs.push({ key, desc: '', files: list });
  73. }
  74. return { dirs, rootFiles };
  75. }
  76. /** 图片后缀 → mime。只列位图:svg 是文本、后端会当 content 返回,走不到 base64 分支 */
  77. const IMAGE_MIME: Record<string, string> = {
  78. png: 'image/png',
  79. jpg: 'image/jpeg',
  80. jpeg: 'image/jpeg',
  81. gif: 'image/gif',
  82. webp: 'image/webp',
  83. bmp: 'image/bmp',
  84. ico: 'image/x-icon',
  85. };
  86. function extOf(path: string) {
  87. const idx = path.lastIndexOf('.');
  88. return idx > 0 ? path.slice(idx + 1).toLowerCase() : '';
  89. }
  90. /** 是否可用 <img> 预览的图片文件 */
  91. export function isImagePath(path: string): boolean {
  92. return !!IMAGE_MIME[extOf(path)];
  93. }
  94. /** 图片文件的 mime(非图片回落到 octet-stream) */
  95. export function imageMimeOf(path: string): string {
  96. return IMAGE_MIME[extOf(path)] || 'application/octet-stream';
  97. }