diff --git a/Docs/api-client.md b/Docs/api-client.md index 654b427..93697ed 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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:`);未注入时退化回渠道会话列表 | +| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:"`);未注入 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:}}`;主会话不可删回 `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://:8080/ws/chat?token=` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 -- **下行(服务端→客户端)**: 经 bus 广播 `{type:"", 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:"", 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/.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/成本后端不统计、不返回(不伪造指标)。 diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 343cc7d..4953d57 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -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; + 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) { diff --git a/frontend/web/src/components/Nav.tsx b/frontend/web/src/components/Nav.tsx index 85bd1b2..68a9eb2 100644 --- a/frontend/web/src/components/Nav.tsx +++ b/frontend/web/src/components/Nav.tsx @@ -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(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 ( <>
@@ -55,6 +89,32 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
) : null} + {/* conversation sessions (design/16 §2) — main fixed, secondary created */} + {hasToken ? ( +
+
+ 会话 +
+ {sessionList.map((s) => ( + selectSession(s.id)} + /> + ))} + +
+ ) : null} +
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 ? ( + + ) : null} + {collapsed ? ( +
+ 已收起 {messages.length - MAX_RENDER} 条较早消息 +
+ ) : null} + {visible.map((m) => ( + + ))} + + ); +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 57a0893..5687cdc 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -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 { - return request("/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 { + return request("/sessions"); +} + +/** POST /api/v1/sessions — creates a new secondary session. */ +export function createSubSession(title?: string): Promise { + return request("/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 { + const qs = new URLSearchParams(); + qs.set("limit", String(limit)); + if (before !== undefined) qs.set("before", String(before)); + const list = await request( + `/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. */ diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 4f9b223..a5997a7 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -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" diff --git a/frontend/web/src/lib/useChat.ts b/frontend/web/src/lib/useChat.ts index 58d433c..d327961 100644 --- a/frontend/web/src/lib/useChat.ts +++ b/frontend/web/src/lib/useChat.ts @@ -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); diff --git a/frontend/web/src/modes/ChatShell.tsx b/frontend/web/src/modes/ChatShell.tsx index 356f246..e29c8eb 100644 --- a/frontend/web/src/modes/ChatShell.tsx +++ b/frontend/web/src/modes/ChatShell.tsx @@ -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 (
@@ -77,17 +81,7 @@ export function ChatShell(): React.ReactNode { hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变" /> ) : ( - messages.map((m) => ( - - )) + )}
diff --git a/frontend/web/src/store/useAppStore.ts b/frontend/web/src/store/useAppStore.ts index 3515953..802deb8 100644 --- a/frontend/web/src/store/useAppStore.ts +++ b/frontend/web/src/store/useAppStore.ts @@ -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; + /** 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) => 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()( persist( (set, get) => ({ - sessionId: "web", + sessions: {}, + activeSessionId: "", wsStatus: "idle", messages: [], mood: null, @@ -97,12 +116,32 @@ export const useAppStore = create()( 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()( }), { 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; + 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, }), }