Files
Cyrene_v2/frontend/web/src/lib/renderContent.tsx
T
AskaEth f01fd7db20 feat(web): u 加回白名单 + 空气泡丢弃 + 非 chat 模式不再显示字面标签
主人 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(并补按行数断言)
2026-10-07 18:44:45 +08:00

70 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}
}