| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100 |
- /**
- * 容器交付协议:结构化文件清单
- *
- * ## 为什么需要
- *
- * 原先靠**从正文正则找 URL** 来推断容器产出了哪些文件。这条路上已经踩过两次:
- * ① 容器举例写 `https://s3.fmode.cn/...` → 被当成真实文件建卡
- * ② 用户提问里写同样的 URL → 也被收进「成果」
- *
- * 根子是「靠猜」。改成**容器显式声明**:正文照常给人读,文件清单用标记段给机器解析。
- *
- * ## 协议
- *
- * 容器在回复末尾追加一段被 HTML 注释包裹的 JSON:
- *
- * ```
- * 本周工作计划已生成,含 5 条任务与时间分布。
- *
- * <!--fmode:files
- * [
- * {"name": "weekly-plan-20261109.md", "url": "https://s3.fmode.cn/xxx/a.md", "size": 1587},
- * {"name": "report.png", "url": "https://s3.fmode.cn/xxx/b.png"}
- * ]
- * -->
- * ```
- *
- * 用 HTML 注释包裹的好处:
- * · 即使解析失败,Markdown 渲染器也不会把 JSON 显示成正文(注释不可见)
- * · 人读回复时完全无感
- *
- * ## 兼容
- *
- * 没有标记段时返回 `null`,调用方回退到「从正文提取」(老格式仍可用)。
- */
- /** 标记段:`<!--fmode:files ... -->`,内容为 JSON 数组 */
- const FILES_BLOCK = /<!--\s*fmode:files\s*([\s\S]*?)-->/i;
- /** 清单里的一项 */
- export interface DeclaredFile {
- name: string;
- url: string;
- /** 字节数;缺省 / 非法时为 -1(表示未知) */
- size: number;
- }
- /**
- * 解析容器声明段里的文件清单。
- *
- * @param text 容器回复原文
- * @returns
- * · `null` —— 没有声明段(老格式,调用方回退到正则提取)
- * · `[]` —— 有声明段但内容为空(容器明确表示「本次没有文件」)
- * · 数组 —— 解析出的文件清单(已过滤非法项)
- */
- export function parseDeclaredFiles(text: string | undefined | null): DeclaredFile[] | null {
- if (!text) return null;
- const m = FILES_BLOCK.exec(text);
- if (!m) return null;
- let raw: unknown;
- try {
- raw = JSON.parse(m[1].trim());
- } catch {
- /*
- * JSON 写坏了:返回空数组而不是 null ——
- * 容器「声明了但写错」时,宁可不显示也不要用正文正则去猜,
- * 否则又会把举例链接当成文件。
- */
- return [];
- }
- if (!Array.isArray(raw)) return [];
- const out: DeclaredFile[] = [];
- for (const it of raw) {
- if (!it || typeof it !== 'object') continue;
- const o = it as Record<string, unknown>;
- const url = typeof o['url'] === 'string' ? o['url'].trim() : '';
- if (!url || !/^https?:\/\//i.test(url)) continue;
- const name =
- typeof o['name'] === 'string' && o['name'].trim()
- ? o['name'].trim()
- : (decodeURIComponent(url.split('?')[0].split('/').pop() ?? '文件') || '文件');
- const size = typeof o['size'] === 'number' && Number.isFinite(o['size']) && o['size'] >= 0
- ? o['size']
- : -1;
- out.push({ name, url, size });
- }
- return out;
- }
- /**
- * 把声明段从正文里**移除**(用户不该看到那段 JSON)。
- *
- * 即使解析失败也移除 —— 它是给机器看的,不是给用户看的。
- */
- export function stripDeclaredFiles(text: string | undefined | null): string {
- if (!text) return '';
- return text.replace(FILES_BLOCK, '').trim();
- }
|