| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296 |
- /**
- * AI 回复正文的 Markdown 归一化
- *
- * ## 解决什么
- *
- * 容器(agent)发回的回复里,小标题和正文之间只有**单个换行**:
- *
- * ```
- * **核心能力**
- * 我是你的数字分身…
- * **对话与协作**
- * 我在 Matrix 私聊…
- * ```
- *
- * CommonMark 规则里,单换行等于**空格**,于是整段被渲染成同一个 `<p>`,
- * 三个小标题和正文全部粘在一起(用户实测截图)。
- *
- * 本函数只做一件事:**在「标题行」和「正文」之间补一个空行**,
- * 让它们各自成段。真正的 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℃
- * ↑__________↑ → <del>16℃ … 3</del>
- * ```
- *
- * 实测 marked@16:`温度 8~16℃ 和 3~12℃ 对比` → `<del>16℃ 和 3</del>`。
- *
- * ## 为什么用 HTML 实体而不是反斜杠转义
- *
- * `\~` 实测无效 —— `~` 不在 CommonMark 的可转义字符表里,反斜杠会被吃掉,
- * 波浪号照样参与解析。`~` 是 HTML 实体,渲染出来就是 `~`,且
- * **不参与 markdown 语法解析**,两头都满足。
- *
- * ## 只处理单个 `~`,保留 `~~`
- *
- * 真正的删除线 `~~xxx~~` 必须保留(容器会用它标注作废方案),
- * 所以用负向断言排除「紧邻另一个 ~」的情况。
- */
- /*
- * 🔴 只做行内替换,**不 continue**:这一行后面还要参与标题断行判定,
- * 跳过去会让「带 ~ 的标题行」失去分段(用户实测过同类漏判)。
- */
- const line0 = line.includes('~') ? raw.replace(/(?<!~)~(?!~)/g, '~') : raw;
- /*
- * ⓪ 行尾是标题 → 先把它拆成两行(2026-11-09)。
- *
- * 容器的常见排版是「正文 + 换行 + 标题 + 换行 + 正文」,
- * 但标题也可能粘在上一段末尾:
- * 我是主人的数字分身…结论先行。**我的能力**
- * 我能操作浏览器…
- * CommonMark 会把它们算成同一段。这里先把行尾标题切到独立一行,
- * 再走下面的常规判定,最后自然得到「正文 \n\n 标题 \n\n 正文」。
- */
- const headBefore = line0.trim() ? splitTrailingHeading(line0.trim()) : null;
- if (headBefore) {
- out.push(headBefore.head);
- out.push('');
- out.push(headBefore.heading);
- // 标题行后面跟的是正文(下一行)→ 再补一个空行
- const nextRaw0 = lines[i + 1];
- if (nextRaw0 !== undefined && nextRaw0.trim()) out.push('');
- continue;
- }
- /*
- * 🔴 标题在**行首**时,要在它**之前**断开(2026-11-09)。
- *
- * 实测容器的排版:
- * ```
- * 我在主人的私聊房间里直接对话。不会编造结论。
- * **第二段:工具与执行**
- * 我可以调用终端…
- * ```
- * CommonMark 把「正文行」和「标题行」算成同一段(单换行 = 空格),
- * 标题被吃进上一段。必须在标题行**之前**补空行。
- *
- * 与「标题之后补空行」方向相反,两者都要做。
- */
- if (i > 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');
- }
|