feat(web): 会话级角色 — 角色跟随激活会话 + 选角入口 + 面板挂角色(B 批次 1 前端)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 DevDocs/design/22 §4.6/§5(7) 实现(后端由并行代理在做,按已定契约先行)。
- currentPersona 由「全局单值」改为「跟随激活会话」:新增 lib/sessionPersona.ts 解析(personaId 空 → 回落默认角色),App/TopBar/chat/lib/persona.ts 全部改为按激活会话取角色(头像/背景/名字/心情随会话切换)
- 交互:新建次级会话可选角色;会话换角色入口;管理面板 Sessions 页加「挂角色」下拉(走 PATCH /sessions/{id})
- 组件拆分:Nav.tsx 228→143(抽出 NavItem.tsx 53)、新增 SessionRail.tsx 275(会话侧栏)
- 契约:types.ts / lib/api/sessions.ts 加 personaId?(建会话/改会话支持)
- mood 改为**按角色分桶**(同一角色的多个会话共享该角色心情);升级后旧 mood 值不保留(刷新即空)——属行为变化,已在报告注明
- 测试:新增 personaSession.test.ts 40 checks(角色解析/回落/乐观更新与回滚)+ 既有 messageSync 54 checks 回归
- 验收:npm run build 绿;**未做浏览器端到端**(无 jsdom/渲染测试底座),UI 交互与失败路径提示未渲染验证
- 已知余量:useAppStore.ts 494/500 行(下次改动前应先拆 selectCurrentPersona/useCurrentPersona/useCurrentMood)
This commit is contained in:
AskaEth committed 2026-10-03 20:05:40 +08:00
1 parent 38a403afca
commit 66a96d2f93
16 files changed
+1044 -205

No files matched your search

