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(并补按行数断言)
This commit is contained in:
1 parent
1107b2a717
commit
f01fd7db20
5 files changed
+269
-71
No files matched your search
@@ -11,7 +11,7 @@ import { modeBody } from "./messages";
|
||||
import { DocumentList } from "./messages/Documents";
|
||||
import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta";
|
||||
import { UserMessageActions } from "./messages/MessageActions";
|
||||
import { renderContent } from "../lib/renderContent";
|
||||
import { CARD_RENDER, renderContent } from "../lib/renderContent";
|
||||
import { splitBubble, type ContentPart } from "../lib/parseContent";
|
||||
|
||||
/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
|
||||
@@ -213,11 +213,15 @@ export function MessageBubble({
|
||||
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
|
||||
|
||||
/** 把一个已切好的段落再按 `<br>` 切开:`<br>` 与 `<p>` **同级** —— 两边的文本各自
|
||||
* 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。空气泡丢弃
|
||||
* (`<p>甲<br><br>乙</p>` ⇒ `甲` / `乙`;`<br>` 两侧都是空白 ⇒ 不产生多余气泡)。
|
||||
* **不含 `<br>` 的段落逐字返回原样**(空段也原样保留)⇒ 既有 `<p>` 口径一个字节不动。 */
|
||||
* 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。
|
||||
*
|
||||
* 🔴 **空气泡一律丢弃**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):`<p>甲<br><br>乙</p>`
|
||||
* ⇒ `甲` / `乙`;`<br>` 两侧都是空白 ⇒ 不产生多余气泡;**空的 `<p>` 块也丢** ——
|
||||
* `<p></p>` / `<p> </p>`,以及注记被剥走后只剩空壳的 `<p><action>…</action></p>`,
|
||||
* 从此与 `<br>` 碎片同一套口径(上一轮只丢了 `<br>` 碎片、空 `<p>` 仍留一个气泡)。
|
||||
* **不含 `<br>` 的段落逐字返回原样**(输入早已 trim 过)⇒ 既有 `<p>` 口径一个字节不动。 */
|
||||
function splitBubbleBreaks(segment: string): string[] {
|
||||
if (!BR_SEPARATOR_RE.test(segment)) return [segment];
|
||||
if (!BR_SEPARATOR_RE.test(segment)) return segment ? [segment] : [];
|
||||
return segment
|
||||
.split(BR_SEPARATOR_RE)
|
||||
.map((part) => part.trim())
|
||||
@@ -281,20 +285,30 @@ function BubbleNotes({
|
||||
);
|
||||
}
|
||||
|
||||
/** 零气泡时是否还有「回复外壳」要出(见 `SplitChatBubble`):思维链 / 工具卡 / 心情是
|
||||
* 用户可见内容,流式中的「生成中…」是必要的等待反馈 —— 有其一就补一行外壳;纯注记回复
|
||||
* (这几样都没有)保持「只剩注记本身」的观感,与改动前一致。 */
|
||||
function hasBubbleChrome(message: ChatMessage): boolean {
|
||||
return Boolean(message.streaming || message.thinking || message.mood || message.tools?.length);
|
||||
}
|
||||
|
||||
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span.
|
||||
* `text` is already the annotation-stripped body paragraph from `splitBubble`,
|
||||
* so it renders as plain text only — action/narration notes live outside. */
|
||||
* so it renders as plain text only — action/narration notes live outside.
|
||||
* Empty text with no stream ⇒ **不画外壳**(零气泡回复里的那行「注记外壳」不因为是空的
|
||||
* 就变成一个空气泡)。 */
|
||||
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||
if (!text && !streaming) return null;
|
||||
return (
|
||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)] shadow-[var(--mu-shadow-1)]">
|
||||
{text ? (
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
) : streaming ? (
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
||||
<Icon name="refresh" size={12} className="animate-spin" />
|
||||
生成中…
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -375,9 +389,9 @@ function UserParaBubble({
|
||||
* **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. */
|
||||
* to MessageBubble → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念),
|
||||
* 所以那边不走气泡切分,`<br>` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行**
|
||||
* (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */
|
||||
export function SplitChatBubble({
|
||||
message,
|
||||
active = false,
|
||||
@@ -393,13 +407,14 @@ 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, "生成中…"). 一处例外:正文只剩 `<br>`(气泡分隔被切
|
||||
// 光)时**不能**把原文交下去 —— 那会把字面量 `<br>` 画进气泡,等于凭空造一个空气泡。
|
||||
// 这种情况按「空正文」交下去(外壳照旧),与空气泡丢弃的口径一致。
|
||||
const collapsedByBreaks = BR_SEPARATOR_RE.test(body);
|
||||
// usual chrome (avatar, mood, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
|
||||
// (只剩 `<br>` 分隔符,或只剩空 `<p>` 块)时**不能**把原文交下去 —— 那会把字面量
|
||||
// `<br>` / `<p>` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
|
||||
// 与空气泡丢弃的口径一致。
|
||||
const bodyExhausted = body.length > 0;
|
||||
return (
|
||||
<MessageBubble
|
||||
message={collapsedByBreaks ? { ...message, content: "" } : message}
|
||||
message={bodyExhausted ? { ...message, content: "" } : message}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
@@ -410,11 +425,19 @@ export function SplitChatBubble({
|
||||
// The annotation notes (head/tail) attach to the whole group — above the first
|
||||
// bubble and below the last — never inside a bubble's body.
|
||||
const align = message.role === "user" ? "right" : "left";
|
||||
// 零气泡(纯动作 / 纯独白回复):注记之外还有用户可见内容(思维链 / 工具卡 / 心情)
|
||||
// 或还在流式等待中时,补一行**无正文**的外壳 —— 头像、思维链、工具卡是内容,不该因为
|
||||
// 丢了空气泡就一并消失;`ChatBody` 对空正文返回 null,所以这里不会画出空气泡。
|
||||
const chromeOnly =
|
||||
segments.length === 0 && message.role === "assistant" && hasBubbleChrome(message);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
|
||||
>
|
||||
<BubbleNotes items={head} align={align} />
|
||||
{chromeOnly ? (
|
||||
<AssistantParaBubble message={message} text="" full active={active} />
|
||||
) : null}
|
||||
{segments.map((seg, i) =>
|
||||
message.role === "user" ? (
|
||||
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
||||
@@ -437,11 +460,15 @@ export function SplitChatBubble({
|
||||
/** User-side bubble. The outer accent pill is shared; the inner content may be
|
||||
* mode-shaped (e.g. code request renders in a mono pre). */
|
||||
function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
|
||||
// 非 chat 模式的用户气泡同样是**单卡正文**(`<br>` ⇒ 换行);chat 模式的正文在
|
||||
// `SplitChatBubble` 里就已按 `<br>` 切成多条,能落到这里的只剩「空正文」的情形,
|
||||
// 因此保持气泡口径 —— `<br>` 绝不在这里变回换行。
|
||||
const options = mode === "chat" ? undefined : CARD_RENDER;
|
||||
const body: React.ReactNode =
|
||||
mode === "code" ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content)}</pre>
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content, options)}</pre>
|
||||
) : message.content ? (
|
||||
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
|
||||
<span className="whitespace-pre-wrap">{renderContent(message.content, options)}</span>
|
||||
) : null;
|
||||
return (
|
||||
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChatMessage, Mode } from "../../lib/types";
|
||||
import { Tag } from "../primitives";
|
||||
import { Icon } from "../Icon";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { renderContent } from "../../lib/renderContent";
|
||||
import { CARD_RENDER, renderContent as renderContentIn } from "../../lib/renderContent";
|
||||
|
||||
/**
|
||||
* Mode-specific assistant message bodies (design/07 §2). The shared outer
|
||||
@@ -13,6 +13,14 @@ import { renderContent } from "../../lib/renderContent";
|
||||
* role under different modes looks different while a single session mixes them.
|
||||
*/
|
||||
|
||||
/** 本文件(**非 chat 单卡正文**面)的统一正文入口:`<br>` ⇒ **换行**,不切气泡 ——
|
||||
* 工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置这些卡片没有「多条气泡」的概念
|
||||
* (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。绑定在这里写一次,
|
||||
* 免得各卡片各写一份口径、日后漂移;chat 气泡不走这里(`components/chat.tsx` 另有一层)。 */
|
||||
function renderContent(text: string): ReactNode[] {
|
||||
return renderContentIn(text, CARD_RENDER);
|
||||
}
|
||||
|
||||
function shell(children: ReactNode): ReactNode {
|
||||
return (
|
||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||
@@ -218,7 +226,11 @@ export function SettingsMessageBody({ message }: { message: ChatMessage }): Reac
|
||||
return shell(fallbackText(message));
|
||||
}
|
||||
|
||||
/** Map a mode to its message body component for the dispatcher. */
|
||||
/** Map a mode to its message body component for the dispatcher.
|
||||
* 除 `chat` 外的八个模式都是**单卡正文**(本文件所有卡片都绑定 `CARD_RENDER`)。`default`
|
||||
* 落到的就是 `chat` —— 那条路只在 `SplitChatBubble` 把「切不出气泡的空正文」交下来时走
|
||||
* (见 `components/chat.tsx`),此时 content 必为空/纯空白,因此这里的 `<br>` 口径无从生效;
|
||||
* chat 模式的 `<br>` 仍由气泡切分层处理,绝不会在这里变成换行。 */
|
||||
export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
|
||||
switch (mode) {
|
||||
case "code": return <CodeMessageBody message={message} />;
|
||||
|
||||
@@ -5,54 +5,65 @@
|
||||
// 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `<b>` 之类强调标签。
|
||||
// 后两类此前没有处理 ⇒ `<b>18:35</b>` 会把字面量 `<b>` 直接显示给用户。
|
||||
//
|
||||
// 🚫 **`<br>` 不在这里**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为多条消息
|
||||
// 隔断」):它是**气泡/消息分隔符**,与 `<p>` 同级,由 `components/chat.tsx` 的
|
||||
// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 `<br>` 走到本模块
|
||||
// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示,
|
||||
// 绝不在这里伪造一个「气泡内换行」。
|
||||
// 🔴 **`<br>` 有两种身份,由调用方的 `lineBreaks` 决定**(主人 2026-10-07 口径
|
||||
// 「对消息内的 `<br>` 始终处理为多条消息隔断」+ 2026-10-07 晚「非聊天模式按照应当有的
|
||||
// 专业卡片和格式来显示」):
|
||||
// · **chat 模式(默认,`lineBreaks` 关)**:`<br>` 是**气泡/消息分隔符**,与 `<p>` 同级,
|
||||
// 由 `components/chat.tsx` 的 `splitParagraphs`(气泡切分层)切掉;到这里它已不存在。
|
||||
// 万一有 `<br>` 走到本模块(注记文本等不切气泡的路径),按**白名单外**处理 ⇒ 逐字原样,
|
||||
// 绝不在这里伪造一个「气泡内换行」。
|
||||
// · **非 chat 的单卡正文(`lineBreaks` 开)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 /
|
||||
// 智能体 / 设置这些界面**没有「多条气泡」的概念** ⇒ `<br>` 只**换行**、不切分,解析成
|
||||
// `{kind:"break"}`(渲染为真 `<br/>` 元素,不依赖父容器的 `white-space`)。
|
||||
//
|
||||
// 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导),
|
||||
// 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。
|
||||
// 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React
|
||||
// 对文本节点默认转义,天然安全。规则:
|
||||
// ① 只认**行内**白名单标签(五项:`b` / `strong` / `i` / `em` / `code`),块级一律
|
||||
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达,`<br>` 由气泡切分层处理);
|
||||
// ① 只认**行内**白名单元素(六项:`b` / `strong` / `i` / `em` / `code` / `u`),块级一律
|
||||
// 不支持(段落仍由 §4.16 的 `<p>` 机制表达);
|
||||
// ② **属性一律丢弃**(`<b class=x onclick=y>` 只取标签名 `b`,属性绝不进 DOM);
|
||||
// ③ 白名单外标签**逐字原样**当文本(`<script>` 显示成 `<script>`,不丢字符);
|
||||
// ④ 未闭合 / 交错 / 大小写混写 / 带属性 —— 结构不平衡时**整段降级为纯文本**,
|
||||
// 保证「不崩、字符一个不丢」(降级输出 = 原始字符串,逐字相同);
|
||||
// ⑤ 大小写不敏感(`<B>` 等于 `<b>`)。
|
||||
|
||||
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。白名单经主人 2026-10-07 晚收窄为
|
||||
* **五项**:`u` / `s` 在她百条输出里 0 命中、不值得各端为它们返工;`br` **不是行内标签**
|
||||
* (它是气泡分隔符,见文件头),两者从此一律按白名单外处理(逐字原样显示)。 */
|
||||
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code";
|
||||
/** 允许渲染的行内**元素**标签(成对出现、有子节点)。主人 2026-10-07 晚先把它收窄为
|
||||
* 五项(`u` / `s` 在她百条输出里 0 命中),当日复核后又把 **`u` 加回**(「下划线确实
|
||||
* 可以保留」)⇒ 现为 **六项**。`s` **仍在白名单外**(照旧逐字原样显示);`br` 也**不是**
|
||||
* 行内元素 —— 它是 void 标签,见文件头(chat 里是气泡分隔符、单卡正文里是换行)。 */
|
||||
export type InlineElementTag = "b" | "strong" | "i" | "em" | "code" | "u";
|
||||
|
||||
/** 解析结果:一段文本被拆成的有序节点序列。 */
|
||||
/** 解析结果:一段文本被拆成的有序节点序列。
|
||||
* `break` 只在 `lineBreaks` 打开时产生(非 chat 单卡正文里的一个 `<br>` ⇒ 一次换行)。 */
|
||||
export type InlineNode =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "element"; tag: InlineElementTag; children: InlineNode[] };
|
||||
| { kind: "element"; tag: InlineElementTag; children: InlineNode[] }
|
||||
| { kind: "break" };
|
||||
|
||||
/** 词法单元:文本 / 开标签 / 闭标签。 */
|
||||
/** 词法单元:文本 / 开标签 / 闭标签 / 换行(`<br>`)。 */
|
||||
type Token =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "open"; tag: InlineElementTag }
|
||||
| { kind: "close"; tag: InlineElementTag };
|
||||
| { kind: "close"; tag: InlineElementTag }
|
||||
| { kind: "break" };
|
||||
|
||||
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code"]);
|
||||
const INLINE_ELEMENT_TAGS = new Set<string>(["b", "strong", "i", "em", "code", "u"]);
|
||||
|
||||
/** 标签候选:`<` 紧跟字母、标签体里不再出现尖括号。因此 `1 < 2`、`a<b`(无 `>`)、
|
||||
* `<p`(无 `>`)都**不是**标签候选,会原样留在文本里(契约 §4.16 的护栏同一条口径)。 */
|
||||
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>/g;
|
||||
|
||||
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。 */
|
||||
export function parseInlineMarkup(text: string): InlineNode[] {
|
||||
const built = buildTree(tokenize(text));
|
||||
/** 把一段文本解析成行内节点序列。结构不平衡时整段降级为单个文本节点(逐字保留原文)。
|
||||
* `lineBreaks` 打开时(非 chat 单卡正文)`<br>` 变成 `{kind:"break"}`;关闭时(chat 气泡)
|
||||
* 它按白名单外文本处理 —— 因为那里 `<br>` 早被 `splitParagraphs` 当气泡分隔符切掉了。 */
|
||||
export function parseInlineMarkup(text: string, lineBreaks = false): InlineNode[] {
|
||||
const built = buildTree(tokenize(text, lineBreaks));
|
||||
return built ?? [{ kind: "text", text }];
|
||||
}
|
||||
|
||||
/** 切出标签候选,其余按文本切段。非白名单标签连同其尖括号一起作为**文本**单元。 */
|
||||
function tokenize(text: string): Token[] {
|
||||
function tokenize(text: string, lineBreaks: boolean): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let last = 0;
|
||||
TAG_RE.lastIndex = 0;
|
||||
@@ -60,7 +71,7 @@ function tokenize(text: string): Token[] {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TAG_RE.exec(text)) !== null) {
|
||||
if (m.index > last) tokens.push({ kind: "text", text: text.slice(last, m.index) });
|
||||
tokens.push(classify(m));
|
||||
tokens.push(classify(m, lineBreaks));
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) tokens.push({ kind: "text", text: text.slice(last) });
|
||||
@@ -68,11 +79,13 @@ function tokenize(text: string): Token[] {
|
||||
}
|
||||
|
||||
/** 判定一个标签候选的词法身份。白名单外 ⇒ 原样文本(含尖括号,字符不丢)。
|
||||
* `<br>` 走的就是这一支(它已从白名单移除):到本模块时它只是**字面量文本**,
|
||||
* 气泡分隔在 `components/chat.tsx` 的 `splitParagraphs` 里就已经切完。 */
|
||||
function classify(m: RegExpExecArray): Token {
|
||||
* `<br>` 在 `lineBreaks` 打开时是换行;关闭时走**白名单外**那一支(chat 模式下它本就被
|
||||
* 气泡切分层切走,`components/chat.tsx#BR_SEPARATOR_RE` 用的是同一条标签候选口径:
|
||||
* `<br>` / `<br/>` / `</br>` 等价、属性不解析、`<brow>` / `<br2>` 都不是 br)。 */
|
||||
function classify(m: RegExpExecArray, lineBreaks: boolean): Token {
|
||||
const raw = m[0];
|
||||
const name = m[1].toLowerCase();
|
||||
if (lineBreaks && 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 };
|
||||
}
|
||||
@@ -82,7 +95,8 @@ function isInlineElementTag(name: string): name is InlineElementTag {
|
||||
}
|
||||
|
||||
/** 栈式建树。返回 `null` 表示结构不平衡(落单闭标签 / 交错 / 未闭合)⇒ 调用方整段降级。
|
||||
* 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。 */
|
||||
* 只做「标签名 + 位置」这一层配对,不解析任何属性 —— 属性在 `classify` 里已被丢弃。
|
||||
* `break` 不参与配对(void 标签),只按位置落进当前节点的子序列。 */
|
||||
function buildTree(tokens: Token[]): InlineNode[] | null {
|
||||
const root: InlineNode[] = [];
|
||||
const stack: { tag: InlineElementTag; children: InlineNode[] }[] = [];
|
||||
@@ -93,6 +107,10 @@ 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,17 +7,25 @@
|
||||
// 这个测试锁住「行内标签渲染」这一层,以及**它不能带来的风险**:产物是不可信文本
|
||||
// (模型生成、用户可诱导),任何把它当 HTML 注入的写法都必须在这里变红。
|
||||
//
|
||||
// 分七组:
|
||||
// ① 白名单标签渲染成真元素(不是字面量);白名单同日收窄到**五项** —— `u` / `s` 出列
|
||||
// (她百条输出 0 命中)⇒ 从此按白名单外口径逐字原样;
|
||||
// 分九组:
|
||||
// ① 白名单标签渲染成真元素(不是字面量);白名单当天先收窄到五项(`u`/`s` 0 命中),
|
||||
// 当日复核后又把 **`u` 加回**(主人「下划线确实可以保留」)⇒ 现为**六项**:`b`/`strong`/
|
||||
// `i`/`em`/`code`/`u`。**`s` 仍在白名单外** ⇒ 从此按白名单外口径逐字原样;
|
||||
// ② 白名单外(`<script>` / `<img onerror>` / `<div>` / `<a>`)⇒ **纯文本字面量**,
|
||||
// 树里不出现这些元素、不出现任何来自产物的属性;
|
||||
// ③ 未闭合 / 落单闭标签 ⇒ 整段降级为纯文本,不崩、字符一个不丢;
|
||||
// ④ 交错标签(`<b>a<i>b</b>c</i>`)⇒ 同上,不崩、字符一个不丢;
|
||||
// ⑤ 带属性 ⇒ 只出元素本身,属性既不进 markup 也不进 props;
|
||||
// ⑥ 既有 `<p>` / `<action>` 行为无回归(走真实的 SplitChatBubble 链路);
|
||||
// ⑦ 🆕 `<br>` = **气泡/消息分隔符**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理
|
||||
// 为多条消息隔断」):与 `<p>` 同级切气泡、空气泡丢弃、**绝不**是气泡内换行。
|
||||
// ⑥ 既有 `<p>` / `<action>` 行为(走真实的 SplitChatBubble 链路)—— 含 **2026-10-07 晚
|
||||
// 的口径变更:空 `<p>` 块不再产出空气泡**(旧的「三个 `<p>` ⇒ 三个气泡」断言随之改为
|
||||
// 两个气泡);
|
||||
// ⑦ `<br>` = **气泡/消息分隔符**(主人 2026-10-07 口径「对消息内的 `<br>` 始终处理为
|
||||
// 多条消息隔断」):与 `<p>` 同级切气泡、空气泡丢弃、**绝不**是气泡内换行;
|
||||
// ⑧ 🆕 **纯动作 / 纯独白回复 ⇒ 零个气泡**(不崩、不画空气泡),且工具卡 / 思维链 / 心情
|
||||
// 这些**用户可见内容**不能跟着被丢;
|
||||
// ⑨ 🆕 **非 chat 单卡正文**(工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置):
|
||||
// `<br>` ⇒ **换行**(那些界面没有「多条气泡」的概念,所以不切分)、行内白名单出样式、
|
||||
// 白名单外仍逐字字面;chat 模式的 `<br>` 分隔口径不受影响(⑨ 里有一条回归断言)。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
@@ -35,6 +43,7 @@ import { createElement, Fragment, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { renderContent } from "./renderContent";
|
||||
import { SplitChatBubble } from "../components/chat";
|
||||
import { modeBody } from "../components/messages";
|
||||
import type { ChatMessage } from "./types";
|
||||
|
||||
let checks = 0;
|
||||
@@ -131,16 +140,20 @@ const REAL_SAMPLE =
|
||||
ok(!has(html, "<b>"), "① 不再把 `<b>` 当字面量显示");
|
||||
ok(seen("日落 <b>18:35</b> 了") === "日落 18:35 了", "① 标签本身不作为文字出现(只剩正文)");
|
||||
|
||||
// 白名单 = **五项**(主人 2026-10-07 晚收窄:`u` / `s` / `br` 出列)。
|
||||
// 白名单 = **六项**(主人 2026-10-07 晚先收窄到五项,当日复核后把 `u` 加回:
|
||||
// 「下划线确实可以保留」)⇒ `<u>` 从此渲染成下划线元素,不再是字面量。
|
||||
const tags: [string, string][] = [
|
||||
["<b>粗</b>", "<b>粗</b>"],
|
||||
["<strong>粗</strong>", "<strong>粗</strong>"],
|
||||
["<i>斜</i>", "<i>斜</i>"],
|
||||
["<em>强调</em>", "<em>强调</em>"],
|
||||
["<u>下划</u>", "<u>下划</u>"],
|
||||
];
|
||||
for (const [src, want] of tags) ok(has(markup(src), want), `① ${want} 正常渲染`);
|
||||
ok(has(markup("<code>go build</code>"), "<code"), "① `<code>` 渲染成等宽元素");
|
||||
ok(seen("<code>go build</code>") === "go build", "① code 内文本逐字保留");
|
||||
ok(seen("<u>下划</u>") === "下划", "① `u` 的标签本身不作为文字出现(只剩正文)");
|
||||
ok(foreignTypes("<u>下划</u>").join(",") === "u", "① `u` 是一个真元素(不是字面量)");
|
||||
|
||||
// 大小写混写:按 HTML 口径不敏感(`<B>` 就是 `<b>`)。
|
||||
ok(has(markup("<B>大写</B>"), "<b>大写</b>"), "① `<B>` 大小写不敏感");
|
||||
@@ -149,12 +162,10 @@ const REAL_SAMPLE =
|
||||
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}\` 产出元素`);
|
||||
}
|
||||
// 🔴 只有 `s` 出列(`u` 已加回白名单):它按**白名单外**口径逐字原样,不产出元素 ——
|
||||
// 别顺手把 `s` 也加回来。
|
||||
ok(seen("<s>划掉</s>") === "<s>划掉</s>", "① 出列的 `<s>` 逐字原样显示(字符不丢)");
|
||||
ok(!has(markup("<s>划掉</s>"), "<s>"), "① 不再为 `s` 产出元素");
|
||||
}
|
||||
|
||||
// ── ② 白名单外一律纯文本字面量,且不产生任何节点/属性 ──────────────────────────
|
||||
@@ -244,7 +255,14 @@ const REAL_SAMPLE =
|
||||
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, "⑥ 三个 <p> ⇒ 三个气泡(首段只有动作注记 ⇒ 空气泡,与改动前逐字一致)");
|
||||
// 🔴 **断言变更**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):首段是
|
||||
// `<p><action>…</action></p>`,注记被剥走后只剩空壳 `<p></p>` ⇒ 与 `<br>` 碎片同一套
|
||||
// 口径,**不再产出空气泡**。原断言为 `count(…) === 3`,理由是「首段只有动作注记 ⇒
|
||||
// 空气泡,与改动前逐字一致」;现为 **2**(就是正文那两段)。
|
||||
ok(count(html, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ 三个 <p> 里首段被注记剥空 ⇒ 只剩两个气泡(空气泡丢弃)");
|
||||
// 光数「气泡正文」还不够 —— 空段若仍在,会留下一行「只有头像、没有正文」的空行
|
||||
// (`ChatBody` 对空正文返回 null ⇒ 空气泡的**壳**没了,但那一行仍在)。所以按**行数**再锁一次。
|
||||
ok(count(html, "mu-rise-in flex gap-3") === 2, "⑥ 首段空壳连「气泡行」一起消失(不是只把壳画没了)");
|
||||
ok(has(html, "花溪呀——人家按贵阳花溪区算的哦,不对的话你得纠正人家♪"), "⑥ 第二段正文照常");
|
||||
ok(has(html, "今天那儿是晴,13~25℃,日落 <b>18:35</b>,比上海晚了整整一小时呢。"), "⑥ 第三段正文照常,且行内 <b> 渲染为粗体");
|
||||
ok(!has(html, "<p>") && !has(html, "<p>"), "⑥ `<p>` 一个都不进 DOM(段落机制不变)");
|
||||
@@ -255,6 +273,17 @@ const REAL_SAMPLE =
|
||||
ok(count(three, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 3, "⑥ 纯 <p> 三段 ⇒ 三个气泡");
|
||||
ok(has(three, "第一段") && has(three, "第二段") && has(three, "第三段"), "⑥ 三段正文都在");
|
||||
|
||||
// 🆕 **空 `<p>` 块也丢**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):与 `<br>`
|
||||
// 碎片同一套口径 —— 上一轮只丢了 `<br>` 碎片,空的 `<p>` 仍留一个空气泡,本轮改掉。
|
||||
const emptyP = render({ id: "a5", role: "assistant", content: "<p></p><p>只有这段</p>", createdAt: 5 } as ChatMessage);
|
||||
ok(count(emptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1, "⑥ 空 `<p></p>` 块丢弃 ⇒ 只剩正文那一个气泡");
|
||||
ok(count(emptyP, "mu-rise-in flex gap-3") === 1, "⑥ 空 `<p></p>` 块不留「只有头像」的空行");
|
||||
const blankP = render({ id: "a6", role: "assistant", content: "<p>甲</p><p> </p><p>乙</p>", createdAt: 6 } as ChatMessage);
|
||||
ok(count(blankP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 2, "⑥ `<p> </p>`(全空白)同样丢弃 ⇒ 两个气泡");
|
||||
ok(count(blankP, "mu-rise-in flex gap-3") === 2, "⑥ `<p> </p>` 不留「只有头像」的空行");
|
||||
const onlyEmptyP = render({ id: "a7", role: "assistant", content: "<p></p>", createdAt: 7 } as ChatMessage);
|
||||
ok(count(onlyEmptyP, "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5") === 1 && !has(onlyEmptyP, "<p>"), "⑥ 正文只剩空 `<p>` ⇒ 不画字面量 `<p>`,只留空回复外壳(与只剩 `<br>` 同一处置)");
|
||||
|
||||
// 🔴 `<action>` 注记**不参与**行内渲染(注记文案是原样文本)—— 既有行为一个字没改。
|
||||
const noteWithTag = render({ id: "a3", role: "assistant", content: "<action>她 <b>笑</b> 了</action><p>正文</p>", createdAt: 3 } as ChatMessage);
|
||||
ok(has(noteWithTag, "*动作:她 <b>笑</b> 了*"), "⑥ 注记内的标签保持字面量(不改注记行为)");
|
||||
@@ -327,4 +356,96 @@ const REAL_SAMPLE =
|
||||
ok(count(noteBr, BUBBLE) === 2 && has(noteBr, "*动作:她 笑 了*"), "⑦ 注记照旧在气泡外,正文照常切成两段");
|
||||
}
|
||||
|
||||
// ── ⑧ 纯动作 / 纯独白回复 ⇒ **零气泡**(空气泡丢弃的边界 + 下游核实) ──────────────
|
||||
{
|
||||
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||||
const asst = (content: string, extra: Partial<ChatMessage> = {}) =>
|
||||
render({ id: "z1", role: "assistant", content, createdAt: 1, ...extra } as ChatMessage);
|
||||
|
||||
// `<p>` 包的纯注记回复:注记被剥走后只剩空壳 `<p></p>` ⇒ **零气泡**
|
||||
// (上一轮这里还是 1 个空气泡;本轮口径变更的边界就在这里)。
|
||||
const pureP = asst("<p><action>她点点头</action></p>");
|
||||
ok(count(pureP, BUBBLE) === 0, "⑧ `<p><action>…</action></p>` ⇒ 零个气泡");
|
||||
ok(has(pureP, "*动作:她点点头*"), "⑧ 注记照常显示(没连注记一起丢)");
|
||||
ok(!has(pureP, "<p>") && !has(pureP, "<action>"), "⑧ 零气泡时也不留任何字面标签");
|
||||
|
||||
// 不带 `<p>` 的纯注记回复(更早的形态):零气泡是**既有行为**,此处锁住不回归。
|
||||
ok(count(asst("<action>她点点头</action>"), BUBBLE) === 0, "⑧ 无 `<p>` 的纯注记回复同样零气泡(既有行为不变)");
|
||||
ok(count(asst("*她点点头*"), BUBBLE) === 0, "⑧ 全角星号形态的纯注记回复同样零气泡");
|
||||
|
||||
// 🔴 下游核实:零气泡不等于「什么都不画」—— 思维链 / 工具卡 / 心情是**用户可见内容**,
|
||||
// 不能跟着空气泡一起消失,所以补一行**无正文**的外壳(正文本就是空的 ⇒ 没有空气泡)。
|
||||
const withChrome = asst("<p><action>她查了下天气</action></p>", {
|
||||
thinking: "先查花溪天气",
|
||||
mood: "calm",
|
||||
tools: [{ name: "web-search", summary: "花溪天气", ok: true, at: 1 }],
|
||||
});
|
||||
ok(count(withChrome, BUBBLE) === 0, "⑧ 带工具卡的纯注记回复依然零气泡(空气泡没有回来)");
|
||||
ok(has(withChrome, "web-search") && has(withChrome, "花溪天气"), "⑧ 零气泡时工具卡仍渲染");
|
||||
ok(has(withChrome, "思维链") && has(withChrome, "先查花溪天气"), "⑧ 零气泡时思维链仍渲染");
|
||||
ok(has(withChrome, "心情"), "⑧ 零气泡时心情小字仍渲染");
|
||||
|
||||
// 流式中的零气泡回复:仍要给「生成中…」的等待反馈(这一条外壳是有内容的,不是空气泡)。
|
||||
const streamingNotes = asst("<p><action>她正要说</action></p>", { streaming: true });
|
||||
ok(count(streamingNotes, BUBBLE) === 1 && has(streamingNotes, "生成中…"), "⑧ 零气泡 + 流式中给「生成中…」反馈(外壳非空)");
|
||||
|
||||
// 零气泡 + 只有尾注记(注记在正文之后)同样不崩。
|
||||
const tailOnly = asst("<p>先说这句</p><p><action>然后她笑了</action></p>");
|
||||
ok(count(tailOnly, BUBBLE) === 1 && has(tailOnly, "*动作:然后她笑了*"), "⑧ 尾注记 + 空壳段 ⇒ 一个正文气泡 + 一条注记");
|
||||
}
|
||||
|
||||
// ── ⑨ 非 chat 单卡正文:`<br>` ⇒ 换行、行内出样式、白名单外仍字面 ──────────────────
|
||||
// 主人 2026-10-07 晚:「非聊天模式按照应当有的专业卡片和格式来显示」。这些界面是**单卡
|
||||
// 正文**(没有「多条气泡」的概念)⇒ `<br>` 只换行、不切分;chat 模式的 `<br>` 分隔口径
|
||||
// 不受影响(本组末尾有回归断言)。全部走真实的 `modeBody` 入口(8 个非 chat 模式一个不漏)。
|
||||
{
|
||||
const BUBBLE = "liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5";
|
||||
const CARD_MODES = ["work", "code", "docs", "home", "vision", "voice", "agents", "settings"] as const;
|
||||
const src = "甲<br>日落 <b>18:35</b>、<u>下划</u>、<s>划掉</s>、<script>x</script>";
|
||||
const card = (mode: (typeof CARD_MODES)[number], content: string) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(Fragment, null, modeBody(mode, { id: `card-${mode}`, role: "assistant", content, createdAt: 1 } as ChatMessage))
|
||||
);
|
||||
|
||||
for (const mode of CARD_MODES) {
|
||||
const html = card(mode, src);
|
||||
ok(count(html, "<br/>") === 1, `⑨ ${mode}:\`<br>\` ⇒ 一个真换行元素`);
|
||||
ok(!has(html, "<br"), `⑨ ${mode}:不再把 \`<br>\` 当字面量显示`);
|
||||
ok(has(html, "<b>18:35</b>"), `⑨ ${mode}:行内 \`<b>\` 出粗体`);
|
||||
ok(has(html, "<u>下划</u>"), `⑨ ${mode}:行内 \`<u>\` 出下划线`);
|
||||
ok(!has(html, "<s>"), `⑨ ${mode}:\`<s>\` 仍在白名单外(不产出元素)`);
|
||||
ok(has(html, "<s>划掉</s>"), `⑨ ${mode}:白名单外的 \`<s>\` 仍逐字字面`);
|
||||
ok(has(html, "<script>x</script>"), `⑨ ${mode}:白名单外的 \`<script>\` 逐字字面(不执行、不丢字符)`);
|
||||
}
|
||||
|
||||
// `<br>` 的三种写法在单卡正文里等价(与 chat 侧 BR_SEPARATOR_RE 同一条标签候选口径)。
|
||||
for (const s of ["甲<br>乙", "甲<br/>乙", "甲</br>乙", "甲<br class=\"x\">乙"]) {
|
||||
ok(count(card("settings", s), "<br/>") === 1, `⑨ 单卡正文里等价写法都换行:${s}`);
|
||||
}
|
||||
const one = card("docs", "甲<br>乙");
|
||||
ok(has(one, "甲") && has(one, "乙"), "⑨ 换行两侧文本都在同一个正文节点里(不切分、不丢字)");
|
||||
ok(count(one, "<br") === 0, "⑨ 换行后不留任何字面量");
|
||||
|
||||
// 护栏:`<brow>` / `<br2>` 不是 br(与 chat 侧、与 `1 < 2` / `a<b` / `<p2>` 同一条口径)。
|
||||
ok(count(card("settings", "甲<brow>乙</brow>丙"), "<br/>") === 0, "⑨ `<brow>` 不是 br ⇒ 不换行");
|
||||
ok(has(card("settings", "甲<brow>乙</brow>丙"), "<brow>"), "⑨ `<brow>` 按白名单外口径逐字原样");
|
||||
ok(count(card("settings", "甲<br2>乙</br2>丙"), "<br/>") === 0, "⑨ `<br2>` / `</br2>` 同样不是 br");
|
||||
|
||||
// 结构不平衡 ⇒ 仍按既有硬口径:整段降级为原文(连那个 `<br>` 也逐字保留,不换行)。
|
||||
const broken = card("settings", "日落 <b>18:35 了<br>晚安");
|
||||
ok(has(broken, "<b>18:35 了<br>晚安"), "⑨ 结构不平衡时整段降级为原文(字符一个不丢)");
|
||||
ok(count(broken, "<br/>") === 0, "⑨ 降级段不产出任何元素(含换行)");
|
||||
|
||||
// 🔴 回归:chat 模式的 `<br>` 仍是**气泡分隔符**,绝不因本组而变成换行。
|
||||
const chatBr = render({ id: "c1", role: "assistant", content: "<p>甲<br>乙</p>", createdAt: 1 } as ChatMessage);
|
||||
ok(count(chatBr, BUBBLE) === 2, "⑨ 回归:chat 模式 `<p>甲<br>乙</p>` 仍是两个气泡");
|
||||
ok(count(chatBr, "<br/>") === 0, "⑨ 回归:chat 模式不产出换行元素(`<br>` 是分隔符,不是换行)");
|
||||
|
||||
// 非 chat 模式走**真实入口**(SplitChatBubble → MessageBubble → modeBody)同样是换行。
|
||||
const docsCard = render({ id: "c2", role: "assistant", mode: "docs", content: "甲<br>乙", createdAt: 2 } as ChatMessage);
|
||||
ok(count(docsCard, "<br/>") === 1, "⑨ 非 chat 模式经 SplitChatBubble 也是换行");
|
||||
ok(has(docsCard, "<span>甲<br/>乙</span>"), "⑨ …且两侧文本在同一个正文节点里(没有切成多气泡)");
|
||||
ok(!has(docsCard, "<br"), "⑨ 非 chat 模式的字面 `<br>` 彻底消失");
|
||||
}
|
||||
|
||||
console.log(`inline markup render test: ${checks} checks passed`);
|
||||
@@ -2,32 +2,50 @@ import { Fragment, type ReactNode } from "react";
|
||||
import { parseContent } from "../lib/parseContent";
|
||||
import { parseInlineMarkup, type InlineNode } from "../lib/inlineMarkup";
|
||||
|
||||
/** 渲染助手消息正文。`<action>` / `<narration>` 块在这里**不**进入气泡 —— 它们由
|
||||
* `splitBubble` 剥出、以气泡外的注记呈现;剩下的文本段落交给行内白名单渲染。
|
||||
/** 正文渲染口径(两种界面形态各一套,别混用):
|
||||
* · 缺省 / `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`)的事:段落 `<p>` 与消息断隔
|
||||
* `<br>` 都在上一层(`components/chat.tsx` 的 `splitParagraphs`)就已切成独立气泡,
|
||||
* 本函数拿到的每一段都是**单个气泡的正文** —— 所以这里绝不返回 `<br/>`(否则等于把
|
||||
* 「多条消息」偷偷变回「一条消息里的换行」,与主人口径冲突)。
|
||||
* 🔴 这里**只**做行内(`b`/`strong`/`i`/`em`/`code`/`u`)的事:段落 `<p>` 与消息断隔
|
||||
* `<br>` 都在上一层(`components/chat.tsx` 的 `splitParagraphs`)就已切成独立气泡。
|
||||
*
|
||||
* 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有
|
||||
* `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签
|
||||
* (`<script>` / `<br>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。 */
|
||||
export function renderContent(content: string): ReactNode[] {
|
||||
* (`<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)}</span>);
|
||||
.map((part, i) => <span key={i}>{renderInline(part.text, lineBreaks)}</span>);
|
||||
}
|
||||
|
||||
/** 一段(`<p>` / `<br>` 切分后的单气泡)文本 ⇒ 行内节点序列 ⇒ React 元素。 */
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
return parseInlineMarkup(text).map((node, i) => renderInlineNode(node, `n${i}`));
|
||||
function renderInline(text: string, lineBreaks: boolean): ReactNode[] {
|
||||
return parseInlineMarkup(text, lineBreaks).map((node, i) => renderInlineNode(node, `n${i}`));
|
||||
}
|
||||
|
||||
/** 单个行内节点 ⇒ React 元素。文本走 Fragment(React 转义),标签走原生元素名,
|
||||
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。 */
|
||||
* 因此不存在「把标签名当 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) {
|
||||
@@ -39,6 +57,8 @@ 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 "code":
|
||||
return (
|
||||
<code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono">
|
||||
|
||||
Reference in new issue
Block a user