From f01fd7db206419c080361c7beb02cf6dce2e07d0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 18:44:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20u=20=E5=8A=A0=E5=9B=9E=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=20+=20=E7=A9=BA=E6=B0=94=E6=B3=A1=E4=B8=A2?= =?UTF-8?q?=E5=BC=83=20+=20=E9=9D=9E=20chat=20=E6=A8=A1=E5=BC=8F=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=98=BE=E7=A4=BA=E5=AD=97=E9=9D=A2=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 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(并补按行数断言) --- frontend/web/src/components/chat.tsx | 63 +++++--- .../web/src/components/messages/index.tsx | 16 +- frontend/web/src/lib/inlineMarkup.ts | 68 +++++--- .../web/src/lib/renderContent.render.test.ts | 149 ++++++++++++++++-- frontend/web/src/lib/renderContent.tsx | 44 ++++-- 5 files changed, 269 insertions(+), 71 deletions(-) diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index 186b9cc..90c359d 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -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; /** 把一个已切好的段落再按 `
` 切开:`
` 与 `

` **同级** —— 两边的文本各自 - * 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。空气泡丢弃 - * (`

甲

乙

` ⇒ `甲` / `乙`;`
` 两侧都是空白 ⇒ 不产生多余气泡)。 - * **不含 `
` 的段落逐字返回原样**(空段也原样保留)⇒ 既有 `

` 口径一个字节不动。 */ + * 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。 + * + * 🔴 **空气泡一律丢弃**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):`

甲

乙

` + * ⇒ `甲` / `乙`;`
` 两侧都是空白 ⇒ 不产生多余气泡;**空的 `

` 块也丢** —— + * `

` / `

`,以及注记被剥走后只剩空壳的 `

…

`, + * 从此与 `
` 碎片同一套口径(上一轮只丢了 `
` 碎片、空 `

` 仍留一个气泡)。 + * **不含 `
` 的段落逐字返回原样**(输入早已 trim 过)⇒ 既有 `

` 口径一个字节不动。 */ 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 (

{text ? ( {renderContent(text)} - ) : streaming ? ( + ) : ( 生成中… - ) : null} + )}
); } @@ -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 `

` / `
` splitting, and - * its content (which may itself contain `

` / `` / `` as - * literal text or code) is never re-parsed. */ + * to MessageBubble → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念), + * 所以那边不走气泡切分,`
` 由 `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, "生成中…"). 一处例外:正文只剩 `
`(气泡分隔被切 - // 光)时**不能**把原文交下去 —— 那会把字面量 `
` 画进气泡,等于凭空造一个空气泡。 - // 这种情况按「空正文」交下去(外壳照旧),与空气泡丢弃的口径一致。 - const collapsedByBreaks = BR_SEPARATOR_RE.test(body); + // usual chrome (avatar, mood, "生成中…"). 一处例外:正文**非空却一个气泡都切不出** + // (只剩 `
` 分隔符,或只剩空 `

` 块)时**不能**把原文交下去 —— 那会把字面量 + // `
` / `

` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧), + // 与空气泡丢弃的口径一致。 + const bodyExhausted = body.length > 0; return ( ); @@ -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 (

+ {chromeOnly ? ( + + ) : null} {segments.map((seg, i) => message.role === "user" ? ( @@ -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 模式的用户气泡同样是**单卡正文**(`
` ⇒ 换行);chat 模式的正文在 + // `SplitChatBubble` 里就已按 `
` 切成多条,能落到这里的只剩「空正文」的情形, + // 因此保持气泡口径 —— `
` 绝不在这里变回换行。 + const options = mode === "chat" ? undefined : CARD_RENDER; const body: React.ReactNode = mode === "code" ? ( -
{renderContent(message.content)}
+
{renderContent(message.content, options)}
) : message.content ? ( - {renderContent(message.content)} + {renderContent(message.content, options)} ) : null; return (
diff --git a/frontend/web/src/components/messages/index.tsx b/frontend/web/src/components/messages/index.tsx index efa865a..43e5aa2 100644 --- a/frontend/web/src/components/messages/index.tsx +++ b/frontend/web/src/components/messages/index.tsx @@ -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 单卡正文**面)的统一正文入口:`
` ⇒ **换行**,不切气泡 —— + * 工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置这些卡片没有「多条气泡」的概念 + * (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。绑定在这里写一次, + * 免得各卡片各写一份口径、日后漂移;chat 气泡不走这里(`components/chat.tsx` 另有一层)。 */ +function renderContent(text: string): ReactNode[] { + return renderContentIn(text, CARD_RENDER); +} + function shell(children: ReactNode): ReactNode { return (
@@ -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 必为空/纯空白,因此这里的 `
` 口径无从生效; + * chat 模式的 `
` 仍由气泡切分层处理,绝不会在这里变成换行。 */ export function modeBody(mode: Mode, message: ChatMessage): ReactNode { switch (mode) { case "code": return ; diff --git a/frontend/web/src/lib/inlineMarkup.ts b/frontend/web/src/lib/inlineMarkup.ts index 57df78e..58d75af 100644 --- a/frontend/web/src/lib/inlineMarkup.ts +++ b/frontend/web/src/lib/inlineMarkup.ts @@ -5,54 +5,65 @@ // 处理,Web 渲染为气泡外的灰斜体注记),**行内**则偶发 `` 之类强调标签。 // 后两类此前没有处理 ⇒ `18:35` 会把字面量 `<b>` 直接显示给用户。 // -// 🚫 **`
` 不在这里**(主人 2026-10-07 口径「对消息内的 `
` 始终处理为多条消息 -// 隔断」):它是**气泡/消息分隔符**,与 `

` 同级,由 `components/chat.tsx` 的 -// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 `
` 走到本模块 -// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示, -// 绝不在这里伪造一个「气泡内换行」。 +// 🔴 **`
` 有两种身份,由调用方的 `lineBreaks` 决定**(主人 2026-10-07 口径 +// 「对消息内的 `
` 始终处理为多条消息隔断」+ 2026-10-07 晚「非聊天模式按照应当有的 +// 专业卡片和格式来显示」): +// · **chat 模式(默认,`lineBreaks` 关)**:`
` 是**气泡/消息分隔符**,与 `

` 同级, +// 由 `components/chat.tsx` 的 `splitParagraphs`(气泡切分层)切掉;到这里它已不存在。 +// 万一有 `
` 走到本模块(注记文本等不切气泡的路径),按**白名单外**处理 ⇒ 逐字原样, +// 绝不在这里伪造一个「气泡内换行」。 +// · **非 chat 的单卡正文(`lineBreaks` 开)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / +// 智能体 / 设置这些界面**没有「多条气泡」的概念** ⇒ `
` 只**换行**、不切分,解析成 +// `{kind:"break"}`(渲染为真 `
` 元素,不依赖父容器的 `white-space`)。 // // 🔴 安全口径(本模块存在的理由):产物是**不可信文本**(模型生成 + 用户可诱导), // 因此**绝不**把它当 HTML 注入(无 `innerHTML` / `dangerouslySetInnerHTML`)。 // 这里的做法是**解析成结构化节点**(`InlineNode[]`),交给 React 元素渲染 —— React // 对文本节点默认转义,天然安全。规则: -// ① 只认**行内**白名单标签(五项:`b` / `strong` / `i` / `em` / `code`),块级一律 -// 不支持(段落仍由 §4.16 的 `

` 机制表达,`
` 由气泡切分层处理); +// ① 只认**行内**白名单元素(六项:`b` / `strong` / `i` / `em` / `code` / `u`),块级一律 +// 不支持(段落仍由 §4.16 的 `

` 机制表达); // ② **属性一律丢弃**(`` 只取标签名 `b`,属性绝不进 DOM); // ③ 白名单外标签**逐字原样**当文本(`"; + 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, "
") === 1, `⑨ ${mode}:\`
\` ⇒ 一个真换行元素`); + ok(!has(html, "<br"), `⑨ ${mode}:不再把 \`
\` 当字面量显示`); + ok(has(html, "18:35"), `⑨ ${mode}:行内 \`\` 出粗体`); + ok(has(html, "下划"), `⑨ ${mode}:行内 \`\` 出下划线`); + ok(!has(html, ""), `⑨ ${mode}:\`\` 仍在白名单外(不产出元素)`); + ok(has(html, "<s>划掉</s>"), `⑨ ${mode}:白名单外的 \`\` 仍逐字字面`); + ok(has(html, "<script>x</script>"), `⑨ ${mode}:白名单外的 \`