diff --git a/frontend/web/src/lib/inlineMarkup.ts b/frontend/web/src/lib/inlineMarkup.ts
new file mode 100644
index 0000000..ea5b4fa
--- /dev/null
+++ b/frontend/web/src/lib/inlineMarkup.ts
@@ -0,0 +1,111 @@
+// 助手文本的**行内白名单标签**解析(主人 2026-10-07 需求「基本渲染支持」)。
+//
+// 背景:昔涟产出的是**少量 HTML 标签**而不是 Markdown —— 段落用 `
`(契约 §4.16,
+// 由 `splitParagraphs` 处理),动作/旁白用 ``/``(由 `splitBubble`
+// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `` 之类强调标签。
+// 后两类此前没有处理 ⇒ `18:35` 会把字面量 `<b>` 直接显示给用户。
+//
+// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
+// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
+// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
+// 对文本节点默认转义,天然安全。规则:
+// ① 只认**行内**白名单标签,块级一律不支持(段落仍由 §4.16 的 `` 机制表达);
+// ② **属性一律丢弃**(`` 只取标签名 `b`,属性绝不进 DOM);
+// ③ 白名单外标签**逐字原样**当文本(`", "") === "",
+ "② `").length === 0,
+ "② 元素树里除最外层 span 外没有任何节点"
+ );
+
+ // 「没有可执行的 DOM 属性」:树里除 children 外**不存在**任何属性位。
+ for (const e of tree("
")) {
+ ok(e.propKeys.length === 0, `② 元素 ${e.type} 上没有任何属性(含 onerror)`);
+ }
+}
+
+// ── ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本 ────────────────────────────────────
+{
+ const broken = [
+ "x",
+ "日落 18:35 了",
+ "孤立闭标签",
+ "x y", // 开 b 未闭合(i 已闭合)⇒ 整段降级
+ "x", // 交错(i 未完)⇒ 整段降级
+ ];
+ for (const src of broken) {
+ const html = markup(src);
+ ok(seen(src) === src, `③ 结构不平衡时整段降级为纯文本:${src}`);
+ ok(!has(html, "") && !has(html, ""), `③ 降级后不产出元素:${src}`);
+ ok(foreignTypes(src).length === 0, `③ 降级后没有多余节点:${src}`);
+ }
+ ok(seen("1 < 2") === "1 < 2", "③ §4.16 护栏:`1 < 2` 不是标签");
+ ok(seen("a`)不是标签");
+ ok(seen("x") === "x", "③ §4.16 护栏:`` 不是标签");
+ ok(seen("文本 `)不是标签");
+}
+
+// ── ④ 交错标签:不崩、字符一个不丢 ──────────────────────────────────────────────
+{
+ const interleaved = "abc
";
+ ok(seen(interleaved) === interleaved, "④ `abc` 逐字保留,不崩");
+ ok(foreignTypes(interleaved).length === 0, "④ 交错时不产出元素(不猜结构)");
+
+ // 同一段里既有合法标签又有畸形标签:**整段降级**(不猜结构),因此连合法的 `` 对
+ // 也一并以字面量显示 —— 代价是「这一段没有行内渲染」,换来的是「一个字符都不丢」。
+ const mixed = "日落 18:35,还有 未闭合";
+ ok(seen(mixed) === mixed, "④ 畸形参与时整段降级为纯文本,字符不丢");
+ ok(foreignTypes(mixed).length === 0, "④ 整段降级时不产出任何元素");
+}
+
+// ── ⑤ 带属性 ⇒ 只出元素,属性不生效 ────────────────────────────────────────────
+{
+ const html = markup("z");
+ ok(has(html, "z"), "⑤ 只出粗体元素本身");
+ ok(!has(html, "onclick") && !has(html, "class="), "⑤ 属性不进入 markup");
+
+ const bEl = tree("z").find((e) => e.type === "b");
+ ok(Boolean(bEl), "⑤ 树里存在 b 元素");
+ ok(bEl !== undefined && bEl.propKeys.length === 0, "⑤ b 元素上没有任何属性(class/onclick 被丢弃)");
+
+ ok(seen("z") === "z", "⑤ 属性被丢弃,正文逐字保留");
+
+ const codeHtml = markup("c");
+ ok(has(codeHtml, "提示") === "提示", "⑤ 引号含空格的属性同样不生效");
+}
+
+// ── ⑥ 既有 `
` / `` 行为无回归(真实气泡链路) ────────────────────────
+{
+ const html = render({ id: "a1", role: "assistant" as const, content: REAL_SAMPLE, createdAt: 1 } as ChatMessage);
+
+ ok(has(html, "*动作:赶紧凑过来看你那边,手指在空气里划了下*"), "⑥ action 仍是气泡外的灰斜体注记");
+ ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 三个 ⇒ 三个气泡(首段只有动作注记 ⇒ 空气泡,与改动前逐字一致)");
+ ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
+ ok(has(html, "今天那儿是晴,13~25℃,日落 18:35,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 渲染为粗体");
+ ok(!has(html, "<p>") && !has(html, "
"), "⑥ `
` 一个都不进 DOM(段落机制不变)");
+ ok(has(html, "italic leading-tight"), "⑥ 注记样式类不变(text-[10px] italic leading-tight)");
+
+ // 纯 `
` 三段 ⇒ 三个气泡(首段 full、其余 compact)。
+ const three = render({ id: "a2", role: "assistant", content: "
第一段
第二段
第三段
", createdAt: 2 } as ChatMessage);
+ ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 三段 ⇒ 三个气泡");
+ ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在");
+
+ // 🔴 `` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
+ const noteWithTag = render({ id: "a3", role: "assistant", content: "她 笑 了正文
", createdAt: 3 } as ChatMessage);
+ ok(has(noteWithTag, "*动作:她 <b>笑</b> 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
+ ok(has(noteWithTag, "正文"), "⑥ 注记后的正文照常渲染");
+
+ // 空正文 + streaming:仍是「生成中…」而不是空渲染。
+ const streaming = render({ id: "a4", role: "assistant", content: "", streaming: true, createdAt: 4 } as ChatMessage);
+ ok(has(streaming, "生成中…"), "⑥ 空正文 streaming 回落到「生成中…」");
+
+ // 用户侧同理(用户气泡也走 renderContent)。
+ const user = render({ id: "u1", role: "user", content: "帮我看看 这个 和 ", createdAt: 5 } as ChatMessage);
+ ok(has(user, "这个"), "⑥ 用户气泡同样渲染行内标签");
+ ok(!has(user, "