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 个自带变异均「改坏 -> 编译过 -> 变红 -> 还原 -> 复绿」。
This commit is contained in:
1 parent
6ac214366e
commit
a19d12b228
7 files changed
+646
-60
No files matched your search
@@ -5,16 +5,21 @@ 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>` 等)只会变成可见的字面量文本,不产生 DOM 节点、不执行。 */
|
||||
* (`<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>` 切分后的)段落文本 ⇒ 行内节点序列 ⇒ React 元素。 */
|
||||
/** 一段(`<p>` / `<br>` 切分后的单气泡)文本 ⇒ 行内节点序列 ⇒ React 元素。 */
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
return parseInlineMarkup(text).map((node, i) => renderInlineNode(node, `n${i}`));
|
||||
}
|
||||
@@ -23,7 +28,6 @@ function renderInline(text: string): ReactNode[] {
|
||||
* 因此不存在「把标签名当 HTML 执行」的路径;节点树里也没有任何属性位。 */
|
||||
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) {
|
||||
@@ -35,10 +39,6 @@ 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 "s":
|
||||
return <s key={key}>{children}</s>;
|
||||
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