refactor(desktop): ChatStore structured split + IDB persistence + stream cancel
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

Split monolithic chatStore.ts (908 lines) into 4 focused stores:
- chatStore.ts: facade layer, owns messages[], backward-compatible selectors
- conversationStore.ts: conversation CRUD, agent switching, IndexedDB persistence
- streamStore.ts: streaming orchestration, chat mode, suggestions
- messageStore.ts: token tracking

Key fixes from 3-round deep audit:
- C1: Fix Rust serde camelCase vs TS snake_case mismatch (toolStart/toolEnd/iterationStart)
- C2: Fix IDB async rehydration race with persist.hasHydrated() subscribe
- C3: Add sessionKey to partialize to survive page refresh
- H3: Fix IDB migration retry on failure (don't set migrated=true in catch)
- M3: Fix ToolCallStep deduplication (toolStart creates, toolEnd updates)
- M-NEW-2: Clear sessionKey on cancelStream

Also adds:
- Rust backend stream cancellation via AtomicBool + cancel_stream command
- IndexedDB storage adapter with one-time localStorage migration
- HMR cleanup for cross-store subscriptions
This commit is contained in:
iven
2026-04-03 00:24:16 +08:00
parent da438ad868
commit 0a04b260a4
22 changed files with 1269 additions and 767 deletions

View File

@@ -8,12 +8,10 @@
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { generateRandomString } from '../lib/crypto-utils';
import { createLogger } from '../lib/logger';
import type { Message } from './chatStore';
const log = createLogger('ConversationStore');
import { persist, createJSONStorage } from 'zustand/middleware';
import { generateRandomString } from '../../lib/crypto-utils';
import { createIdbStorageAdapter } from '../../lib/idb-storage';
import type { ChatMessage } from '../../types/chat';
// ---------------------------------------------------------------------------
// Types
@@ -22,7 +20,7 @@ const log = createLogger('ConversationStore');
export interface Conversation {
id: string;
title: string;
messages: Message[];
messages: ChatMessage[];
sessionKey: string | null;
agentId: string | null;
createdAt: Date;
@@ -45,9 +43,6 @@ export interface AgentProfileLike {
role?: string;
}
// Re-export Message for internal use (avoids circular imports during migration)
export type { Message };
// ---------------------------------------------------------------------------
// State interface
// ---------------------------------------------------------------------------
@@ -61,10 +56,10 @@ export interface ConversationState {
currentModel: string;
// Actions
newConversation: (currentMessages: Message[]) => Conversation[];
switchConversation: (id: string, currentMessages: Message[]) => {
newConversation: (currentMessages: ChatMessage[]) => Conversation[];
switchConversation: (id: string, currentMessages: ChatMessage[]) => {
conversations: Conversation[];
messages: Message[];
messages: ChatMessage[];
sessionKey: string | null;
currentAgent: Agent;
currentConversationId: string;
@@ -74,10 +69,10 @@ export interface ConversationState {
conversations: Conversation[];
resetMessages: boolean;
};
setCurrentAgent: (agent: Agent, currentMessages: Message[]) => {
setCurrentAgent: (agent: Agent, currentMessages: ChatMessage[]) => {
conversations: Conversation[];
currentAgent: Agent;
messages: Message[];
messages: ChatMessage[];
sessionKey: string | null;
isStreaming: boolean;
currentConversationId: string | null;
@@ -87,7 +82,7 @@ export interface ConversationState {
currentAgent: Agent;
};
setCurrentModel: (model: string) => void;
upsertActiveConversation: (currentMessages: Message[]) => Conversation[];
upsertActiveConversation: (currentMessages: ChatMessage[]) => Conversation[];
getCurrentConversationId: () => string | null;
getCurrentAgent: () => Agent | null;
getSessionKey: () => string | null;
@@ -101,7 +96,7 @@ function generateConvId(): string {
return `conv_${Date.now()}_${generateRandomString(4)}`;
}
function deriveTitle(messages: Message[]): string {
function deriveTitle(messages: ChatMessage[]): string {
const firstUser = messages.find(m => m.role === 'user');
if (firstUser) {
const text = firstUser.content.trim();
@@ -155,7 +150,7 @@ export function resolveAgentForConversation(agentId: string | null, agents: Agen
function upsertActiveConversation(
conversations: Conversation[],
messages: Message[],
messages: ChatMessage[],
sessionKey: string | null,
currentConversationId: string | null,
currentAgent: Agent | null,
@@ -199,7 +194,7 @@ export const useConversationStore = create<ConversationState>()(
sessionKey: null,
currentModel: 'glm-4-flash',
newConversation: (currentMessages: Message[]) => {
newConversation: (currentMessages: ChatMessage[]) => {
const state = get();
const conversations = upsertActiveConversation(
[...state.conversations], currentMessages, state.sessionKey,
@@ -213,7 +208,7 @@ export const useConversationStore = create<ConversationState>()(
return conversations;
},
switchConversation: (id: string, currentMessages: Message[]) => {
switchConversation: (id: string, currentMessages: ChatMessage[]) => {
const state = get();
const conversations = upsertActiveConversation(
[...state.conversations], currentMessages, state.sessionKey,
@@ -251,7 +246,7 @@ export const useConversationStore = create<ConversationState>()(
return { conversations, resetMessages };
},
setCurrentAgent: (agent: Agent, currentMessages: Message[]) => {
setCurrentAgent: (agent: Agent, currentMessages: ChatMessage[]) => {
const state = get();
if (state.currentAgent?.id === agent.id) {
set({ currentAgent: agent });
@@ -328,7 +323,7 @@ export const useConversationStore = create<ConversationState>()(
setCurrentModel: (model: string) => set({ currentModel: model }),
upsertActiveConversation: (currentMessages: Message[]) => {
upsertActiveConversation: (currentMessages: ChatMessage[]) => {
const state = get();
const conversations = upsertActiveConversation(
[...state.conversations], currentMessages, state.sessionKey,
@@ -344,11 +339,12 @@ export const useConversationStore = create<ConversationState>()(
}),
{
name: 'zclaw-conversation-storage',
storage: createJSONStorage(() => createIdbStorageAdapter()),
partialize: (state) => ({
conversations: state.conversations,
currentModel: state.currentModel,
currentAgentId: state.currentAgent?.id,
currentConversationId: state.currentConversationId,
sessionKey: state.sessionKey,
}),
onRehydrateStorage: () => (state) => {
if (state?.conversations) {