feat(web): 会话级角色 — 角色跟随激活会话 + 选角入口 + 面板挂角色(B 批次 1 前端)
按 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:
1 parent
38a403afca
commit
66a96d2f93
16 files changed
+1044
-205
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
Reference in new issue
Block a user