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];
}