/** * 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');
}