refactor(web): chat.tsx 551 行按职责拆成 5 片(守 500 行铁律,零行为变化)
- 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 空段过滤 => ⑦ 红)证明测试仍在真测那些逻辑。
This commit is contained in:
1 parent
789e3af74e
commit
811e463cea
6 files changed
+565
-551
No files matched your search
@@ -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 (
|
||||
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||
>
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
|
||||
<Icon name="memory" size={13} />
|
||||
</span>
|
||||
<span className="font-medium text-[var(--mu-text-mid)]">思维链</span>
|
||||
{streaming && !text ? (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--mu-text-lo)]">
|
||||
<Icon name="refresh" size={11} className="animate-spin" />
|
||||
思考中…
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--mu-text-lo)]">{open ? "收起" : "展开"}</span>
|
||||
)}
|
||||
<span
|
||||
className={`ml-auto transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}
|
||||
>
|
||||
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
||||
</span>
|
||||
</button>
|
||||
{open && text ? (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs leading-relaxed text-[var(--mu-text-mid)]">
|
||||
{text}
|
||||
</pre>
|
||||
) : !open && text ? (
|
||||
<p className="truncate px-3 pb-2 text-xs text-[var(--mu-text-lo)]">{text}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] ${statusBadge(status)}`}
|
||||
>
|
||||
<Icon name={statusIcon(status)} size={12} className={status === "neutral" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate font-medium text-[var(--mu-text-mid)]">{tool.name}</span>
|
||||
{tool.summary ? (
|
||||
<span className="block truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Tag tone={status} className="shrink-0">{statusLabel(status)}</Tag>
|
||||
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}>
|
||||
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
||||
</span>
|
||||
</button>
|
||||
{(open || tool.detail) && tool.detail ? (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs text-[var(--mu-text-mid)]">
|
||||
{tool.detail}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||
{dir} {deviceId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||
第三方 · {name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">角色 · {name}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
||||
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
||||
function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<TurnTag turnId={message.turnId} align="right" />
|
||||
<DeliveryChip
|
||||
delivery={message.delivery}
|
||||
steerStep={message.steerStep}
|
||||
rejectReason={message.rejectReason}
|
||||
/>
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <UserBubble message={message} mode={mode} />;
|
||||
}
|
||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||
}
|
||||
|
||||
/** `<br>` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条
|
||||
* 消息隔断」)。`<br>` / `<br/>` / `</br>` 三种写法等价(HTML5 口径),属性不解析
|
||||
* (`<br class=x>` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
|
||||
* —— `<brow>` / `<br2>` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」
|
||||
* 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`<br` 无 `>` 不算标签)。 */
|
||||
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
|
||||
|
||||
/** 把一个已切好的段落再按 `<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 ? [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: `<p>…</p>` (contract §4.16, one `<p>` = one
|
||||
* bubble) and `<br>` (主人 2026-10-07: `<br>` is a message separator, never an
|
||||
* in-bubble line break). A body with no `<p>` 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 `<p>` simply does not match and stays out until it
|
||||
* closes. */
|
||||
function splitParagraphs(content: string): string[] {
|
||||
// Preferred: the agent wraps each paragraph in <p>…</p> (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 <action>/<narration> 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 <br> inside a paragraph cuts it into several bubbles.
|
||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||
if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks);
|
||||
|
||||
// Keep non-<p> 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 `<action>` / `<narration>` 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 (
|
||||
<div className={container}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
|
||||
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 零气泡时是否还有「回复外壳」要出(见 `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 (
|
||||
<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>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
||||
<Icon name="refresh" size={12} className="animate-spin" />
|
||||
生成中…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
{full ? (
|
||||
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
||||
) : (
|
||||
<div className="w-7 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
|
||||
{full && message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
{full ? <TurnTag turnId={message.turnId} /> : null}
|
||||
{full ? <WakePath active={active} /> : null}
|
||||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||||
<ChatBody text={text} streaming={message.streaming} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||
>
|
||||
{full && message.image ? (
|
||||
<img
|
||||
src={message.image}
|
||||
alt="附件"
|
||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{full ? <DocumentList documents={message.documents} /> : null}
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
</div>
|
||||
{full ? <UserMeta message={message} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` 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: `<p>…</p>` (§4.16) and `<br>` (主人 2026-10-07: a `<br>` 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 **单卡正文**(没有「多条气泡」的概念),
|
||||
* 所以那边不走气泡切分,`<br>` 由 `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 <MessageBubble message={message} active={active} />;
|
||||
|
||||
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, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
|
||||
// (只剩 `<br>` 分隔符,或只剩空 `<p>` 块)时**不能**把原文交下去 —— 那会把字面量
|
||||
// `<br>` / `<p>` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
|
||||
// 与空气泡丢弃的口径一致。
|
||||
const bodyExhausted = body.length > 0;
|
||||
return (
|
||||
<MessageBubble
|
||||
message={bodyExhausted ? { ...message, content: "" } : message}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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} />
|
||||
) : (
|
||||
<AssistantParaBubble
|
||||
key={i}
|
||||
message={message}
|
||||
text={seg}
|
||||
full={i === 0}
|
||||
active={active}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<BubbleNotes items={tail} align={align} />
|
||||
{message.role === "user" ? <UserMessageActions message={message} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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, options)}</pre>
|
||||
) : message.content ? (
|
||||
<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">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||
>
|
||||
{message.image ? (
|
||||
<img
|
||||
src={message.image}
|
||||
alt="附件"
|
||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<DocumentList documents={message.documents} />
|
||||
{body}
|
||||
</div>
|
||||
<UserMeta message={message} />
|
||||
<SenderLabel name={message.senderName} />
|
||||
<UserMessageActions message={message} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
<ModeTag mode={mode} />
|
||||
{message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
<TurnTag turnId={message.turnId} />
|
||||
<WakePath active={active} />
|
||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||
{modeBody(mode, message)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small chip showing the message's behavior mode. */
|
||||
function ModeTag({ mode }: { mode: Mode }): React.ReactNode {
|
||||
if (mode === "chat") return null;
|
||||
return (
|
||||
<div className="mb-1 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-mid)]">
|
||||
<Icon name={modeIcon(mode)} size={11} />
|
||||
{modeLabel(mode)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function modeLabel(mode: Mode): string {
|
||||
const map: Record<Mode, string> = {
|
||||
chat: "对话", code: "代码", home: "家居", work: "工作",
|
||||
docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
function modeIcon(mode: Mode): IconName {
|
||||
const map: Record<Mode, IconName> = {
|
||||
chat: "chat", code: "terminal", home: "home", work: "folder",
|
||||
docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
@@ -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 `<action>` / `<narration>` 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 (
|
||||
<div className={container}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
|
||||
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 零气泡时是否还有「回复外壳」要出(见 `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 (
|
||||
<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>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
||||
<Icon name="refresh" size={12} className="animate-spin" />
|
||||
生成中…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <UserBubble message={message} mode={mode} />;
|
||||
}
|
||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
{full ? (
|
||||
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
||||
) : (
|
||||
<div className="w-7 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
|
||||
{full && message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
{full ? <TurnTag turnId={message.turnId} /> : null}
|
||||
{full ? <WakePath active={active} /> : null}
|
||||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||||
<ChatBody text={text} streaming={message.streaming} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||
>
|
||||
{full && message.image ? (
|
||||
<img
|
||||
src={message.image}
|
||||
alt="附件"
|
||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{full ? <DocumentList documents={message.documents} /> : null}
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
</div>
|
||||
{full ? <UserMeta message={message} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` 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: `<p>…</p>` (§4.16) and `<br>` (主人 2026-10-07: a `<br>` 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 **单卡正文**(没有「多条气泡」的概念),
|
||||
* 所以那边不走气泡切分,`<br>` 由 `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 <MessageBubble message={message} active={active} />;
|
||||
|
||||
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, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
|
||||
// (只剩 `<br>` 分隔符,或只剩空 `<p>` 块)时**不能**把原文交下去 —— 那会把字面量
|
||||
// `<br>` / `<p>` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
|
||||
// 与空气泡丢弃的口径一致。
|
||||
const bodyExhausted = body.length > 0;
|
||||
return (
|
||||
<MessageBubble
|
||||
message={bodyExhausted ? { ...message, content: "" } : message}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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} />
|
||||
) : (
|
||||
<AssistantParaBubble
|
||||
key={i}
|
||||
message={message}
|
||||
text={seg}
|
||||
full={i === 0}
|
||||
active={active}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<BubbleNotes items={tail} align={align} />
|
||||
{message.role === "user" ? <UserMessageActions message={message} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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, options)}</pre>
|
||||
) : message.content ? (
|
||||
<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">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||
>
|
||||
{message.image ? (
|
||||
<img
|
||||
src={message.image}
|
||||
alt="附件"
|
||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<DocumentList documents={message.documents} />
|
||||
{body}
|
||||
</div>
|
||||
<UserMeta message={message} />
|
||||
<SenderLabel name={message.senderName} />
|
||||
<UserMessageActions message={message} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src={personaAvatar(persona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
<ModeTag mode={mode} />
|
||||
{message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
<TurnTag turnId={message.turnId} />
|
||||
<WakePath active={active} />
|
||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||
{modeBody(mode, message)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small chip showing the message's behavior mode. */
|
||||
function ModeTag({ mode }: { mode: Mode }): React.ReactNode {
|
||||
if (mode === "chat") return null;
|
||||
return (
|
||||
<div className="mb-1 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-mid)]">
|
||||
<Icon name={modeIcon(mode)} size={11} />
|
||||
{modeLabel(mode)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function modeLabel(mode: Mode): string {
|
||||
const map: Record<Mode, string> = {
|
||||
chat: "对话", code: "代码", home: "家居", work: "工作",
|
||||
docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
function modeIcon(mode: Mode): IconName {
|
||||
const map: Record<Mode, IconName> = {
|
||||
chat: "chat", code: "terminal", home: "home", work: "folder",
|
||||
docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||
{dir} {deviceId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||||
第三方 · {name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">角色 · {name}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
||||
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
||||
export function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<TurnTag turnId={message.turnId} align="right" />
|
||||
<DeliveryChip
|
||||
delivery={message.delivery}
|
||||
steerStep={message.steerStep}
|
||||
rejectReason={message.rejectReason}
|
||||
/>
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** `<br>` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条
|
||||
* 消息隔断」)。`<br>` / `<br/>` / `</br>` 三种写法等价(HTML5 口径),属性不解析
|
||||
* (`<br class=x>` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
|
||||
* —— `<brow>` / `<br2>` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」
|
||||
* 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`<br` 无 `>` 不算标签)。 */
|
||||
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
|
||||
|
||||
/** 把一个已切好的段落再按 `<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>` 口径一个字节不动。 */
|
||||
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: `<p>…</p>` (contract §4.16, one `<p>` = one
|
||||
* bubble) and `<br>` (主人 2026-10-07: `<br>` is a message separator, never an
|
||||
* in-bubble line break). A body with no `<p>` 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 `<p>` simply does not match and stays out until it
|
||||
* closes. */
|
||||
export function splitParagraphs(content: string): string[] {
|
||||
// Preferred: the agent wraps each paragraph in <p>…</p> (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 <action>/<narration> 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 <br> inside a paragraph cuts it into several bubbles.
|
||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||
if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks);
|
||||
|
||||
// Keep non-<p> 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);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||
>
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
|
||||
<Icon name="memory" size={13} />
|
||||
</span>
|
||||
<span className="font-medium text-[var(--mu-text-mid)]">思维链</span>
|
||||
{streaming && !text ? (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--mu-text-lo)]">
|
||||
<Icon name="refresh" size={11} className="animate-spin" />
|
||||
思考中…
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--mu-text-lo)]">{open ? "收起" : "展开"}</span>
|
||||
)}
|
||||
<span
|
||||
className={`ml-auto transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}
|
||||
>
|
||||
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
||||
</span>
|
||||
</button>
|
||||
{open && text ? (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs leading-relaxed text-[var(--mu-text-mid)]">
|
||||
{text}
|
||||
</pre>
|
||||
) : !open && text ? (
|
||||
<p className="truncate px-3 pb-2 text-xs text-[var(--mu-text-lo)]">{text}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] ${statusBadge(status)}`}
|
||||
>
|
||||
<Icon name={statusIcon(status)} size={12} className={status === "neutral" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate font-medium text-[var(--mu-text-mid)]">{tool.name}</span>
|
||||
{tool.summary ? (
|
||||
<span className="block truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Tag tone={status} className="shrink-0">{statusLabel(status)}</Tag>
|
||||
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}>
|
||||
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
|
||||
</span>
|
||||
</button>
|
||||
{(open || tool.detail) && tool.detail ? (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs text-[var(--mu-text-mid)]">
|
||||
{tool.detail}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 "运行中";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user