normalize-md.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /**
  2. * AI 回复正文的 Markdown 归一化
  3. *
  4. * ## 解决什么
  5. *
  6. * 容器(agent)发回的回复里,小标题和正文之间只有**单个换行**:
  7. *
  8. * ```
  9. * **核心能力**
  10. * 我是你的数字分身…
  11. * **对话与协作**
  12. * 我在 Matrix 私聊…
  13. * ```
  14. *
  15. * CommonMark 规则里,单换行等于**空格**,于是整段被渲染成同一个 `<p>`,
  16. * 三个小标题和正文全部粘在一起(用户实测截图)。
  17. *
  18. * 本函数只做一件事:**在「标题行」和「正文」之间补一个空行**,
  19. * 让它们各自成段。真正的 markdown(已有空行、代码块、列表)一律原样保留。
  20. *
  21. * ## 设计约束
  22. *
  23. * 🔴 **只补不拆**:永远不删除用户/AI 写的内容,只插入换行。
  24. * 🔴 **幂等**:对同一段文本执行两次,结果与执行一次相同。
  25. * 🔴 **不碰代码块**:``` 围栏内的内容逐字保留(里面的换行有语义)。
  26. */
  27. /** 代码围栏标记(``` 或 ~~~,允许缩进) */
  28. const FENCE = /^\s*(```|~~~)/;
  29. /**
  30. * 形如「小标题」的短行特征。
  31. *
  32. * 命中任一即视为标题行:
  33. * · `**核心能力**` —— 已是 markdown 加粗
  34. * · `一、产品定位` / `(一)…` —— 中文序号
  35. * · `1. 产品定位` / `1、…` —— 数字序号
  36. * · `核心能力:` —— 短句 + 冒号
  37. * · `核心能力` —— 纯短句(≤ 12 字,无句末标点)
  38. */
  39. const HEADING_PATTERNS: readonly RegExp[] = [
  40. /^\*\*[^*]+\*\*[::]?$/, // **标题**
  41. /^#{1,6}\s+\S/, // 已是 markdown 标题
  42. /^[一二三四五六七八九十]+[、..]\s*\S/, // 一、标题
  43. /^[((][一二三四五六七八九十\d]+[))]\s*\S/, // (一)标题
  44. /^\d+[、..]\s*\S/, // 1、标题
  45. ];
  46. /** 句末标点 —— 有这些说明是正文句子,不是标题 */
  47. const SENTENCE_END = /[。!?;.!?;]$/;
  48. /** 纯短句标题的最大长度(字) */
  49. const BARE_HEADING_MAX = 12;
  50. /**
  51. * 这一行**整行**是不是一个小标题。
  52. *
  53. * 用于「标题在行首」的场景(在它**之前**补空行)。
  54. * 比 `HEADING_PATTERNS` 更保守 —— 只认明确的标题形态,不做「短句猜测」,
  55. * 否则「好的」这种正常回复也会被当成标题切出来。
  56. */
  57. function isHeadingLine(line: string): boolean {
  58. if (!line) return false;
  59. // **xxx** 独占一行
  60. if (/^\*\*[^*]+\*\*[::]?$/.test(line)) return true;
  61. // markdown 标题
  62. if (/^#{1,6}\s+\S/.test(line)) return true;
  63. // 中文/数字序号 + 短标题
  64. if (
  65. /^([一二三四五六七八九十]+[、..]|第[一二三四五六七八九十\d]+[段步条点]|\d+[、..])\s*\S/.test(
  66. line,
  67. ) &&
  68. line.length <= 24 &&
  69. !SENTENCE_END.test(line)
  70. ) {
  71. return true;
  72. }
  73. return false;
  74. }
  75. /**
  76. * 判断某一行的「下一行」是否应该与它隔开。
  77. *
  78. * @param line 当前行(已 trim)
  79. * @param next 下一行(已 trim)
  80. * @returns true 表示两者之间要插一个空行
  81. */
  82. function needsBreakAfter(line: string, next: string): boolean {
  83. if (!line || !next) return false;
  84. /*
  85. * ⓪ 行**尾部**是标题 —— 真正要断的是「标题之后」而不是「这一行之后」。
  86. *
  87. * 实测容器的另一种排版:正文写完,紧接着一行 `**我的能力**` 收尾,
  88. * 标题本身在行尾,正文在**下一行**:
  89. *
  90. * ```
  91. * 我是主人的数字分身…结论先行。
  92. * **我的能力**
  93. * 我能操作浏览器访问网页…
  94. * ```
  95. *
  96. * 这时要在「标题行」和它下一行之间断开,而不是在当前行之后断开。
  97. * 由调用方 `splitTrailingHeading` 先把这一行拆开再处理。
  98. */
  99. // ① 已命中的标题形态 → 与后文隔开
  100. for (const re of HEADING_PATTERNS) {
  101. if (re.test(line)) return true;
  102. }
  103. // ② 纯短句标题:够短、无句末标点、且下一行明显更长(是正文而非同类短行)
  104. const isBareCandidate =
  105. line.length <= BARE_HEADING_MAX && !SENTENCE_END.test(line) && !/^[-*+]\s/.test(line);
  106. if (isBareCandidate && next.length > line.length) return true;
  107. // ③ 短句 + 冒号结尾(如「核心能力:」)→ 与后文隔开
  108. if (/[::]$/.test(line) && line.length <= BARE_HEADING_MAX + 4) return true;
  109. return false;
  110. }
  111. /**
  112. * 判断整行文本的**末尾**是否是一个独立小标题。
  113. *
  114. * 两种实测形态都认:
  115. * · `…结论先行。**我的能力**` —— markdown 加粗收尾
  116. * · `…对外发声。 职责二:在授权范围内执行任务` —— 空格分隔 + 序号短语
  117. *
  118. * @returns 命中时返回标题位置之前的正文部分;否则返回 null
  119. */
  120. function splitTrailingHeading(line: string): { head: string; heading: string } | null {
  121. /*
  122. * ① `**xxx**` 收尾 —— 中间可能**紧贴**(`结论先行。**我的能力**`)
  123. * 或**隔空格**(`…结论先行。 **我的能力**`),两种都认。
  124. */
  125. const bold = /^(.*\S)\s*(\*\*[^*\n]+\*\*)\s*[::]?\s*$/.exec(line);
  126. if (bold) return { head: bold[1].trim(), heading: bold[2].trim() };
  127. /*
  128. * ② 「空白 + 短标题短语」收尾:「…绝不编造。 我的边界」「…发了。 职责二:执行」
  129. *
  130. * 🔴 实测容器的空格数量不固定(1 个、2 个都有),句末标点也可能没有,
  131. * 所以判据换成**标题本身的长相**,不依赖空格数与前置标点:
  132. * · 尾部短语够短(≤ 24 字)
  133. * · 本身不含句末标点(是短语,不是句子)
  134. * · 以「我的/你的/职责/步骤/阶段/目标/结论/要点/第X/一二三、/1.」等开头
  135. */
  136. const tail = /^(.*\S)\s+(\S[^。!?.!?;]*)$/.exec(line);
  137. if (tail) {
  138. const cand = tail[2].trim();
  139. const looksHeading =
  140. cand.length > 0 &&
  141. cand.length <= 24 &&
  142. !SENTENCE_END.test(cand) &&
  143. /^(我的|你的|它的|我们的|职责|步骤|阶段|目标|结论|要点|特点|优势|背景|现状|问题|建议|方案|注意|小结|总结|第[一二三四五六七八九十\d]+|[一二三四五六七八九十]+[、..]|\d+[、..])/.test(
  144. cand,
  145. );
  146. // 正文部分必须够长,否则「第 1 步」这种正常用法会被误切
  147. if (looksHeading && tail[1].trim().length >= 8) {
  148. return { head: tail[1].trim(), heading: cand };
  149. }
  150. }
  151. return null;
  152. }
  153. /**
  154. * 把「单换行分隔的标题 + 正文」补成空行分段。
  155. *
  156. * @param text 原始正文
  157. * @returns 归一化后的正文(只插入换行,不删除任何字符)
  158. */
  159. export function normalizeMarkdown(text: string): string {
  160. if (!text) return text;
  161. const lines = text.split('\n');
  162. const out: string[] = [];
  163. /** 是否在代码围栏内(围栏内逐字保留) */
  164. let inFence = false;
  165. for (let i = 0; i < lines.length; i++) {
  166. const raw = lines[i];
  167. const line = raw.trim();
  168. if (FENCE.test(raw)) {
  169. inFence = !inFence;
  170. out.push(raw);
  171. continue;
  172. }
  173. if (inFence) {
  174. out.push(raw);
  175. continue;
  176. }
  177. /*
  178. * ⓪-1 中文正文里的单个 `~` → HTML 实体,防止被当成删除线(2026-11-11 用户实测)。
  179. *
  180. * ## 现象(截图实证)
  181. *
  182. * AI 回复里「**南昌**:…全天 8~16℃ … **北京**:…全天 3~12℃」整段被划了横线,
  183. * 看起来像正文被删除 —— 用户反复反馈「为什么会有横线」。
  184. *
  185. * ## 根因
  186. *
  187. * 温度区间习惯写成 `8~16℃` / `3~12℃`。这些单波浪号在整段里**成对出现**,
  188. * GFM 把两个 `~` 之间的所有内容解析成 `~~删除线~~`:
  189. *
  190. * ```
  191. * 全天 8~16℃ … 3~12℃
  192. * ↑__________↑ → <del>16℃ … 3</del>
  193. * ```
  194. *
  195. * 实测 marked@16:`温度 8~16℃ 和 3~12℃ 对比` → `<del>16℃ 和 3</del>`。
  196. *
  197. * ## 为什么用 HTML 实体而不是反斜杠转义
  198. *
  199. * `\~` 实测无效 —— `~` 不在 CommonMark 的可转义字符表里,反斜杠会被吃掉,
  200. * 波浪号照样参与解析。`&#126;` 是 HTML 实体,渲染出来就是 `~`,且
  201. * **不参与 markdown 语法解析**,两头都满足。
  202. *
  203. * ## 只处理单个 `~`,保留 `~~`
  204. *
  205. * 真正的删除线 `~~xxx~~` 必须保留(容器会用它标注作废方案),
  206. * 所以用负向断言排除「紧邻另一个 ~」的情况。
  207. */
  208. /*
  209. * 🔴 只做行内替换,**不 continue**:这一行后面还要参与标题断行判定,
  210. * 跳过去会让「带 ~ 的标题行」失去分段(用户实测过同类漏判)。
  211. */
  212. const line0 = line.includes('~') ? raw.replace(/(?<!~)~(?!~)/g, '&#126;') : raw;
  213. /*
  214. * ⓪ 行尾是标题 → 先把它拆成两行(2026-11-09)。
  215. *
  216. * 容器的常见排版是「正文 + 换行 + 标题 + 换行 + 正文」,
  217. * 但标题也可能粘在上一段末尾:
  218. * 我是主人的数字分身…结论先行。**我的能力**
  219. * 我能操作浏览器…
  220. * CommonMark 会把它们算成同一段。这里先把行尾标题切到独立一行,
  221. * 再走下面的常规判定,最后自然得到「正文 \n\n 标题 \n\n 正文」。
  222. */
  223. const headBefore = line0.trim() ? splitTrailingHeading(line0.trim()) : null;
  224. if (headBefore) {
  225. out.push(headBefore.head);
  226. out.push('');
  227. out.push(headBefore.heading);
  228. // 标题行后面跟的是正文(下一行)→ 再补一个空行
  229. const nextRaw0 = lines[i + 1];
  230. if (nextRaw0 !== undefined && nextRaw0.trim()) out.push('');
  231. continue;
  232. }
  233. /*
  234. * 🔴 标题在**行首**时,要在它**之前**断开(2026-11-09)。
  235. *
  236. * 实测容器的排版:
  237. * ```
  238. * 我在主人的私聊房间里直接对话。不会编造结论。
  239. * **第二段:工具与执行**
  240. * 我可以调用终端…
  241. * ```
  242. * CommonMark 把「正文行」和「标题行」算成同一段(单换行 = 空格),
  243. * 标题被吃进上一段。必须在标题行**之前**补空行。
  244. *
  245. * 与「标题之后补空行」方向相反,两者都要做。
  246. */
  247. if (i > 0 && isHeadingLine(line) && out.length >= 1 && out[out.length - 1].trim()) {
  248. out.push('');
  249. }
  250. out.push(line0);
  251. /*
  252. * 只在「当前行有内容、下一行也有内容」时才考虑补空行。
  253. * 已经是空行的位置(正常 markdown 分段)不动 —— 幂等的关键。
  254. */
  255. if (!line) continue;
  256. const nextRaw = lines[i + 1];
  257. if (nextRaw === undefined) continue;
  258. const next = nextRaw.trim();
  259. if (!next) continue;
  260. /*
  261. * 列表项内部不断开:`- xxx` 后面跟 `- yyy` 是同一个列表。
  262. * 缩进的续行(以空白开头)也不断开。
  263. */
  264. if (/^\s*[-*+]\s|^\s*\d+[、..)]\s/.test(nextRaw)) continue;
  265. if (/^\s{2,}/.test(nextRaw)) continue;
  266. if (needsBreakAfter(line, next)) out.push('');
  267. }
  268. return out.join('\n');
  269. }