@agentman/chat-widget
Version:
Agentman Chat Widget for easy integration with web applications
956 lines (774 loc) • 24.6 kB
Markdown
# 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