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
+11
-10
@@ -22,7 +22,7 @@ export default function App(): React.ReactNode {
|
|||||||
// when we hold a token — otherwise the gateway rejects the handshake with 401.
|
// when we hold a token — otherwise the gateway rejects the handshake with 401.
|
||||||
const [route, setRoute] = useState(() => parseAdminHash(window.location.hash));
|
const [route, setRoute] = useState(() => parseAdminHash(window.location.hash));
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const setCurrentPersona = useAppStore((s) => s.setCurrentPersona);
|
const setPersonas = useAppStore((s) => s.setPersonas);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initTheme();
|
initTheme();
|
||||||
@@ -35,13 +35,14 @@ export default function App(): React.ReactNode {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load personas and pick the default one as the web main-session persona. This
|
// Load the role catalog (design/22 §4.6). The rendered role is no longer one
|
||||||
// must not hinge on a single mount-time fetch: a token may appear (login) after
|
// global value: it is derived per active session from this catalog plus that
|
||||||
// mount, or the fetch may transiently fail — in which case currentPersona would
|
// session's own `personaId` (see useAppStore's selectCurrentPersona), so this
|
||||||
// stay null and the UI would show a neutral placeholder (or worse, silently
|
// fetch only has to fill the catalog. It must not hinge on a single mount-time
|
||||||
// persist a null). We re-run whenever the token appears, and retry on failure.
|
// request: a token may appear (login) after mount, or the fetch may transiently
|
||||||
// The resolved persona is also persisted by useAppStore so a refresh keeps the
|
// fail — in which case the avatar/name would stay on the neutral placeholder. We
|
||||||
// correct avatar/background.
|
// re-run when the token appears, and retry on failure. The catalog is persisted
|
||||||
|
// by useAppStore, so a refresh renders the right role before the fetch returns.
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -53,7 +54,7 @@ export default function App(): React.ReactNode {
|
|||||||
fetchPersonas()
|
fetchPersonas()
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCurrentPersona(list.find((p) => p.isDefault) ?? list[0] ?? null);
|
setPersonas(list);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Transient failure — retry rather than leaving the placeholder forever.
|
// Transient failure — retry rather than leaving the placeholder forever.
|
||||||
@@ -66,7 +67,7 @@ export default function App(): React.ReactNode {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (retry !== null) window.clearTimeout(retry);
|
if (retry !== null) window.clearTimeout(retry);
|
||||||
};
|
};
|
||||||
}, [token, setCurrentPersona]);
|
}, [token, setPersonas]);
|
||||||
|
|
||||||
const sessions = useAppStore((s) => s.sessions);
|
const sessions = useAppStore((s) => s.sessions);
|
||||||
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAsyncData } from "../../lib/useAsyncData";
|
import { useAsyncData } from "../../lib/useAsyncData";
|
||||||
import { adminListSessions, createSubSession } from "../../lib/api";
|
import {
|
||||||
import type { SessionMeta } from "../../lib/types";
|
adminListPersonas,
|
||||||
|
adminListSessions,
|
||||||
|
createSubSession,
|
||||||
|
patchSession,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import type { PersonaRecord, SessionMeta } from "../../lib/types";
|
||||||
import { useAutoReload } from "../hooks";
|
import { useAutoReload } from "../hooks";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||||
@@ -17,14 +22,130 @@ function exportMeta(sess: SessionMeta): void {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionsModule(): React.ReactNode {
|
/** The role cell of one row: the session's bound role, editable in place. An id
|
||||||
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
|
* that is not in the catalog (role deleted, catalog still loading) keeps its own
|
||||||
useAutoReload(reload, 10000);
|
* option, so the cell never silently reads as "inherit the default". */
|
||||||
const [query, setQuery] = useState("");
|
function PersonaCell({
|
||||||
|
session,
|
||||||
|
personas,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
session: SessionMeta;
|
||||||
|
personas: PersonaRecord[];
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (personaId: string) => void;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const bound = session.personaId ?? "";
|
||||||
|
const unknown = bound !== "" && !personas.some((p) => p.id === bound);
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={bound}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={`会话 ${session.id} 的角色`}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="max-w-[180px] rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||||
|
>
|
||||||
|
<option value="">(继承默认角色)</option>
|
||||||
|
{unknown ? <option value={bound}>{bound}(不在角色列表)</option> : null}
|
||||||
|
{personas.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}{p.isDefault ? " · 默认" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session table's columns. Built per render because two of the cells are
|
||||||
|
* wired to the module's live state (the busy flag, the role catalog). */
|
||||||
|
function buildColumns(opts: {
|
||||||
|
personas: PersonaRecord[];
|
||||||
|
busy: string | null;
|
||||||
|
onChangePersona: (sessionId: string, personaId: string) => void;
|
||||||
|
}): AdminColumn<SessionMeta>[] {
|
||||||
|
const { personas, busy, onChangePersona } = opts;
|
||||||
|
return [
|
||||||
|
{ key: "type", title: "类型", render: (s) => <Tag tone={s.type === "main" ? "ok" : "accent"}>{s.type === "main" ? "主会话" : "次级会话"}</Tag> },
|
||||||
|
{ key: "title", title: "标题", render: (s) => <span>{s.title ? s.title : <span className="text-[var(--mu-text-lo)]">(未命名)</span>}</span> },
|
||||||
|
{ key: "id", title: "会话 ID", render: (s) => <span className="font-mono text-xs">{s.id}</span> },
|
||||||
|
{
|
||||||
|
key: "persona",
|
||||||
|
title: "角色",
|
||||||
|
render: (s) => (
|
||||||
|
<PersonaCell
|
||||||
|
session={s}
|
||||||
|
personas={personas}
|
||||||
|
disabled={busy !== null}
|
||||||
|
onChange={(personaId) => onChangePersona(s.id, personaId)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "createdAt", title: "创建时间", render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span> },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
title: "操作",
|
||||||
|
render: (s) => (
|
||||||
|
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => exportMeta(s)}>
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page's two write actions, both audited. The list is re-read on success —
|
||||||
|
* the PATCH reply is not a full session row — which also refreshes the row from
|
||||||
|
* server truth instead of a local guess. */
|
||||||
|
function useSessionMutations(reload: () => void): {
|
||||||
|
busy: string | null;
|
||||||
|
fail: string | null;
|
||||||
|
newSubSession: () => void;
|
||||||
|
changePersona: (sessionId: string, personaId: string) => void;
|
||||||
|
} {
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [fail, setFail] = useState<string | null>(null);
|
const [fail, setFail] = useState<string | null>(null);
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
|
|
||||||
|
const run = (
|
||||||
|
busyKey: string,
|
||||||
|
action: string,
|
||||||
|
call: () => Promise<unknown>,
|
||||||
|
fallback: string
|
||||||
|
) => {
|
||||||
|
setBusy(busyKey);
|
||||||
|
setFail(null);
|
||||||
|
call()
|
||||||
|
.then(() => {
|
||||||
|
log(action, "ok");
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
log(action, "error");
|
||||||
|
setFail(e instanceof Error ? e.message : fallback);
|
||||||
|
})
|
||||||
|
.finally(() => setBusy(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
fail,
|
||||||
|
newSubSession: () =>
|
||||||
|
run("new", "session.create", () => createSubSession(), "新建次级会话失败"),
|
||||||
|
changePersona: (sessionId, personaId) =>
|
||||||
|
run(sessionId, "session.persona", () => patchSession(sessionId, { personaId }), "挂角色失败"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionsModule(): React.ReactNode {
|
||||||
|
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
|
||||||
|
useAutoReload(reload, 10000);
|
||||||
|
// The role catalog for the "挂角色" column (design/22 §4.6); the panel reads it
|
||||||
|
// from the admin endpoint, and the write goes through the ordinary session PATCH.
|
||||||
|
const personas = useAsyncData(adminListPersonas, []);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { busy, fail, newSubSession, changePersona } = useSessionMutations(reload);
|
||||||
|
|
||||||
const filtered = (data ?? []).filter((s) => {
|
const filtered = (data ?? []).filter((s) => {
|
||||||
if (!query.trim()) return true;
|
if (!query.trim()) return true;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
@@ -35,44 +156,11 @@ export function SessionsModule(): React.ReactNode {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const newSubSession = () => {
|
const columns: AdminColumn<SessionMeta>[] = buildColumns({
|
||||||
setBusy("new");
|
personas: personas.data ?? [],
|
||||||
setFail(null);
|
busy,
|
||||||
createSubSession()
|
onChangePersona: changePersona,
|
||||||
.then(() => {
|
});
|
||||||
log("session.create", "ok");
|
|
||||||
reload();
|
|
||||||
})
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
log("session.create", "error");
|
|
||||||
setFail(e instanceof Error ? e.message : "新建次级会话失败");
|
|
||||||
})
|
|
||||||
.finally(() => setBusy(null));
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: AdminColumn<SessionMeta>[] = [
|
|
||||||
{ key: "type", title: "类型", render: (s) => <Tag tone={s.type === "main" ? "ok" : "accent"}>{s.type === "main" ? "主会话" : "次级会话"}</Tag> },
|
|
||||||
{ key: "title", title: "标题", render: (s) => <span>{s.title ? s.title : <span className="text-[var(--mu-text-lo)]">(未命名)</span>}</span> },
|
|
||||||
{
|
|
||||||
key: "id",
|
|
||||||
title: "会话 ID",
|
|
||||||
render: (s) => <span className="font-mono text-xs">{s.id}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "createdAt",
|
|
||||||
title: "创建时间",
|
|
||||||
render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
title: "操作",
|
|
||||||
render: (s) => (
|
|
||||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => exportMeta(s)}>
|
|
||||||
导出
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -112,6 +200,8 @@ export function SessionsModule(): React.ReactNode {
|
|||||||
|
|
||||||
<Notice>
|
<Notice>
|
||||||
列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。
|
列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。
|
||||||
|
「角色」列是会话挂载的角色(design/22 §4.6):「继承默认角色」= 会话不绑定,随全局默认角色走;
|
||||||
|
改角色决定该会话记忆/情绪/提示词的归属(owner-only + 审计)。
|
||||||
</Notice>
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
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 { labelFor } from "../lib/enums";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { personaAvatar } from "../lib/persona";
|
||||||
import { Icon, type IconName } from "./Icon";
|
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { NavItem } from "./NavItem";
|
||||||
|
import { SessionRail } from "./SessionRail";
|
||||||
import { Tag } from "./primitives";
|
import { Tag } from "./primitives";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left rail — NOT a behavior-mode list (design/01 §3: mode switching is driven
|
* 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
|
* 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:
|
* Two surface variants share the same NavContent:
|
||||||
* - Sidebar (desktop): fixed rail, `hidden md:flex`.
|
* - Sidebar (desktop): fixed rail, `hidden md:flex`.
|
||||||
@@ -20,13 +25,11 @@ import { Tag } from "./primitives";
|
|||||||
|
|
||||||
function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode {
|
function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode {
|
||||||
const wsStatus = useAppStore((s) => s.wsStatus);
|
const wsStatus = useAppStore((s) => s.wsStatus);
|
||||||
const mood = useAppStore((s) => s.mood);
|
// The mood and the avatar/name belong to the *active session's* role (design/22
|
||||||
const sessions = useAppStore((s) => s.sessions);
|
// §4.6): switching sessions switches the role the rail speaks for.
|
||||||
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
const mood = useCurrentMood();
|
||||||
const setSessions = useAppStore((s) => s.setSessions);
|
const currentPersona = useCurrentPersona();
|
||||||
const setActiveSession = useAppStore((s) => s.setActiveSession);
|
|
||||||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const connected = wsStatus === "connected";
|
const connected = wsStatus === "connected";
|
||||||
const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接";
|
const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接";
|
||||||
const hasToken = !!getAuthToken();
|
const hasToken = !!getAuthToken();
|
||||||
@@ -37,42 +40,17 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
|
|||||||
setTheme(next);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 px-2 py-2">
|
<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="leading-tight">
|
||||||
<div className="text-sm font-semibold">Cyrene</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,32 +69,9 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* conversation sessions (design/16 §2) — main fixed, secondary created */}
|
{/* conversation sessions (design/16 §2 + design/22 §4.6) — main fixed,
|
||||||
{hasToken ? (
|
secondary created, each row able to pick the role it talks to */}
|
||||||
<div className="flex flex-col gap-1">
|
{hasToken ? <SessionRail onNavigate={onNavigate} /> : null}
|
||||||
<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}
|
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-1">
|
<div className="mt-auto flex flex-col gap-1">
|
||||||
<NavItem
|
<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 { useState } from "react";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore, useCurrentMood, useCurrentPersona } from "../store/useAppStore";
|
||||||
import { useModeStore } from "../store/useModeStore";
|
import { useModeStore } from "../store/useModeStore";
|
||||||
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||||
import { personaAvatar } from "../lib/persona";
|
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 {
|
export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
|
||||||
const wsStatus = useAppStore((s) => s.wsStatus);
|
const wsStatus = useAppStore((s) => s.wsStatus);
|
||||||
const mood = useAppStore((s) => s.mood);
|
// Both follow the *active session's* role (design/22 §4.6): switching sessions
|
||||||
const currentPersona = useAppStore((s) => s.currentPersona);
|
// 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 mode = useModeStore((s) => s.mode);
|
||||||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
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">
|
<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" />
|
<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="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)]">
|
<div className="truncate text-[11px] text-[var(--mu-text-lo)]">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Icon name={MODE_ICON[mode]} size={12} />
|
<Icon name={MODE_ICON[mode]} size={12} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
|
import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore, useCurrentPersona } from "../store/useAppStore";
|
||||||
import { personaAvatar } from "../lib/persona";
|
import { personaAvatar, resolveMessagePersona } from "../lib/persona";
|
||||||
import { labelFor } from "../lib/enums";
|
import { labelFor } from "../lib/enums";
|
||||||
import { Tag } from "./primitives";
|
import { Tag } from "./primitives";
|
||||||
import { Icon, type IconName } from "./Icon";
|
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
|
/** 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,
|
* 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
|
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
||||||
@@ -251,11 +272,11 @@ function AssistantParaBubble({
|
|||||||
full: boolean;
|
full: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
const currentPersona = useAppStore((s) => s.currentPersona);
|
const { persona, switched } = useSpokenPersona(message.personaId);
|
||||||
return (
|
return (
|
||||||
<div className="mu-rise-in flex gap-3">
|
<div className="mu-rise-in flex gap-3">
|
||||||
{full ? (
|
{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 />
|
<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>
|
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||||
|
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||||
{full ? <WakePath active={active} /> : null}
|
{full ? <WakePath active={active} /> : null}
|
||||||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||||||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||||||
@@ -396,16 +418,17 @@ function AssistantBubble({
|
|||||||
mode: Mode;
|
mode: Mode;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
const currentPersona = useAppStore((s) => s.currentPersona);
|
const { persona, switched } = useSpokenPersona(message.personaId);
|
||||||
return (
|
return (
|
||||||
<div className="mu-rise-in flex gap-3">
|
<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%]">
|
<div className="min-w-0 max-w-[85%]">
|
||||||
<ModeTag mode={mode} />
|
<ModeTag mode={mode} />
|
||||||
{message.mood ? (
|
{message.mood ? (
|
||||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||||
|
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||||
<WakePath active={active} />
|
<WakePath active={active} />
|
||||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export {
|
|||||||
fetchMessages,
|
fetchMessages,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
markSessionRead,
|
markSessionRead,
|
||||||
|
patchSession,
|
||||||
} from "./api/sessions";
|
} from "./api/sessions";
|
||||||
export type { MessageDto } from "./api/sessions";
|
export type { CreateSessionResult, MessageDto, SessionPatch } from "./api/sessions";
|
||||||
|
|
||||||
export { deleteMessage, patchMessage } from "./api/messages";
|
export { deleteMessage, patchMessage } from "./api/messages";
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export interface MessageDto {
|
|||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
|
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
|
/** The role that spoke this message (design/22 §7-5, backing column
|
||||||
|
* `chat_messages.persona_id`); `''` means "inherit the current default role". */
|
||||||
|
personaId?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ function toChatMessage(m: MessageDto): ChatMessage {
|
|||||||
platform: m.platform,
|
platform: m.platform,
|
||||||
deviceId: m.deviceId,
|
deviceId: m.deviceId,
|
||||||
senderName: m.senderName,
|
senderName: m.senderName,
|
||||||
|
personaId: m.personaId,
|
||||||
createdAt: m.createdAt,
|
createdAt: m.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,14 +48,54 @@ export function fetchSessions(): Promise<SessionMeta[]> {
|
|||||||
return request<SessionMeta[]>("/sessions");
|
return request<SessionMeta[]>("/sessions");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/v1/sessions — creates a new secondary session. */
|
/** `POST /api/v1/sessions` reply: the new session's id, plus the bound role when
|
||||||
export function createSubSession(title?: string): Promise<SessionMeta> {
|
* one was requested (docs/api-client.md §5) — the full summary is not echoed. `id`
|
||||||
return request<SessionMeta>("/sessions", {
|
* is accepted too so a backend that returns a summary still reads correctly. */
|
||||||
|
export interface CreateSessionResult {
|
||||||
|
sessionId?: string;
|
||||||
|
id?: string;
|
||||||
|
personaId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/sessions — creates a new secondary session. `title` is the
|
||||||
|
* optional display name; `personaId` binds the session to a role (design/22
|
||||||
|
* §4.5). An omitted/empty `personaId` leaves the session inheriting the global
|
||||||
|
* default role. The reply carries only the new id, so callers seed the new row
|
||||||
|
* locally and let the next `GET /sessions` supply the metadata. */
|
||||||
|
export function createSubSession(opts: {
|
||||||
|
title?: string;
|
||||||
|
personaId?: string;
|
||||||
|
} = {}): Promise<CreateSessionResult> {
|
||||||
|
return request<CreateSessionResult>("/sessions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(title ? { title } : {}),
|
body: JSON.stringify({
|
||||||
|
...(opts.title ? { title: opts.title } : {}),
|
||||||
|
...(opts.personaId ? { personaId: opts.personaId } : {}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One PATCH /api/v1/sessions/{id} change set. At least one field must be set;
|
||||||
|
* the gateway rejects an empty body with 400. `personaId: ""` explicitly
|
||||||
|
* re-binds the session to "inherit the default role" (design/22 §4.5). */
|
||||||
|
export interface SessionPatch {
|
||||||
|
title?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
personaId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/v1/sessions/{id} — updates a session's display metadata and/or its
|
||||||
|
* bound role. The reply echoes only the fields that were changed. */
|
||||||
|
export function patchSession(
|
||||||
|
sessionId: string,
|
||||||
|
patch: SessionPatch
|
||||||
|
): Promise<{ id: string } & SessionPatch> {
|
||||||
|
return request<{ id: string } & SessionPatch>(
|
||||||
|
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify(patch) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/v1/sessions/{id}/messages?before=&limit=&platform= — hot-paginated
|
/** GET /api/v1/sessions/{id}/messages?before=&limit=&platform= — hot-paginated
|
||||||
* history, optionally filtered to one source platform (design/16 §4.4). The wire
|
* history, optionally filtered to one source platform (design/16 §4.4). The wire
|
||||||
* returns newest-first, but callers render chronologically (oldest first), so we
|
* returns newest-first, but callers render chronologically (oldest first), so we
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PersonaRecord } from "./types";
|
import type { PersonaRecord, SessionMeta } from "./types";
|
||||||
|
|
||||||
/** Neutral fallback avatar when a persona has no avatar of its own. Never another
|
/** Neutral fallback avatar when a persona has no avatar of its own. Never another
|
||||||
* role's image — a persona without an avatar gets this neutral default. */
|
* role's image — a persona without an avatar gets this neutral default. */
|
||||||
@@ -21,3 +21,52 @@ export function personaChatBackground(
|
|||||||
): string | undefined {
|
): string | undefined {
|
||||||
return p?.chatBackground?.[orientation];
|
return p?.chatBackground?.[orientation];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The globally configured default role: the one flagged `isDefault`, else the
|
||||||
|
* first the catalog lists (an older registry may flag none), else none at all —
|
||||||
|
* in which case the UI keeps its neutral placeholder. */
|
||||||
|
export function defaultPersona(personas: PersonaRecord[]): PersonaRecord | null {
|
||||||
|
return personas.find((p) => p.isDefault) ?? personas[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which role does `sessionId` talk to (design/22 §4.6)?
|
||||||
|
*
|
||||||
|
* Session-bound role wins; an empty/absent `personaId` — and a binding whose
|
||||||
|
* role is no longer in the catalog (deleted persona, or a catalog we have not
|
||||||
|
* loaded yet) — falls back to the global default. This is the ONE resolution
|
||||||
|
* point: the avatar, the wallpaper, the name, the mood and the message labels
|
||||||
|
* all read the value it returns, so no two of them can disagree about "who is
|
||||||
|
* speaking" (design/22 §5 risk row 1). */
|
||||||
|
export function resolveSessionPersona(
|
||||||
|
sessionId: string,
|
||||||
|
sessions: Record<string, SessionMeta>,
|
||||||
|
personas: PersonaRecord[]
|
||||||
|
): PersonaRecord | null {
|
||||||
|
const boundId = sessionId ? sessions[sessionId]?.personaId ?? "" : "";
|
||||||
|
const bound = boundId ? personas.find((p) => p.id === boundId) : undefined;
|
||||||
|
return bound ?? defaultPersona(personas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who spoke one message (design/22 §7-5), and whether the session has since moved
|
||||||
|
* to a different role — the display half of "切换角色后旧消息仍属于旧角色".
|
||||||
|
*
|
||||||
|
* The message's own `personaId` wins, so a role switch cannot retroactively
|
||||||
|
* re-attribute turns that were already sent. A blank id (`''` = "inherit the
|
||||||
|
* current default role", the migration's meaning), an absent id (a client-side
|
||||||
|
* optimistic bubble, or an older backend) and an id that is not in the catalog
|
||||||
|
* (role deleted, catalog still loading) all fall back to the role the session
|
||||||
|
* speaks as now — never a name we cannot resolve. `switched` is true only when the
|
||||||
|
* message's role is known AND differs from the session's current one; that is the
|
||||||
|
* only case worth labelling, since repeating the session's own role on every
|
||||||
|
* bubble is noise. */
|
||||||
|
export function resolveMessagePersona(
|
||||||
|
personaId: string | undefined,
|
||||||
|
personas: PersonaRecord[],
|
||||||
|
active: PersonaRecord | null
|
||||||
|
): { persona: PersonaRecord | null; switched: boolean } {
|
||||||
|
const spoken = personaId ? personas.find((p) => p.id === personaId) ?? null : null;
|
||||||
|
return {
|
||||||
|
persona: spoken ?? active,
|
||||||
|
switched: Boolean(spoken && active && spoken.id !== active.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Re-binding one session to another role (design/22 §4.6).
|
||||||
|
*
|
||||||
|
* Picking a role is a two-step write: the local session list is updated first so
|
||||||
|
* the avatar / name / wallpaper / mood switch at once, then `PATCH
|
||||||
|
* /api/v1/sessions/{id}` persists it. A rejected request must not leave the UI
|
||||||
|
* showing a role the server never accepted, so the optimistic write is rolled
|
||||||
|
* back on failure — otherwise the next `GET /sessions` would silently snap every
|
||||||
|
* consumer back and the user would be looking at a lie in the meantime.
|
||||||
|
*
|
||||||
|
* The store and the REST call arrive as `io` rather than being imported here, so
|
||||||
|
* the ordering (write → persist → maybe roll back) is a plain function that can
|
||||||
|
* be driven by a logic test with fakes — this project ships no test runner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SessionPersonaIO {
|
||||||
|
/** The locally-known bound role id for a session ("" = inherits the default). */
|
||||||
|
read: (sessionId: string) => string;
|
||||||
|
/** Write the locally-known bound role id (the optimistic and rollback write). */
|
||||||
|
write: (sessionId: string, personaId: string) => void;
|
||||||
|
/** Persist the binding remotely; the returned promise rejects on failure. */
|
||||||
|
persist: (sessionId: string, personaId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bind `sessionId` to `personaId` ("" = inherit the default role).
|
||||||
|
*
|
||||||
|
* Resolves true when the server accepted it — including the no-op case where the
|
||||||
|
* session is already bound to that role, which fires no request at all. Resolves
|
||||||
|
* false when the session id is empty or the request failed (the local value is
|
||||||
|
* then restored, so the store and the server list agree again). */
|
||||||
|
export async function setSessionPersona(
|
||||||
|
io: SessionPersonaIO,
|
||||||
|
sessionId: string,
|
||||||
|
personaId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!sessionId) return false;
|
||||||
|
const previous = io.read(sessionId);
|
||||||
|
if (previous === personaId) return true;
|
||||||
|
io.write(sessionId, personaId);
|
||||||
|
try {
|
||||||
|
await io.persist(sessionId, personaId);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Roll back only while our optimistic value is still the one on screen: a
|
||||||
|
// second pick that landed meanwhile owns its own request and its own answer,
|
||||||
|
// and reverting it here would clobber a newer choice with a stale value.
|
||||||
|
if (io.read(sessionId) === personaId) io.write(sessionId, previous);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,16 @@ export interface ChatMessage {
|
|||||||
* design/16 §4.6). Blank for the owner's private chat, so present only when a
|
* design/16 §4.6). Blank for the owner's private chat, so present only when a
|
||||||
* non-owner is speaking; the recipient renders it as the sender label. */
|
* non-owner is speaking; the recipient renders it as the sender label. */
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
|
/** The role that produced this message (design/22 §7-5, `chat_messages
|
||||||
|
* .persona_id`, served by the message DTO). A session may switch roles without
|
||||||
|
* losing its context, so the authoring role is recorded per turn: a historical
|
||||||
|
* bubble keeps the role of *its own* turn instead of being re-attributed to the
|
||||||
|
* role the session speaks as now.
|
||||||
|
*
|
||||||
|
* Absent on a client-side optimistic bubble and on an older backend; `''` means
|
||||||
|
* "inherit the current global default role". Consumers fall back to the active
|
||||||
|
* session's role only in those cases — never to a name they cannot resolve. */
|
||||||
|
personaId?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +123,11 @@ export interface SessionMeta {
|
|||||||
* 协同 B3/D4). Optional so an older backend stays compatible — absent means
|
* 协同 B3/D4). Optional so an older backend stays compatible — absent means
|
||||||
* "unknown", which the UI and the read path both treat as 0. */
|
* "unknown", which the UI and the read path both treat as 0. */
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
/** The role this session talks to (design/22 §2 option (a): the session is the
|
||||||
|
* single source of truth). `''`/absent means "inherit the global default role",
|
||||||
|
* which is the pre-multi-persona behaviour, so an older backend keeps working
|
||||||
|
* unchanged. Resolved in `lib/persona.ts#resolveSessionPersona`. */
|
||||||
|
personaId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentStatus =
|
export type AgentStatus =
|
||||||
|
|||||||
@@ -225,8 +225,15 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "mood": {
|
case "mood": {
|
||||||
if (typeof ev.data === "string") s.setMood(ev.data);
|
// Role-scoped (design/22 §4.6): the frame names the persona the change
|
||||||
else if (r && typeof r.mood === "string") s.setMood(r.mood);
|
// belongs to and carries no sessionId, so the bucket key is personaId —
|
||||||
|
// each role's mood stays with its own conversations. A legacy string frame
|
||||||
|
// has nowhere to put it, so it lands in the unscoped "" bucket.
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
s.setMood(ev.data);
|
||||||
|
} else if (r && typeof r.mood === "string") {
|
||||||
|
s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : "");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "tool_used": {
|
case "tool_used": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore, useCurrentPersona } from "../store/useAppStore";
|
||||||
import { useModeStore } from "../store/useModeStore";
|
import { useModeStore } from "../store/useModeStore";
|
||||||
import { useSendChat } from "../lib/useChat";
|
import { useSendChat } from "../lib/useChat";
|
||||||
import { sendUp } from "../lib/ws";
|
import { sendUp } from "../lib/ws";
|
||||||
@@ -18,7 +18,8 @@ import { personaChatBackground, type Orientation } from "../lib/persona";
|
|||||||
*/
|
*/
|
||||||
export function ChatShell(): React.ReactNode {
|
export function ChatShell(): React.ReactNode {
|
||||||
const messages = useAppStore((s) => s.messages);
|
const messages = useAppStore((s) => s.messages);
|
||||||
const currentPersona = useAppStore((s) => s.currentPersona);
|
// The chat wallpaper follows the *active session's* role (design/22 §4.6).
|
||||||
|
const currentPersona = useCurrentPersona();
|
||||||
const { send, sending } = useSendChat();
|
const { send, sending } = useSendChat();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// Logic test for per-session role selection (design/22 §4.6/§7, batch 1 frontend):
|
||||||
|
// the role the UI renders is *derived* from the active session — never a global
|
||||||
|
// value — an unbound (empty/absent) `personaId` falls back to the default role, a
|
||||||
|
// re-bind is written locally first and rolled back when the PATCH is rejected, and
|
||||||
|
// the role-scoped `mood` frame lands in its own role's bucket. The project has no
|
||||||
|
// frontend test runner and no jsdom, so this is a dependency-free plain-node check
|
||||||
|
// that drives the real resolver (lib/persona.ts), the real re-bind helper
|
||||||
|
// (lib/sessionPersona.ts), the real WS frame router (lib/ws.ts) and the real
|
||||||
|
// store, and throws on the first failure.
|
||||||
|
//
|
||||||
|
// Run it with the esbuild that already ships with vite (no new dependency, no
|
||||||
|
// package.json change); the banner supplies the `window.localStorage` the store's
|
||||||
|
// persist middleware probes, which is simply absent under node:
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/store/personaSession.test.ts \
|
||||||
|
// --bundle --platform=node --format=esm \
|
||||||
|
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||||
|
// --outfile=/tmp/cyrene-persona-session.test.mjs \
|
||||||
|
// && node /tmp/cyrene-persona-session.test.mjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||||
|
// imports nothing from node:*.
|
||||||
|
|
||||||
|
import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
|
||||||
|
import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
|
||||||
|
import { dispatchFrame } from "../lib/ws";
|
||||||
|
import { selectCurrentPersona, useAppStore } from "./useAppStore";
|
||||||
|
import type { PersonaRecord, SessionMeta } from "../lib/types";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function same<T>(got: T, want: T, what: string): void {
|
||||||
|
ok(
|
||||||
|
JSON.stringify(got) === JSON.stringify(want),
|
||||||
|
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAIN = "web";
|
||||||
|
const SUB_A = "web:sub-a";
|
||||||
|
const SUB_B = "web:sub-b";
|
||||||
|
|
||||||
|
const PERSONAS: PersonaRecord[] = [
|
||||||
|
{ id: "cyrene", name: "昔涟", character: "cyrene", isDefault: true, mode: "chat", memoryNamespace: "persona.cyrene" },
|
||||||
|
{ id: "yuki", name: "雪", character: "yuki", isDefault: false, mode: "chat", memoryNamespace: "persona.yuki" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Three sessions: A bound to role Y, B explicitly unbound, main with no field at
|
||||||
|
* all (the shape an older backend returns). */
|
||||||
|
function sessionFixtures(): Record<string, SessionMeta> {
|
||||||
|
return {
|
||||||
|
[MAIN]: { id: MAIN, type: "main", createdAt: 1 },
|
||||||
|
[SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "yuki" },
|
||||||
|
[SUB_B]: { id: SUB_B, type: "sub", createdAt: 3, personaId: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixtures = sessionFixtures();
|
||||||
|
|
||||||
|
/** 1. The rendered role follows the ACTIVE session (design/22 §4.6/§7-5). */
|
||||||
|
function testResolutionFollowsActiveSession(): void {
|
||||||
|
same(resolveSessionPersona(SUB_A, fixtures, PERSONAS)?.id, "yuki", "session A resolves role Y");
|
||||||
|
same(resolveSessionPersona(SUB_B, fixtures, PERSONAS)?.id, "cyrene", "session B resolves the default role");
|
||||||
|
same(resolveSessionPersona(MAIN, fixtures, PERSONAS)?.id, "cyrene", "a session without personaId resolves the default");
|
||||||
|
same(resolveSessionPersona("", fixtures, PERSONAS)?.id, "cyrene", "no active session resolves the default");
|
||||||
|
|
||||||
|
// …and the store selector is that same answer, so the avatar/name/wallpaper
|
||||||
|
// cannot disagree with the picker.
|
||||||
|
useAppStore.setState({ personas: PERSONAS, sessions: fixtures, activeSessionId: SUB_A });
|
||||||
|
same(selectCurrentPersona(useAppStore.getState())?.name, "雪", "active session A renders role Y");
|
||||||
|
useAppStore.setState({ activeSessionId: SUB_B });
|
||||||
|
same(selectCurrentPersona(useAppStore.getState())?.name, "昔涟", "switching to B follows its own role");
|
||||||
|
useAppStore.setState({ activeSessionId: SUB_A });
|
||||||
|
same(selectCurrentPersona(useAppStore.getState())?.name, "雪", "switching back follows A's role again");
|
||||||
|
useAppStore.setState({ activeSessionId: "" });
|
||||||
|
same(selectCurrentPersona(useAppStore.getState())?.name, "昔涟", "still loading / no session falls back to the default");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2. An empty binding — and one we cannot resolve — falls back to the default. */
|
||||||
|
function testFallbackToDefaultRole(): void {
|
||||||
|
const deleted: Record<string, SessionMeta> = {
|
||||||
|
[SUB_A]: { id: SUB_A, type: "sub", createdAt: 2, personaId: "gone" },
|
||||||
|
};
|
||||||
|
same(resolveSessionPersona(SUB_A, deleted, PERSONAS)?.id, "cyrene", "a binding to a deleted role falls back to the default");
|
||||||
|
same(resolveSessionPersona(SUB_A, deleted, []), null, "an empty catalog resolves nothing (neutral placeholder)");
|
||||||
|
|
||||||
|
// A catalog that flags no default still yields the first listed role.
|
||||||
|
const noFlag: PersonaRecord[] = [{ ...PERSONAS[1], isDefault: false }];
|
||||||
|
same(resolveSessionPersona(MAIN, fixtures, noFlag)?.id, "yuki", "no isDefault flag falls back to the first role");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3. A re-bind writes locally first, then persists; a rejection restores it. */
|
||||||
|
async function testOptimisticRebind(): Promise<void> {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const state: Record<string, string> = { [SUB_A]: "yuki" };
|
||||||
|
const io: SessionPersonaIO = {
|
||||||
|
read: (id) => state[id] ?? "",
|
||||||
|
write: (id, personaId) => {
|
||||||
|
state[id] = personaId;
|
||||||
|
calls.push(`write ${id}=${personaId}`);
|
||||||
|
},
|
||||||
|
persist: (id, personaId) => {
|
||||||
|
calls.push(`persist ${id}=${personaId}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
same(await setSessionPersona(io, SUB_A, "cyrene"), true, "an accepted re-bind resolves true");
|
||||||
|
same(state[SUB_A], "cyrene", "local binding after a successful PATCH");
|
||||||
|
same(
|
||||||
|
calls,
|
||||||
|
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`],
|
||||||
|
"the local write happens before the request (no request would mean no UI change)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rejection: the local value must go back, or the UI would show a role the
|
||||||
|
// server never accepted until the next GET /sessions corrects it.
|
||||||
|
const failedCalls: string[] = [];
|
||||||
|
const failedState: Record<string, string> = { [SUB_A]: "yuki" };
|
||||||
|
const failing: SessionPersonaIO = {
|
||||||
|
read: (id) => failedState[id] ?? "",
|
||||||
|
write: (id, personaId) => {
|
||||||
|
failedState[id] = personaId;
|
||||||
|
failedCalls.push(`write ${id}=${personaId}`);
|
||||||
|
},
|
||||||
|
persist: (id, personaId) => {
|
||||||
|
failedCalls.push(`persist ${id}=${personaId}`);
|
||||||
|
return Promise.reject(new Error("patch failed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
same(await setSessionPersona(failing, SUB_A, "cyrene"), false, "a rejected re-bind resolves false");
|
||||||
|
same(failedState[SUB_A], "yuki", "local binding restored after a failed PATCH");
|
||||||
|
same(
|
||||||
|
failedCalls,
|
||||||
|
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`, `write ${SUB_A}=yuki`],
|
||||||
|
"the rollback writes the previous value back"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unchanged binding: no write, no request at all.
|
||||||
|
let writes = 0;
|
||||||
|
let persists = 0;
|
||||||
|
const noop: SessionPersonaIO = {
|
||||||
|
read: () => "yuki",
|
||||||
|
write: () => {
|
||||||
|
writes += 1;
|
||||||
|
},
|
||||||
|
persist: () => {
|
||||||
|
persists += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
same(await setSessionPersona(noop, SUB_A, "yuki"), true, "re-picking the bound role resolves true");
|
||||||
|
same([writes, persists], [0, 0], "re-picking the bound role fires no write and no request");
|
||||||
|
same(await setSessionPersona(noop, "", "yuki"), false, "an empty session id is refused");
|
||||||
|
|
||||||
|
// A second pick that lands while the first request is in flight owns the value:
|
||||||
|
// the older failure must not revert it.
|
||||||
|
let rejectFirst: (err: Error) => void = () => {};
|
||||||
|
let firstPersist = true;
|
||||||
|
const state2: Record<string, string> = { [SUB_A]: "yuki" };
|
||||||
|
const racing: SessionPersonaIO = {
|
||||||
|
read: (id) => state2[id] ?? "",
|
||||||
|
write: (id, personaId) => {
|
||||||
|
state2[id] = personaId;
|
||||||
|
},
|
||||||
|
persist: () =>
|
||||||
|
firstPersist
|
||||||
|
? new Promise<void>((_, reject) => {
|
||||||
|
firstPersist = false;
|
||||||
|
rejectFirst = reject;
|
||||||
|
})
|
||||||
|
: Promise.resolve(),
|
||||||
|
};
|
||||||
|
const first = setSessionPersona(racing, SUB_A, "cyrene");
|
||||||
|
const second = setSessionPersona(racing, SUB_A, "yuki");
|
||||||
|
rejectFirst(new Error("patch failed"));
|
||||||
|
same(await first, false, "the superseded request still reports its failure");
|
||||||
|
same(await second, true, "the newer pick is accepted");
|
||||||
|
same(state2[SUB_A], "yuki", "a stale failure does not revert the newer choice");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4. The store's optimistic write re-resolves the rendered role at once. */
|
||||||
|
function testStoreRebind(): void {
|
||||||
|
useAppStore.setState({ personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_B });
|
||||||
|
useAppStore.getState().setSessionPersona(SUB_B, "yuki");
|
||||||
|
same(selectCurrentPersona(useAppStore.getState())?.id, "yuki", "the optimistic re-bind re-resolves the rendered role");
|
||||||
|
same(useAppStore.getState().sessions[SUB_B].personaId, "yuki", "the session list carries the new binding");
|
||||||
|
|
||||||
|
const after = useAppStore.getState().sessions;
|
||||||
|
useAppStore.getState().setSessionPersona("web:missing", "yuki");
|
||||||
|
ok(useAppStore.getState().sessions === after, "an unknown session id is a no-op (map untouched)");
|
||||||
|
same(
|
||||||
|
Object.keys(useAppStore.getState().sessions).sort(),
|
||||||
|
[MAIN, SUB_A, SUB_B].sort(),
|
||||||
|
"an unknown session id cannot invent a row"
|
||||||
|
);
|
||||||
|
useAppStore.getState().setSessionPersona(SUB_A, "yuki");
|
||||||
|
ok(useAppStore.getState().sessions === after, "re-picking the already-bound role is a no-op (map untouched)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5. A message keeps the role that spoke it (design/22 §7-5): switching a
|
||||||
|
* session's role must not re-attribute turns that were already sent. */
|
||||||
|
function testMessageAuthoringRole(): void {
|
||||||
|
const yuki = PERSONAS[1];
|
||||||
|
const cyrene = PERSONAS[0];
|
||||||
|
// The session speaks as Cyrene now; this reply was authored by Yuki.
|
||||||
|
const before = resolveMessagePersona("yuki", PERSONAS, cyrene);
|
||||||
|
same(before.persona?.id, "yuki", "a reply keeps its own authoring role");
|
||||||
|
same(before.switched, true, "…and is flagged as coming from another role");
|
||||||
|
// Same role as the session: no label worth showing (and no mis-attribution).
|
||||||
|
const same2 = resolveMessagePersona("cyrene", PERSONAS, cyrene);
|
||||||
|
same(same2.persona?.id, "cyrene", "a reply from the current role resolves to it");
|
||||||
|
same(same2.switched, false, "a reply from the current role is not flagged");
|
||||||
|
// '' (inherit the default) / absent / unknown all fall back to the session's
|
||||||
|
// role rather than inventing a name.
|
||||||
|
const blank = resolveMessagePersona("", PERSONAS, yuki);
|
||||||
|
same([blank.persona?.id, blank.switched], ["yuki", false], "an empty id falls back to the session's role");
|
||||||
|
const absent = resolveMessagePersona(undefined, PERSONAS, yuki);
|
||||||
|
same([absent.persona?.id, absent.switched], ["yuki", false], "an absent id falls back to the session's role");
|
||||||
|
const gone = resolveMessagePersona("gone", PERSONAS, yuki);
|
||||||
|
same([gone.persona?.id, gone.switched], ["yuki", false], "an unknown role id falls back to the session's role");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 6. `mood` frames are role-scoped: each role keeps its own mood. */
|
||||||
|
function testMoodBuckets(): void {
|
||||||
|
useAppStore.setState({ moods: {}, personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_A });
|
||||||
|
dispatchFrame({ type: "mood", data: { mood: "happy", personaId: "yuki" } });
|
||||||
|
same(useAppStore.getState().moods.yuki, "happy", "a role-scoped mood frame lands in that role's bucket");
|
||||||
|
dispatchFrame({ type: "mood", data: { mood: "sad", personaId: "cyrene" } });
|
||||||
|
same(useAppStore.getState().moods.cyrene, "sad", "another role's mood does not overwrite it");
|
||||||
|
dispatchFrame({ type: "mood", data: "calm" });
|
||||||
|
same(useAppStore.getState().moods[""], "calm", "an unscoped (legacy) mood frame keeps its own bucket");
|
||||||
|
useAppStore.getState().setMood(null, "yuki");
|
||||||
|
same(useAppStore.getState().moods.yuki, undefined, "a null mood clears only that role's bucket");
|
||||||
|
same(useAppStore.getState().moods[""], "calm", "clearing one bucket leaves the others alone");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
testResolutionFollowsActiveSession();
|
||||||
|
testFallbackToDefaultRole();
|
||||||
|
await testOptimisticRebind();
|
||||||
|
testStoreRebind();
|
||||||
|
testMessageAuthoringRole();
|
||||||
|
testMoodBuckets();
|
||||||
|
console.log(`personaSession.test: ${checks} checks passed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ToolUsed,
|
ToolUsed,
|
||||||
WsStatus,
|
WsStatus,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
|
import { resolveSessionPersona } from "../lib/persona";
|
||||||
import { now, uid } from "../lib/util";
|
import { now, uid } from "../lib/util";
|
||||||
|
|
||||||
interface ChatContext {
|
interface ChatContext {
|
||||||
@@ -32,7 +33,12 @@ export interface AppStore {
|
|||||||
/** Messages of the *active* session, in display order (oldest first). Reset on
|
/** Messages of the *active* session, in display order (oldest first). Reset on
|
||||||
* session switch and refilled from the backend (design/16 §4/§5). */
|
* session switch and refilled from the backend (design/16 §4/§5). */
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
mood: string | null;
|
/** Latest mood per role id (design/22 §4.6). The `mood` WS event is
|
||||||
|
* persona-scoped and carries no `sessionId`, so each role gets its own bucket
|
||||||
|
* and the UI shows the bucket of the *active session's* role — role X's mood
|
||||||
|
* must not sit above role Y's conversation. `""` holds events that arrived
|
||||||
|
* without a persona (legacy/unscoped frames). */
|
||||||
|
moods: Record<string, string>;
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
agents: AgentInfo[];
|
agents: AgentInfo[];
|
||||||
backthink: BackthinkItem[];
|
backthink: BackthinkItem[];
|
||||||
@@ -53,16 +59,27 @@ export interface AppStore {
|
|||||||
/** Push one `channel_persisted` message (newest first, dedup by id, capped ~200). */
|
/** Push one `channel_persisted` message (newest first, dedup by id, capped ~200). */
|
||||||
pushChannelEvent: (msg: AdminChannelMessage) => void;
|
pushChannelEvent: (msg: AdminChannelMessage) => void;
|
||||||
|
|
||||||
/** 当前 persona(web 主会话=默认 persona)。加载前/失败时为 null,头像回退中性默认占位。 */
|
/** The role catalog from `GET /personas` (design/22 §4.6). The role the UI
|
||||||
currentPersona: PersonaRecord | null;
|
* renders is *derived* from it plus the active session — never stored — so the
|
||||||
|
* picker, the avatar and the mood cannot drift apart. Empty until loaded (or
|
||||||
|
* when the fetch fails), and the UI then keeps its neutral placeholder. */
|
||||||
|
personas: PersonaRecord[];
|
||||||
|
/** Replace the role catalog (`GET /personas`). */
|
||||||
|
setPersonas: (list: PersonaRecord[]) => void;
|
||||||
|
|
||||||
setWsStatus: (status: WsStatus) => void;
|
setWsStatus: (status: WsStatus) => void;
|
||||||
setCurrentPersona: (p: PersonaRecord | null) => void;
|
|
||||||
setContext: (patch: Partial<ChatContext>) => void;
|
setContext: (patch: Partial<ChatContext>) => void;
|
||||||
setMood: (mood: string | null) => void;
|
/** Record one role's new mood. `personaId` is the role the event named; "" keeps
|
||||||
|
* an unscoped/legacy frame in its own bucket. A null mood drops the bucket. */
|
||||||
|
setMood: (mood: string | null, personaId?: string) => void;
|
||||||
|
|
||||||
/** Replace the session metadata list. */
|
/** Replace the session metadata list. */
|
||||||
setSessions: (list: SessionMeta[]) => void;
|
setSessions: (list: SessionMeta[]) => void;
|
||||||
|
/** Re-bind one session to a role locally — the optimistic write and the rollback
|
||||||
|
* target of `lib/sessionPersona.ts#setSessionPersona`. `""` re-binds it to
|
||||||
|
* "inherit the default role". A no-op for an unknown session or one already
|
||||||
|
* bound to that role. */
|
||||||
|
setSessionPersona: (sessionId: string, personaId: string) => void;
|
||||||
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
|
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
|
||||||
* user opens the session and its page has loaded, and on the `session_read`
|
* user opens the session and its page has loaded, and on the `session_read`
|
||||||
* frame another end broadcasts — a no-op for an already-read or unknown
|
* frame another end broadcasts — a no-op for an already-read or unknown
|
||||||
@@ -121,7 +138,7 @@ export const useAppStore = create<AppStore>()(
|
|||||||
activeSessionId: "",
|
activeSessionId: "",
|
||||||
wsStatus: "idle",
|
wsStatus: "idle",
|
||||||
messages: [],
|
messages: [],
|
||||||
mood: null,
|
moods: {},
|
||||||
context: {},
|
context: {},
|
||||||
agents: [
|
agents: [
|
||||||
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
|
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
|
||||||
@@ -134,20 +151,34 @@ export const useAppStore = create<AppStore>()(
|
|||||||
awakenTool: null,
|
awakenTool: null,
|
||||||
sessionBusy: false,
|
sessionBusy: false,
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
currentPersona: null,
|
personas: [],
|
||||||
taskLogs: {},
|
taskLogs: {},
|
||||||
channelEvents: [],
|
channelEvents: [],
|
||||||
|
|
||||||
setWsStatus: (status) => set({ wsStatus: status }),
|
setWsStatus: (status) => set({ wsStatus: status }),
|
||||||
setCurrentPersona: (p) => set({ currentPersona: p }),
|
setPersonas: (list) => set({ personas: list }),
|
||||||
setContext: (patch) =>
|
setContext: (patch) =>
|
||||||
set((s) => ({ context: { ...s.context, ...patch } })),
|
set((s) => ({ context: { ...s.context, ...patch } })),
|
||||||
setMood: (mood) => set({ mood }),
|
setMood: (mood, personaId = "") =>
|
||||||
|
set((s) => {
|
||||||
|
const moods = { ...s.moods };
|
||||||
|
if (mood) moods[personaId] = mood;
|
||||||
|
else delete moods[personaId];
|
||||||
|
return { moods };
|
||||||
|
}),
|
||||||
|
|
||||||
setSessions: (list) =>
|
setSessions: (list) =>
|
||||||
set({
|
set({
|
||||||
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
|
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
|
||||||
}),
|
}),
|
||||||
|
setSessionPersona: (sessionId, personaId) =>
|
||||||
|
set((s) => {
|
||||||
|
const meta = s.sessions[sessionId];
|
||||||
|
if (!meta || (meta.personaId ?? "") === personaId) return s;
|
||||||
|
return {
|
||||||
|
sessions: { ...s.sessions, [sessionId]: { ...meta, personaId } },
|
||||||
|
};
|
||||||
|
}),
|
||||||
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
|
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
|
||||||
clearUnread: (sessionId) =>
|
clearUnread: (sessionId) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
@@ -203,6 +234,7 @@ export const useAppStore = create<AppStore>()(
|
|||||||
image: msg.image ?? m.image,
|
image: msg.image ?? m.image,
|
||||||
platform: msg.platform ?? m.platform,
|
platform: msg.platform ?? m.platform,
|
||||||
deviceId: msg.deviceId ?? m.deviceId,
|
deviceId: msg.deviceId ?? m.deviceId,
|
||||||
|
personaId: msg.personaId ?? m.personaId,
|
||||||
createdAt: msg.createdAt ?? m.createdAt,
|
createdAt: msg.createdAt ?? m.createdAt,
|
||||||
};
|
};
|
||||||
return { messages };
|
return { messages };
|
||||||
@@ -369,7 +401,7 @@ export const useAppStore = create<AppStore>()(
|
|||||||
resetChat: () =>
|
resetChat: () =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
messages: [],
|
messages: [],
|
||||||
mood: null,
|
moods: {},
|
||||||
flowGraph: null,
|
flowGraph: null,
|
||||||
backthink: s.backthink,
|
backthink: s.backthink,
|
||||||
awakenState: "quiet",
|
awakenState: "quiet",
|
||||||
@@ -381,38 +413,63 @@ export const useAppStore = create<AppStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "cyrene.app",
|
name: "cyrene.app",
|
||||||
version: 2,
|
version: 3,
|
||||||
// Upgrade from v1 (which persisted a full `messages` array + `sessionId`):
|
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
|
||||||
// drop those — the backend is now the message truth — and carry forward the
|
// one resolved `currentPersona`, which can no longer describe the active role
|
||||||
// still-meaningful bits (persona avatar, mood, context, backthink).
|
// now that the role belongs to a *session*. Both shapes are dropped:
|
||||||
migrate: (persisted) => {
|
// `personas` is refilled from `GET /personas` and the role is re-derived from
|
||||||
|
// the session list. `moods` is transient by nature (a restart resets a role's
|
||||||
|
// mood), so it is not carried across either. A v1 state was never trusted for
|
||||||
|
// the session metadata, so those users simply restart on the main session.
|
||||||
|
migrate: (persisted, version) => {
|
||||||
const p = (persisted ?? {}) as Partial<AppStore>;
|
const p = (persisted ?? {}) as Partial<AppStore>;
|
||||||
|
const fromV1 = version < 2;
|
||||||
return {
|
return {
|
||||||
activeSessionId: "",
|
activeSessionId: fromV1 ? "" : p.activeSessionId ?? "",
|
||||||
sessions: {},
|
sessions: fromV1 ? {} : p.sessions ?? {},
|
||||||
mood: p.mood ?? null,
|
personas: p.personas ?? [],
|
||||||
|
moods: {},
|
||||||
context: p.context ?? {},
|
context: p.context ?? {},
|
||||||
backthink: p.backthink ?? [],
|
backthink: p.backthink ?? [],
|
||||||
currentPersona: p.currentPersona ?? null,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
partialize: (s) => ({
|
partialize: (s) => ({
|
||||||
// messages deliberately excluded: the backend is the message truth
|
// messages deliberately excluded: the backend is the message truth
|
||||||
// (design/16 §1), so we never persist the whole chat into localStorage.
|
// (design/16 §1), so we never persist the whole chat into localStorage.
|
||||||
// We keep the (small) session metadata + the focused session id so a
|
// We keep the (small) session metadata + the focused session id so a
|
||||||
// refresh lands on the same conversation, plus the resolved persona for
|
// refresh lands on the same conversation, plus the role catalog so the
|
||||||
// the avatar/background.
|
// avatar/name/wallpaper resolve before the first fetch returns.
|
||||||
activeSessionId: s.activeSessionId,
|
activeSessionId: s.activeSessionId,
|
||||||
sessions: s.sessions,
|
sessions: s.sessions,
|
||||||
mood: s.mood,
|
personas: s.personas,
|
||||||
|
moods: s.moods,
|
||||||
context: s.context,
|
context: s.context,
|
||||||
backthink: s.backthink,
|
backthink: s.backthink,
|
||||||
currentPersona: s.currentPersona,
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The role the UI renders (design/22 §4.6): the active session's bound role,
|
||||||
|
* else the global default. THE single resolution point — the avatar, name,
|
||||||
|
* wallpaper, mood bucket and message labels all read it, so none of them can
|
||||||
|
* disagree about "who is speaking" (design/22 §5 risk row 1). */
|
||||||
|
export function selectCurrentPersona(s: AppStore): PersonaRecord | null {
|
||||||
|
return resolveSessionPersona(s.activeSessionId, s.sessions, s.personas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to the active session's role. */
|
||||||
|
export function useCurrentPersona(): PersonaRecord | null {
|
||||||
|
return useAppStore(selectCurrentPersona);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to the active role's mood. `mood` frames are role-scoped and carry
|
||||||
|
* no sessionId, so a role's mood is only shown above its own sessions. */
|
||||||
|
export function useCurrentMood(): string | null {
|
||||||
|
const personaId = useAppStore((s) => selectCurrentPersona(s)?.id ?? "");
|
||||||
|
return useAppStore((s) => (personaId ? s.moods[personaId] ?? null : null));
|
||||||
|
}
|
||||||
|
|
||||||
/** Convenience selector for the last streaming assistant message id. */
|
/** Convenience selector for the last streaming assistant message id. */
|
||||||
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
|
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
|
||||||
const last = s.messages[s.messages.length - 1];
|
const last = s.messages[s.messages.length - 1];
|
||||||
|
|||||||
Reference in new issue
Block a user