feat(web): 前端接会话持久化——拉历史热分页 + 侧栏会话列表
- 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:
1 parent
48421923f6
commit
bcc53d68f3
9 files changed
+371
-32
No files matched your search
@@ -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,
|
||||
}),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user