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

+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,
}),
}