feat(markup): br 改为气泡/消息分隔符 + 行内白名单收窄到五项(Web + IM 两侧)
主人 2026-10-07:「对消息内的 <br> 始终处理为多条消息隔断」。 Web 侧(frontend/web): - chat.tsx 的 splitParagraphs(SplitChatBubble 的切分层)新增 BR_SEPARATOR_RE 与 splitBubbleBreaks:<br>/<br/>/</br> 等价、属性不解析、<brow>/<br2> 挡掉、 流式半截 <br 不切;不含 <br> 的段逐字返回原样(既有 p 口径一字节不动) - inlineMarkup.ts 删掉 break 节点(br 不再是行内元素),白名单收窄为 b/strong/i/em/code 五项;renderContent.tsx 同步;测试 76 -> 99 checks - 40 条基线逐字节对照:28 IDENTICAL,12 个 DIFF 恰是含 br/u/s/<BR> 的样例 IM 侧(internal/services/channelbridge): - 新增 inline_tags.go:成对平衡的白名单行内标签剥标签留文本、白名单外与 结构不平衡整段原样保留;br 作为第二级分隔符交给 splitBubbles - 两级切分:p 块为第一级,块内按 br 再切为第二级;空气泡丢弃 - br 切分放在角色注记摘除之后:否则 action 里的 br 会把注记正文漏成聊天气泡 (变异专门钉住这条顺序) 两路各 4 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
This commit is contained in:
1 parent
6ac214366e
commit
a19d12b228
7 files changed
+646
-60
No files matched your search
@@ -205,22 +205,44 @@ export function MessageBubble({
|
||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||
}
|
||||
|
||||
/** Split a chat reply's annotation-stripped body into `<p>`-delimited paragraph
|
||||
* bodies. Each complete `<p>…</p>` block becomes one bubble segment; a body that
|
||||
* carries no `<p>` wrapper falls back to splitting on `\n\n` runs, and a body
|
||||
* with neither yields an empty list so the caller handles the empty reply.
|
||||
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
|
||||
* unclosed `<p>` simply does not match and stays out until it closes. */
|
||||
/** `<br>` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条
|
||||
* 消息隔断」)。`<br>` / `<br/>` / `</br>` 三种写法等价(HTML5 口径),属性不解析
|
||||
* (`<br class=x>` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
|
||||
* —— `<brow>` / `<br2>` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」
|
||||
* 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`<br` 无 `>` 不算标签)。 */
|
||||
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
|
||||
|
||||
/** 把一个已切好的段落再按 `<br>` 切开:`<br>` 与 `<p>` **同级** —— 两边的文本各自
|
||||
* 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。空气泡丢弃
|
||||
* (`<p>甲<br><br>乙</p>` ⇒ `甲` / `乙`;`<br>` 两侧都是空白 ⇒ 不产生多余气泡)。
|
||||
* **不含 `<br>` 的段落逐字返回原样**(空段也原样保留)⇒ 既有 `<p>` 口径一个字节不动。 */
|
||||
function splitBubbleBreaks(segment: string): string[] {
|
||||
if (!BR_SEPARATOR_RE.test(segment)) return [segment];
|
||||
return segment
|
||||
.split(BR_SEPARATOR_RE)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble
|
||||
* each. Two peers delimit bubbles: `<p>…</p>` (contract §4.16, one `<p>` = one
|
||||
* bubble) and `<br>` (主人 2026-10-07: `<br>` is a message separator, never an
|
||||
* in-bubble line break). A body with no `<p>` wrapper falls back to splitting on
|
||||
* `\n\n` runs; a body with none of the three yields an empty list so the caller
|
||||
* handles the empty reply. Non-greedy + multi-line so a tag may span newlines; a
|
||||
* still-streaming, unclosed `<p>` simply does not match and stays out until it
|
||||
* closes. */
|
||||
function splitParagraphs(content: string): string[] {
|
||||
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
|
||||
// emits plain \n\n runs instead (the model often does), split on those too so
|
||||
// each logical paragraph becomes its own bubble. `content` here is the
|
||||
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
|
||||
// can be cut across; never split on a single \n (that is an in-line break, kept
|
||||
// inside the paragraph).
|
||||
// inside the paragraph). Every produced segment then goes through
|
||||
// `splitBubbleBreaks`, so a <br> inside a paragraph cuts it into several bubbles.
|
||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||
if (pSegments.length > 0) return pSegments;
|
||||
if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks);
|
||||
|
||||
// Keep non-<p> remainder as its own paragraph bodies.
|
||||
const segments: string[] = [];
|
||||
@@ -228,7 +250,7 @@ function splitParagraphs(content: string): string[] {
|
||||
if (!part.trim()) continue;
|
||||
segments.push(part.trim());
|
||||
}
|
||||
return segments;
|
||||
return segments.flatMap(splitBubbleBreaks);
|
||||
}
|
||||
|
||||
/** Small muted attachment note for a single `<action>` / `<narration>` block.
|
||||
@@ -348,12 +370,14 @@ function UserParaBubble({
|
||||
|
||||
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
|
||||
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
|
||||
* plain-text body is split into stacked bubbles (first = full bubble, remainder
|
||||
* = compact). Any other mode — code / home / work / docs / vision / voice /
|
||||
* agents / settings — is passed straight through to MessageBubble so its body
|
||||
* renders untouched: no `<p>` splitting, and its content (which may itself
|
||||
* contain `<p>` / `<action>` / `<narration>` as literal text or code) is never
|
||||
* re-parsed. */
|
||||
* plain-text body is split into stacked bubbles by `splitParagraphs` — two peers
|
||||
* delimit a bubble: `<p>…</p>` (§4.16) and `<br>` (主人 2026-10-07: a `<br>` is a
|
||||
* **message separator**, so it must never render as an in-bubble line break).
|
||||
* First segment = full bubble, the rest compact. Any other mode — code / home /
|
||||
* work / docs / vision / voice / agents / settings — is passed straight through
|
||||
* to MessageBubble so its body renders untouched: no `<p>` / `<br>` splitting, and
|
||||
* its content (which may itself contain `<p>` / `<action>` / `<narration>` as
|
||||
* literal text or code) is never re-parsed. */
|
||||
export function SplitChatBubble({
|
||||
message,
|
||||
active = false,
|
||||
@@ -369,8 +393,16 @@ export function SplitChatBubble({
|
||||
|
||||
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
|
||||
// No visible content: hand off so the empty / still-streaming reply keeps its
|
||||
// usual chrome (avatar, mood, "生成中…").
|
||||
return <MessageBubble message={message} active={active} />;
|
||||
// usual chrome (avatar, mood, "生成中…"). 一处例外:正文只剩 `<br>`(气泡分隔被切
|
||||
// 光)时**不能**把原文交下去 —— 那会把字面量 `<br>` 画进气泡,等于凭空造一个空气泡。
|
||||
// 这种情况按「空正文」交下去(外壳照旧),与空气泡丢弃的口径一致。
|
||||
const collapsedByBreaks = BR_SEPARATOR_RE.test(body);
|
||||
return (
|
||||
<MessageBubble
|
||||
message={collapsedByBreaks ? { ...message, content: "" } : message}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Group the paragraphs of one reply into a tighter column so intra-reply
|
||||
|
||||
@@ -5,34 +5,41 @@
|
||||
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
|
||||
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `<b>` 直接显示给用户。
|
||||
//
|
||||
// 🚫 **`<br>` 不在这里**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为多条消息
|
||||
// 隔断」):它是**气泡/消息分隔符**,与 `<p>` 同级,由 `components/chat.tsx` 的
|
||||
// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 `<br>` 走到本模块
|
||||
// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示,
|
||||
// 绝不在这里伪造一个「气泡内换行」。
|
||||
//
|
||||
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
|
||||
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
|
||||
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
|
||||
// 对文本节点默认转义,天然安全。规则:
|
||||
// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `<p>` 机制表达);
|
||||
// ① 只认**行内**白名单标签(五项:`b` / `strong` / `i` / `em` / `code`),块级一律
|
||||
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达,`<br>` 由气泡切分层处理);
|
||||
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
|
||||
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `<script>`,不丢字符);
|
||||
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
|
||||
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
|
||||
// ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。
|
||||
|
||||
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。`br` 是 void 元素,单独处理。 */
|
||||
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u" | "s";
|
||||
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。白名单经主人 2026-10-07 晚收窄为
|
||||
* **五项**:`u` / `s` 在她百条输出里 0 命中、不值得各端为它们返工;`br` **不是行内标签**
|
||||
* (它是气泡分隔符,见文件头),两者从此一律按白名单外处理(逐字原样显示)。 */
|
||||
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code";
|
||||
|
||||
/** 解析结果:一段文本被拆成的有序节点序列。 */
|
||||
export type InlineNode =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "break" }
|
||||
| { kind: "element"; tag: InlineElementTag; children: InlineNode[] };
|
||||
|
||||
/** 词法单元:文本 / 换行(void)/ 开标签 / 闭标签。 */
|
||||
/** 词法单元:文本 / 开标签 / 闭标签。 */
|
||||
type Token =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "break" }
|
||||
| { kind: "open"; tag: InlineElementTag }
|
||||
| { kind: "close"; tag: InlineElementTag };
|
||||
|
||||
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code", "u", "s"]);
|
||||
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code"]);
|
||||
|
||||
/** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、
|
||||
* `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */
|
||||
@@ -60,12 +67,12 @@ function tokenize(text: string): Token[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。 */
|
||||
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。
|
||||
* `<br>` 走的就是这一支(它已从白名单移除):到本模块时它只是**字面量文本**,
|
||||
* 气泡分隔在 `components/chat.tsx` 的 `splitParagraphs` 里就已经切完。 */
|
||||
function classify(m: RegExpExecArray): Token {
|
||||
const raw = m[0];
|
||||
const name = m[1].toLowerCase();
|
||||
// `br` 是 void 元素:`<br>`、`<br/>` 都出换行;`</br>` 按 HTML5 口径等同 `<br>`。
|
||||
if (name === "br") return { kind: "break" };
|
||||
if (!isInlineElementTag(name)) return { kind: "text", text: raw };
|
||||
return raw.charAt(1) === "/" ? { kind: "close", tag: name } : { kind: "open", tag: name };
|
||||
}
|
||||
@@ -86,10 +93,6 @@ function buildTree(tokens: Token[]): InlineNode[] | null {
|
||||
appendText(current(), token.text);
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "break") {
|
||||
current().push({ kind: "break" });
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "open") {
|
||||
const children: InlineNode[] = [];
|
||||
current().push({ kind: "element", tag: token.tag, children });
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
|
||||
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
|
||||
//
|
||||
// 分六组:
|
||||
// ① 白名单标签渲染成真元素(不是字面量);
|
||||
// 分七组:
|
||||
// ① 白名单标签渲染成真元素(不是字面量);白名单同日收窄到**五项** —— `u` / `s` 出列
|
||||
// (她百条输出 0 命中)⇒ 从此按白名单外口径逐字原样;
|
||||
// ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**,
|
||||
// 树里不出现这些元素、不出现任何来自产物的属性;
|
||||
// ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢;
|
||||
// ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢;
|
||||
// ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props;
|
||||
// ⑥ 既有 `<p>` / `<action>` 行为无回归(走真实的 SplitChatBubble 链路)。
|
||||
// ⑥ 既有 `<p>` / `<action>` 行为无回归(走真实的 SplitChatBubble 链路);
|
||||
// ⑦ 🆕 `<br>` = **气泡/消息分隔符**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理
|
||||
// 为多条消息隔断」):与 `<p>` 同级切气泡、空气泡丢弃、**绝不**是气泡内换行。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
@@ -128,12 +131,12 @@ const REAL_SAMPLE =
|
||||
ok(!has(html, "<b>"), "① 不再把 `<b>` 当字面量显示");
|
||||
ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)");
|
||||
|
||||
// 白名单 = **五项**(主人 2026-10-07 晚收窄:`u` / `s` / `br` 出列)。
|
||||
const tags: [string, string][] = [
|
||||
["<b>粗</b>", "<b>粗</b>"],
|
||||
["<strong>粗</strong>", "<strong>粗</strong>"],
|
||||
["<i>斜</i>", "<i>斜</i>"],
|
||||
["<em>强调</em>", "<em>强调</em>"],
|
||||
["<u>下划</u>", "<u>下划</u>"],
|
||||
["<s>划掉</s>", "<s>划掉</s>"],
|
||||
];
|
||||
for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`);
|
||||
ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素");
|
||||
@@ -141,15 +144,17 @@ const REAL_SAMPLE =
|
||||
|
||||
// 大小写混写:按 HTML 口径不敏感(`<B>` 就是 `<b>`)。
|
||||
ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感");
|
||||
ok(has(markup("<b>x</b><BR>y"), "<br/>"), "① `<BR>` 也认(标签名大小写不敏感)");
|
||||
|
||||
// br 是 void 元素:`<br>` / `<br/>` / `</br>`(HTML5 等价写法)都出换行,两侧文本不丢。
|
||||
ok(seen("上<br>下") === "上下" && has(markup("上<br/>下"), "<br/>"), "① `<br>` / `<br/>` 出换行");
|
||||
ok(has(markup("上</br>下"), "<br/>"), "① `</br>` 按 HTML5 口径等同 `<br>`");
|
||||
|
||||
// 嵌套白名单标签(正常闭合)照常渲染。
|
||||
const nested = markup("<b>外 <i>内</i></b>");
|
||||
ok(has(nested, "<b>外 <i>内</i></b>"), "① 正常嵌套逐层渲染");
|
||||
|
||||
// 🔴 白名单收窄:`u` / `s` 出列(她百条输出 0 命中、不值得各端返工)⇒ 它们从此按
|
||||
// **白名单外**口径逐字原样,不再产出元素。
|
||||
for (const [src, tag] of [["<u>下划</u>", "u"], ["<s>划掉</s>", "s"]] as [string, string][]) {
|
||||
ok(seen(src) === src, `① 出列的 \`<${tag}>\` 逐字原样显示(字符不丢)`);
|
||||
ok(!has(markup(src), `<${tag}>`), `① 不再为 \`${tag}\` 产出元素`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ──────────────────────────
|
||||
@@ -265,4 +270,61 @@ const REAL_SAMPLE =
|
||||
ok(!has(user, "<script") && has(user, "<script>x</script>"), "⑥ 用户气泡里的白名单外标签仍是字面量");
|
||||
}
|
||||
|
||||
// ── ⑦ `<br>` = 气泡/消息分隔(与 `<p>` 同级,**不是**气泡内换行) ────────────────
|
||||
// 主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条消息隔断」。这一组全部走
|
||||
// **真实链路**(`SplitChatBubble` → `splitBubble` → `splitParagraphs` → `renderContent`),
|
||||
// 所以它同时是「上一轮把 `<br>` 当行内换行」那个错误实现的**反向断言**。
|
||||
{
|
||||
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||||
const asst = (content: string) => render({ id: "br1", role: "assistant", content, createdAt: 1 } as ChatMessage);
|
||||
|
||||
// 主人给的四条口径。
|
||||
const inP = asst("<p>甲<br>乙</p>");
|
||||
ok(count(inP, BUBBLE) === 2, "⑦ `<p>甲<br>乙</p>` ⇒ 两个气泡");
|
||||
ok(has(inP, "<span>甲</span>") && has(inP, "<span>乙</span>"), "⑦ 两个气泡的正文分别是 甲 / 乙");
|
||||
ok(!has(inP, "<br/>") && !has(inP, "<br"), "⑦ `<br>` 既不进 DOM、也不作为字面量出现在正文里");
|
||||
|
||||
const noP = asst("甲<br>乙");
|
||||
ok(count(noP, BUBBLE) === 2, "⑦ 无 `<p>` 时 `甲<br>乙` ⇒ 两个气泡");
|
||||
ok(has(noP, "<span>甲</span>") && has(noP, "<span>乙</span>"), "⑦ 无 `<p>` 时同样按 `<br>` 切开");
|
||||
|
||||
ok(count(asst("<p>甲<br><br>乙</p>"), BUBBLE) === 2, "⑦ `<p>甲<br><br>乙</p>` ⇒ 两个气泡(中间空气泡丢弃)");
|
||||
ok(count(asst("<p>甲 <br> 乙</p>"), BUBBLE) === 2, "⑦ `<br>` 两侧空白 ⇒ 不产生多余空气泡");
|
||||
ok(count(asst("<p>甲<br></p>"), BUBBLE) === 1, "⑦ 尾随 `<br>` 不留空气泡");
|
||||
ok(count(asst("<p>甲<br> </p>"), BUBBLE) === 1, "⑦ `<br>` 后全空白不留空气泡");
|
||||
|
||||
// 正文只剩 `<br>`:气泡分隔把它切光 ⇒ 按空正文交下去(空气泡丢弃,别把字面量画出来)。
|
||||
const onlyBr = asst("<br>");
|
||||
ok(count(onlyBr, BUBBLE) === 1 && !has(onlyBr, "<br"), "⑦ 正文只剩 `<br>` ⇒ 不产生字面量、只留空回复外壳");
|
||||
|
||||
// 三种写法等价(HTML5):`<br>` / `<br/>` / `</br>`;属性不解析(`<br class=x>` 照样切)。
|
||||
for (const src of ["<p>甲<br>乙</p>", "<p>甲<br/>乙</p>", "<p>甲</br>乙</p>", "<p>甲<br class=\"x\">乙</p>"]) {
|
||||
ok(count(asst(src), BUBBLE) === 2, `⑦ 等价写法都切成两个气泡:${src}`);
|
||||
}
|
||||
|
||||
// 护栏:`<brow>` / `<br2>` 不是 br 标签(与 `1 < 2`、`a<b`、`<p2>` 同一条口径)。
|
||||
const brow = asst("<p>甲<brow>乙</brow>丙</p>");
|
||||
ok(count(brow, BUBBLE) === 1, "⑦ `<brow>` 不是 br ⇒ 不切气泡");
|
||||
ok(has(brow, "<brow>"), "⑦ `<brow>` 按白名单外口径逐字原样");
|
||||
ok(count(asst("<p>甲<br2>乙</br2>丙</p>"), BUBBLE) === 1, "⑦ `<br2>` / `</br2>` 同样不是 br");
|
||||
|
||||
// 流式半截 `<br`(还没等到 `>`)⇒ 不是分隔符,本段仍是一个气泡。
|
||||
const partial = asst("<p>甲<br</p>");
|
||||
ok(count(partial, BUBBLE) === 1, "⑦ 流式半截 `<br`(无 `>`)不切气泡");
|
||||
ok(has(partial, "<br"), "⑦ 半截标签按字面量显示(字符不丢)");
|
||||
|
||||
// 与行内标签混用:切开的两段各自照常做行内渲染。
|
||||
const mixed = asst("<p>日落 <b>18:35</b><br>晚安</p>");
|
||||
ok(count(mixed, BUBBLE) === 2, "⑦ 含行内标签的段落同样按 `<br>` 切开");
|
||||
ok(has(mixed, "<b>18:35</b>") && has(mixed, "<span>晚安</span>"), "⑦ 两段各自渲染(第一段保留粗体)");
|
||||
|
||||
// 用户气泡走同一条链。
|
||||
const userBr = render({ id: "u2", role: "user", content: "<p>甲<br>乙</p>", createdAt: 2 } as ChatMessage);
|
||||
ok(count(userBr, "mu-rise-in flex flex-col items-end gap-0.5") === 2, "⑦ 用户气泡同样按 `<br>` 切成两个");
|
||||
|
||||
// `<action>` 注记不受 `<br>` 影响(注记文案原样、仍在气泡外),正文照常切。
|
||||
const noteBr = asst("<action>她 笑 了</action><p>甲<br>乙</p>");
|
||||
ok(count(noteBr, BUBBLE) === 2 && has(noteBr, "*动作:她 笑 了*"), "⑦ 注记照旧在气泡外,正文照常切成两段");
|
||||
}
|
||||
|
||||
console.log(`inline markup render test: ${checks} checks passed`);
|
||||
@@ -5,16 +5,21 @@ import { parseInlineMarkup, type InlineNode } from "../lib/inlineMarkup";
|
||||
/** 渲染助手消息正文。`<action>` / `<narration>` 块在这里**不**进入气泡 —— 它们由
|
||||
* `splitBubble` 剥出、以气泡外的注记呈现;剩下的文本段落交给行内白名单渲染。
|
||||
*
|
||||
* 🔴 这里**只**做行内(`b`/`strong`/`i`/`em`/`code`)的事:段落 `<p>` 与消息断隔
|
||||
* `<br>` 都在上一层(`components/chat.tsx` 的 `splitParagraphs`)就已切成独立气泡,
|
||||
* 本函数拿到的每一段都是**单个气泡的正文** —— 所以这里绝不返回 `<br/>`(否则等于把
|
||||
* 「多条消息」偷偷变回「一条消息里的换行」,与主人口径冲突)。
|
||||
*
|
||||
* 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有
|
||||
* `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签
|
||||
* (`<script>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。 */
|
||||
* (`<script>` / `<br>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。 */
|
||||
export function renderContent(content: string): ReactNode[] {
|
||||
return parseContent(content)
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part, i) => <span key={i}>{renderInline(part.text)}</span>);
|
||||
}
|
||||
|
||||
/** 一段(`<p>` 切分后的)段落文本 ⇒ 行内节点序列 ⇒ React 元素。 */
|
||||
/** 一段(`<p>` / `<br>` 切分后的单气泡)文本 ⇒ 行内节点序列 ⇒ React 元素。 */
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
return parseInlineMarkup(text).map((node, i) => renderInlineNode(node, `n${i}`));
|
||||
}
|
||||
@@ -23,7 +28,6 @@ function renderInline(text: string): ReactNode[] {
|
||||
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。 */
|
||||
function renderInlineNode(node: InlineNode, key: string): ReactNode {
|
||||
if (node.kind === "text") return <Fragment key={key}>{node.text}</Fragment>;
|
||||
if (node.kind === "break") return <br key={key} />;
|
||||
|
||||
const children = node.children.map((child, i) => renderInlineNode(child, `${key}-${i}`));
|
||||
switch (node.tag) {
|
||||
@@ -35,10 +39,6 @@ function renderInlineNode(node: InlineNode, key: string): ReactNode {
|
||||
return <i key={key}>{children}</i>;
|
||||
case "em":
|
||||
return <em key={key}>{children}</em>;
|
||||
case "u":
|
||||
return <u key={key}>{children}</u>;
|
||||
case "s":
|
||||
return <s key={key}>{children}</s>;
|
||||
case "code":
|
||||
return (
|
||||
<code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件承载 IM 出站的**行内标签白名单**处理(契约 §6 v0.89「助手消息的行内标签白名单」)。
|
||||
//
|
||||
// 背景(主人 2026-10-07 实测报障):昔涟会在正文里插行内 HTML 标签,真样例
|
||||
//
|
||||
// <p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>
|
||||
//
|
||||
// Web 端已按白名单渲染(`2dae469`),而 IM 出站此前只剥 `<p>` / `<action>` / `<narration>`
|
||||
// 与星号块 ⇒ `<b>18:35</b>` 连标签一起字面发给微信/QQ,用户看到裸标签。
|
||||
//
|
||||
// 🔴 处置口径(必须与契约 v0.89 及 Web 端渲染器逐条一致,否则同一段文本两端显示不同):
|
||||
// 1. 行内白名单 = **五项** `b` / `strong` / `i` / `em` / `code`;**成对且平衡**的
|
||||
// 白名单标签 ⇒ **剥标签、留内部文本**(微信/QQ 不支持 HTML,没有别的选择);
|
||||
// 2. **白名单外**的标签(`<script>` / `<div>` / `<u>` / `<s>` …)⇒ **逐字原样保留**
|
||||
// (它们是文本,一个字符不丢);
|
||||
// 3. **结构不平衡**(未闭合 / 落单闭标签 / 交错)⇒ **整段保持原样**(契约把这一条的
|
||||
// 粒度列为实现细节,本端与 Web 端取同一粒度,保证两端对同一段文本给出同一结果);
|
||||
// 4. 属性一律丢弃(`<b class=x onclick=y>` 只取标签名 `b`);标签名大小写不敏感。
|
||||
//
|
||||
// ⚠️ 1/2 与既有的 `<p>` / `<action>` 行为**刻意不同**:那两个是「角色扮演/段落标记」,
|
||||
// 要剥;行内标签是「排版标记」,剥样式留文本;白名单外则**根本不该动**。
|
||||
//
|
||||
// ⚠️ **`<br>` 不在这里** —— 它是**气泡/消息分隔符**(与 `<p>` 同级),切分在 splitBubbles,
|
||||
// 见 sanitize.go 的 splitBubbles / bubblesOfSegment;本文件只负责在清洗层把它摘掉。
|
||||
//
|
||||
// 安全口径:产物是不可信文本(模型生成 + 用户可诱导),这里只做**移除**,
|
||||
// 绝不引入任何属性、标签或 HTML 实体(输出的尖括号一定不多于输入)。
|
||||
|
||||
// inlineTagRe 匹配标签候选:`<` 紧跟字母、标签体内不再出现尖括号。
|
||||
// 与 Web 端 TAG_RE 同一形状 ⇒ `1 < 2`、`a<b`、`<b`(无 `>`)都不是标签候选。
|
||||
var inlineTagRe = regexp.MustCompile(`</?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>`)
|
||||
|
||||
// inlineBreakTag 是气泡分隔符的标签名(`<br>` / `<br/>` / `</br>` 三种写法按 HTML5 口径等价)。
|
||||
const inlineBreakTag = "br"
|
||||
|
||||
// isBreakTagName 报告标签名是否属于 `<br>` 类气泡分隔符(大小写不敏感)。
|
||||
// **切分层与清洗层共用这一个判据**,两层对 `<br>` 的认法因此不可能漂移。
|
||||
func isBreakTagName(name string) bool {
|
||||
return strings.EqualFold(name, inlineBreakTag)
|
||||
}
|
||||
|
||||
// isPairedInlineTag 报告标签名是否属于需配对处理的行内白名单(契约 v0.89:五项)。
|
||||
func isPairedInlineTag(name string) bool {
|
||||
switch name {
|
||||
case "b", "strong", "i", "em", "code":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// breakSeparatorSpans 返回文本里 `<br>` 类**气泡分隔符**的字节区间 `[start,end)`,
|
||||
// 用于把一段文本切成多条消息/气泡。它复用行内标签的同一套词法(inlineTagRe)
|
||||
// 与同一个名字判据(isBreakTagName),因此切分层与清洗层面对 `<br>` 的认法一致。
|
||||
func breakSeparatorSpans(text string) [][]int {
|
||||
if !strings.Contains(text, "<") {
|
||||
return nil
|
||||
}
|
||||
var spans [][]int
|
||||
for _, m := range inlineTagRe.FindAllStringSubmatchIndex(text, -1) {
|
||||
if isBreakTagName(text[m[2]:m[3]]) {
|
||||
spans = append(spans, []int{m[0], m[1]})
|
||||
}
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// stripInlineTags 按行内标签白名单处理一段气泡文本:剥掉成对白名单标签,
|
||||
// 白名单外标签原样保留;**只要结构不平衡就整段返回原文**。
|
||||
//
|
||||
// 粒度是「整段」(调用方给的一段气泡文本),与 Web 端按气泡调 parseInlineMarkup
|
||||
// 的粒度一致:整段降级必须整段一起做,否则会丢掉字符。
|
||||
//
|
||||
// ⚠️ 生产路径里本函数拿到的文本已经过 splitBubbles 的 `<br>` 切分,因此正常情况下
|
||||
// 不会见到 `br`;万一见到(有人绕过切分层直接调 stripNarration),这里与 `<p>` 同级地
|
||||
// **把它摘掉** —— 绝不把分隔符漏成一个可见的字面标签。真正的「切成多条消息」只由
|
||||
// splitBubbles 负责(见 sanitize.go)。
|
||||
func stripInlineTags(text string) string {
|
||||
if !strings.Contains(text, "<") {
|
||||
return text
|
||||
}
|
||||
matches := inlineTagRe.FindAllStringSubmatchIndex(text, -1)
|
||||
if matches == nil {
|
||||
return text
|
||||
}
|
||||
var (
|
||||
out strings.Builder
|
||||
stack []string
|
||||
last int
|
||||
)
|
||||
for _, m := range matches {
|
||||
name := strings.ToLower(text[m[2]:m[3]])
|
||||
out.WriteString(text[last:m[0]])
|
||||
last = m[1]
|
||||
switch {
|
||||
case isBreakTagName(name):
|
||||
// 分隔符:隔离已由 splitBubbles 做过,这一层只摘除标记。
|
||||
case isPairedInlineTag(name):
|
||||
if text[m[0]+1] == '/' {
|
||||
if len(stack) == 0 || stack[len(stack)-1] != name {
|
||||
// 落单闭标签 / 交错 ⇒ 整段降级为原文。
|
||||
return text
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
continue
|
||||
}
|
||||
stack = append(stack, name)
|
||||
default:
|
||||
// 白名单外:连同尖括号一起当文本,逐字保留。
|
||||
out.WriteString(text[m[0]:m[1]])
|
||||
}
|
||||
}
|
||||
if len(stack) != 0 {
|
||||
// 有未闭合的开标签 ⇒ 整段降级为原文。
|
||||
return text
|
||||
}
|
||||
out.WriteString(text[last:])
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件覆盖 IM 出站的行内标签白名单(契约 §6 v0.89:`b` / `strong` / `i` / `em` /
|
||||
// `code` **五项**)与 `<br>` 的**气泡/消息分隔符**语义(与 `<p>` 同级)。
|
||||
// 口径与 Web 端渲染器(frontend/web/src/lib/inlineMarkup.ts)逐条对齐。
|
||||
|
||||
// TestInlineTags_RealSampleParagraph 复现主人 2026-10-07 报障的真样例:
|
||||
// `<b>18:35</b>` 此前原样发给微信/QQ,对方看到字面标签。
|
||||
func TestInlineTags_RealSampleParagraph(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>"
|
||||
want := "今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。"
|
||||
got := stripNarration(in)
|
||||
if got != want {
|
||||
t.Errorf("stripNarration = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(got, "<b") || strings.Contains(got, "</b") {
|
||||
t.Errorf("stripNarration leaked inline tag: %q", got)
|
||||
}
|
||||
// 冒泡切分链路里同样不得带出标签。
|
||||
bubbles := splitBubbles(in)
|
||||
if len(bubbles) != 1 || bubbles[0] != want {
|
||||
t.Errorf("splitBubbles = %#v, want [%q]", bubbles, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_PairedWhitelistTagsStripped 报告五项白名单的成对标签只去标签、
|
||||
// 留内部文本,且大小写不敏感。
|
||||
func TestInlineTags_PairedWhitelistTagsStripped(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ in, want string }{
|
||||
{"日落 <b>18:35</b> 呢", "日落 18:35 呢"},
|
||||
{"<strong>重要</strong>", "重要"},
|
||||
{"<i>斜</i>", "斜"},
|
||||
{"<em>强调</em>", "强调"},
|
||||
{"<code>go test</code>", "go test"},
|
||||
{"<B>大写</B>", "大写"},
|
||||
{"<STRONG>大写</STRONG>", "大写"},
|
||||
{"<b><i>嵌套</i></b>", "嵌套"},
|
||||
{"a<b>1</b>b<code>2</code>c", "a1b2c"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripNarration(c.in); got != c.want {
|
||||
t.Errorf("stripNarration(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBreakTags_SplitIntoSeparateMessages 是契约 §6 v0.89「`<br>` 是气泡/消息分隔符」
|
||||
// 的核心断言:`<br>` 两边的正文必须成为**两条独立的消息**,而不是同一条里的一个换行。
|
||||
func TestBreakTags_SplitIntoSeparateMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"<p> 块内", "<p>甲<br>乙</p>", []string{"甲", "乙"}},
|
||||
{"无 <p> 标记", "甲<br>乙", []string{"甲", "乙"}},
|
||||
{"连续 <br> 不留空气泡", "<p>甲<br><br>乙</p>", []string{"甲", "乙"}},
|
||||
{"两侧都是空白", "<p>甲<br> <br>乙</p>", []string{"甲", "乙"}},
|
||||
{"只有空白与分隔符", "<br> <br>", nil},
|
||||
{"首尾分隔符", "<br>甲<br>", []string{"甲"}},
|
||||
{"与 <p> 混用", "<p>甲<br>乙</p><p>丙</p>", []string{"甲", "乙", "丙"}},
|
||||
{"<br/> 等价", "甲<br/>乙", []string{"甲", "乙"}},
|
||||
{"</br> 等价", "甲</br>乙", []string{"甲", "乙"}},
|
||||
{"大小写不敏感", "甲<BR>乙", []string{"甲", "乙"}},
|
||||
{"分隔符与行内标签同现", "<p>甲<b>粗</b><br><code>乙</code></p>", []string{"甲粗", "乙"}},
|
||||
{"<br> 不是标签(无 >)", "甲<br 乙", []string{"甲<br 乙"}},
|
||||
{"<bravo> 不是分隔符", "甲<bravo>乙</bravo>", []string{"甲<bravo>乙</bravo>"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := splitBubbles(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("%s: splitBubbles(%q) = %#v, want %#v", c.name, c.in, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range c.want {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("%s: splitBubbles(%q)[%d] = %q, want %q", c.name, c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBreakTags_NotSplitInsideRoleplayMarkup 锁住切分与角色扮演摘除的**先后**:
|
||||
// `<br>` 写在 `<action>` / `<narration>` / 星号注记**内部**时不得把注记劈成聊天气泡
|
||||
// —— 否则注记正文会漏成一条消息,是对既有「注记整体剥除」口径的回归。
|
||||
func TestBreakTags_NotSplitInsideRoleplayMarkup(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"<p><action>她<br>点点头</action>甲</p>", []string{"甲"}},
|
||||
{"<p><narration>心里<br>有点甜</narration>甲</p>", []string{"甲"}},
|
||||
{"<p>*动作:她<br>点点头*甲</p>", []string{"甲"}},
|
||||
{"<action>他<br>走了</action>", nil},
|
||||
{"甲<br><action>他<br>走了</action>", []string{"甲"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := splitBubbles(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("splitBubbles(%q) = %#v, want %#v", c.in, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range c.want {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("splitBubbles(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBreakTags_NoLiteralLeak 掏底断言:即便有人绕过切分层直接调 stripNarration,
|
||||
// `<br>` 三种写法也**绝不能漏成字面量标签**(清洗层与 `<p>` 同级地摘除它)。
|
||||
// 真正的「切成多条消息」只由 splitBubbles 负责(见上一条测试)。
|
||||
func TestBreakTags_NoLiteralLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, in := range []string{"<p>甲<br>乙</p>", "甲<br/>乙", "甲</br>乙", "甲<BR>乙"} {
|
||||
got := stripNarration(in)
|
||||
if strings.Contains(strings.ToLower(got), "br") {
|
||||
t.Errorf("stripNarration(%q) 漏出了分隔符字面量: %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_NonWhitelistKeptVerbatim 白名单外标签是文本,逐字不丢 ——
|
||||
// 这与 `<p>` / `<action>`(角色扮演/段落标记,要剥)刻意不同。
|
||||
// `u` / `s` 自 v0.89 修订起**不在**白名单内,因此也按文本逐字保留。
|
||||
func TestInlineTags_NonWhitelistKeptVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"<script>alert(1)</script>",
|
||||
"<div>块级</div>",
|
||||
"<a href=\"http://x\">链接</a>",
|
||||
"<img src=x onerror=y>",
|
||||
"<iframe src=x></iframe>",
|
||||
"<u>下划线</u>",
|
||||
"<s>删除线</s>",
|
||||
"<U>大写也不认</U>",
|
||||
}
|
||||
for _, in := range cases {
|
||||
if got := stripNarration(in); got != in {
|
||||
t.Errorf("stripNarration(%q) = %q, want verbatim", in, got)
|
||||
}
|
||||
}
|
||||
// 白名单与外标签混排:只动白名单那对,其余逐字保留。
|
||||
in := "她说了 <b>这样</b> 和 <script>alert(1)</script> 两回事"
|
||||
want := "她说了 这样 和 <script>alert(1)</script> 两回事"
|
||||
if got := stripNarration(in); got != want {
|
||||
t.Errorf("stripNarration(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_UnbalancedDegradesWholeSegment 结构不平衡(未闭合 / 落单闭标签 /
|
||||
// 交错)⇒ 整段保持原样(契约把粒度列为实现细节;本端与 Web 端取同一粒度)。
|
||||
func TestInlineTags_UnbalancedDegradesWholeSegment(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"未闭合 <b>x",
|
||||
"落单闭标签 x</b>",
|
||||
"交错 <b><i>x</b></i>",
|
||||
"自闭合 <b/>x",
|
||||
"a<b>c",
|
||||
"<code>没收尾",
|
||||
"混排 <b>开了 <i>也开了</i> 只剩 b 未闭合",
|
||||
}
|
||||
for _, in := range cases {
|
||||
if got := stripNarration(in); got != in {
|
||||
t.Errorf("stripNarration(%q) = %q, want verbatim (unbalanced)", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_AttributesDropped 属性一律不进结果。
|
||||
func TestInlineTags_AttributesDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "答案 <b class=x onclick=y>y</b>"
|
||||
if got := stripNarration(in); got != "答案 y" {
|
||||
t.Errorf("stripNarration(%q) = %q, want %q", in, got, "答案 y")
|
||||
}
|
||||
in2 := `<code data-x="1">ok</code>`
|
||||
if got := stripNarration(in2); got != "ok" {
|
||||
t.Errorf("stripNarration(%q) = %q, want %q", in2, got, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_LookalikesNotTreatedAsTags 标签候选的护栏(契约 §4.16 同一口径):
|
||||
// 不是标签候选的形状一律原样保留。
|
||||
func TestInlineTags_LookalikesNotTreatedAsTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"1 < 2 且 3 > 2",
|
||||
"a<b",
|
||||
"a <b 没有尖括号收尾",
|
||||
"<p2>不是段落标签</p2>",
|
||||
"<b",
|
||||
}
|
||||
for _, in := range cases {
|
||||
if got := stripNarration(in); got != in {
|
||||
t.Errorf("stripNarration(%q) = %q, want verbatim", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_NoRegression_ExistingSanitizeBehaviour 既有清洗行为逐字不变
|
||||
// (角色扮演标签 / 星号块 / 段落标记 / 空行折叠)。
|
||||
func TestInlineTags_NoRegression_ExistingSanitizeBehaviour(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ in, want string }{
|
||||
{"<action>弯起眼睛笑了</action>\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"},
|
||||
{"<p>你好呀</p>\n<narration>心里有点甜</narration>\n<p>今天天气不错</p>", "你好呀\n今天天气不错"},
|
||||
{"就是普通一句话,没有动作。\n第二行。", "就是普通一句话,没有动作。\n第二行。"},
|
||||
{"<action>拍拍你</action>", ""},
|
||||
{"*动作:弯起眼睛笑了*\n当然看得到呀,亲爱的。", "当然看得到呀,亲爱的。"},
|
||||
{"*旁白:心里有点甜*\n这是正文。", "这是正文。"},
|
||||
{"这是一段 *强调* 文字,不是动作。", "这是一段 *强调* 文字,不是动作。"},
|
||||
{"*弯起眼睛*", ""},
|
||||
{"<p>a</p>\n\n\n<p>b</p>", "a\nb"},
|
||||
{"<p><action>满意地点点头</action></p>\n<p>真乖♪</p>", "真乖♪"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripNarration(c.in); got != c.want {
|
||||
t.Errorf("stripNarration(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
// 注记被剥掉后,注记内部的行内标签也不该参与渲染(契约 §6 v0.89 末条)。
|
||||
in := "<action>她 <b>点点头</b></action>好呀。"
|
||||
if got := stripNarration(in); got != "好呀。" {
|
||||
t.Errorf("stripNarration(%q) = %q, want %q", in, got, "好呀。")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_NoRegression_BubbleSplitting 气泡切分语义不受影响:
|
||||
// 一个 `<p>` 一个气泡、空气泡丢弃、无 `<p>` 且无 `<br>` 时整段一个气泡。
|
||||
func TestInlineTags_NoRegression_BubbleSplitting(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := splitBubbles("<p>第一段 <b>粗</b></p><p>第二段</p>"); len(got) != 2 || got[0] != "第一段 粗" || got[1] != "第二段" {
|
||||
t.Errorf("splitBubbles = %#v", got)
|
||||
}
|
||||
if got := splitBubbles("<p><action>点点头</action></p><p>正文</p>"); len(got) != 1 || got[0] != "正文" {
|
||||
t.Errorf("splitBubbles = %#v", got)
|
||||
}
|
||||
in := "无段落标记的一段\n带 <b>粗</b> 的行"
|
||||
if got := splitBubbles(in); len(got) != 1 || got[0] != "无段落标记的一段\n带 粗 的行" {
|
||||
t.Errorf("splitBubbles = %#v", got)
|
||||
}
|
||||
if got := splitBubbles("<action>拍拍你</action>"); len(got) != 0 {
|
||||
t.Errorf("splitBubbles = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInlineTags_EndToEndThroughRealPipeline 走**真出站链路**:入站消息 → 会话 /
|
||||
// actor → agent 循环(模型侧是桩)→ 清洗切分 → 适配器出站。断言的正是适配器
|
||||
// 真正收到的那条消息内容与**条数** —— 也就是用户在微信/QQ 上会看到的字面文本,
|
||||
// 以及 `<br>` 是否真的变成了多条消息。
|
||||
func TestInlineTags_EndToEndThroughRealPipeline(t *testing.T) {
|
||||
t.Parallel()
|
||||
const reply = "<p>今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。</p>" +
|
||||
"<p>第二段<br>第三段:<code>go test</code> 跑过了</p>"
|
||||
chat := &stubChatter{res: &loop.Result{Text: reply}}
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(ad)
|
||||
b.bubbleGapFn = func() time.Duration { return 0 }
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
want := []string{
|
||||
"今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。",
|
||||
"第二段",
|
||||
"第三段:go test 跑过了",
|
||||
}
|
||||
if len(ad.sends) != len(want) {
|
||||
t.Fatalf("出站气泡 = %d 条, want %d: %+v", len(ad.sends), len(want), ad.sends)
|
||||
}
|
||||
for i, out := range ad.sends {
|
||||
if out.Content != want[i] {
|
||||
t.Fatalf("气泡[%d] 内容 = %q, want %q", i, out.Content, want[i])
|
||||
}
|
||||
if strings.ContainsAny(out.Content, "<>") {
|
||||
t.Fatalf("气泡[%d] 仍带标签: %q", i, out.Content)
|
||||
}
|
||||
if out.ContentType != adapter.ContentText {
|
||||
t.Fatalf("气泡[%d] ContentType = %q, want text", i, out.ContentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,23 @@ var (
|
||||
const shortRoleplayRunes = 40
|
||||
|
||||
// stripNarration 返回去掉角色扮演/动作/
|
||||
// 旁白标记、段落标记被抹平、连续空行被折叠后的
|
||||
// 旁白标记、段落标记被抹平、连续空行被折叠、行内白名单标签被剥掉的
|
||||
// 纯聊天文本。它对畸形输入绝不报错或 panic;纯动作/独白的
|
||||
// 回复会变成空字符串(调用方可据此跳过发送)。
|
||||
//
|
||||
// 它是 finishNarration ∘ stripRoleplayMarkup 两步的组合 —— 拆成两步是为了让
|
||||
// splitBubbles 能在中间插入 `<br>` 的分隔符切分(见 bubblesOfSegment)。
|
||||
func stripNarration(text string) string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return ""
|
||||
}
|
||||
return finishNarration(stripRoleplayMarkup(text))
|
||||
}
|
||||
|
||||
// stripRoleplayMarkup 摘除角色扮演/动作/旁白标记:成对与落单的
|
||||
// `<action>`/`<narration>`、全角星号块、带角色扮演关键词的半角星号块。
|
||||
// 顺序与参数逐字沿用既有实现(本函数只是把原来的前四步搬出来,行为不变)。
|
||||
func stripRoleplayMarkup(text string) string {
|
||||
s := pairedRoleplayRe.ReplaceAllString(text, "")
|
||||
s = strayRoleplayRe.ReplaceAllString(s, "")
|
||||
s = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||
@@ -56,9 +66,16 @@ func stripNarration(text string) string {
|
||||
}
|
||||
return m
|
||||
})
|
||||
s = halfStarRoleplayRe.ReplaceAllString(s, "")
|
||||
s = paragraphRe.ReplaceAllString(s, "")
|
||||
return halfStarRoleplayRe.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// finishNarration 完成一段**气泡文本**的清洗:抹平 `<p>` 段落标记、折叠连续空行、
|
||||
// 剥掉行内白名单标签。这三步必须留在切分**之后**:段落与空行折叠是「一条消息内部」
|
||||
// 的排版整理,行内标签的「整段降级」也以气泡为粒度(与 Web 端同口径)。
|
||||
func finishNarration(segment string) string {
|
||||
s := paragraphRe.ReplaceAllString(segment, "")
|
||||
s = blankLinesRe.ReplaceAllString(s, "\n")
|
||||
s = stripInlineTags(s)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
@@ -93,31 +110,72 @@ func hasRoleplayPrefix(t string) bool {
|
||||
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
|
||||
|
||||
// splitBubbles 把一条回复按顺序拆成它承载的聊天气泡列表。
|
||||
// 每个 <p>...</p> 块为一个气泡,其文本已剥离角色扮演/动作/
|
||||
// 旁白;空气泡被丢弃。没有 <p> 标记的回复按
|
||||
// 单个气泡处理(保留换行)。纯动作/独白得到空切片。
|
||||
//
|
||||
// **两个同级的分隔符**(契约 §6 v0.89):
|
||||
// - 每个 `<p>...</p>` 块 = 一个气泡(既有语义,逐字不变);
|
||||
// - 每个 `<br>` 类分隔符(`<br>` / `<br/>` / `</br>`,大小写不敏感)= 一次
|
||||
// 「切成多条消息」的边界,与 `<p>` 同级 —— **不是**一个换行字符。
|
||||
//
|
||||
// 空气泡(含只由空白构成的气泡)被丢弃。没有 `<p>` 标记的回复**不是**按单个气泡处理
|
||||
// 了(这一条被 v0.89 改写):它同样会按 `<br>` 切开,例如 `甲<br>乙` ⇒ 两条消息。
|
||||
// 纯动作/独白得到空切片。
|
||||
func splitBubbles(text string) []string {
|
||||
if !strings.Contains(strings.ToLower(text), "<p") {
|
||||
if c := stripNarration(text); c != "" {
|
||||
return []string{c}
|
||||
if c := bubblesOfSegment(text); len(c) != 0 {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0)
|
||||
for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) {
|
||||
if c := stripNarration(m[1]); c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
out = append(out, bubblesOfSegment(m[1])...)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// 有 <p> 标记但没有格式良好的块——回退为整段文本。
|
||||
if c := stripNarration(text); c != "" {
|
||||
out = append(out, bubblesOfSegment(text)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bubblesOfSegment 把一个**段落区段**(`<p>` 块内部文本,或无 `<p>` 时的整段文本)
|
||||
// 切成它承载的若干气泡文本。
|
||||
//
|
||||
// 🔴 两步顺序是本文件的要害:
|
||||
// 1. 先 `stripRoleplayMarkup` —— 角色扮演/星号标记整块消失。因此 `<br>` 写在
|
||||
// `<action>` / 星号块**内部**时不会把一段注记劈成两条聊天消息(那样会把注记正文
|
||||
// 漏成聊天气泡,是对既有「注记整体剥除」口径的回归);
|
||||
// 2. 再按 `<br>` 切分 —— 此时剩下的 `<br>` 都只可能出现在**真正要对用户说的正文**里,
|
||||
// 切开正合契约「始终处理为多条消息隔断」。
|
||||
//
|
||||
// 切分**之后**的每一段各自走 finishNarration(抹平 `<p>` / 折叠空行 / 剥行内标签),
|
||||
// 因此「空气泡丢弃」与 `<p>` 空气泡同一套口径:只由空白构成的段会被 TrimSpace 成空串后丢掉。
|
||||
func bubblesOfSegment(segment string) []string {
|
||||
parts := splitByBreakTags(stripRoleplayMarkup(segment))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if c := finishNarration(p); c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitByBreakTags 只按 `<br>` 类分隔符切分(分隔符本身丢弃),不做任何清洗。
|
||||
// 没有分隔符时返回单元素切片,即「整段一个气泡」的既有语义。
|
||||
func splitByBreakTags(segment string) []string {
|
||||
spans := breakSeparatorSpans(segment)
|
||||
if len(spans) == 0 {
|
||||
return []string{segment}
|
||||
}
|
||||
parts := make([]string, 0, len(spans)+1)
|
||||
last := 0
|
||||
for _, sp := range spans {
|
||||
parts = append(parts, segment[last:sp[0]])
|
||||
last = sp[1]
|
||||
}
|
||||
return append(parts, segment[last:])
|
||||
}
|
||||
|
||||
// bubbleGap 返回 IM 出站气泡之间使用的 0.5s..2s 随机延时,
|
||||
// 使连发段落不会刷屏(design/17 §2)。Web 前端
|
||||
// 不受影响——它从已持久化的流渲染所有气泡,没有任何延时。
|
||||
|
||||
Reference in new issue
Block a user