declared-files.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. /**
  2. * 容器交付协议:结构化文件清单
  3. *
  4. * ## 为什么需要
  5. *
  6. * 原先靠**从正文正则找 URL** 来推断容器产出了哪些文件。这条路上已经踩过两次:
  7. * ① 容器举例写 `https://s3.fmode.cn/...` → 被当成真实文件建卡
  8. * ② 用户提问里写同样的 URL → 也被收进「成果」
  9. *
  10. * 根子是「靠猜」。改成**容器显式声明**:正文照常给人读,文件清单用标记段给机器解析。
  11. *
  12. * ## 协议
  13. *
  14. * 容器在回复末尾追加一段被 HTML 注释包裹的 JSON:
  15. *
  16. * ```
  17. * 本周工作计划已生成,含 5 条任务与时间分布。
  18. *
  19. * <!--fmode:files
  20. * [
  21. * {"name": "weekly-plan-20261109.md", "url": "https://s3.fmode.cn/xxx/a.md", "size": 1587},
  22. * {"name": "report.png", "url": "https://s3.fmode.cn/xxx/b.png"}
  23. * ]
  24. * -->
  25. * ```
  26. *
  27. * 用 HTML 注释包裹的好处:
  28. * · 即使解析失败,Markdown 渲染器也不会把 JSON 显示成正文(注释不可见)
  29. * · 人读回复时完全无感
  30. *
  31. * ## 兼容
  32. *
  33. * 没有标记段时返回 `null`,调用方回退到「从正文提取」(老格式仍可用)。
  34. */
  35. /** 标记段:`<!--fmode:files ... -->`,内容为 JSON 数组 */
  36. const FILES_BLOCK = /<!--\s*fmode:files\s*([\s\S]*?)-->/i;
  37. /** 清单里的一项 */
  38. export interface DeclaredFile {
  39. name: string;
  40. url: string;
  41. /** 字节数;缺省 / 非法时为 -1(表示未知) */
  42. size: number;
  43. }
  44. /**
  45. * 解析容器声明段里的文件清单。
  46. *
  47. * @param text 容器回复原文
  48. * @returns
  49. * · `null` —— 没有声明段(老格式,调用方回退到正则提取)
  50. * · `[]` —— 有声明段但内容为空(容器明确表示「本次没有文件」)
  51. * · 数组 —— 解析出的文件清单(已过滤非法项)
  52. */
  53. export function parseDeclaredFiles(text: string | undefined | null): DeclaredFile[] | null {
  54. if (!text) return null;
  55. const m = FILES_BLOCK.exec(text);
  56. if (!m) return null;
  57. let raw: unknown;
  58. try {
  59. raw = JSON.parse(m[1].trim());
  60. } catch {
  61. /*
  62. * JSON 写坏了:返回空数组而不是 null ——
  63. * 容器「声明了但写错」时,宁可不显示也不要用正文正则去猜,
  64. * 否则又会把举例链接当成文件。
  65. */
  66. return [];
  67. }
  68. if (!Array.isArray(raw)) return [];
  69. const out: DeclaredFile[] = [];
  70. for (const it of raw) {
  71. if (!it || typeof it !== 'object') continue;
  72. const o = it as Record<string, unknown>;
  73. const url = typeof o['url'] === 'string' ? o['url'].trim() : '';
  74. if (!url || !/^https?:\/\//i.test(url)) continue;
  75. const name =
  76. typeof o['name'] === 'string' && o['name'].trim()
  77. ? o['name'].trim()
  78. : (decodeURIComponent(url.split('?')[0].split('/').pop() ?? '文件') || '文件');
  79. const size = typeof o['size'] === 'number' && Number.isFinite(o['size']) && o['size'] >= 0
  80. ? o['size']
  81. : -1;
  82. out.push({ name, url, size });
  83. }
  84. return out;
  85. }
  86. /**
  87. * 把声明段从正文里**移除**(用户不该看到那段 JSON)。
  88. *
  89. * 即使解析失败也移除 —— 它是给机器看的,不是给用户看的。
  90. */
  91. export function stripDeclaredFiles(text: string | undefined | null): string {
  92. if (!text) return '';
  93. return text.replace(FILES_BLOCK, '').trim();
  94. }