主人 2026-10-07 三点裁定:「下划线确实可以保留」「多余的空气泡确实可以丢掉」
「非聊天模式 按照应当有的专业卡片和格式来显示」。
- 白名单回到六项(u 加回,s 仍在外)
- 空 p 块也丢(与 br 碎片同一口径);纯注记回复 ⇒ 零气泡
- 非 chat 模式(8 个单卡正文)给它自己的渲染口径 CARD_RENDER:
br ⇒ 真换行元素(那些容器没有 whitespace-pre-wrap,文本换行会被 CSS 折叠)、
行内白名单正常出样式;WorkMessageBody 的按行切清单、CodeMessageBody 的 pre
等既有较强约定一律保留
- 🔴 发现并修了一个会炸的下游损失:空气泡原本是承载头像/思维链/工具卡/心情/
「生成中…」的那一行,丢气泡会把这些用户可见内容一起丢 ⇒ 加 hasBubbleChrome,
补一行无正文外壳
- 测试 99 -> 189 checks;6 个变异(含「外壳行摘掉 ⇒ 工具卡丢失」)
- 改掉一条既有断言:三个 p 中首段被注记剥空 ⇒ 气泡数由 3 改为 2(并补按行数断言)
70 lines
3.8 KiB
TypeScript
70 lines
3.8 KiB
TypeScript
import { Fragment, type ReactNode } from "react";
|
||
import { parseContent } from "../lib/parseContent";
|
||
import { parseInlineMarkup, type InlineNode } from "../lib/inlineMarkup";
|
||
|
||
/** 正文渲染口径(两种界面形态各一套,别混用):
|
||
* · 缺省 / `lineBreaks:false` = **chat 气泡正文** —— `<br>` 是气泡分隔符,在上一层
|
||
* (`components/chat.tsx` 的 `splitParagraphs`)就切成独立气泡了;这里绝不把 `<br>`
|
||
* 变成换行(等于把「多条消息」偷偷还原成「一条消息里的换行」,与主人口径冲突)。
|
||
* · `lineBreaks:true` = **非 chat 的单卡正文**(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 /
|
||
* 智能体 / 设置)—— 那些界面没有「多条气泡」的概念,`<br>` 只换行、不切分。 */
|
||
export interface RenderOptions {
|
||
lineBreaks?: boolean;
|
||
}
|
||
|
||
/** 非 chat **单卡正文**的统一渲染口径(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 /
|
||
* 设置,以及这些模式里的用户气泡):`<br>` ⇒ 换行。抽成一处常量,避免各卡片各写一份
|
||
* 而在日后漂移(chat 气泡**不**用它 —— 那里 `<br>` 是气泡分隔符)。 */
|
||
export const CARD_RENDER: RenderOptions = { lineBreaks: true };
|
||
|
||
/** 渲染助手/用户消息正文。`<action>` / `<narration>` 块在这里**不**进入气泡 —— 它们由
|
||
* `splitBubble` 剥出、以气泡外的注记呈现(单卡正文里则整块略去);剩下的文本段落交给
|
||
* 行内白名单渲染。
|
||
*
|
||
* 🔴 这里**只**做行内(`b`/`strong`/`i`/`em`/`code`/`u`)的事:段落 `<p>` 与消息断隔
|
||
* `<br>` 都在上一层(`components/chat.tsx` 的 `splitParagraphs`)就已切成独立气泡。
|
||
*
|
||
* 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有
|
||
* `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签
|
||
* (`<script>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。
|
||
* `lineBreaks` 也只多产出一个 `<br/>` 元素(结构节点),不外泄任何来自产物的属性。 */
|
||
export function renderContent(content: string, options: RenderOptions = {}): ReactNode[] {
|
||
const lineBreaks = options.lineBreaks ?? false;
|
||
return parseContent(content)
|
||
.filter((part) => part.type === "text")
|
||
.map((part, i) => <span key={i}>{renderInline(part.text, lineBreaks)}</span>);
|
||
}
|
||
|
||
/** 一段(`<p>` / `<br>` 切分后的单气泡)文本 ⇒ 行内节点序列 ⇒ React 元素。 */
|
||
function renderInline(text: string, lineBreaks: boolean): ReactNode[] {
|
||
return parseInlineMarkup(text, lineBreaks).map((node, i) => renderInlineNode(node, `n${i}`));
|
||
}
|
||
|
||
/** 单个行内节点 ⇒ React 元素。文本走 Fragment(React 转义),标签走原生元素名,
|
||
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。
|
||
* `break` 只在单卡正文里出现(那个 `<br>` 是**换行**,不是气泡分隔符)。 */
|
||
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) {
|
||
case "b":
|
||
return <b key={key}>{children}</b>;
|
||
case "strong":
|
||
return <strong key={key}>{children}</strong>;
|
||
case "i":
|
||
return <i key={key}>{children}</i>;
|
||
case "em":
|
||
return <em key={key}>{children}</em>;
|
||
case "u":
|
||
return <u key={key}>{children}</u>;
|
||
case "code":
|
||
return (
|
||
<code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono">
|
||
{children}
|
||
</code>
|
||
);
|
||
}
|
||
}
|