UNPKG

@agentman/chat-widget

Version:

Agentman Chat Widget for easy integration with web applications

956 lines (774 loc) 24.6 kB
# State Management System ## Overview The state management system maintains conversation context, form sessions, component states, and cached responses across the chat widget lifecycle. It ensures data persistence, enables intelligent routing decisions, and provides a seamless user experience. ## State Architecture ```typescript // StateManager.ts export class StateManager { // Core state stores private conversationState: ConversationState; private formSessions: Map<string, FormSession>; private componentStates: Map<string, ComponentState>; private responseCache: ResponseCache; private userPreferences: UserPreferences; // Persistence layer private persistenceManager: PersistenceManager; // Event emitter for state changes private eventBus: EventBus; constructor(config: StateConfig) { this.initializeStores(); this.persistenceManager = new PersistenceManager(config.persistence); this.eventBus = new EventBus(); // Load persisted state this.loadPersistedState(); // Set up auto-save this.setupAutoSave(config.autoSaveInterval); } private initializeStores(): void { this.conversationState = { id: this.generateConversationId(), messages: [], context: {}, metadata: { startedAt: Date.now(), lastActiveAt: Date.now(), messageCount: 0 } }; this.formSessions = new Map(); this.componentStates = new Map(); this.responseCache = new ResponseCache(); this.userPreferences = this.loadUserPreferences(); } } ``` ## Conversation State Management ### Conversation State Structure ```typescript interface ConversationState { id: string; messages: Message[]; context: ConversationContext; metadata: ConversationMetadata; } interface ConversationContext { // User information user?: { id?: string; name?: string; email?: string; preferences?: Record<string, any>; }; // Current session data session: { startTime: number; lastInteraction: number; interactionCount: number; }; // Business context business: { lastProductViewed?: ProductContext; cartItems?: CartItem[]; lastFormSubmitted?: FormContext; searchHistory?: SearchQuery[]; }; // AI context ai: { lastIntent?: string; topicHistory?: string[]; sentiment?: 'positive' | 'neutral' | 'negative'; }; } interface Message { id: string; type: 'user' | 'assistant' | 'system' | 'component'; content: string | ComponentMessage; timestamp: number; metadata?: { tokenCount?: number; responseTime?: number; routingDecision?: string; componentType?: string; }; } ``` ### Conversation State Operations ```typescript class ConversationStateManager { addMessage(message: Message): void { // Add to messages array this.conversationState.messages.push(message); // Update metadata this.conversationState.metadata.messageCount++; this.conversationState.metadata.lastActiveAt = Date.now(); // Update context based on message type this.updateContextFromMessage(message); // Emit event this.eventBus.emit('message-added', message); // Trigger persistence this.schedulePersistence(); } updateContext(updates: Partial<ConversationContext>): void { this.conversationState.context = { ...this.conversationState.context, ...updates, session: { ...this.conversationState.context.session, lastInteraction: Date.now() } }; this.eventBus.emit('context-updated', updates); } private updateContextFromMessage(message: Message): void { // Extract and store relevant context if (message.type === 'user') { // Update search history const searchTerms = this.extractSearchTerms(message.content as string); if (searchTerms.length > 0) { this.addToSearchHistory(searchTerms); } // Update sentiment this.updateSentiment(message.content as string); } else if (message.type === 'component') { // Store component interaction const componentMsg = message.content as ComponentMessage; this.storeComponentInteraction(componentMsg); } } getRecentContext(messageCount: number = 10): any { const recentMessages = this.conversationState.messages.slice(-messageCount); return { messages: recentMessages.map(m => ({ type: m.type, content: this.summarizeMessage(m), timestamp: m.timestamp })), currentContext: { user: this.conversationState.context.user, lastProduct: this.conversationState.context.business.lastProductViewed, cartItemCount: this.conversationState.context.business.cartItems?.length || 0 } }; } private summarizeMessage(message: Message): string { if (typeof message.content === 'string') { // Truncate long messages return message.content.length > 200 ? message.content.substring(0, 200) + '...' : message.content; } else { // Summarize component messages const componentMsg = message.content as ComponentMessage; return `[${componentMsg.type}: ${componentMsg.summary}]`; } } } ``` ## Form Session Management ```typescript interface FormSession { id: string; formType: string; startedAt: number; lastUpdatedAt?: number; completedAt?: number; // Form data data: Record<string, any>; // Validation state validation: { errors: Record<string, string>; warnings: Record<string, string>; touched: string[]; }; // Progress tracking progress: { currentStep?: number; totalSteps?: number; completedFields: string[]; requiredFields: string[]; }; // Submission state submission?: { attempts: number; lastAttempt?: number; success?: boolean; result?: any; }; } class FormSessionManager { startSession(formId: string, formType: string, schema: FormSchema): FormSession { const session: FormSession = { id: formId, formType, startedAt: Date.now(), data: {}, validation: { errors: {}, warnings: {}, touched: [] }, progress: { completedFields: [], requiredFields: this.extractRequiredFields(schema) } }; this.formSessions.set(formId, session); // Set up auto-save for form data this.setupFormAutoSave(formId); return session; } updateField(formId: string, fieldId: string, value: any): void { const session = this.formSessions.get(formId); if (!session) return; // Update data session.data[fieldId] = value; session.lastUpdatedAt = Date.now(); // Mark as touched if (!session.validation.touched.includes(fieldId)) { session.validation.touched.push(fieldId); } // Update progress if (value && !session.progress.completedFields.includes(fieldId)) { session.progress.completedFields.push(fieldId); } else if (!value && session.progress.completedFields.includes(fieldId)) { session.progress.completedFields = session.progress.completedFields.filter(f => f !== fieldId); } // Calculate completion percentage const completion = this.calculateCompletion(session); this.eventBus.emit('form-progress', { formId, completion }); } completeSession(formId: string, result: any): void { const session = this.formSessions.get(formId); if (!session) return; session.completedAt = Date.now(); session.submission = { attempts: (session.submission?.attempts || 0) + 1, lastAttempt: Date.now(), success: true, result }; // Add to conversation context this.addFormToContext(session); // Clean up after delay (keep for recovery) setTimeout(() => { this.formSessions.delete(formId); }, 300000); // 5 minutes } recoverSession(formId: string): FormSession | null { // Try to recover from persistence const persisted = this.persistenceManager.getFormSession(formId); if (persisted) { this.formSessions.set(formId, persisted); return persisted; } return null; } private setupFormAutoSave(formId: string): void { const saveInterval = setInterval(() => { const session = this.formSessions.get(formId); if (!session) { clearInterval(saveInterval); return; } if (session.completedAt) { clearInterval(saveInterval); return; } // Save to persistence this.persistenceManager.saveFormSession(formId, session); }, 5000); // Save every 5 seconds } } ``` ## Component State Management ```typescript interface ComponentState { id: string; type: string; createdAt: number; lastInteraction?: number; // Component-specific state state: Record<string, any>; // User interactions interactions: ComponentInteraction[]; // Performance metrics metrics?: { renderTime?: number; interactionCount?: number; errorCount?: number; }; } interface ComponentInteraction { type: string; timestamp: number; data?: any; } class ComponentStateManager { registerComponent(componentId: string, type: string, initialState?: any): void { const state: ComponentState = { id: componentId, type, createdAt: Date.now(), state: initialState || {}, interactions: [], metrics: { interactionCount: 0, errorCount: 0 } }; this.componentStates.set(componentId, state); } updateComponentState(componentId: string, updates: any): void { const state = this.componentStates.get(componentId); if (!state) return; state.state = { ...state.state, ...updates }; state.lastInteraction = Date.now(); this.eventBus.emit('component-state-updated', { componentId, updates }); } recordInteraction(componentId: string, interaction: ComponentInteraction): void { const state = this.componentStates.get(componentId); if (!state) return; state.interactions.push(interaction); state.lastInteraction = interaction.timestamp; if (state.metrics) { state.metrics.interactionCount++; } // Keep only recent interactions (memory management) if (state.interactions.length > 100) { state.interactions = state.interactions.slice(-50); } } getComponentState(componentId: string): any { return this.componentStates.get(componentId)?.state; } cleanupInactiveComponents(maxAge: number = 3600000): void { const now = Date.now(); for (const [id, state] of this.componentStates.entries()) { const age = now - (state.lastInteraction || state.createdAt); if (age > maxAge) { this.componentStates.delete(id); this.eventBus.emit('component-cleaned-up', { componentId: id }); } } } } ``` ## Response Cache Management ```typescript interface CachedResponse { key: string; response: MCPResponse; timestamp: number; ttl: number; hits: number; metadata: { source: 'mcp' | 'llm' | 'local'; routingDecision?: string; responseTime?: number; }; } class ResponseCache { private cache: Map<string, CachedResponse> = new Map(); private maxSize: number = 100; private defaultTTL: number = 300000; // 5 minutes set(key: string, response: MCPResponse, ttl?: number): void { // Implement LRU if at capacity if (this.cache.size >= this.maxSize) { this.evictLRU(); } const cached: CachedResponse = { key, response, timestamp: Date.now(), ttl: ttl || this.defaultTTL, hits: 0, metadata: { source: 'mcp' } }; this.cache.set(key, cached); } get(key: string): MCPResponse | null { const cached = this.cache.get(key); if (!cached) return null; // Check if expired if (this.isExpired(cached)) { this.cache.delete(key); return null; } // Update hit count cached.hits++; return cached.response; } findByPattern(pattern: string | RegExp): MCPResponse[] { const results: MCPResponse[] = []; const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern; for (const [key, cached] of this.cache.entries()) { if (regex.test(key) && !this.isExpired(cached)) { results.push(cached.response); } } return results; } private isExpired(cached: CachedResponse): boolean { return Date.now() - cached.timestamp > cached.ttl; } private evictLRU(): void { let lruKey: string | null = null; let lruTime = Infinity; for (const [key, cached] of this.cache.entries()) { const lastAccess = cached.timestamp + (cached.hits * 1000); // Factor in hits if (lastAccess < lruTime) { lruTime = lastAccess; lruKey = key; } } if (lruKey) { this.cache.delete(lruKey); } } getStats(): CacheStats { let totalHits = 0; let totalSize = 0; for (const cached of this.cache.values()) { totalHits += cached.hits; totalSize += JSON.stringify(cached.response).length; } return { entries: this.cache.size, totalHits, totalSizeBytes: totalSize, hitRate: totalHits / (totalHits + this.cache.size), averageHits: totalHits / this.cache.size || 0 }; } } ``` ## Persistence Strategy ```typescript class PersistenceManager { private storage: StorageAdapter; private encryptor?: Encryptor; constructor(config: PersistenceConfig) { this.storage = this.createStorageAdapter(config); if (config.encryption) { this.encryptor = new Encryptor(config.encryptionKey); } } private createStorageAdapter(config: PersistenceConfig): StorageAdapter { switch (config.type) { case 'localStorage': return new LocalStorageAdapter(); case 'sessionStorage': return new SessionStorageAdapter(); case 'indexedDB': return new IndexedDBAdapter(config.dbName); case 'hybrid': return new HybridStorageAdapter(); default: return new LocalStorageAdapter(); } } async saveState(state: StateSnapshot): Promise<void> { const data = this.prepareForStorage(state); if (this.encryptor) { const encrypted = await this.encryptor.encrypt(data); await this.storage.set('chat_state', encrypted); } else { await this.storage.set('chat_state', data); } } async loadState(): Promise<StateSnapshot | null> { try { let data = await this.storage.get('chat_state'); if (!data) return null; if (this.encryptor) { data = await this.encryptor.decrypt(data); } return this.parseStoredData(data); } catch (error) { console.error('Failed to load state:', error); return null; } } private prepareForStorage(state: StateSnapshot): any { // Remove non-serializable data const cleaned = { ...state, messages: state.messages.slice(-50), // Keep only recent messages componentStates: this.cleanComponentStates(state.componentStates) }; return JSON.stringify(cleaned); } private cleanComponentStates(states: Map<string, ComponentState>): any { const cleaned: Record<string, any> = {}; for (const [id, state] of states.entries()) { // Only persist essential state cleaned[id] = { type: state.type, state: state.state, lastInteraction: state.lastInteraction }; } return cleaned; } } // Storage Adapters abstract class StorageAdapter { abstract get(key: string): Promise<any>; abstract set(key: string, value: any): Promise<void>; abstract remove(key: string): Promise<void>; abstract clear(): Promise<void>; } class LocalStorageAdapter extends StorageAdapter { async get(key: string): Promise<any> { return localStorage.getItem(key); } async set(key: string, value: any): Promise<void> { localStorage.setItem(key, value); } async remove(key: string): Promise<void> { localStorage.removeItem(key); } async clear(): Promise<void> { localStorage.clear(); } } class IndexedDBAdapter extends StorageAdapter { private db: IDBDatabase | null = null; constructor(private dbName: string) { super(); this.initDB(); } private async initDB(): Promise<void> { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); request.onsuccess = () => { this.db = request.result; resolve(); }; request.onerror = () => reject(request.error); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains('state')) { db.createObjectStore('state', { keyPath: 'key' }); } }; }); } async get(key: string): Promise<any> { if (!this.db) await this.initDB(); return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['state'], 'readonly'); const store = transaction.objectStore('state'); const request = store.get(key); request.onsuccess = () => resolve(request.result?.value); request.onerror = () => reject(request.error); }); } async set(key: string, value: any): Promise<void> { if (!this.db) await this.initDB(); return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['state'], 'readwrite'); const store = transaction.objectStore('state'); const request = store.put({ key, value }); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async remove(key: string): Promise<void> { if (!this.db) await this.initDB(); return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['state'], 'readwrite'); const store = transaction.objectStore('state'); const request = store.delete(key); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } async clear(): Promise<void> { if (!this.db) await this.initDB(); return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['state'], 'readwrite'); const store = transaction.objectStore('state'); const request = store.clear(); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } } ``` ## Session Management ```typescript class SessionManager { private sessionId: string; private sessionTimeout: number = 1800000; // 30 minutes private lastActivity: number; private inactivityTimer?: NodeJS.Timeout; constructor() { this.sessionId = this.createSessionId(); this.lastActivity = Date.now(); this.startInactivityTimer(); } private createSessionId(): string { return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } recordActivity(): void { this.lastActivity = Date.now(); this.resetInactivityTimer(); } private startInactivityTimer(): void { this.inactivityTimer = setInterval(() => { if (Date.now() - this.lastActivity > this.sessionTimeout) { this.handleSessionTimeout(); } }, 60000); // Check every minute } private resetInactivityTimer(): void { if (this.inactivityTimer) { clearInterval(this.inactivityTimer); this.startInactivityTimer(); } } private handleSessionTimeout(): void { // Save state before timeout this.stateManager.saveState(); // Emit timeout event this.eventBus.emit('session-timeout', { sessionId: this.sessionId, duration: Date.now() - this.lastActivity }); // Show timeout message this.showTimeoutMessage(); // Clear sensitive data this.clearSensitiveData(); } private clearSensitiveData(): void { // Clear form sessions with sensitive data for (const [id, session] of this.formSessions.entries()) { if (this.hasSensitiveData(session)) { this.formSessions.delete(id); } } // Clear sensitive context delete this.conversationState.context.user?.email; // ... other sensitive fields } } ``` ## State Recovery ```typescript class StateRecovery { async recoverState(): Promise<boolean> { try { // Load persisted state const persisted = await this.persistenceManager.loadState(); if (!persisted) return false; // Check if state is still valid if (!this.isStateValid(persisted)) { await this.persistenceManager.clear(); return false; } // Restore conversation this.restoreConversation(persisted.conversation); // Restore active forms this.restoreFormSessions(persisted.formSessions); // Restore user preferences this.restoreUserPreferences(persisted.preferences); // Show recovery message this.showRecoveryMessage(); return true; } catch (error) { console.error('State recovery failed:', error); return false; } } private isStateValid(state: StateSnapshot): boolean { // Check age const age = Date.now() - state.timestamp; if (age > 86400000) return false; // 24 hours // Check version compatibility if (state.version !== this.currentVersion) { return this.canMigrateState(state.version); } return true; } private restoreConversation(conversation: ConversationState): void { // Restore messages this.conversationState.messages = conversation.messages; // Restore context this.conversationState.context = { ...conversation.context, session: { ...conversation.context.session, restored: true, restoredAt: Date.now() } }; // Re-render messages in UI this.renderRestoredMessages(conversation.messages); } } ``` ## Testing State Management ```typescript describe('StateManager', () => { let stateManager: StateManager; beforeEach(() => { stateManager = new StateManager({ persistence: { type: 'localStorage' }, autoSaveInterval: 1000 }); }); test('should add messages to conversation', () => { const message: Message = { id: '1', type: 'user', content: 'Hello', timestamp: Date.now() }; stateManager.addMessage(message); const context = stateManager.getRecentContext(); expect(context.messages).toHaveLength(1); expect(context.messages[0].content).toBe('Hello'); }); test('should manage form sessions', () => { const formId = 'test-form'; const session = stateManager.startFormSession(formId, 'lead', mockFormSchema); stateManager.updateFormField(formId, 'email', 'test@example.com'); const updated = stateManager.getFormSession(formId); expect(updated?.data.email).toBe('test@example.com'); }); test('should cache responses', () => { const response: MCPResponse = { uiType: 'test', structuredContent: { data: 'test' }, _meta: {} }; stateManager.cacheResponse('test-key', response); const cached = stateManager.getCachedResponse('test-key'); expect(cached).toEqual(response); }); test('should persist and recover state', async () => { stateManager.addMessage({ id: '1', type: 'user', content: 'Test message', timestamp: Date.now() }); await stateManager.saveState(); const newStateManager = new StateManager({ persistence: { type: 'localStorage' } }); const recovered = await newStateManager.recoverState(); expect(recovered).toBe(true); const context = newStateManager.getRecentContext(); expect(context.messages[0].content).toBe('Test message'); }); }); ``` ## Next Steps 1. See `ARCHITECTURE.md` for system overview 2. Check `CHAT_WIDGET_IMPLEMENTATION.md` for integration 3. Review `FORM_SUBMISSION_FLOW.md` for form state handling 4. Read `TESTING_GUIDE.md` for testing strategies