/** * 容器交付协议:结构化文件清单 * * ## 为什么需要 * * 原先靠**从正文正则找 URL** 来推断容器产出了哪些文件。这条路上已经踩过两次: * ① 容器举例写 `https://s3.fmode.cn/...` → 被当成真实文件建卡 * ② 用户提问里写同样的 URL → 也被收进「成果」 * * 根子是「靠猜」。改成**容器显式声明**:正文照常给人读,文件清单用标记段给机器解析。 * * ## 协议 * * 容器在回复末尾追加一段被 HTML 注释包裹的 JSON: * * ``` * 本周工作计划已生成,含 5 条任务与时间分布。 * * * ``` * * 用 HTML 注释包裹的好处: * · 即使解析失败,Markdown 渲染器也不会把 JSON 显示成正文(注释不可见) * · 人读回复时完全无感 * * ## 兼容 * * 没有标记段时返回 `null`,调用方回退到「从正文提取」(老格式仍可用)。 */ /** 标记段:``,内容为 JSON 数组 */ const FILES_BLOCK = //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; 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(); }