From 811e463cea6344d63f143d8f94661bff91431651 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 18:56:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20chat.tsx=20551=20=E8=A1=8C?= =?UTF-8?q?=E6=8C=89=E8=81=8C=E8=B4=A3=E6=8B=86=E6=88=90=205=20=E7=89=87?= =?UTF-8?q?=EF=BC=88=E5=AE=88=20500=20=E8=A1=8C=E9=93=81=E5=BE=8B=EF=BC=8C?= =?UTF-8?q?=E9=9B=B6=E8=A1=8C=E4=B8=BA=E5=8F=98=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat/index.tsx 272 行(编排层:SplitChatBubble + MessageBubble + 各气泡) - chat/toolviz.tsx 112(工具卡 / 思维链可视件) - chat/labels.tsx 70(元数据小字) - chat/chrome.tsx 60(外壳判定与注记) - chat/split.ts 51(纯函数:br 分隔与段落切分,最易测) - 旧 chat.tsx 删除;调用方一行未动(沿用既有目录索引解析,与 messages/ 同一模式) 纯搬家证据:把旧文件与新 5 文件做行多重集 diff,差异只有 export 前缀、 一行 re-export(ThinkingBlock / ToolCard)与 import 重写 —— 没有一行 JSX / 样式类名 / 逻辑变化;测试文件一字未改。 对外导出名与形状逐字不变(仍是 ThinkingBlock / ToolCard / MessageBubble / SplitChatBubble)。 验收:tsc 0、render 189 checks、bubble 27 checks、四个邻接套件全绿、 vite build 294 模块;变异(拆后去掉 br 空段过滤 => ⑦ 红)证明测试仍在真测那些逻辑。 --- frontend/web/src/components/chat.tsx | 551 ------------------- frontend/web/src/components/chat/chrome.tsx | 60 ++ frontend/web/src/components/chat/index.tsx | 272 +++++++++ frontend/web/src/components/chat/labels.tsx | 70 +++ frontend/web/src/components/chat/split.ts | 51 ++ frontend/web/src/components/chat/toolviz.tsx | 112 ++++ 6 files changed, 565 insertions(+), 551 deletions(-) delete mode 100644 frontend/web/src/components/chat.tsx create mode 100644 frontend/web/src/components/chat/chrome.tsx create mode 100644 frontend/web/src/components/chat/index.tsx create mode 100644 frontend/web/src/components/chat/labels.tsx create mode 100644 frontend/web/src/components/chat/split.ts create mode 100644 frontend/web/src/components/chat/toolviz.tsx diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx deleted file mode 100644 index 90c359d..0000000 --- a/frontend/web/src/components/chat.tsx +++ /dev/null @@ -1,551 +0,0 @@ -import { useState } from "react"; -import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types"; -import { useAppStore } from "../store/useAppStore"; -import { useCurrentPersona } from "../store/personaSelectors"; -import { personaAvatar, resolveMessagePersona } from "../lib/persona"; -import { labelFor } from "../lib/enums"; -import { Tag } from "./primitives"; -import { Icon, type IconName } from "./Icon"; -import { WakePath } from "./status"; -import { modeBody } from "./messages"; -import { DocumentList } from "./messages/Documents"; -import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta"; -import { UserMessageActions } from "./messages/MessageActions"; -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 - * + label, spinner when still thinking, and a single-line muted preview of the - * reasoning while collapsed so the block reads at a glance. */ -export function ThinkingBlock({ - text, - streaming, -}: { - text?: string; - streaming?: boolean; -}): React.ReactNode | null { - const [open, setOpen] = useState(false); - if (!text && !streaming) return null; - - return ( -
- - {open && text ? ( -
-          {text}
-        
- ) : !open && text ? ( -

{text}

- ) : null} -
- ); -} - -/** Tool echo card (工具回显). A status chip + tool name + muted one-line summary - * on the left, a color-coded result tag on the right, and collapsible detail. */ -export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode { - const [open, setOpen] = useState(false); - const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err"; - return ( -
- - {(open || tool.detail) && tool.detail ? ( -
-          {tool.detail}
-        
- ) : null} -
- ); -} - -type ToolStatus = "ok" | "err" | "neutral"; - -function statusBadge(status: ToolStatus): string { - switch (status) { - case "ok": return "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]"; - case "err": return "bg-[var(--mu-err)]/15 text-[var(--mu-err)]"; - default: return "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"; - } -} -function statusIcon(status: ToolStatus): IconName { - switch (status) { - case "ok": return "check"; - case "err": return "close"; - default: return "refresh"; - } -} -function statusLabel(status: ToolStatus): string { - switch (status) { - case "ok": return "成功"; - case "err": return "失败"; - default: return "运行中"; - } -} - -/** Small muted source label for a persisted message (design/16 §4.5): shows the - * concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered - * only when a deviceId is present, so legacy/unlabelled messages stay clean. The - * Web client defaults to this label; other clients decide themselves. */ -function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode { - if (!deviceId) return null; - // A user message is received FROM a client; an assistant reply goes TO the - // client that prompted it. Semantic direction differs — "来自" vs "发送至". - const dir = role === "assistant" ? "发送至" : "来自"; - return ( -
- {dir} {deviceId} -
- ); -} - -/** Small sender label for a third-party message (design/16 §4.6): shows the - * sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat - * (no senderName), so owner bubbles stay unlabelled. */ -function SenderLabel({ name }: { name?: string }): React.ReactNode { - if (!name) return null; - return ( -
- 第三方 · {name} -
- ); -} - -/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22 - * §7-5): which role spoke this message, and whether it differs from the role the - * session speaks as now. */ -function useSpokenPersona(personaId?: string): { - persona: PersonaRecord | null; - switched: boolean; -} { - const personas = useAppStore((s) => s.personas); - const active = useCurrentPersona(); - return resolveMessagePersona(personaId, personas, active); -} - -/** Muted "角色 · X" line for a bubble whose authoring role differs from the role - * the session speaks as now. */ -function PersonaLabel({ name }: { name?: string }): React.ReactNode { - if (!name) return null; - return ( -
角色 · {name}
- ); -} - -/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段) - * 共用一处,避免「四份渲染各写一遍、日后漂移」。 */ -function UserMeta({ message }: { message: ChatMessage }): React.ReactNode { - return ( -
- - - -
- ); -} - -/** A single message bubble. Per design/07 §1 the bubble shape is driven by the - * message's own `mode`: the same role renders differently under different modes, - * and a single session may mix shapes. Falls back to `chat` when mode is absent - * (legacy persisted messages). */ -export function MessageBubble({ - message, - active = false, -}: { - message: ChatMessage; - active?: boolean; -}): React.ReactNode { - const mode = message.mode ?? "chat"; - if (message.role === "user") { - return ; - } - return ; -} - -/** `
` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `
` 始终处理为多条 - * 消息隔断」)。`
` / `
` / `
` 三种写法等价(HTML5 口径),属性不解析 - * (`
` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏 - * —— `` / `` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」 - * 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`` 不算标签)。 */ -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 ? [segment] : []; - return segment - .split(BR_SEPARATOR_RE) - .map((part) => part.trim()) - .filter((part) => part.length > 0); -} - -/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble - * each. Two peers delimit bubbles: `

…

` (contract §4.16, one `

` = one - * bubble) and `
` (主人 2026-10-07: `
` is a message separator, never an - * in-bubble line break). A body with no `

` wrapper falls back to splitting on - * `\n\n` runs; a body with none of the three yields an empty list so the caller - * handles the empty reply. Non-greedy + multi-line so a tag may span newlines; a - * still-streaming, unclosed `

` simply does not match and stays out until it - * closes. */ -function splitParagraphs(content: string): string[] { - // Preferred: the agent wraps each paragraph in

…

(design/07 §2). When it - // emits plain \n\n runs instead (the model often does), split on those too so - // each logical paragraph becomes its own bubble. `content` here is the - // annotation-stripped body from `splitBubble`, so no / block - // can be cut across; never split on a single \n (that is an in-line break, kept - // inside the paragraph). Every produced segment then goes through - // `splitBubbleBreaks`, so a
inside a paragraph cuts it into several bubbles. - const pRe = /]*>([\s\S]*?)<\/p>/g; - const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim()); - if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks); - - // Keep non-

