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 (
` / `
` splitting, and
- * its content (which may itself contain `
` / ` ` 块)时**不能**把原文交下去 —— 那会把字面量
+ // ` ` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
+ // 与空气泡丢弃的口径一致。
+ const bodyExhausted = body.length > 0;
return (
` 同级,由 `components/chat.tsx` 的
-// `splitParagraphs`(气泡切分层)切掉;到了这里它已经不存在。万一有 ` ` 同级,
+// 由 `components/chat.tsx` 的 `splitParagraphs`(气泡切分层)切掉;到这里它已不存在。
+// 万一有 ` ` 机制表达,` ` 机制表达);
// ② **属性一律丢弃**(`` 只取标签名 `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, "
` 由 `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, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
+ // (只剩 `
` 分隔符,或只剩空 `
` / `
` ⇒ 换行);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 (
` ⇒ **换行**,不切气泡 ——
+ * 工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置这些卡片没有「多条气泡」的概念
+ * (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。绑定在这里写一次,
+ * 免得各卡片各写一份口径、日后漂移;chat 气泡不走这里(`components/chat.tsx` 另有一层)。 */
+function renderContent(text: string): ReactNode[] {
+ return renderContentIn(text, CARD_RENDER);
+}
+
function shell(children: ReactNode): ReactNode {
return (
` 口径无从生效;
+ * chat 模式的 `
` 仍由气泡切分层处理,绝不会在这里变成换行。 */
export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
switch (mode) {
case "code": return
` 不在这里**(主人 2026-10-07 口径「对消息内的 `
` 始终处理为多条消息
-// 隔断」):它是**气泡/消息分隔符**,与 `
` 走到本模块
-// (非 chat 模式的正文、注记等不切气泡的路径),它按**白名单外**处理 ⇒ 逐字原样显示,
-// 绝不在这里伪造一个「气泡内换行」。
+// 🔴 **`
` 有两种身份,由调用方的 `lineBreaks` 决定**(主人 2026-10-07 口径
+// 「对消息内的 `
` 始终处理为多条消息隔断」+ 2026-10-07 晚「非聊天模式按照应当有的
+// 专业卡片和格式来显示」):
+// · **chat 模式(默认,`lineBreaks` 关)**:`
` 是**气泡/消息分隔符**,与 `
` 走到本模块(注记文本等不切气泡的路径),按**白名单外**处理 ⇒ 逐字原样,
+// 绝不在这里伪造一个「气泡内换行」。
+// · **非 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 的 `
") === 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}:白名单外的 \`