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