/** * AI 回复正文的 Markdown 归一化 * * ## 解决什么 * * 容器(agent)发回的回复里,小标题和正文之间只有**单个换行**: * * ``` * **核心能力** * 我是你的数字分身… * **对话与协作** * 我在 Matrix 私聊… * ``` * * CommonMark 规则里,单换行等于**空格**,于是整段被渲染成同一个 `

`, * 三个小标题和正文全部粘在一起(用户实测截图)。 * * 本函数只做一件事:**在「标题行」和「正文」之间补一个空行**, * 让它们各自成段。真正的 markdown(已有空行、代码块、列表)一律原样保留。 * * ## 设计约束 * * 🔴 **只补不拆**:永远不删除用户/AI 写的内容,只插入换行。 * 🔴 **幂等**:对同一段文本执行两次,结果与执行一次相同。 * 🔴 **不碰代码块**:``` 围栏内的内容逐字保留(里面的换行有语义)。 */ /** 代码围栏标记(``` 或 ~~~,允许缩进) */ const FENCE = /^\s*(```|~~~)/; /** * 形如「小标题」的短行特征。 * * 命中任一即视为标题行: * · `**核心能力**` —— 已是 markdown 加粗 * · `一、产品定位` / `(一)…` —— 中文序号 * · `1. 产品定位` / `1、…` —— 数字序号 * · `核心能力:` —— 短句 + 冒号 * · `核心能力` —— 纯短句(≤ 12 字,无句末标点) */ const HEADING_PATTERNS: readonly RegExp[] = [ /^\*\*[^*]+\*\*[::]?$/, // **标题** /^#{1,6}\s+\S/, // 已是 markdown 标题 /^[一二三四五六七八九十]+[、..]\s*\S/, // 一、标题 /^[((][一二三四五六七八九十\d]+[))]\s*\S/, // (一)标题 /^\d+[、..]\s*\S/, // 1、标题 ]; /** 句末标点 —— 有这些说明是正文句子,不是标题 */ const SENTENCE_END = /[。!?;.!?;]$/; /** 纯短句标题的最大长度(字) */ const BARE_HEADING_MAX = 12; /** * 这一行**整行**是不是一个小标题。 * * 用于「标题在行首」的场景(在它**之前**补空行)。 * 比 `HEADING_PATTERNS` 更保守 —— 只认明确的标题形态,不做「短句猜测」, * 否则「好的」这种正常回复也会被当成标题切出来。 */ function isHeadingLine(line: string): boolean { if (!line) return false; // **xxx** 独占一行 if (/^\*\*[^*]+\*\*[::]?$/.test(line)) return true; // markdown 标题 if (/^#{1,6}\s+\S/.test(line)) return true; // 中文/数字序号 + 短标题 if ( /^([一二三四五六七八九十]+[、..]|第[一二三四五六七八九十\d]+[段步条点]|\d+[、..])\s*\S/.test( line, ) && line.length <= 24 && !SENTENCE_END.test(line) ) { return true; } return false; } /** * 判断某一行的「下一行」是否应该与它隔开。 * * @param line 当前行(已 trim) * @param next 下一行(已 trim) * @returns true 表示两者之间要插一个空行 */ function needsBreakAfter(line: string, next: string): boolean { if (!line || !next) return false; /* * ⓪ 行**尾部**是标题 —— 真正要断的是「标题之后」而不是「这一行之后」。 * * 实测容器的另一种排版:正文写完,紧接着一行 `**我的能力**` 收尾, * 标题本身在行尾,正文在**下一行**: * * ``` * 我是主人的数字分身…结论先行。 * **我的能力** * 我能操作浏览器访问网页… * ``` * * 这时要在「标题行」和它下一行之间断开,而不是在当前行之后断开。 * 由调用方 `splitTrailingHeading` 先把这一行拆开再处理。 */ // ① 已命中的标题形态 → 与后文隔开 for (const re of HEADING_PATTERNS) { if (re.test(line)) return true; } // ② 纯短句标题:够短、无句末标点、且下一行明显更长(是正文而非同类短行) const isBareCandidate = line.length <= BARE_HEADING_MAX && !SENTENCE_END.test(line) && !/^[-*+]\s/.test(line); if (isBareCandidate && next.length > line.length) return true; // ③ 短句 + 冒号结尾(如「核心能力:」)→ 与后文隔开 if (/[::]$/.test(line) && line.length <= BARE_HEADING_MAX + 4) return true; return false; } /** * 判断整行文本的**末尾**是否是一个独立小标题。 * * 两种实测形态都认: * · `…结论先行。**我的能力**` —— markdown 加粗收尾 * · `…对外发声。 职责二:在授权范围内执行任务` —— 空格分隔 + 序号短语 * * @returns 命中时返回标题位置之前的正文部分;否则返回 null */ function splitTrailingHeading(line: string): { head: string; heading: string } | null { /* * ① `**xxx**` 收尾 —— 中间可能**紧贴**(`结论先行。**我的能力**`) * 或**隔空格**(`…结论先行。 **我的能力**`),两种都认。 */ const bold = /^(.*\S)\s*(\*\*[^*\n]+\*\*)\s*[::]?\s*$/.exec(line); if (bold) return { head: bold[1].trim(), heading: bold[2].trim() }; /* * ② 「空白 + 短标题短语」收尾:「…绝不编造。 我的边界」「…发了。 职责二:执行」 * * 🔴 实测容器的空格数量不固定(1 个、2 个都有),句末标点也可能没有, * 所以判据换成**标题本身的长相**,不依赖空格数与前置标点: * · 尾部短语够短(≤ 24 字) * · 本身不含句末标点(是短语,不是句子) * · 以「我的/你的/职责/步骤/阶段/目标/结论/要点/第X/一二三、/1.」等开头 */ const tail = /^(.*\S)\s+(\S[^。!?.!?;]*)$/.exec(line); if (tail) { const cand = tail[2].trim(); const looksHeading = cand.length > 0 && cand.length <= 24 && !SENTENCE_END.test(cand) && /^(我的|你的|它的|我们的|职责|步骤|阶段|目标|结论|要点|特点|优势|背景|现状|问题|建议|方案|注意|小结|总结|第[一二三四五六七八九十\d]+|[一二三四五六七八九十]+[、..]|\d+[、..])/.test( cand, ); // 正文部分必须够长,否则「第 1 步」这种正常用法会被误切 if (looksHeading && tail[1].trim().length >= 8) { return { head: tail[1].trim(), heading: cand }; } } return null; } /** * 把「单换行分隔的标题 + 正文」补成空行分段。 * * @param text 原始正文 * @returns 归一化后的正文(只插入换行,不删除任何字符) */ export function normalizeMarkdown(text: string): string { if (!text) return text; const lines = text.split('\n'); const out: string[] = []; /** 是否在代码围栏内(围栏内逐字保留) */ let inFence = false; for (let i = 0; i < lines.length; i++) { const raw = lines[i]; const line = raw.trim(); if (FENCE.test(raw)) { inFence = !inFence; out.push(raw); continue; } if (inFence) { out.push(raw); continue; } /* * ⓪-1 中文正文里的单个 `~` → HTML 实体,防止被当成删除线(2026-11-11 用户实测)。 * * ## 现象(截图实证) * * AI 回复里「**南昌**:…全天 8~16℃ … **北京**:…全天 3~12℃」整段被划了横线, * 看起来像正文被删除 —— 用户反复反馈「为什么会有横线」。 * * ## 根因 * * 温度区间习惯写成 `8~16℃` / `3~12℃`。这些单波浪号在整段里**成对出现**, * GFM 把两个 `~` 之间的所有内容解析成 `~~删除线~~`: * * ``` * 全天 8~16℃ … 3~12℃ * ↑__________↑ → 16℃ … 3 * ``` * * 实测 marked@16:`温度 8~16℃ 和 3~12℃ 对比` → `16℃ 和 3`。 * * ## 为什么用 HTML 实体而不是反斜杠转义 * * `\~` 实测无效 —— `~` 不在 CommonMark 的可转义字符表里,反斜杠会被吃掉, * 波浪号照样参与解析。`~` 是 HTML 实体,渲染出来就是 `~`,且 * **不参与 markdown 语法解析**,两头都满足。 * * ## 只处理单个 `~`,保留 `~~` * * 真正的删除线 `~~xxx~~` 必须保留(容器会用它标注作废方案), * 所以用负向断言排除「紧邻另一个 ~」的情况。 */ /* * 🔴 只做行内替换,**不 continue**:这一行后面还要参与标题断行判定, * 跳过去会让「带 ~ 的标题行」失去分段(用户实测过同类漏判)。 */ const line0 = line.includes('~') ? raw.replace(/(? 0 && isHeadingLine(line) && out.length >= 1 && out[out.length - 1].trim()) { out.push(''); } out.push(line0); /* * 只在「当前行有内容、下一行也有内容」时才考虑补空行。 * 已经是空行的位置(正常 markdown 分段)不动 —— 幂等的关键。 */ if (!line) continue; const nextRaw = lines[i + 1]; if (nextRaw === undefined) continue; const next = nextRaw.trim(); if (!next) continue; /* * 列表项内部不断开:`- xxx` 后面跟 `- yyy` 是同一个列表。 * 缩进的续行(以空白开头)也不断开。 */ if (/^\s*[-*+]\s|^\s*\d+[、..)]\s/.test(nextRaw)) continue; if (/^\s{2,}/.test(nextRaw)) continue; if (needsBreakAfter(line, next)) out.push(''); } return out.join('\n'); }