import { useState } from "react"; import type { ChatMessage, Mode, ToolUsed } from "../lib/types"; import { useAppStore } from "../store/useAppStore"; import { personaAvatar } from "../lib/persona"; import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; import { WakePath } from "./status"; import { modeBody } from "./messages"; import { UserMessageActions } from "./messages/MessageActions"; import { 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}
); } /** 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 ; } /** Split a chat reply's annotation-stripped body into `

`-delimited paragraph * bodies. Each complete `

…

` block becomes one bubble segment; a body that * carries no `

` wrapper falls back to splitting on `\n\n` runs, and a body * with neither 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). const pRe = /]*>([\s\S]*?)<\/p>/g; const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim()); if (pSegments.length > 0) return pSegments; // 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; } /** 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}*
))}
); } /** 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. */ function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode { return (
{text ? ( {renderContent(text)} ) : streaming ? ( 生成中… ) : null}
); } /** 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 currentPersona = useAppStore((s) => s.currentPersona); return (
{full ? ( ) : (
)}
{full ? : null} {full && message.mood ? (
心情 · {message.mood}
) : 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} {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 (first = full bubble, remainder * = 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. */ 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, "生成中…"). 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"; return (

{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 { const body: React.ReactNode = mode === "code" ? (
{renderContent(message.content)}
) : message.content ? ( {renderContent(message.content)} ) : 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 currentPersona = useAppStore((s) => s.currentPersona); return (
{message.mood ? (
心情 · {message.mood}
) : 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]; }