Files
Cyrene_v2/frontend/web/src/lib/renderContent.tsx
T
AskaEth a19d12b228 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 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
2026-10-07 17:32:05 +08:00

50 lines
2.4 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";
/** 渲染助手消息正文。`<action>` / `<narration>` 块在这里**不**进入气泡 —— 它们由
* `splitBubble` 剥出、以气泡外的注记呈现;剩下的文本段落交给行内白名单渲染。
*
* 🔴 这里**只**做行内(`b`/`strong`/`i`/`em`/`code`)的事:段落 `<p>` 与消息断隔
* `<br>` 都在上一层(`components/chat.tsx` 的 `splitParagraphs`)就已切成独立气泡,
* 本函数拿到的每一段都是**单个气泡的正文** —— 所以这里绝不返回 `<br/>`(否则等于把
* 「多条消息」偷偷变回「一条消息里的换行」,与主人口径冲突)。
*
* 🔴 安全:文本一律交给 React 当**文本子节点**渲染(默认转义),全程没有
* `innerHTML` / `dangerouslySetInnerHTML`,也没有字符串拼 HTML —— 白名单外的标签
* (`<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>` / `<br>` 切分后的单气泡)文本 ⇒ 行内节点序列 ⇒ React 元素。 */
function renderInline(text: string): ReactNode[] {
return parseInlineMarkup(text).map((node, i) => renderInlineNode(node, `n${i}`));
}
/** 单个行内节点 ⇒ React 元素。文本走 Fragment(React 转义),标签走原生元素名,
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。 */
function renderInlineNode(node: InlineNode, key: string): ReactNode {
if (node.kind === "text") return <Fragment key={key}>{node.text}</Fragment>;
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 "code":
return (
<code key={key} className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-1 font-mono">
{children}
</code>
);
}
}