feat(web): 前端接会话持久化——拉历史热分页 + 侧栏会话列表
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- store: 消息真源移到后端(sessions元数据map + activeSessionId,partialize 移除 messages 改存会话元数据,version:2 migrate 清旧数据)
- api: fetchSessions/createSubSession/fetchMessages(热分页before游标)/deleteSession + MessageDto→toChatMessage
- Nav: 侧栏「会话」区——主会话置顶 + 次级列表 + +新建会话(Miuix)
- 新增 HistoryList: 顶部「加载更早」向上分页 + 保守虚拟化(>200只渲染最近200)
- useChat: send 用 activeSessionId;App: token 后 fetchSessions→激活主会话→拉近50条
- Docs/api-client: v0.10 补 WS sessionId + history 回放事件契约
This commit is contained in:
Aska committed 2026-09-07 03:52:50 +08:00
1 parent 48421923f6
commit bcc53d68f3
9 files changed
+371 -32

No files matched your search

+10 -3
View File
@@ -22,8 +22,11 @@
| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,jwt}}`(24h,owner) |
| 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` |
| 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉真推理(design/06)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}` |
| 5 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` |
| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt}]}`(主会话 `id="web"` 恒在首位,次级 `web:<uuid>`);未注入时退化回渠道会话列表 |
| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:<uuid>"`);未注入 HistoryStore 回 `501 not_implemented` |
| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=` 默认 `limit=50`(上限 200)、`before`=上一页最旧 `createdAt`(毫秒),按时间倒序 `{data:[{id,sessionId,role,content,mode?,mood?,image?,thinking?,createdAt}]}`;非法 `limit`/`before` 回 `400`;未注入 HistoryStore 回 `501` |
| 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:<id>}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` |
| 8 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` |
## 2. REST 端点(管理,`/api/v1/admin/*`)
@@ -84,7 +87,9 @@
## 3. WebSocket `/ws/chat`(实时)
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?}`(`turnId`/`seq` 为流式关联字段,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood` / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update`。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)
- **事件壳**:`{type, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。
@@ -117,6 +122,8 @@
- **离线**: PWA / 原生端建议缓存 `sessions`/`personas` 列表。
## 5. 变更记录
- `v0.10`(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选 `sessionId` 字段(能确定会话的事件带上);新增 **`history` 回放事件**——WS 握手成功即回放主会话最新 50 条(`{type:"history", sessionId:"web", data:{messages:[...]}}`,`thinking` 已剥离)。`stream_end`/`stream_chunk`/会话编排状态帧(`session_busy` 等)在能确定 session 时带 `sessionId`;`message.delta` 等结构化流帧因 `model.StreamEvent` 无 session 上下文不带(即"若知道就带上"边界)。
- `v0.9`(会话消息持久化 + 主/次级会话,design/16,后端-1):新增 `POST /api/v1/sessions`(新建次级会话)、`GET /api/v1/sessions/{id}/messages?limit=&before=`(游标热分页拉历史,时间倒序)、`DELETE /api/v1/sessions/{id}`(删次级会话及其消息);`GET /api/v1/sessions` 在注入 `HistoryStore` 后改返回持久化会话元数据数组(主 + 次级,`type` 区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(临时记录)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
+56 -1
View File
@@ -6,7 +6,13 @@ import { AdminApp } from "./admin/AdminApp";
import { parseAdminHash } from "./admin/router";
import { connectWs, disconnectWs } from "./lib/ws";
import { initTheme } from "./lib/theme";
import { fetchPersonas, getAuthToken } from "./lib/api";
import {
fetchMessages,
fetchPersonas,
fetchSessions,
getAuthToken,
} from "./lib/api";
import type { SessionMeta } from "./lib/types";
import { useAppStore } from "./store/useAppStore";
export default function App(): React.ReactNode {
@@ -60,6 +66,55 @@ export default function App(): React.ReactNode {
};
}, [token, setCurrentPersona]);
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 setMessages = useAppStore((s) => s.setMessages);
// Load the session list once we hold a token, then focus a session: a persisted
// (still-present) session id, else the main session (design/16 §4.1). The main
// session has `type === "main"`; fall back to the first entry, then clear.
useEffect(() => {
if (!token) return;
let cancelled = false;
fetchSessions()
.then((list) => {
if (cancelled) return;
const byId = Object.fromEntries(
list.map((s) => [s.id, s] as const)
) as Record<string, SessionMeta>;
setSessions(list);
const main = list.find((s) => s.type === "main") ?? list[0];
const next =
activeSessionId && byId[activeSessionId] ? activeSessionId : main?.id;
setActiveSession(next ?? "");
})
.catch(() => {
// Backend not wired yet — leave the shell in its untouched state.
});
return () => {
cancelled = true;
};
}, [token, setSessions, setActiveSession]);
// Whenever the focused session changes (switch, new, or first load) and it is
// a session we know about, pull its initial page of history into the panel.
useEffect(() => {
if (!activeSessionId || !sessions[activeSessionId]) return;
let cancelled = false;
fetchMessages(activeSessionId, undefined, 50)
.then((list) => {
if (!cancelled) setMessages(list);
})
.catch(() => {
if (!cancelled) setMessages([]);
});
return () => {
cancelled = true;
};
}, [activeSessionId, sessions, setMessages]);
// The M9 modal UI and the admin subsystem (design/10) are two separate shells.
// `/admin` is reached via `#/admin`; every admin module is a hash segment.
if (route.admin) {
+61 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken } from "../lib/api";
import { createSubSession, getAuthToken } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import { Icon, type IconName } from "./Icon";
import { Tag } from "./primitives";
@@ -20,7 +20,12 @@ 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);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const [creating, setCreating] = useState(false);
const connected = wsStatus === "connected";
const hasToken = !!getAuthToken();
@@ -30,6 +35,35 @@ 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">
@@ -55,6 +89,32 @@ 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" ? "主会话" : "次级会话")}
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">
<NavItem
icon="settings"
@@ -0,0 +1,83 @@
import { useState } from "react";
import { fetchMessages } from "../../lib/api";
import { useAppStore } from "../../store/useAppStore";
import { SplitChatBubble } from "../chat";
/** Page size for the history cursor (design/16 §5.1); the wire returns rows
* newest-first, `fetchMessages` reverses them into display order for us. */
const PAGE = 50;
/** Conservative DOM cap: beyond this we stop mounting every bubble so an
* extremely long session never explodes the DOM (design/16 §5.2). Older messages
* already loaded into the store are still fetched on "load earlier"; we just
* stop rendering the furthest-back ones. */
const MAX_RENDER = 200;
/**
* The scrollable message history for the focused session. Owns the "load earlier"
* hot-pagination button at the top and a conservative render cap. Streaming
* bubbles (the active turn) render exactly as before via SplitChatBubble.
*/
export function HistoryList({
lastAssistantId,
}: {
lastAssistantId?: string;
}): React.ReactNode {
const messages = useAppStore((s) => s.messages);
const activeSessionId = useAppStore((s) => s.activeSessionId);
const prependMessages = useAppStore((s) => s.prependMessages);
const [exhausted, setExhausted] = useState(false);
const [loadingEarlier, setLoadingEarlier] = useState(false);
// The oldest loaded message's createdAt is the `before` cursor. We show the
// button only after a full page has loaded (so there might be more) and until
// an upward fetch returns a short page (signalling the beginning reached).
const before = messages[0]?.createdAt;
const showLoadEarlier = !exhausted && messages.length >= PAGE;
const loadEarlier = async () => {
if (!activeSessionId || before === undefined || loadingEarlier) return;
setLoadingEarlier(true);
try {
const older = await fetchMessages(activeSessionId, before, PAGE);
prependMessages(older);
if (older.length < PAGE) setExhausted(true);
} catch {
// Transient backend not wired / offline — user can retry; keep the button.
} finally {
setLoadingEarlier(false);
}
};
const collapsed = messages.length > MAX_RENDER;
const visible = collapsed ? messages.slice(-MAX_RENDER) : messages;
return (
<>
{showLoadEarlier ? (
<button
onClick={() => void loadEarlier()}
disabled={loadingEarlier}
className="mx-auto flex items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 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"
>
{loadingEarlier ? "加载中…" : "↥ 加载更早的消息"}
</button>
) : null}
{collapsed ? (
<div className="px-2 text-center text-[11px] text-[var(--mu-text-lo)]">
已收起 {messages.length - MAX_RENDER} 条较早消息
</div>
) : null}
{visible.map((m) => (
<SplitChatBubble
key={m.id}
message={m}
active={
m.id === lastAssistantId &&
m.role === "assistant" &&
(m.streaming ?? false)
}
/>
))}
</>
);
}
+70 -3
View File
@@ -16,6 +16,7 @@ import type {
AutomationCreateResult,
AutomationRunResult,
ChannelTestResult,
ChatMessage,
ChatSyncResult,
LlmTestRequest,
LlmTestResult,
@@ -27,6 +28,7 @@ import type {
PersonaRecord,
PersonaUpsertRequest,
Role,
SessionMeta,
SessionRecord,
} from "./types";
@@ -184,9 +186,74 @@ export function chatStream(
});
}
/** GET /api/v1/sessions — known channel sessions. */
export function fetchSessions(): Promise<SessionRecord[]> {
return request<SessionRecord[]>("/sessions");
/** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors
* the `chat_messages` row; `mode`/`mood`/`image`/`thinking` are optional. */
export interface MessageDto {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
mode?: Mode;
mood?: string;
image?: string;
thinking?: string;
createdAt: number;
}
/** Map a wire message into a display ChatMessage (carrying its sessionId). */
function toChatMessage(m: MessageDto): ChatMessage {
return {
id: m.id,
sessionId: m.sessionId,
role: m.role,
content: m.content,
mode: m.mode,
mood: m.mood,
image: m.image,
thinking: m.thinking,
createdAt: m.createdAt,
};
}
/** GET /api/v1/sessions — the main + secondary sessions for this web account. */
export function fetchSessions(): Promise<SessionMeta[]> {
return request<SessionMeta[]>("/sessions");
}
/** POST /api/v1/sessions — creates a new secondary session. */
export function createSubSession(title?: string): Promise<SessionMeta> {
return request<SessionMeta>("/sessions", {
method: "POST",
body: JSON.stringify(title ? { title } : {}),
});
}
/** GET /api/v1/sessions/{id}/messages?before=&limit= — hot-paginated history.
* The wire returns newest-first, but callers render chronologically (oldest
* first), so we reverse here: page 1 is the most recent `limit` messages in
* display order, and `before` (the oldest message's createdAt) walks upward. */
export async function fetchMessages(
sessionId: string,
before?: number,
limit = 50
): Promise<ChatMessage[]> {
const qs = new URLSearchParams();
qs.set("limit", String(limit));
if (before !== undefined) qs.set("before", String(before));
const list = await request<MessageDto[]>(
`/sessions/${encodeURIComponent(sessionId)}/messages?${qs.toString()}`
);
return list.map(toChatMessage).reverse();
}
/** DELETE /api/v1/sessions/{id} — removes a secondary session (and its messages). */
export function deleteSession(
sessionId: string
): Promise<{ id: string; deleted: true }> {
return request<{ id: string; deleted: true }>(
`/sessions/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
}
/** GET /api/v1/personas — persona summaries. */
+18
View File
@@ -59,6 +59,9 @@ export interface SessionStatePayload {
export interface ChatMessage {
id: string;
/** The owning session (backend `session_id`). Set when loaded via the sessions
* API; absent on a client-side optimistic bubble until it is persisted. */
sessionId?: string;
role: "user" | "assistant" | "system";
content: string;
/** Behavior mode this message was produced in — drives its bubble rendering.
@@ -76,6 +79,21 @@ export interface ChatMessage {
createdAt: number;
}
/** The two web session kinds (design/16 §2.1): the fixed main session that every
* turn falls into by default, and a user-created secondary session that shares
* the same persona memory. */
export type SessionType = "main" | "sub";
/** A web conversation session as returned by GET /api/v1/sessions. The main
* session has a fixed id; a secondary session's id is minted on creation. The
* message truth lives server-side, so this is metadata only. */
export interface SessionMeta {
id: string;
type: SessionType;
title?: string;
createdAt: number;
}
export type AgentStatus =
| "idle"
| "running"
+1 -1
View File
@@ -19,7 +19,7 @@ export function useSendChat(): {
sending: boolean;
} {
const [sending, setSending] = useState(false);
const sessionId = useAppStore((s) => s.sessionId);
const sessionId = useAppStore((s) => s.activeSessionId);
const addUserMessage = useAppStore((s) => s.addUserMessage);
const beginAssistant = useAppStore((s) => s.beginAssistant);
const endStream = useAppStore((s) => s.endStream);
+7 -13
View File
@@ -3,7 +3,7 @@ import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
import { sendUp } from "../lib/ws";
import { SplitChatBubble } from "../components/chat";
import { HistoryList } from "../components/messages/HistoryList";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
import { Icon } from "../components/Icon";
@@ -49,9 +49,13 @@ export function ChatShell(): React.ReactNode {
return undefined;
}, [messages]);
// Scroll to the newest message only when a *new* message reaches the tail
// (append/session-switch), not when the user hot-pages older history into the
// list — otherwise "load earlier" would yank the viewport to the bottom.
const lastMessageId = messages[messages.length - 1]?.id;
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
}, [lastMessageId]);
return (
<div className="flex h-full flex-col">
@@ -77,17 +81,7 @@ export function ChatShell(): React.ReactNode {
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
/>
) : (
messages.map((m) => (
<SplitChatBubble
key={m.id}
message={m}
active={
m.id === lastAssistantId &&
m.role === "assistant" &&
(m.streaming ?? false)
}
/>
))
<HistoryList lastAssistantId={lastAssistantId} />
)}
<div ref={bottomRef} />
</div>
+65 -10
View File
@@ -8,6 +8,7 @@ import type {
FlowGraph,
Mode,
PersonaRecord,
SessionMeta,
ToolUsed,
WsStatus,
} from "../lib/types";
@@ -20,8 +21,15 @@ interface ChatContext {
}
export interface AppStore {
sessionId: string;
/** Session metadata (main + secondary) keyed by session id. The message truth
* lives server-side, so this is a small list — safe to persist. */
sessions: Record<string, SessionMeta>;
/** The session the UI is currently focused on (design/16 §2.1). "" until the
* session list is loaded. Messages in `messages` always belong to this id. */
activeSessionId: string;
wsStatus: WsStatus;
/** Messages of the *active* session, in display order (oldest first). Reset on
* session switch and refilled from the backend (design/16 §4/§5). */
messages: ChatMessage[];
mood: string | null;
context: ChatContext;
@@ -41,11 +49,21 @@ export interface AppStore {
currentPersona: PersonaRecord | null;
setWsStatus: (status: WsStatus) => void;
setSessionId: (id: string) => void;
setCurrentPersona: (p: PersonaRecord | null) => void;
setContext: (patch: Partial<ChatContext>) => void;
setMood: (mood: string | null) => void;
/** Replace the session metadata list. */
setSessions: (list: SessionMeta[]) => void;
/** Focus a session and clear its message cache (refilled by the caller). */
setActiveSession: (id: string) => void;
/** Replace the active session's message list (initial page on switch). */
setMessages: (list: ChatMessage[]) => void;
/** Prepend an older page (chronological) onto the active session's messages. */
prependMessages: (list: ChatMessage[]) => void;
/** Append a new message, or merge by id if it already exists (WS/dedup). */
appendOrUpdateMessage: (msg: ChatMessage) => void;
addUserMessage: (text: string, mode?: Mode, image?: string) => void;
beginAssistant: (mode?: Mode) => string;
appendChunk: (delta: string) => void;
@@ -77,7 +95,8 @@ export interface AppStore {
export const useAppStore = create<AppStore>()(
persist(
(set, get) => ({
sessionId: "web",
sessions: {},
activeSessionId: "",
wsStatus: "idle",
messages: [],
mood: null,
@@ -97,12 +116,32 @@ export const useAppStore = create<AppStore>()(
taskLogs: {},
setWsStatus: (status) => set({ wsStatus: status }),
setSessionId: (id) => set({ sessionId: id }),
setCurrentPersona: (p) => set({ currentPersona: p }),
setContext: (patch) =>
set((s) => ({ context: { ...s.context, ...patch } })),
setMood: (mood) => set({ mood }),
setSessions: (list) =>
set({
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
}),
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
setMessages: (list) => set({ messages: list }),
prependMessages: (list) =>
set((s) => {
const seen = new Set(s.messages.map((m) => m.id));
const older = list.filter((m) => !seen.has(m.id));
return { messages: [...older, ...s.messages] };
}),
appendOrUpdateMessage: (msg) =>
set((s) => {
const idx = s.messages.findIndex((m) => m.id === msg.id);
if (idx === -1) return { messages: [...s.messages, msg] };
const messages = s.messages.slice();
messages[idx] = { ...messages[idx], ...msg };
return { messages };
}),
addUserMessage: (text, mode, image) =>
set((s) => ({
messages: [
@@ -249,16 +288,32 @@ export const useAppStore = create<AppStore>()(
}),
{
name: "cyrene.app",
version: 1,
version: 2,
// Upgrade from v1 (which persisted a full `messages` array + `sessionId`):
// drop those — the backend is now the message truth — and carry forward the
// still-meaningful bits (persona avatar, mood, context, backthink).
migrate: (persisted) => {
const p = (persisted ?? {}) as Partial<AppStore>;
return {
activeSessionId: "",
sessions: {},
mood: p.mood ?? null,
context: p.context ?? {},
backthink: p.backthink ?? [],
currentPersona: p.currentPersona ?? null,
};
},
partialize: (s) => ({
sessionId: s.sessionId,
messages: s.messages,
// messages deliberately excluded: the backend is the message truth
// (design/16 §1), so we never persist the whole chat into localStorage.
// We keep the (small) session metadata + the focused session id so a
// refresh lands on the same conversation, plus the resolved persona for
// the avatar/background.
activeSessionId: s.activeSessionId,
sessions: s.sessions,
mood: s.mood,
context: s.context,
backthink: s.backthink,
// Persist the resolved persona so a refresh keeps showing THIS persona's
// own avatar/background — never falling back to a neutral placeholder or
// (worse) another role's image because the mount-time fetch missed.
currentPersona: s.currentPersona,
}),
}