UNPKG

@agentman/chat-widget

Version:

Agentman Chat Widget for easy integration with web applications

934 lines (775 loc) 26.3 kB
# Chat Widget Implementation Guide ## Overview This guide provides a complete implementation of the TypeScript chat widget that handles MCP responses with intelligent routing and rich UI rendering. ## Core Classes ### Main ChatWidget Class ```typescript // ChatWidget.ts import { ComponentRegistry } from './ComponentRegistry'; import { MCPClient } from './MCPClient'; import { StateManager } from './StateManager'; import { ResponseRouter } from './ResponseRouter'; import { FormManager } from './FormManager'; import type { MCPResponse, ChatConfig, Message } from './types'; export class ChatWidget { private container: HTMLElement; private componentRegistry: ComponentRegistry; private mcpClient: MCPClient; private stateManager: StateManager; private responseRouter: ResponseRouter; private formManager: FormManager; private messages: Message[] = []; private activeComponents: Map<string, HTMLElement> = new Map(); constructor(containerId: string, config: ChatConfig) { this.container = document.getElementById(containerId)!; this.initializeServices(config); this.setupUI(); this.attachEventListeners(); } private initializeServices(config: ChatConfig): void { // Initialize core services this.componentRegistry = new ComponentRegistry(); this.mcpClient = new MCPClient(config.mcpEndpoint, config.apiKey); this.stateManager = new StateManager(); this.responseRouter = new ResponseRouter(config.routingRules); this.formManager = new FormManager(this.mcpClient); // Register default components this.registerDefaultComponents(); } private registerDefaultComponents(): void { // Register built-in components this.componentRegistry.register('shopify-products', ShopifyProductGrid); this.componentRegistry.register('form-lead', LeadFormComponent); this.componentRegistry.register('form-payment', PaymentFormComponent); this.componentRegistry.register('form-choices', ChoiceFormComponent); this.componentRegistry.register('chart', ChartComponent); this.componentRegistry.register('table', TableComponent); this.componentRegistry.register('success', SuccessComponent); this.componentRegistry.register('error', ErrorComponent); } private setupUI(): void { this.container.innerHTML = ` <div class="agentman-chat-widget"> <div class="chat-header"> <h3 class="chat-title">Assistant</h3> <button class="chat-minimize" aria-label="Minimize">−</button> </div> <div class="chat-messages" id="chat-messages"></div> <div class="chat-input-container"> <div class="chat-input-wrapper"> <textarea class="chat-input" id="chat-input" placeholder="Type your message..." rows="1" ></textarea> <button class="chat-send" id="chat-send" aria-label="Send"> <svg><!-- Send icon --></svg> </button> </div> </div> </div> `; } private attachEventListeners(): void { const input = document.getElementById('chat-input') as HTMLTextAreaElement; const sendBtn = document.getElementById('chat-send') as HTMLButtonElement; // Send message on button click sendBtn.addEventListener('click', () => this.handleSendMessage()); // Send on Enter, new line on Shift+Enter input.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.handleSendMessage(); } }); // Auto-resize textarea input.addEventListener('input', () => this.autoResizeTextarea(input)); } private async handleSendMessage(): Promise<void> { const input = document.getElementById('chat-input') as HTMLTextAreaElement; const message = input.value.trim(); if (!message) return; // Clear input input.value = ''; this.autoResizeTextarea(input); // Add user message to chat this.addMessage({ type: 'user', content: message, timestamp: Date.now() }); // Process message await this.processUserMessage(message); } private async processUserMessage(message: string): Promise<void> { try { // Show typing indicator this.showTypingIndicator(); // Send to agent/MCP const response = await this.mcpClient.sendMessage(message); // Remove typing indicator this.hideTypingIndicator(); // Handle the response await this.handleMCPResponse(response); } catch (error) { this.hideTypingIndicator(); this.handleError(error); } } public async handleMCPResponse(response: MCPResponse): Promise<void> { // Store in state manager this.stateManager.addResponse(response); // Determine routing const routingDecision = this.responseRouter.route(response); switch (routingDecision.action) { case 'direct': // Render directly without LLM await this.renderDirectResponse(response); break; case 'llm': // Send to LLM for processing await this.sendToLLM(response); break; case 'hybrid': // Render UI and get LLM narrative await this.hybridRender(response); break; default: // Fallback to direct rendering await this.renderDirectResponse(response); } } private async renderDirectResponse(response: MCPResponse): Promise<void> { // Get the appropriate component const component = this.componentRegistry.get(response.uiType); if (!component) { // Fallback to generic renderer this.renderGenericResponse(response); return; } // Create component instance const componentInstance = new component(response._meta, { onAction: (action: string, data: any) => this.handleComponentAction(action, data), onSubmit: (data: any) => this.handleFormSubmission(response, data) }); // Render component const element = componentInstance.render(); // Add to chat this.addComponentToChat(element, response.uiType); // Store active component const componentId = this.generateComponentId(); this.activeComponents.set(componentId, element); // Add optional message if provided if (response.content) { this.addMessage({ type: 'assistant', content: response.content[0].text, timestamp: Date.now() }); } } private async sendToLLM(response: MCPResponse): Promise<void> { // Send only structuredContent to LLM const llmResponse = await this.mcpClient.sendToLLM({ data: response.structuredContent, context: this.stateManager.getConversationContext() }); // Add LLM response to chat this.addMessage({ type: 'assistant', content: llmResponse.message, timestamp: Date.now() }); // If response includes UI component, render it if (response._meta && response.uiType !== 'none') { await this.renderDirectResponse(response); } } private async hybridRender(response: MCPResponse): Promise<void> { // Render UI component immediately await this.renderDirectResponse(response); // Get LLM narrative in parallel const llmPromise = this.mcpClient.sendToLLM({ data: response.structuredContent, instruction: 'Provide a brief explanation of the displayed content' }); // Add LLM narrative when ready llmPromise.then(llmResponse => { this.addMessage({ type: 'assistant', content: llmResponse.message, timestamp: Date.now() }); }).catch(error => { console.error('LLM narrative failed:', error); // Silent fail - UI is already rendered }); } private handleComponentAction(action: string, data: any): void { // Handle actions from components switch (action) { case 'product-selected': this.handleProductSelection(data); break; case 'form-field-changed': this.handleFormFieldChange(data); break; case 'view-details': this.requestDetails(data); break; default: console.log('Unknown component action:', action, data); } } private async handleFormSubmission(response: MCPResponse, formData: any): Promise<void> { try { // Show loading state this.showLoadingState(); // Submit to MCP server const result = await this.formManager.submitForm({ formType: response.uiType, formData: formData, context: response.structuredContent }); // Hide loading this.hideLoadingState(); // Handle submission result if (result.success) { // Show success message this.addMessage({ type: 'system', content: result.message || 'Form submitted successfully!', timestamp: Date.now() }); // Handle next action if (result.nextAction) { await this.handleNextAction(result.nextAction, result.data); } } else { // Show error this.showError(result.error || 'Submission failed'); } } catch (error) { this.hideLoadingState(); this.handleError(error); } } private addMessage(message: Message): void { this.messages.push(message); const messagesContainer = document.getElementById('chat-messages')!; const messageElement = this.createMessageElement(message); messagesContainer.appendChild(messageElement); // Scroll to bottom messagesContainer.scrollTop = messagesContainer.scrollHeight; } private createMessageElement(message: Message): HTMLElement { const div = document.createElement('div'); div.className = `chat-message chat-message--${message.type}`; if (message.type === 'user') { div.innerHTML = ` <div class="message-content">${this.escapeHtml(message.content)}</div> <div class="message-time">${this.formatTime(message.timestamp)}</div> `; } else if (message.type === 'assistant') { div.innerHTML = ` <div class="message-content">${this.renderMarkdown(message.content)}</div> <div class="message-time">${this.formatTime(message.timestamp)}</div> `; } else if (message.type === 'system') { div.innerHTML = ` <div class="message-content">${message.content}</div> `; } return div; } private addComponentToChat(element: HTMLElement, type: string): void { const messagesContainer = document.getElementById('chat-messages')!; const wrapper = document.createElement('div'); wrapper.className = `chat-component chat-component--${type}`; wrapper.appendChild(element); messagesContainer.appendChild(wrapper); messagesContainer.scrollTop = messagesContainer.scrollHeight; } // Utility methods private autoResizeTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; } private showTypingIndicator(): void { const indicator = document.createElement('div'); indicator.className = 'typing-indicator'; indicator.id = 'typing-indicator'; indicator.innerHTML = ` <span></span><span></span><span></span> `; document.getElementById('chat-messages')!.appendChild(indicator); } private hideTypingIndicator(): void { const indicator = document.getElementById('typing-indicator'); indicator?.remove(); } private showLoadingState(): void { // Implementation for loading state } private hideLoadingState(): void { // Implementation for hiding loading state } private showError(message: string): void { this.addMessage({ type: 'system', content: `Error: ${message}`, timestamp: Date.now() }); } private handleError(error: any): void { console.error('Chat widget error:', error); this.showError(error.message || 'An error occurred'); } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } private renderMarkdown(text: string): string { // Simple markdown rendering (use a library like marked.js in production) return text .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/\n/g, '<br>'); } private formatTime(timestamp: number): string { return new Date(timestamp).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } private generateComponentId(): string { return `component-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } // Public API public registerComponent(type: string, component: any): void { this.componentRegistry.register(type, component); } public async sendMessage(message: string): Promise<void> { await this.processUserMessage(message); } public clear(): void { this.messages = []; this.activeComponents.clear(); document.getElementById('chat-messages')!.innerHTML = ''; } public destroy(): void { this.clear(); this.container.innerHTML = ''; // Clean up event listeners and subscriptions } } ``` ## Component Registry Pattern ```typescript // ComponentRegistry.ts export class ComponentRegistry { private components: Map<string, ComponentConstructor> = new Map(); register(type: string, component: ComponentConstructor): void { this.components.set(type, component); } get(type: string): ComponentConstructor | undefined { return this.components.get(type); } has(type: string): boolean { return this.components.has(type); } list(): string[] { return Array.from(this.components.keys()); } createComponent(type: string, data: any, callbacks?: ComponentCallbacks): UIComponent | null { const ComponentClass = this.get(type); if (!ComponentClass) return null; return new ComponentClass(data, callbacks); } } // Base component interface export interface UIComponent { render(): HTMLElement; update(data: any): void; destroy(): void; } export interface ComponentCallbacks { onAction?: (action: string, data: any) => void; onSubmit?: (data: any) => void; onChange?: (field: string, value: any) => void; } export type ComponentConstructor = new (data: any, callbacks?: ComponentCallbacks) => UIComponent; ``` ## MCP Client Implementation ```typescript // MCPClient.ts export class MCPClient { private endpoint: string; private apiKey: string; private websocket?: WebSocket; constructor(endpoint: string, apiKey: string) { this.endpoint = endpoint; this.apiKey = apiKey; } async sendMessage(message: string): Promise<MCPResponse> { const response = await fetch(`${this.endpoint}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify({ message }) }); if (!response.ok) { throw new Error(`MCP request failed: ${response.statusText}`); } return response.json(); } async callTool(toolName: string, params: any): Promise<MCPResponse> { const response = await fetch(`${this.endpoint}/tools/${toolName}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify(params) }); if (!response.ok) { throw new Error(`Tool call failed: ${response.statusText}`); } return response.json(); } async sendToLLM(data: any): Promise<{ message: string }> { const response = await fetch(`${this.endpoint}/llm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify(data) }); if (!response.ok) { throw new Error(`LLM request failed: ${response.statusText}`); } return response.json(); } // WebSocket support for streaming connectWebSocket(onMessage: (data: any) => void): void { this.websocket = new WebSocket(this.endpoint.replace('http', 'ws')); this.websocket.onopen = () => { console.log('WebSocket connected'); this.websocket!.send(JSON.stringify({ type: 'auth', apiKey: this.apiKey })); }; this.websocket.onmessage = (event) => { const data = JSON.parse(event.data); onMessage(data); }; this.websocket.onerror = (error) => { console.error('WebSocket error:', error); }; this.websocket.onclose = () => { console.log('WebSocket disconnected'); // Implement reconnection logic }; } disconnect(): void { if (this.websocket) { this.websocket.close(); this.websocket = undefined; } } } ``` ## State Manager Implementation ```typescript // StateManager.ts export class StateManager { private conversationState: ConversationState = { messages: [], context: {}, user: {} }; private responseCache: Map<string, CachedResponse> = new Map(); private formSessions: Map<string, FormSession> = new Map(); private componentStates: Map<string, any> = new Map(); // Conversation state management addMessage(message: Message): void { this.conversationState.messages.push(message); this.persistState(); } addResponse(response: MCPResponse): void { const id = this.generateResponseId(); this.responseCache.set(id, { response, timestamp: Date.now(), ttl: 3600000 // 1 hour }); // Update conversation context this.updateContext(response); } getConversationContext(): any { return { recentMessages: this.conversationState.messages.slice(-10), userInfo: this.conversationState.user, context: this.conversationState.context }; } // Form session management startFormSession(formId: string, formType: string): FormSession { const session: FormSession = { id: formId, type: formType, startedAt: Date.now(), data: {}, completed: false }; this.formSessions.set(formId, session); return session; } updateFormSession(formId: string, data: any): void { const session = this.formSessions.get(formId); if (session) { session.data = { ...session.data, ...data }; session.lastUpdated = Date.now(); } } completeFormSession(formId: string, result: any): void { const session = this.formSessions.get(formId); if (session) { session.completed = true; session.completedAt = Date.now(); session.result = result; // Add to conversation context this.conversationState.context[`form_${formId}`] = { type: session.type, completedAt: session.completedAt, summary: this.summarizeFormData(session.data) }; this.persistState(); } } // Component state management saveComponentState(componentId: string, state: any): void { this.componentStates.set(componentId, state); } getComponentState(componentId: string): any { return this.componentStates.get(componentId); } // Cache management getCachedResponse(query: string): MCPResponse | null { // Simple cache lookup (implement more sophisticated matching) for (const [key, cached] of this.responseCache.entries()) { if (this.isCacheValid(cached) && this.matchesQuery(cached.response, query)) { return cached.response; } } return null; } private isCacheValid(cached: CachedResponse): boolean { return Date.now() - cached.timestamp < cached.ttl; } private matchesQuery(response: MCPResponse, query: string): boolean { // Implement query matching logic return false; } // Persistence private persistState(): void { // Save to localStorage or IndexedDB try { localStorage.setItem('chat_state', JSON.stringify({ conversation: this.conversationState, formSessions: Array.from(this.formSessions.entries()) })); } catch (error) { console.error('Failed to persist state:', error); } } loadState(): void { try { const saved = localStorage.getItem('chat_state'); if (saved) { const state = JSON.parse(saved); this.conversationState = state.conversation; this.formSessions = new Map(state.formSessions); } } catch (error) { console.error('Failed to load state:', error); } } clearState(): void { this.conversationState = { messages: [], context: {}, user: {} }; this.responseCache.clear(); this.formSessions.clear(); this.componentStates.clear(); localStorage.removeItem('chat_state'); } private updateContext(response: MCPResponse): void { // Update context based on response type switch (response.uiType) { case 'shopify-products': this.conversationState.context.lastProductSearch = { query: response.structuredContent, timestamp: Date.now() }; break; case 'form-lead': this.conversationState.context.leadFormShown = true; break; // Add more cases as needed } } private summarizeFormData(data: any): string { // Create a summary of form data for context const fields = Object.keys(data).slice(0, 3); return `Form with ${Object.keys(data).length} fields: ${fields.join(', ')}...`; } private generateResponseId(): string { return `response-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } } // Type definitions interface ConversationState { messages: Message[]; context: Record<string, any>; user: Record<string, any>; } interface CachedResponse { response: MCPResponse; timestamp: number; ttl: number; } interface FormSession { id: string; type: string; startedAt: number; data: Record<string, any>; completed: boolean; lastUpdated?: number; completedAt?: number; result?: any; } ``` ## Integration with Existing Codebase ### Step 1: Install Dependencies ```bash npm install --save-dev typescript @types/node ``` ### Step 2: Update Your Existing ChatWidget ```typescript // Extend your existing ChatWidget class import { ChatWidget as ExistingChatWidget } from './assistantWidget/ChatWidget'; import { MCPChatWidget } from './mcp/ChatWidget'; export class EnhancedChatWidget extends ExistingChatWidget { private mcpHandler: MCPChatWidget; constructor(config: any) { super(config); // Add MCP capabilities this.mcpHandler = new MCPChatWidget('chat-container', { mcpEndpoint: config.mcpEndpoint || '/api/mcp', apiKey: config.apiKey, routingRules: config.routingRules }); } // Override message handling to support MCP protected async handleMessage(message: string): Promise<void> { // Check if this should go through MCP if (this.shouldUseMCP(message)) { await this.mcpHandler.sendMessage(message); } else { // Fall back to existing behavior await super.handleMessage(message); } } private shouldUseMCP(message: string): boolean { // Implement logic to determine if MCP should handle this return true; // or implement your logic } } ``` ### Step 3: Configure Webpack ```javascript // webpack.config.js module.exports = { entry: './src/index.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, ], }, resolve: { extensions: ['.tsx', '.ts', '.js'], alias: { '@components': path.resolve(__dirname, 'src/components'), '@mcp': path.resolve(__dirname, 'src/mcp'), '@types': path.resolve(__dirname, 'src/types'), } }, }; ``` ### Step 4: Initialize in Your Application ```typescript // Initialize the enhanced chat widget const chatWidget = new EnhancedChatWidget({ // Existing config agentToken: 'your-token', theme: { /* ... */ }, // MCP config mcpEndpoint: 'https://api.yourservice.com/mcp', apiKey: 'your-api-key', routingRules: { directRenderThreshold: 10, // Max items for direct render alwaysDirect: ['form-', 'chart', 'table'], // Patterns to always render directly alwaysLLM: ['complex-query', 'analysis'], // Patterns to always send to LLM } }); // Register custom components chatWidget.registerComponent('custom-viz', CustomVisualization); ``` ## Testing ```typescript // ChatWidget.test.ts describe('ChatWidget', () => { let widget: ChatWidget; beforeEach(() => { document.body.innerHTML = '<div id="chat-container"></div>'; widget = new ChatWidget('chat-container', { mcpEndpoint: 'http://localhost:3000/mcp', apiKey: 'test-key' }); }); test('should handle MCP response with direct routing', async () => { const response: MCPResponse = { uiType: 'shopify-products', structuredContent: { totalCount: 5 }, _meta: { products: [/* ... */] } }; await widget.handleMCPResponse(response); const component = document.querySelector('.chat-component--shopify-products'); expect(component).toBeTruthy(); }); test('should route complex queries to LLM', async () => { const response: MCPResponse = { uiType: 'analysis', structuredContent: { /* complex data */ }, _meta: { /* ... */ } }; const spy = jest.spyOn(widget['mcpClient'], 'sendToLLM'); await widget.handleMCPResponse(response); expect(spy).toHaveBeenCalled(); }); }); ``` ## Next Steps 1. Review `COMPONENT_REGISTRY.md` for building components 2. See `ROUTING_LOGIC.md` for routing implementation 3. Check `UNIVERSAL_FORM_SYSTEM.md` for form handling 4. Read `STATE_MANAGEMENT.md` for state persistence