remainder as its own paragraph bodies. - const segments: string[] = []; - for (const part of content.split(/\n\s*\n/)) { - if (!part.trim()) continue; - segments.push(part.trim()); - } - return segments.flatMap(splitBubbleBreaks); -} - -/** Small muted attachment note for a single `` / `` block. - * Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the - * message's head/tail by `splitBubble`) — never inside the bubble body. - * `align` mirrors the bubble column: `left` indents under the assistant avatar - * column, `right` lines the note up with a right-aligned user bubble. */ -function BubbleNotes({ - items, - align = "left", -}: { - items: ContentPart[]; - align?: "left" | "right"; -}): React.ReactNode { - if (items.length === 0) return null; - const container = - align === "right" - ? "flex flex-col items-end gap-0.5" - : "flex flex-col items-start gap-0.5 pl-10"; - return ( -

- {items.map((item, i) => ( -
- *{item.type === "action" ? "动作" : "旁白"}:{item.text}* -
- ))} -
- ); -} - -/** 零气泡时是否还有「回复外壳」要出(见 `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. - * Empty text with no stream ⇒ **不画外壳**(零气泡回复里的那行「注记外壳」不因为是空的 - * 就变成一个空气泡)。 */ -function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode { - if (!text && !streaming) return null; - return ( -
- {text ? ( - {renderContent(text)} - ) : ( - - - 生成中… - - )} -
- ); -} - -/** One assistant paragraph bubble. `full` renders the complete bubble (avatar / - * mood / 思维链 / 工具卡 / wake path); compact segments render just the aligned - * body, so the chrome is not repeated paragraph after paragraph. */ -function AssistantParaBubble({ - message, - text, - full, - active = false, -}: { - message: ChatMessage; - text: string; - full: boolean; - active?: boolean; -}): React.ReactNode { - const { persona, switched } = useSpokenPersona(message.personaId); - return ( -
- {full ? ( - - ) : ( -
- )} -
- {full ? : null} - {full && message.mood ? ( -
心情 · {labelFor("mood", message.mood)}
- ) : null} - {full ? : null} - {full && switched ? : null} - {full ? : null} - {full ? : null} - {full ? : null} - {full ? message.tools?.map((t, i) => ) : null} - -
-
- ); -} - -/** One user paragraph bubble. Only the first segment carries the optional image. */ -function UserParaBubble({ - message, - text, - full, -}: { - message: ChatMessage; - text: string; - full: boolean; -}): React.ReactNode { - return ( -
-
- {full && message.image ? ( - 附件 - ) : null} - {full ? : null} - {renderContent(text)} -
- {full ? : null} -
- ); -} - -/** Chat-only entry: a `chat`-mode reply's ``/`` blocks are - * peeled off by `splitBubble` into head/tail attachment notes, and the remaining - * plain-text body is split into stacked bubbles by `splitParagraphs` — two peers - * delimit a bubble: `

…

` (§4.16) and `
` (主人 2026-10-07: a `
` is a - * **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 → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念), - * 所以那边不走气泡切分,`
` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行** - * (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */ -export function SplitChatBubble({ - message, - active = false, -}: { - message: ChatMessage; - active?: boolean; -}): React.ReactNode { - const mode = message.mode ?? "chat"; - if (mode !== "chat") return ; - - const { body, head, tail } = splitBubble(message.content ?? ""); - const segments = splitParagraphs(body); - - 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 bodyExhausted = body.length > 0; - return ( - - ); - } - - // Group the paragraphs of one reply into a tighter column so intra-reply - // segments read as one bubble while separate replies keep the 16px list gap. - // 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" ? ( - - ) : ( - - ), - )} - - {message.role === "user" ? : null} -
- ); -} - -/** 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, options)}
- ) : message.content ? ( - {renderContent(message.content, options)} - ) : null; - return ( -
-
- {message.image ? ( - 附件 - ) : null} - - {body} -
- - - -
- ); -} - -/** Assistant-side bubble. Outer layout shared; the body renders per mode via the - * mode-specific body (chat / code / docs / home / work / vision …). */ -function AssistantBubble({ - message, - mode, - active = false, -}: { - message: ChatMessage; - mode: Mode; - active?: boolean; -}): React.ReactNode { - const { persona, switched } = useSpokenPersona(message.personaId); - return ( -
- -
- - {message.mood ? ( -
心情 · {labelFor("mood", message.mood)}
- ) : null} - - {switched ? : null} - - - - {message.tools?.map((t, i) => )} - {modeBody(mode, message)} -
-
- ); -} - -/** Small chip showing the message's behavior mode. */ -function ModeTag({ mode }: { mode: Mode }): React.ReactNode { - if (mode === "chat") return null; - return ( -
- - {modeLabel(mode)} -
- ); -} - -function modeLabel(mode: Mode): string { - const map: Record = { - chat: "对话", code: "代码", home: "家居", work: "工作", - docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置", - }; - return map[mode]; -} -function modeIcon(mode: Mode): IconName { - const map: Record = { - chat: "chat", code: "terminal", home: "home", work: "folder", - docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings", - }; - return map[mode]; -} diff --git a/frontend/web/src/components/chat/chrome.tsx b/frontend/web/src/components/chat/chrome.tsx new file mode 100644 index 0000000..dbad88e --- /dev/null +++ b/frontend/web/src/components/chat/chrome.tsx @@ -0,0 +1,60 @@ +import type { ChatMessage } from "../../lib/types"; +import { Icon } from "../Icon"; +import { renderContent } from "../../lib/renderContent"; +import type { ContentPart } from "../../lib/parseContent"; + +/** Small muted attachment note for a single `` / `` block. + * Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the + * message's head/tail by `splitBubble`) — never inside the bubble body. + * `align` mirrors the bubble column: `left` indents under the assistant avatar + * column, `right` lines the note up with a right-aligned user bubble. */ +export function BubbleNotes({ + items, + align = "left", +}: { + items: ContentPart[]; + align?: "left" | "right"; +}): React.ReactNode { + if (items.length === 0) return null; + const container = + align === "right" + ? "flex flex-col items-end gap-0.5" + : "flex flex-col items-start gap-0.5 pl-10"; + return ( +
+ {items.map((item, i) => ( +
+ *{item.type === "action" ? "动作" : "旁白"}:{item.text}* +
+ ))} +
+ ); +} + +/** 零气泡时是否还有「回复外壳」要出(见 `SplitChatBubble`):思维链 / 工具卡 / 心情是 + * 用户可见内容,流式中的「生成中…」是必要的等待反馈 —— 有其一就补一行外壳;纯注记回复 + * (这几样都没有)保持「只剩注记本身」的观感,与改动前一致。 */ +export 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. + * Empty text with no stream ⇒ **不画外壳**(零气泡回复里的那行「注记外壳」不因为是空的 + * 就变成一个空气泡)。 */ +export function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode { + if (!text && !streaming) return null; + return ( +
+ {text ? ( + {renderContent(text)} + ) : ( + + + 生成中… + + )} +
+ ); +} diff --git a/frontend/web/src/components/chat/index.tsx b/frontend/web/src/components/chat/index.tsx new file mode 100644 index 0000000..7423548 --- /dev/null +++ b/frontend/web/src/components/chat/index.tsx @@ -0,0 +1,272 @@ +import type { ChatMessage, Mode } from "../../lib/types"; +import { personaAvatar } from "../../lib/persona"; +import { labelFor } from "../../lib/enums"; +import { Icon, type IconName } from "../Icon"; +import { WakePath } from "../status"; +import { modeBody } from "../messages"; +import { DocumentList } from "../messages/Documents"; +import { TurnTag, userBubbleClass } from "../messages/TurnMeta"; +import { UserMessageActions } from "../messages/MessageActions"; +import { CARD_RENDER, renderContent } from "../../lib/renderContent"; +import { splitBubble } from "../../lib/parseContent"; +import { splitParagraphs } from "./split"; +import { BubbleNotes, ChatBody, hasBubbleChrome } from "./chrome"; +import { ThinkingBlock, ToolCard } from "./toolviz"; +import { PersonaLabel, SenderLabel, SourceLabel, useSpokenPersona, UserMeta } from "./labels"; + +export { ThinkingBlock, ToolCard }; + +/** A single message bubble. Per design/07 §1 the bubble shape is driven by the + * message's own `mode`: the same role renders differently under different modes, + * and a single session may mix shapes. Falls back to `chat` when mode is absent + * (legacy persisted messages). */ +export function MessageBubble({ + message, + active = false, +}: { + message: ChatMessage; + active?: boolean; +}): React.ReactNode { + const mode = message.mode ?? "chat"; + if (message.role === "user") { + return ; + } + return ; +} + +/** One assistant paragraph bubble. `full` renders the complete bubble (avatar / + * mood / 思维链 / 工具卡 / wake path); compact segments render just the aligned + * body, so the chrome is not repeated paragraph after paragraph. */ +function AssistantParaBubble({ + message, + text, + full, + active = false, +}: { + message: ChatMessage; + text: string; + full: boolean; + active?: boolean; +}): React.ReactNode { + const { persona, switched } = useSpokenPersona(message.personaId); + return ( +
+ {full ? ( + + ) : ( +
+ )} +
+ {full ? : null} + {full && message.mood ? ( +
心情 · {labelFor("mood", message.mood)}
+ ) : null} + {full ? : null} + {full && switched ? : null} + {full ? : null} + {full ? : null} + {full ? : null} + {full ? message.tools?.map((t, i) => ) : null} + +
+
+ ); +} + +/** One user paragraph bubble. Only the first segment carries the optional image. */ +function UserParaBubble({ + message, + text, + full, +}: { + message: ChatMessage; + text: string; + full: boolean; +}): React.ReactNode { + return ( +
+
+ {full && message.image ? ( + 附件 + ) : null} + {full ? : null} + {renderContent(text)} +
+ {full ? : null} +
+ ); +} + +/** Chat-only entry: a `chat`-mode reply's ``/`` blocks are + * peeled off by `splitBubble` into head/tail attachment notes, and the remaining + * plain-text body is split into stacked bubbles by `splitParagraphs` — two peers + * delimit a bubble: `

…

` (§4.16) and `
` (主人 2026-10-07: a `
` is a + * **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 → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念), + * 所以那边不走气泡切分,`
` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行** + * (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */ +export function SplitChatBubble({ + message, + active = false, +}: { + message: ChatMessage; + active?: boolean; +}): React.ReactNode { + const mode = message.mode ?? "chat"; + if (mode !== "chat") return ; + + const { body, head, tail } = splitBubble(message.content ?? ""); + const segments = splitParagraphs(body); + + 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 bodyExhausted = body.length > 0; + return ( + + ); + } + + // Group the paragraphs of one reply into a tighter column so intra-reply + // segments read as one bubble while separate replies keep the 16px list gap. + // 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" ? ( + + ) : ( + + ), + )} + + {message.role === "user" ? : null} +
+ ); +} + +/** 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, options)}
+ ) : message.content ? ( + {renderContent(message.content, options)} + ) : null; + return ( +
+
+ {message.image ? ( + 附件 + ) : null} + + {body} +
+ + + +
+ ); +} + +/** Assistant-side bubble. Outer layout shared; the body renders per mode via the + * mode-specific body (chat / code / docs / home / work / vision …). */ +function AssistantBubble({ + message, + mode, + active = false, +}: { + message: ChatMessage; + mode: Mode; + active?: boolean; +}): React.ReactNode { + const { persona, switched } = useSpokenPersona(message.personaId); + return ( +
+ +
+ + {message.mood ? ( +
心情 · {labelFor("mood", message.mood)}
+ ) : null} + + {switched ? : null} + + + + {message.tools?.map((t, i) => )} + {modeBody(mode, message)} +
+
+ ); +} + +/** Small chip showing the message's behavior mode. */ +function ModeTag({ mode }: { mode: Mode }): React.ReactNode { + if (mode === "chat") return null; + return ( +
+ + {modeLabel(mode)} +
+ ); +} + +function modeLabel(mode: Mode): string { + const map: Record = { + chat: "对话", code: "代码", home: "家居", work: "工作", + docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置", + }; + return map[mode]; +} +function modeIcon(mode: Mode): IconName { + const map: Record = { + chat: "chat", code: "terminal", home: "home", work: "folder", + docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings", + }; + return map[mode]; +} diff --git a/frontend/web/src/components/chat/labels.tsx b/frontend/web/src/components/chat/labels.tsx new file mode 100644 index 0000000..687f495 --- /dev/null +++ b/frontend/web/src/components/chat/labels.tsx @@ -0,0 +1,70 @@ +import type { ChatMessage, PersonaRecord } from "../../lib/types"; +import { useAppStore } from "../../store/useAppStore"; +import { useCurrentPersona } from "../../store/personaSelectors"; +import { resolveMessagePersona } from "../../lib/persona"; +import { DeliveryChip, TurnTag } from "../messages/TurnMeta"; + +/** Small muted source label for a persisted message (design/16 §4.5): shows the + * concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered + * only when a deviceId is present, so legacy/unlabelled messages stay clean. The + * Web client defaults to this label; other clients decide themselves. */ +export function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode { + if (!deviceId) return null; + // A user message is received FROM a client; an assistant reply goes TO the + // client that prompted it. Semantic direction differs — "来自" vs "发送至". + const dir = role === "assistant" ? "发送至" : "来自"; + return ( +
+ {dir} {deviceId} +
+ ); +} + +/** Small sender label for a third-party message (design/16 §4.6): shows the + * sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat + * (no senderName), so owner bubbles stay unlabelled. */ +export function SenderLabel({ name }: { name?: string }): React.ReactNode { + if (!name) return null; + return ( +
+ 第三方 · {name} +
+ ); +} + +/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22 + * §7-5): which role spoke this message, and whether it differs from the role the + * session speaks as now. */ +export function useSpokenPersona(personaId?: string): { + persona: PersonaRecord | null; + switched: boolean; +} { + const personas = useAppStore((s) => s.personas); + const active = useCurrentPersona(); + return resolveMessagePersona(personaId, personas, active); +} + +/** Muted "角色 · X" line for a bubble whose authoring role differs from the role + * the session speaks as now. */ +export function PersonaLabel({ name }: { name?: string }): React.ReactNode { + if (!name) return null; + return ( +
角色 · {name}
+ ); +} + +/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段) + * 共用一处,避免「四份渲染各写一遍、日后漂移」。 */ +export function UserMeta({ message }: { message: ChatMessage }): React.ReactNode { + return ( +
+ + + +
+ ); +} diff --git a/frontend/web/src/components/chat/split.ts b/frontend/web/src/components/chat/split.ts new file mode 100644 index 0000000..f183f3b --- /dev/null +++ b/frontend/web/src/components/chat/split.ts @@ -0,0 +1,51 @@ +/** `
` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `
` 始终处理为多条 + * 消息隔断」)。`
` / `
` / `
` 三种写法等价(HTML5 口径),属性不解析 + * (`
` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏 + * —— `` / `` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」 + * 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`` 不算标签)。 */ +const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i; + +/** 把一个已切好的段落再按 `
` 切开:`
` 与 `

` **同级** —— 两边的文本各自 + * 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。 + * + * 🔴 **空气泡一律丢弃**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):`

甲

乙

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

` 块也丢** —— + * `

` / `

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

…

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

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

` 口径一个字节不动。 */ +export function splitBubbleBreaks(segment: string): string[] { + if (!BR_SEPARATOR_RE.test(segment)) return segment ? [segment] : []; + return segment + .split(BR_SEPARATOR_RE) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble + * each. Two peers delimit bubbles: `

…

` (contract §4.16, one `

` = one + * bubble) and `
` (主人 2026-10-07: `
` is a message separator, never an + * in-bubble line break). A body with no `

` wrapper falls back to splitting on + * `\n\n` runs; a body with none of the three yields an empty list so the caller + * handles the empty reply. Non-greedy + multi-line so a tag may span newlines; a + * still-streaming, unclosed `

` simply does not match and stays out until it + * closes. */ +export function splitParagraphs(content: string): string[] { + // Preferred: the agent wraps each paragraph in

…

(design/07 §2). When it + // emits plain \n\n runs instead (the model often does), split on those too so + // each logical paragraph becomes its own bubble. `content` here is the + // annotation-stripped body from `splitBubble`, so no / block + // can be cut across; never split on a single \n (that is an in-line break, kept + // inside the paragraph). Every produced segment then goes through + // `splitBubbleBreaks`, so a
inside a paragraph cuts it into several bubbles. + const pRe = /]*>([\s\S]*?)<\/p>/g; + const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim()); + if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks); + + // Keep non-