+22 -107
View File
@@ -1,15 +1,20 @@
import { useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { createSubSession, getAuthToken } from "../lib/api";
import { getAuthToken } from "../lib/api";
import { labelFor } from "../lib/enums";
import { useAppStore } from "../store/useAppStore";
import { Icon, type IconName } from "./Icon";
import { personaAvatar } from "../lib/persona";
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
import { Icon } from "./Icon";
import { NavItem } from "./NavItem";
import { SessionRail } from "./SessionRail";
import { Tag } from "./primitives";
/**
* Left rail — NOT a behavior-mode list (design/01 §3: mode switching is driven
* at runtime by the agent / user, and rendered per message). This is a small
* navigation rail: brand + connection status + theme + admin/settings entry.
* navigation rail: brand + connection status + the conversation list
* (`SessionRail`, which also owns per-session role picking, design/22 §4.6) +
* theme + admin/settings entry.
*
* Two surface variants share the same NavContent:
* - Sidebar (desktop): fixed rail, `hidden md:flex`.
@@ -20,13 +25,11 @@ import { Tag } from "./primitives";
function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const mood = useAppStore((s) => s.mood);
const sessions = useAppStore((s) => s.sessions);
const activeSessionId = useAppStore((s) => s.activeSessionId);
const setSessions = useAppStore((s) => s.setSessions);
const setActiveSession = useAppStore((s) => s.setActiveSession);
// The mood and the avatar/name belong to the *active session's* role (design/22
// §4.6): switching sessions switches the role the rail speaks for.
const mood = useCurrentMood();
const currentPersona = useCurrentPersona();
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const [creating, setCreating] = useState(false);
const connected = wsStatus === "connected";
const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接";
const hasToken = !!getAuthToken();
@@ -37,42 +40,17 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
setTheme(next);
};
// Main session pinned first; secondary sessions follow by creation time.
const sessionList = Object.values(sessions).sort((a, b) => {
if (a.type !== b.type) return a.type === "main" ? -1 : 1;
return a.createdAt - b.createdAt;
});
const selectSession = (id: string) => {
setActiveSession(id);
onNavigate();
};
const createSession = async () => {
if (creating) return;
setCreating(true);
try {
const meta = await createSubSession();
// Optimistically append so the new session shows immediately; the backend
// will return it on the next GET /sessions too.
const next = Object.values(useAppStore.getState().sessions);
setSessions([...next, ...(next.some((s) => s.id === meta.id) ? [] : [meta])]);
setActiveSession(meta.id);
onNavigate();
} catch {
// Backend not wired yet — leave the list unchanged.
} finally {
setCreating(false);
}
};
return (
<>
<div className="flex items-center gap-2 px-2 py-2">
<img src="/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png" alt="Cyrene" className="h-7 w-7 rounded-full object-cover" />
<img
src={personaAvatar(currentPersona)}
alt={currentPersona?.name ?? "Cyrene"}
className="h-7 w-7 rounded-full object-cover"
/>
<div className="leading-tight">
<div className="text-sm font-semibold">Cyrene</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">昔涟</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">{currentPersona?.name ?? "昔涟"}</div>
</div>
</div>
@@ -91,32 +69,9 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
</div>
) : null}
{/* conversation sessions (design/16 §2) — main fixed, secondary created */}
{hasToken ? (
<div className="flex flex-col gap-1">
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">
会话
</div>
{sessionList.map((s) => (
<NavItem
key={s.id}
active={s.id === activeSessionId}
icon={s.type === "main" ? "chat" : "folder"}
label={s.title ?? (s.type === "main" ? "主会话" : "次级会话")}
badge={s.unreadCount ?? 0}
onClick={() => selectSession(s.id)}
/>
))}
<button
onClick={() => void createSession()}
disabled={creating}
className="flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98] disabled:opacity-60"
>
<Icon name="chat" size={18} />
<span>{creating ? "创建中…" : "+ 新建会话"}</span>
</button>
</div>
) : null}
{/* conversation sessions (design/16 §2 + design/22 §4.6) — main fixed,
secondary created, each row able to pick the role it talks to */}
{hasToken ? <SessionRail onNavigate={onNavigate} /> : null}
<div className="mt-auto flex flex-col gap-1">
<NavItem
@@ -186,43 +141,3 @@ export function MobileNav({ open, onClose }: { open: boolean; onClose: () => voi
);
}
function NavItem({
icon,
label,
onClick,
active = false,
hint,
badge = 0,
}: {
icon: IconName;
label: string;
onClick: () => void;
active?: boolean;
hint?: string;
/** Unread count (协同 B3/D4); 0 or absent renders no badge. */
badge?: number;
}): React.ReactNode {
return (
<button
onClick={onClick}
className={`flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<Icon name={icon} size={18} />
<span>{label}</span>
{badge > 0 ? (
<span
className="mu-rise-in ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[var(--mu-radius-pill)] bg-[var(--mu-err)] px-1.5 text-[10px] font-medium leading-none text-white"
aria-label={`${badge} 条未读`}
>
{badge > 99 ? "99+" : badge}
</span>
) : hint ? (
<span className="ml-auto text-[10px] text-[var(--mu-text-lo)]">{hint}</span>
) : null}
</button>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Icon, type IconName } from "./Icon";
/** One rail row: icon + label + an optional unread badge, hint or trailing slot.
* Shared by the brand/utility rows in `Nav.tsx` and the conversation rows in
* `SessionRail.tsx`, so the two surfaces cannot drift apart. */
export function NavItem({
icon,
label,
onClick,
active = false,
hint,
badge = 0,
trailing,
}: {
icon: IconName;
label: string;
onClick: () => void;
active?: boolean;
hint?: string;
/** Unread count (协同 B3/D4); 0 or absent renders no badge. */
badge?: number;
/** Optional control rendered after the label (e.g. a per-row menu button). */
trailing?: React.ReactNode;
}): React.ReactNode {
return (
<div
className={`flex items-center rounded-[var(--mu-radius)] transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<button
onClick={onClick}
className="flex min-w-0 flex-1 items-center gap-2.5 px-3 py-2 text-sm"
>
<Icon name={icon} size={18} />
<span className="truncate">{label}</span>
{badge > 0 ? (
<span
className="mu-rise-in ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[var(--mu-radius-pill)] bg-[var(--mu-err)] px-1.5 text-[10px] font-medium leading-none text-white"
aria-label={`${badge} 条未读`}
>
{badge > 99 ? "99+" : badge}
</span>
) : hint ? (
<span className="ml-auto text-[10px] text-[var(--mu-text-lo)]">{hint}</span>
) : null}
</button>
{trailing}
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useState } from "react";
import { createSubSession, getAuthToken, patchSession } from "../lib/api";
import { setSessionPersona } from "../lib/sessionPersona";
import { useAppStore } from "../store/useAppStore";
import type { PersonaRecord, SessionMeta } from "../lib/types";
import { Icon } from "./Icon";
import { NavItem } from "./NavItem";
import { Button } from "./primitives";
/**
* The conversation list in the rail (design/16 §2 + design/22 §4.6):
* - main session first, secondary sessions by creation time, unread badges;
* - "新建会话" reveals a role picker, so a new conversation can start bound to a
* role (empty = inherit the global default role);
* - every row has a role menu that re-binds *that* session through
* `PATCH /sessions/{id} {personaId}` (design/22 §4.6). Changing a session's
* role changes which memory / emotion / system prompt the session talks to, so
* it stays an explicit, per-session action instead of a global switch.
*
* Kept out of `Nav.tsx` so no single component grows past the 80-line rule.
*/
export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.ReactNode {
const sessions = useAppStore((s) => s.sessions);
const activeSessionId = useAppStore((s) => s.activeSessionId);
const setSessions = useAppStore((s) => s.setSessions);
const setActiveSession = useAppStore((s) => s.setActiveSession);
const personas = useAppStore((s) => s.personas);
const setSessionPersonaLocal = useAppStore((s) => s.setSessionPersona);
/** The session whose role menu is expanded, or null when none is open. */
const [menuFor, setMenuFor] = useState<string | null>(null);
/** Set when a role change was rejected, so the row can say so instead of
* silently showing the role the server still has. */
const [failed, setFailed] = useState(false);
if (!getAuthToken()) return null;
// Main session pinned first; secondary sessions follow by creation time.
const sessionList = Object.values(sessions).sort((a, b) => {
if (a.type !== b.type) return a.type === "main" ? -1 : 1;
return a.createdAt - b.createdAt;
});
const openSession = (id: string) => {
setActiveSession(id);
onNavigate();
};
/** Show a freshly created session at once and focus it. `POST /sessions` replies
* with the new id (plus the bound role when one was requested), not the summary,
* so the row is seeded from what we know and replaced by the server's summary on
* the next list read. */
const adoptSession = (sessionId: string, personaId: string) => {
if (!sessionId) return;
const next = Object.values(useAppStore.getState().sessions);
const meta: SessionMeta = { id: sessionId, type: "sub", createdAt: Date.now(), personaId };
setSessions(next.some((s) => s.id === sessionId) ? next : [...next, meta]);
setActiveSession(sessionId);
onNavigate();
};
/** Re-bind one session to a role: written optimistically into the store and
* rolled back when the PATCH is rejected (lib/sessionPersona.ts), so the rail
* never keeps showing a binding the server refused. */
const changePersona = async (sessionId: string, personaId: string) => {
setFailed(false);
const applied = await setSessionPersona(
{
read: (id) => useAppStore.getState().sessions[id]?.personaId ?? "",
write: setSessionPersonaLocal,
persist: (id, pid) => patchSession(id, { personaId: pid }),
},
sessionId,
personaId
);
if (applied) setMenuFor(null);
else setFailed(true);
};
return (
<div className="flex flex-col gap-1">
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">会话</div>
{sessionList.map((s) => (
<SessionEntry
key={s.id}
session={s}
active={s.id === activeSessionId}
menuOpen={menuFor === s.id}
failed={failed && menuFor === s.id}
personas={personas}
onOpen={() => openSession(s.id)}
onToggleMenu={() => {
setFailed(false);
setMenuFor(menuFor === s.id ? null : s.id);
}}
onChangePersona={(personaId) => void changePersona(s.id, personaId)}
/>
))}
<CreateSessionSection personas={personas} onCreated={adoptSession} />
</div>
);
}
/** One conversation row plus its role menu. */
function SessionEntry({
session,
active,
menuOpen,
failed,
personas,
onOpen,
onToggleMenu,
onChangePersona,
}: {
session: SessionMeta;
active: boolean;
menuOpen: boolean;
failed: boolean;
personas: PersonaRecord[];
onOpen: () => void;
onToggleMenu: () => void;
onChangePersona: (personaId: string) => void;
}): React.ReactNode {
const label = session.title ?? (session.type === "main" ? "主会话" : "次级会话");
return (
<div className="flex flex-col">
<NavItem
active={active}
icon={session.type === "main" ? "chat" : "folder"}
label={label}
badge={session.unreadCount ?? 0}
onClick={onOpen}
trailing={
<button
onClick={onToggleMenu}
aria-label={`${label} · 切换角色`}
aria-expanded={menuOpen}
title="切换角色"
className="mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--mu-radius-sm)] text-[var(--mu-text-lo)] transition-colors duration-200 hover:bg-[var(--mu-surface-5)] hover:text-[var(--mu-text-hi)]"
>
<Icon name="dna" size={14} />
</button>
}
/>
{menuOpen ? (
<div className="liquid-glass-soft mx-1 mb-1 flex flex-col gap-1.5 rounded-[var(--mu-radius)] p-2">
<span className="text-[11px] text-[var(--mu-text-lo)]">会话角色</span>
<RoleSelect
value={session.personaId ?? ""}
personas={personas}
label={`${label}的角色`}
onChange={onChangePersona}
/>
{failed ? (
<span className="text-[10px] text-[var(--mu-err)]">切换失败,已保持原角色</span>
) : null}
</div>
) : null}
</div>
);
}
/** The "new conversation" affordance: a plain button until opened, then a small
* panel with a role picker. Owns the create request; the rail only adopts the id
* it gets back (the reply carries no summary, docs/api-client.md §5). */
function CreateSessionSection({
personas,
onCreated,
}: {
personas: PersonaRecord[];
onCreated: (sessionId: string, personaId: string) => void;
}): React.ReactNode {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
/** Role the next session is created with ("" = inherit the default role). */
const [personaId, setPersonaId] = useState("");
const create = async () => {
if (creating) return;
setCreating(true);
try {
// The reply is the new id (docs/api-client.md §5); `id` covers a backend
// that echoes a full summary instead. Without either there is nothing to
// focus, so the picker stays open rather than closing on a silent no-op.
const created = await createSubSession({ personaId });
const id = created.sessionId ?? created.id ?? "";
if (!id) return;
onCreated(id, personaId);
setOpen(false);
setPersonaId("");
} catch {
// Backend not wired yet — leave the list and the picker unchanged.
} finally {
setCreating(false);
}
};
if (!open) {
return (
<button
onClick={() => setOpen(true)}
disabled={creating}
className="flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98] disabled:opacity-60"
>
<Icon name="chat" size={18} />
<span>+ 新建会话</span>
</button>
);
}
return (
<div className="liquid-glass-soft flex flex-col gap-2 rounded-[var(--mu-radius)] p-2">
<span className="text-[11px] text-[var(--mu-text-lo)]">新会话角色</span>
<RoleSelect
value={personaId}
personas={personas}
disabled={creating}
label="新会话角色"
onChange={setPersonaId}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="primary" disabled={creating} onClick={() => void create()}>
{creating ? "创建中…" : "创建"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={creating}
onClick={() => {
setOpen(false);
setPersonaId("");
}}
>
取消
</Button>
</div>
</div>
);
}
/** Miuix-styled role picker over `GET /personas` (design/22 §4.6). The empty
* option means "inherit the global default role" — the value a session with no
* binding has — so a session can always be returned to the default. A value that
* is not in the catalog (role deleted, catalog still loading) is shown as its own
* option rather than silently reading as "inherit". */
function RoleSelect({
value,
personas,
disabled,
label,
onChange,
}: {
value: string;
personas: PersonaRecord[];
disabled?: boolean;
label: string;
onChange: (personaId: string) => void;
}): React.ReactNode {
const unknown = value !== "" && !personas.some((p) => p.id === value);
return (
<select
value={value}
disabled={disabled}
aria-label={label}
onChange={(e) => onChange(e.target.value)}
className="min-w-0 flex-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-[11px] text-[var(--mu-text-hi)] outline-none transition-colors duration-200 focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
>
<option value="">继承默认角色</option>
{unknown ? <option value={value}>{value}(不在角色列表)</option> : null}
{personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.isDefault ? " · 默认" : ""}
</option>
))}
</select>
);
}
+7 -4
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useAppStore } from "../store/useAppStore";
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { personaAvatar } from "../lib/persona";
@@ -29,8 +29,11 @@ const wsMeta: Record<WsStatus, { label: string; tone: "ok" | "warn" | "neutral"
export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const mood = useAppStore((s) => s.mood);
const currentPersona = useAppStore((s) => s.currentPersona);
// Both follow the *active session's* role (design/22 §4.6): switching sessions
// switches the avatar/name, and the mood is the one the active role reported
// (mood frames are role-scoped).
const mood = useCurrentMood();
const currentPersona = useCurrentPersona();
const mode = useModeStore((s) => s.mode);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
@@ -56,7 +59,7 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
<div className="flex min-w-0 items-center gap-2">
<img src={personaAvatar(currentPersona)} alt={currentPersona?.name ?? "Cyrene"} className="h-7 w-7 shrink-0 rounded-full object-cover" />
<div className="min-w-0 leading-tight">
<div className="truncate text-sm font-semibold">Cyrene · {currentPersona?.name ?? "昔涟"}</div>
<div className="truncate text-sm font-semibold">{currentPersona?.name ?? "昔涟"}</div>
<div className="truncate text-[11px] text-[var(--mu-text-lo)]">
<span className="inline-flex items-center gap-1">
<Icon name={MODE_ICON[mode]} size={12} />
+30 -7
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
import { useAppStore } from "../store/useAppStore";
import { personaAvatar } from "../lib/persona";
import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types";
import { useAppStore, useCurrentPersona } from "../store/useAppStore";
import { personaAvatar, resolveMessagePersona } from "../lib/persona";
import { labelFor } from "../lib/enums";
import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
@@ -147,6 +147,27 @@ function SenderLabel({ name }: { name?: string }): React.ReactNode {
);
}
/** 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>
);
}
/** 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
@@ -251,11 +272,11 @@ function AssistantParaBubble({
full: boolean;
active?: boolean;
}): React.ReactNode {
const currentPersona = useAppStore((s) => s.currentPersona);
const { persona, switched } = useSpokenPersona(message.personaId);
return (
<div className="mu-rise-in flex gap-3">
{full ? (
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
<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 />
)}
@@ -265,6 +286,7 @@ function AssistantParaBubble({
<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 ? <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}
@@ -396,16 +418,17 @@ function AssistantBubble({
mode: Mode;
active?: boolean;
}): React.ReactNode {
const currentPersona = useAppStore((s) => s.currentPersona);
const { persona, switched } = useSpokenPersona(message.personaId);
return (
<div className="mu-rise-in flex gap-3">
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
<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}
<WakePath active={active} />
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}