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
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:
134
desktop/src/lib/idb-storage.ts
Normal file
134
desktop/src/lib/idb-storage.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* idb-storage.ts — Zustand-compatible async storage adapter using IndexedDB.
|
||||
*
|
||||
* Provides a drop-in replacement for localStorage that uses IndexedDB,
|
||||
* bypassing the 5MB storage limit for conversation data.
|
||||
*
|
||||
* Includes one-time migration from localStorage for the conversation store.
|
||||
*/
|
||||
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const log = createLogger('IDBStorage');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IndexedDB schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DB_NAME = 'zclaw-store';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'keyvalue';
|
||||
|
||||
// localStorage key that holds existing conversation data
|
||||
const CONVERSATION_LS_KEY = 'zclaw-conversation-storage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBPDatabase> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One-time migration from localStorage -> IndexedDB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let migrated = false;
|
||||
|
||||
async function migrateFromLocalStorage(): Promise<void> {
|
||||
if (migrated) return;
|
||||
migrated = true;
|
||||
|
||||
try {
|
||||
const db = await getDB();
|
||||
// Check if IndexedDB already has data
|
||||
const existing = await db.get(STORE_NAME, CONVERSATION_LS_KEY);
|
||||
if (existing !== undefined) {
|
||||
return; // Already migrated
|
||||
}
|
||||
|
||||
// Read from localStorage
|
||||
const lsData = localStorage.getItem(CONVERSATION_LS_KEY);
|
||||
if (!lsData) return;
|
||||
|
||||
log.info('Migrating conversation data from localStorage to IndexedDB...');
|
||||
const parsed = JSON.parse(lsData);
|
||||
|
||||
// Write to IndexedDB
|
||||
await db.put(STORE_NAME, parsed, CONVERSATION_LS_KEY);
|
||||
|
||||
// Delete from localStorage to free space
|
||||
localStorage.removeItem(CONVERSATION_LS_KEY);
|
||||
log.info('Migration complete. localStorage entry removed.');
|
||||
} catch (err) {
|
||||
log.error('Migration from localStorage failed:', err);
|
||||
// Allow retry on next load — don't leave `migrated = true` on failure
|
||||
migrated = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zustand-compatible storage adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a Zustand persist storage adapter backed by IndexedDB.
|
||||
*
|
||||
* Usage:
|
||||
* persist(store, { storage: createJSONStorage(() => createIdbStorageAdapter()) })
|
||||
*/
|
||||
export function createIdbStorageAdapter() {
|
||||
return {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
// Perform migration on first access for conversation store key
|
||||
if (name === CONVERSATION_LS_KEY) {
|
||||
await migrateFromLocalStorage();
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDB();
|
||||
const value = await db.get(STORE_NAME, name);
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
// Zustand persist expects a JSON string
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
} catch (err) {
|
||||
log.error('IndexedDB getItem failed:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const parsed = JSON.parse(value);
|
||||
await db.put(STORE_NAME, parsed, name);
|
||||
} catch (err) {
|
||||
log.error('IndexedDB setItem failed:', err);
|
||||
}
|
||||
},
|
||||
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
try {
|
||||
const db = await getDB();
|
||||
await db.delete(STORE_NAME, name);
|
||||
} catch (err) {
|
||||
log.error('IndexedDB removeItem failed:', err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
case 'toolStart':
|
||||
log.debug('Tool started:', streamEvent.name, streamEvent.input);
|
||||
if (callbacks.onTool) {
|
||||
callbacks.onTool(
|
||||
@@ -120,7 +120,7 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
case 'toolEnd':
|
||||
log.debug('Tool ended:', streamEvent.name, streamEvent.output);
|
||||
if (callbacks.onTool) {
|
||||
callbacks.onTool(
|
||||
@@ -145,7 +145,7 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
}
|
||||
break;
|
||||
|
||||
case 'iteration_start':
|
||||
case 'iterationStart':
|
||||
log.debug('Iteration started:', streamEvent.iteration, '/', streamEvent.maxIterations);
|
||||
// Don't need to notify user about iterations
|
||||
break;
|
||||
@@ -201,10 +201,17 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel a stream (no-op for internal kernel)
|
||||
* Cancel an active stream by session ID.
|
||||
* Invokes the Rust `cancel_stream` command which sets the AtomicBool flag
|
||||
* checked by the spawned streaming task each iteration.
|
||||
*/
|
||||
proto.cancelStream = function (this: KernelClient, _runId: string): void {
|
||||
// No-op: internal kernel doesn't support stream cancellation
|
||||
proto.cancelStream = async function (this: KernelClient, sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('cancel_stream', { sessionId });
|
||||
log.debug('Cancel stream requested for session:', sessionId);
|
||||
} catch (err) {
|
||||
log.warn('Failed to cancel stream:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Default Agent ───
|
||||
|
||||
@@ -404,7 +404,7 @@ export interface KernelClient {
|
||||
// Chat (kernel-chat.ts)
|
||||
chat(message: string, opts?: { sessionKey?: string; agentId?: string }): Promise<{ runId: string; sessionId?: string; response?: string }>;
|
||||
chatStream(message: string, callbacks: import('./kernel-types').StreamCallbacks, opts?: { sessionKey?: string; agentId?: string; thinking_enabled?: boolean; reasoning_effort?: string; plan_mode?: boolean }): Promise<{ runId: string }>;
|
||||
cancelStream(runId: string): void;
|
||||
cancelStream(sessionId: string): Promise<void>;
|
||||
fetchDefaultAgentId(): Promise<string | null>;
|
||||
setDefaultAgentId(agentId: string): void;
|
||||
getDefaultAgentId(): string;
|
||||
|
||||
@@ -78,19 +78,19 @@ export interface StreamEventThinkingDelta {
|
||||
}
|
||||
|
||||
export interface StreamEventToolStart {
|
||||
type: 'tool_start';
|
||||
type: 'toolStart';
|
||||
name: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export interface StreamEventToolEnd {
|
||||
type: 'tool_end';
|
||||
type: 'toolEnd';
|
||||
name: string;
|
||||
output: unknown;
|
||||
}
|
||||
|
||||
export interface StreamEventIterationStart {
|
||||
type: 'iteration_start';
|
||||
type: 'iterationStart';
|
||||
iteration: number;
|
||||
maxIterations: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user