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:
AskaEth committed 2026-10-07 18:44:45 +08:00
1 parent 1107b2a717
commit f01fd7db20
5 files changed
+269 -71

No files matched your search

+45 -18
View File
@@ -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">
+14 -2
View File
@@ -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} />;