remainder as its own paragraph bodies. + const segments: string[] = []; + for (const part of content.split(/\n\s*\n/)) { + if (!part.trim()) continue; + segments.push(part.trim()); + } + return segments.flatMap(splitBubbleBreaks); +} diff --git a/frontend/web/src/components/chat/toolviz.tsx b/frontend/web/src/components/chat/toolviz.tsx new file mode 100644 index 0000000..fc3d81d --- /dev/null +++ b/frontend/web/src/components/chat/toolviz.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import type { ToolUsed } from "../../lib/types"; +import { Tag } from "../primitives"; +import { Icon, type IconName } from "../Icon"; + +/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip + * + label, spinner when still thinking, and a single-line muted preview of the + * reasoning while collapsed so the block reads at a glance. */ +export function ThinkingBlock({ + text, + streaming, +}: { + text?: string; + streaming?: boolean; +}): React.ReactNode | null { + const [open, setOpen] = useState(false); + if (!text && !streaming) return null; + + return ( +

+ + {open && text ? ( +
+          {text}
+        
+ ) : !open && text ? ( +

{text}

+ ) : null} +
+ ); +} + +/** Tool echo card (工具回显). A status chip + tool name + muted one-line summary + * on the left, a color-coded result tag on the right, and collapsible detail. */ +export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode { + const [open, setOpen] = useState(false); + const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err"; + return ( +
+ + {(open || tool.detail) && tool.detail ? ( +
+          {tool.detail}
+        
+ ) : null} +
+ ); +} + +type ToolStatus = "ok" | "err" | "neutral"; + +function statusBadge(status: ToolStatus): string { + switch (status) { + case "ok": return "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]"; + case "err": return "bg-[var(--mu-err)]/15 text-[var(--mu-err)]"; + default: return "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"; + } +} +function statusIcon(status: ToolStatus): IconName { + switch (status) { + case "ok": return "check"; + case "err": return "close"; + default: return "refresh"; + } +} +function statusLabel(status: ToolStatus): string { + switch (status) { + case "ok": return "成功"; + case "err": return "失败"; + default: return "运行中"; + } +}