/** * Rich Component System for AIVANOV * * Provides a generic component registry and rendering system that can display * any rich component sent from the Python backend. */ import { richComponentStyleText } from '../styles/rich-component-styles.js'; import Plotly from 'plotly.js-dist-min'; // Component interfaces matching Python backend export interface RichComponent { id: string; type: string; lifecycle: 'create' | 'update' | 'replace' | 'remove'; data: Record; children: string[]; timestamp: string; visible: boolean; interactive: boolean; } // Artifact event interfaces export interface ArtifactOpenedEventDetail { // Core identification artifactId: string; // Artifact content content: string; // Full HTML/SVG/JS content type: 'html' | 'svg' | 'visualization' | 'interactive' | 'd3' | 'threejs'; title?: string; description?: string; // Trigger context trigger: 'created' | 'user-action'; // How this event was fired // Control preventDefault: () => void; // Prevent default behavior // Helpers getStandaloneHTML: () => string; // Full page HTML with dependencies // Metadata timestamp: string; } declare global { interface GlobalEventHandlersEventMap { 'artifact-opened': CustomEvent; } } const RICH_COMPONENT_STYLE_ATTR = 'data-vanna-rich-component-styles'; function ensureRichComponentStyles(container: HTMLElement): void { const doc = container.ownerDocument; if (!doc) { return; } if (container.querySelector(`style[${RICH_COMPONENT_STYLE_ATTR}]`)) { return; } const styleEl = doc.createElement('style'); styleEl.setAttribute(RICH_COMPONENT_STYLE_ATTR, 'true'); styleEl.textContent = richComponentStyleText; container.prepend(styleEl); } export interface ComponentUpdate { operation: 'create' | 'update' | 'replace' | 'remove' | 'reorder' | 'bulk_update'; target_id: string; component?: RichComponent; updates?: Record; position?: any; timestamp: string; batch_id?: string; } // Component renderer interface export interface ComponentRenderer { render(component: RichComponent): HTMLElement; update(element: HTMLElement, component: RichComponent, updates?: Record): void; remove(element: HTMLElement): void; } // Base component renderer with common functionality export abstract class BaseComponentRenderer implements ComponentRenderer { abstract render(component: RichComponent): HTMLElement; update(element: HTMLElement, component: RichComponent, _updates?: Record): void { // Default implementation - re-render completely const newElement = this.render(component); element.parentNode?.replaceChild(newElement, element); } remove(element: HTMLElement): void { element.remove(); } } // Card component renderer export class CardComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const card = document.createElement('div'); card.className = 'rich-component rich-card'; card.dataset.componentId = component.id; const { title, content, subtitle, icon, status, actions = [], collapsible, collapsed } = component.data; card.innerHTML = `
${icon ? `${icon}` : ''}

${title}

${subtitle ? `

${subtitle}

` : ''}
${status ? `${status}` : ''} ${collapsible ? `` : ''}
${content}
${actions && actions.length > 0 ? `
${actions.map((action: any) => ` `).join('')}
` : ''} `; // Add collapsible functionality if (collapsible) { const toggle = card.querySelector('.card-toggle') as HTMLButtonElement; const content = card.querySelector('.card-content') as HTMLElement; toggle?.addEventListener('click', () => { content.classList.toggle('collapsed'); toggle.textContent = content.classList.contains('collapsed') ? '▶' : '▼'; }); } // Add click handlers for action buttons if (actions && actions.length > 0) { const actionButtons = card.querySelectorAll('.card-action') as NodeListOf; actionButtons.forEach((button, index) => { const action = actions[index]; if (action && action.action) { button.addEventListener('click', async () => { // Apply visual feedback button.disabled = true; button.classList.add('button-transitioning', 'button-clicked'); // Find vanna-chat component and send message const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && typeof vannaChat.sendMessage === 'function') { try { const success = await vannaChat.sendMessage(action.action); if (success) { // Keep button disabled after successful action } else { // Re-enable button on failure button.disabled = false; button.classList.remove('button-transitioning', 'button-clicked'); } } catch (error) { // Re-enable button on error button.disabled = false; button.classList.remove('button-transitioning', 'button-clicked'); } } else { button.disabled = false; button.classList.remove('button-transitioning', 'button-clicked'); } }); } }); } return card; } update(element: HTMLElement, component: RichComponent, updates?: Record): void { if (!updates) return super.update(element, component); // Optimized updates for common properties if (updates.title) { const titleEl = element.querySelector('.card-title'); if (titleEl) titleEl.textContent = updates.title; } if (updates.content) { const contentEl = element.querySelector('.card-content'); if (contentEl) contentEl.innerHTML = updates.content; } if (updates.status) { const statusEl = element.querySelector('.card-status'); if (statusEl) { statusEl.className = `card-status status-${updates.status}`; statusEl.textContent = updates.status; } } // For complex updates, fall back to full re-render if (updates.actions || updates.collapsible) { super.update(element, component); } } } // Task list component renderer export class TaskListComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-task-list'; container.dataset.componentId = component.id; const { title, tasks = [], show_progress, show_timestamps } = component.data; const completedTasks = tasks.filter((task: any) => task.status === 'completed').length; const progress = tasks.length > 0 ? (completedTasks / tasks.length) * 100 : 0; container.innerHTML = `

${title}

${show_progress ? `
${completedTasks}/${tasks.length} completed
` : ''}
${tasks.map((task: any) => this.renderTask(task, show_timestamps)).join('')}
`; return container; } private renderTask(task: any, showTimestamps: boolean): string { const statusIcon = this.getStatusIcon(task.status); const progressBar = task.progress !== null && task.progress !== undefined ? `
${Math.round(task.progress * 100)}%
` : ''; return `
${statusIcon}
${task.title}
${task.description ? `
${task.description}
` : ''} ${progressBar} ${showTimestamps && task.created_at ? `
Created: ${new Date(task.created_at).toLocaleString()}
` : ''}
`; } private getStatusIcon(status: string): string { switch (status) { case 'completed': return '✅'; case 'running': return '🔄'; case 'failed': return '❌'; default: return '⭕'; } } } // Progress bar component renderer export class ProgressBarComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-progress-bar'; container.dataset.componentId = component.id; const { value, label, show_percentage, status, animated } = component.data; const percentage = Math.round(value * 100); container.innerHTML = `
${label ? `${label}` : ''} ${show_percentage ? `${percentage}%` : ''}
`; return container; } update(element: HTMLElement, component: RichComponent, updates?: Record): void { if (!updates) return super.update(element, component); if (updates.value !== undefined) { const fill = element.querySelector('.progress-fill') as HTMLElement; const percentage = Math.round(updates.value * 100); if (fill) { fill.style.width = `${percentage}%`; } const percentageEl = element.querySelector('.progress-percentage'); if (percentageEl) { percentageEl.textContent = `${percentage}%`; } } if (updates.label) { const labelEl = element.querySelector('.progress-label'); if (labelEl) labelEl.textContent = updates.label; } if (updates.status) { const fill = element.querySelector('.progress-fill') as HTMLElement; if (fill) { fill.className = fill.className.replace(/status-\w+/, `status-${updates.status}`); } } } } // Notification component renderer export class NotificationComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-notification'; container.dataset.componentId = component.id; const { message, title, level = 'info', icon, dismissible, auto_dismiss, actions = [] } = component.data; const levelIcon = icon || this.getLevelIcon(level); const dismissButton = dismissible ? ` ` : ''; container.innerHTML = `
${levelIcon ? `${levelIcon}` : ''}
${title ? `
${title}
` : ''}
${message}
${actions.length > 0 ? `
${actions.map((action: any) => ` `).join('')}
` : ''} ${dismissButton}
`; // Auto-dismiss functionality if (auto_dismiss && component.data.auto_dismiss_delay) { setTimeout(() => { if (container.parentElement) { container.remove(); } }, component.data.auto_dismiss_delay); } return container; } private getLevelIcon(level: string): string { switch (level) { case 'success': return '✅'; case 'warning': return '⚠️'; case 'error': return '❌'; case 'info': default: return 'ℹ️'; } } } // Status indicator component renderer export class StatusIndicatorComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-status-indicator'; container.dataset.componentId = component.id; const { status, message, icon, pulse } = component.data; const statusIcon = icon || this.getStatusIcon(status); const pulseClass = pulse ? 'pulse' : ''; container.innerHTML = `
${statusIcon} ${message}
`; return container; } private getStatusIcon(status: string): string { switch (status) { case 'loading': return '🔄'; case 'success': return '✅'; case 'warning': return '⚠️'; case 'error': return '❌'; default: return 'ℹ️'; } } update(element: HTMLElement, component: RichComponent, updates?: Record): void { if (!updates) return super.update(element, component); const content = element.querySelector('.status-indicator-content'); if (content && updates.status) { content.className = content.className.replace(/status-\w+/, `status-${updates.status}`); } if (updates.pulse !== undefined) { const content = element.querySelector('.status-indicator-content'); if (content) { if (updates.pulse) { content.classList.add('pulse'); } else { content.classList.remove('pulse'); } } } if (updates.message) { const messageEl = element.querySelector('.status-message'); if (messageEl) { messageEl.textContent = updates.message; } } } } // DataFrame component renderer export class DataFrameComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-dataframe'; container.dataset.componentId = component.id; const { data = [], columns = [], title, description, row_count = 0, column_count = 0, max_rows_displayed = 100, searchable = true, sortable = true, filterable = true, exportable = true, striped = true, bordered = true, compact = false, column_types = {} } = component.data; // Limit displayed rows const displayedData = data.slice(0, max_rows_displayed); const hasMoreRows = data.length > max_rows_displayed; let headerHTML = ''; if (title || description) { headerHTML = `
${title ? `

${title}

` : ''} ${description ? `

${description}

` : ''}
${row_count} lignes ${column_count} colonnes
`; } let actionsHTML = ''; if (searchable || exportable || filterable) { actionsHTML = `
${searchable ? ` ` : ''} ${exportable ? ` ` : ''}
`; } let tableHTML = ''; if (columns.length > 0 && displayedData.length > 0) { const tableClasses = [ 'dataframe-table', striped ? 'striped' : '', bordered ? 'bordered' : '', compact ? 'compact' : '' ].filter(Boolean).join(' '); tableHTML = `
${columns.map((col: string) => ` `).join('')} ${displayedData.map((row: any) => ` ${columns.map((col: string) => { const value = row[col]; const columnType = column_types[col] || 'string'; const formattedValue = this.formatCellValue(value, columnType); return ``; }).join('')} `).join('')}
${col} ${sortable ? '' : ''}
${formattedValue}
${hasMoreRows ? `
Affichage de ${max_rows_displayed} sur ${row_count} lignes
` : ''}
`; } else { tableHTML = `

Aucune donnée à afficher

`; } // Wrap in collapsible
element const summaryText = `Données brutes — ${row_count} lignes, ${column_count} colonnes`; container.innerHTML = `
📊 ${summaryText}
${headerHTML} ${actionsHTML} ${tableHTML}
`; // Add event listeners this.attachEventListeners(container, displayedData, columns); return container; } private formatCellValue(value: any, columnType: string): string { if (value === null || value === undefined) { return 'NULL'; } switch (columnType) { case 'number': return typeof value === 'number' ? value.toLocaleString() : String(value); case 'date': try { return new Date(value).toLocaleDateString(); } catch { return String(value); } case 'boolean': return value ? '✓' : '✗'; default: return this.escapeHtml(String(value)); } } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } private attachEventListeners(container: HTMLElement, data: any[], columns: string[]): void { // Search functionality const searchInput = container.querySelector('.search-input') as HTMLInputElement; if (searchInput) { searchInput.addEventListener('input', (e) => { const searchTerm = (e.target as HTMLInputElement).value.toLowerCase(); this.filterTable(container, data, columns, searchTerm); }); } // Export CSV functionality const exportBtn = container.querySelector('.export-btn') as HTMLButtonElement; if (exportBtn) { exportBtn.addEventListener('click', () => { this.exportToCSV(data, columns); }); } // Export PDF functionality const exportPdfBtn = container.querySelector('.export-pdf-btn') as HTMLButtonElement; if (exportPdfBtn) { exportPdfBtn.addEventListener('click', () => { const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && typeof vannaChat.sendMessage === 'function') { vannaChat.sendMessage('Exporte ces données en PDF'); } }); } // Sort functionality const sortableHeaders = container.querySelectorAll('th.sortable'); sortableHeaders.forEach(header => { header.addEventListener('click', (e) => { const column = (e.currentTarget as HTMLElement).dataset.column; if (column) { this.sortTable(container, data, columns, column); } }); }); } private filterTable(container: HTMLElement, data: any[], columns: string[], searchTerm: string): void { const tbody = container.querySelector('tbody'); if (!tbody) return; const filteredData = data.filter(row => { return columns.some(col => { const value = String(row[col] || '').toLowerCase(); return value.includes(searchTerm); }); }); tbody.innerHTML = filteredData.map(row => ` ${columns.map(col => { const value = row[col]; const formattedValue = this.formatCellValue(value, 'string'); return `${formattedValue}`; }).join('')} `).join(''); } private sortTable(container: HTMLElement, data: any[], columns: string[], column: string): void { const tbody = container.querySelector('tbody'); const header = container.querySelector(`th[data-column="${column}"]`) as HTMLElement; if (!tbody || !header) return; // Determine sort direction const currentSort = header.dataset.sortDirection || 'none'; const newSort = currentSort === 'asc' ? 'desc' : 'asc'; // Clear all sort indicators container.querySelectorAll('th[data-sort-direction]').forEach(h => { (h as HTMLElement).removeAttribute('data-sort-direction'); const indicator = h.querySelector('.sort-indicator'); if (indicator) indicator.textContent = ''; }); // Set new sort direction header.dataset.sortDirection = newSort; const indicator = header.querySelector('.sort-indicator'); if (indicator) { indicator.textContent = newSort === 'asc' ? '↑' : '↓'; } // Sort data const sortedData = [...data].sort((a, b) => { const aVal = a[column]; const bVal = b[column]; if (aVal === null || aVal === undefined) return 1; if (bVal === null || bVal === undefined) return -1; if (typeof aVal === 'number' && typeof bVal === 'number') { return newSort === 'asc' ? aVal - bVal : bVal - aVal; } const aStr = String(aVal).toLowerCase(); const bStr = String(bVal).toLowerCase(); const comparison = aStr.localeCompare(bStr); return newSort === 'asc' ? comparison : -comparison; }); // Update table tbody.innerHTML = sortedData.map(row => ` ${columns.map(col => { const value = row[col]; const formattedValue = this.formatCellValue(value, 'string'); return `${formattedValue}`; }).join('')} `).join(''); } private exportToCSV(data: any[], columns: string[]): void { const csvContent = [ columns.join(','), ...data.map(row => columns.map(col => { const value = row[col]; const strValue = value === null || value === undefined ? '' : String(value); // Escape quotes and wrap in quotes if contains comma, quote, or newline if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) { return `"${strValue.replace(/"/g, '""')}"`; } return strValue; }).join(',') ) ].join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', 'data.csv'); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } } // Text component renderer export class TextComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-text'; container.dataset.componentId = component.id; const { content, markdown = false, code_language, font_size, font_weight, text_align } = component.data; // Apply text styling let textStyle = ''; if (font_size) textStyle += `font-size: ${font_size}; `; if (font_weight) textStyle += `font-weight: ${font_weight}; `; if (text_align) textStyle += `text-align: ${text_align}; `; if (code_language) { // Code block container.innerHTML = `
${this.escapeHtml(content)}
`; } else if (markdown) { // Markdown text (simple implementation) container.innerHTML = `
${this.renderMarkdown(content)}
`; } else { // Plain text container.innerHTML = `
${this.escapeHtml(content)}
`; } return container; } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } private renderMarkdown(text: string): string { // Simple markdown rendering - just basic formatting return text .replace(/^## (.*$)/gm, '

$1

') .replace(/^# (.*$)/gm, '

$1

') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/^- (.*$)/gm, '
  • $1
  • ') .replace(/(
  • .*<\/li>)/s, '
      $1
    ') .replace(/\n\n/g, '

    ') .replace(/^(?!<[h|u|l])(.+)$/gm, '

    $1

    '); } } // Primitive Component Renderers (Domain-Agnostic) // Status card component renderer export class StatusCardComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-status-card'; container.dataset.componentId = component.id; const { title, status, description, icon, actions = [], collapsible, collapsed, metadata = {} } = component.data; const statusIcon = icon || this.getStatusIcon(status); const hasMetadata = Object.keys(metadata).length > 0; container.innerHTML = `
    ${statusIcon}

    ${title}

    ${status}
    ${collapsible ? `` : ''}
    ${description ? `
    ${description}
    ` : ''} ${hasMetadata ? ` ` : ''} ${actions.length > 0 ? `
    ${actions.map((action: any) => ` `).join('')}
    ` : ''} `; // Add collapsible functionality if (collapsible) { const toggle = container.querySelector('.status-card-toggle') as HTMLButtonElement; const content = container.querySelector('.status-card-content') as HTMLElement; toggle?.addEventListener('click', () => { if (content) { content.classList.toggle('collapsed'); toggle.textContent = content.classList.contains('collapsed') ? '▶' : '▼'; } }); } return container; } private renderMetadataTable(metadata: Record): string { const rows = Object.entries(metadata).map(([key, value]) => { const formattedValue = this.formatMetadataValue(value); return ` ${this.escapeHtml(key)} ${formattedValue} `; }).join(''); return ` ${rows} `; } private formatMetadataValue(value: any): string { if (value === null) { return ''; } if (value === undefined) { return ''; } if (typeof value === 'boolean') { return ``; } if (typeof value === 'number') { return ``; } if (typeof value === 'string') { return ``; } if (Array.isArray(value) || typeof value === 'object') { return ``; } return this.escapeHtml(String(value)); } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } private getStatusIcon(status: string): string { switch (status) { case 'pending': return '⏳'; case 'running': return '⚙️'; case 'completed': return '✅'; case 'success': return '✅'; case 'failed': return '❌'; case 'error': return '❌'; case 'warning': return '⚠️'; default: return 'ℹ️'; } } } // Progress display component renderer export class ProgressDisplayComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-progress-display'; container.dataset.componentId = component.id; const { label, value, description, status, show_percentage, animated, indeterminate } = component.data; const percentage = Math.round(value * 100); container.innerHTML = `
    ${label} ${show_percentage && !indeterminate ? `${percentage}%` : ''}
    ${description ? `
    ${description}
    ` : ''}
    `; return container; } } // Log viewer component renderer export class LogViewerComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-log-viewer'; container.dataset.componentId = component.id; const { title, entries = [], searchable, show_timestamps, auto_scroll } = component.data; container.innerHTML = `

    ${title}

    ${searchable ? ` ` : ''}
    ${entries.map((entry: any) => `
    ${show_timestamps ? `${new Date(entry.timestamp).toLocaleTimeString()}` : ''} [${entry.level.toUpperCase()}] ${entry.message}
    `).join('')}
    `; // Auto-scroll to bottom if enabled if (auto_scroll) { const content = container.querySelector('.log-viewer-content'); if (content) { content.scrollTop = content.scrollHeight; } } return container; } } // Badge component renderer export class BadgeComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('span'); container.className = `rich-component rich-badge badge-${component.data.variant} badge-${component.data.size}`; container.dataset.componentId = component.id; const { text, icon } = component.data; container.innerHTML = ` ${icon ? `${icon}` : ''} ${text} `; return container; } } // Icon text component renderer export class IconTextComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = `rich-component rich-icon-text icon-text-${component.data.variant} icon-text-${component.data.size} icon-text-${component.data.alignment}`; container.dataset.componentId = component.id; const { icon, text } = component.data; container.innerHTML = ` ${icon} ${text} `; return container; } } // Button component renderer export class ButtonComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const button = document.createElement('button'); button.className = `rich-component rich-button button-${component.data.variant} button-${component.data.size}`; button.dataset.componentId = component.id; const { label, action, disabled, icon, icon_position, full_width, loading } = component.data; if (disabled || loading) { button.disabled = true; } if (full_width) { button.classList.add('button-full-width'); } if (loading) { button.classList.add('button-loading'); } // Build button content let buttonContent = ''; if (loading) { buttonContent = `${label}`; } else if (icon) { if (icon_position === 'right') { buttonContent = `${label}${icon}`; } else { buttonContent = `${icon}${label}`; } } else { buttonContent = `${label}`; } button.innerHTML = buttonContent; // Add click handler if (action && !disabled && !loading) { button.addEventListener('click', async () => { // Apply visual feedback immediately button.disabled = true; button.classList.add('button-transitioning', 'button-clicked'); // Find vanna-chat component and send message with button action const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && typeof vannaChat.sendMessage === 'function') { try { const success = await vannaChat.sendMessage(action); if (!success) { if (!disabled) { button.disabled = false; } button.classList.remove('button-transitioning', 'button-clicked'); } } catch (error) { if (!disabled) { button.disabled = false; } button.classList.remove('button-transitioning', 'button-clicked'); } } else { if (!disabled) { button.disabled = false; } button.classList.remove('button-transitioning', 'button-clicked'); } }); } return button; } update(element: HTMLElement, component: RichComponent, updates?: Record): void { if (!updates) return super.update(element, component); const button = element as HTMLButtonElement; if (updates.disabled !== undefined) { button.disabled = updates.disabled; } if (updates.loading !== undefined) { button.disabled = updates.loading; if (updates.loading) { button.classList.add('button-loading'); } else { button.classList.remove('button-loading'); } } if (updates.label || updates.icon || updates.icon_position) { // Re-render content super.update(element, component); } } } // Button group component renderer export class ButtonGroupComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = `rich-component rich-button-group button-group-${component.data.orientation} button-group-spacing-${component.data.spacing} button-group-align-${component.data.align}`; container.dataset.componentId = component.id; const { buttons = [], full_width } = component.data; if (full_width) { container.classList.add('button-group-full-width'); } // Render each button buttons.forEach((buttonConfig: any, index: number) => { const button = document.createElement('button'); button.className = `rich-button button-${buttonConfig.variant || 'secondary'} button-${buttonConfig.size || 'medium'}`; button.dataset.buttonIndex = String(index); // Store original disabled state if (buttonConfig.disabled) { button.disabled = true; button.dataset.originallyDisabled = 'true'; } else { button.dataset.originallyDisabled = 'false'; } // Build button content let buttonContent = ''; if (buttonConfig.icon) { if (buttonConfig.icon_position === 'right') { buttonContent = `${buttonConfig.label}${buttonConfig.icon}`; } else { buttonContent = `${buttonConfig.icon}${buttonConfig.label}`; } } else { buttonContent = `${buttonConfig.label}`; } button.innerHTML = buttonContent; // Add click handler with enhanced functionality if (buttonConfig.action && !buttonConfig.disabled) { button.addEventListener('click', async () => { // Immediately apply visual changes to all buttons in the group this.applyButtonGroupClickState(container, index); const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && typeof vannaChat.sendMessage === 'function') { try { const success = await vannaChat.sendMessage(buttonConfig.action); if (!success) { this.restoreButtonGroupState(container); } } catch (error) { this.restoreButtonGroupState(container); } } else { this.restoreButtonGroupState(container); } }); } container.appendChild(button); }); return container; } private applyButtonGroupClickState(container: HTMLElement, clickedIndex: number): void { const buttons = container.querySelectorAll('button') as NodeListOf; buttons.forEach((button, index) => { // Disable all buttons button.disabled = true; // Add transition class for animation button.classList.add('button-transitioning'); if (index === clickedIndex) { // Highlight the clicked button button.classList.add('button-clicked', 'button-highlighted'); } else { // Gray out other buttons button.classList.add('button-grayed-out'); } }); } private restoreButtonGroupState(container: HTMLElement): void { const buttons = container.querySelectorAll('button') as NodeListOf; buttons.forEach((button) => { // Re-enable buttons (unless they were originally disabled) const originallyDisabled = button.dataset.originallyDisabled === 'true'; if (!originallyDisabled) { button.disabled = false; } // Remove all state classes button.classList.remove( 'button-clicked', 'button-highlighted', 'button-grayed-out', 'button-transitioning' ); }); } } // Chart component renderer (for Plotly charts) export class ChartComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-chart'; container.dataset.componentId = component.id; const { data: plotlyData, layout, title, config = {} } = component.data; if (plotlyData && Array.isArray(plotlyData) && layout) { // Add title if provided if (title) { const header = document.createElement('h3'); header.className = 'chart-title'; header.style.cssText = 'margin:0 0 8px;font-size:14px;font-weight:600;color:#023d60;'; header.textContent = title; container.appendChild(header); } // Create export button const exportBar = document.createElement('div'); exportBar.className = 'chart-export-bar'; exportBar.innerHTML = ``; container.appendChild(exportBar); // PDF export handler const pdfBtn = exportBar.querySelector('.export-pdf-btn') as HTMLButtonElement; if (pdfBtn) { pdfBtn.addEventListener('click', () => { const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && typeof vannaChat.sendMessage === 'function') { vannaChat.sendMessage('Exporte ces données en PDF'); } }); } // Create a plain div for Plotly to render into directly (no web component) const plotDiv = document.createElement('div'); plotDiv.style.cssText = 'width:100%;min-height:400px;overflow:hidden;'; container.appendChild(plotDiv); // Merge layout defaults with explicit dimensions for shadow DOM compatibility const mergedLayout = { ...layout, autosize: false, width: layout.width || 700, height: layout.height || 400, paper_bgcolor: layout.paper_bgcolor || 'white', plot_bgcolor: layout.plot_bgcolor || 'white', }; const mergedConfig = { responsive: true, displayModeBar: false, ...config, }; // Render Plotly after the element is in the DOM const doRender = () => { try { (Plotly as any).newPlot(plotDiv, plotlyData, mergedLayout, mergedConfig); } catch (err) { plotDiv.textContent = 'Erreur lors du rendu du graphique'; } }; // Wait until container is in the DOM, then render const checkAndRender = () => { if (container.isConnected) { doRender(); } else { // Retry with increasing delays setTimeout(() => { if (container.isConnected) { doRender(); } else { setTimeout(() => doRender(), 500); } }, 100); } }; requestAnimationFrame(checkAndRender); } else { container.innerHTML = `

    Format de données du graphique invalide

    ${JSON.stringify(component.data, null, 2).substring(0, 200)}...
    `; } return container; } } // Artifact component renderer export class ArtifactComponentRenderer extends BaseComponentRenderer { private defaultPrevented = false; render(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-artifact'; container.dataset.componentId = component.id; container.dataset.artifactId = component.data.artifact_id; const { content, artifact_type, title, description, editable, fullscreen_capable, external_renderable } = component.data; // Create artifact preview and controls container.innerHTML = `

    ${title || 'Artefact'}

    ${description ? `

    ${description}

    ` : ''} ${artifact_type}
    ${editable ? '' : ''} ${fullscreen_capable ? '' : ''} ${external_renderable ? '' : ''}
    `; // Attach event listeners this.attachEventListeners(container, component); // Fire artifact-opened event for creation const shouldRenderInChat = this.fireArtifactOpenedEvent(component, 'created', container); // If default was prevented, show a placeholder instead if (!shouldRenderInChat) { container.innerHTML = `
    🎨
    ${title || 'Artefact'} ouvert dans une fenêtre externe
    ${artifact_type}
    `; // Add reopen functionality const reopenBtn = container.querySelector('.placeholder-reopen') as HTMLButtonElement; if (reopenBtn) { reopenBtn.addEventListener('click', () => { this.fireArtifactOpenedEvent(component, 'user-action', container); }); } } return container; } private attachEventListeners(container: HTMLElement, component: RichComponent): void { // External button click const externalBtn = container.querySelector('.external-btn') as HTMLButtonElement; if (externalBtn) { externalBtn.addEventListener('click', () => { this.fireArtifactOpenedEvent(component, 'user-action', container); }); } // Fullscreen button click const fullscreenBtn = container.querySelector('.fullscreen-btn') as HTMLButtonElement; if (fullscreenBtn) { fullscreenBtn.addEventListener('click', () => { this.openFullscreen(component); }); } // Edit button click (placeholder for future implementation) const editBtn = container.querySelector('.edit-btn') as HTMLButtonElement; if (editBtn) { editBtn.addEventListener('click', () => { this.openEditor(component); }); } } private fireArtifactOpenedEvent(component: RichComponent, trigger: 'created' | 'user-action', container: HTMLElement): boolean { this.defaultPrevented = false; const eventDetail: ArtifactOpenedEventDetail = { artifactId: component.data.artifact_id, content: component.data.content, type: component.data.artifact_type, title: component.data.title, description: component.data.description, trigger, preventDefault: () => { this.defaultPrevented = true; }, getStandaloneHTML: () => this.generateStandaloneHTML(component), timestamp: new Date().toISOString() }; const event = new CustomEvent('artifact-opened', { detail: eventDetail, bubbles: true, cancelable: true }); // Fire the event from the container element (should bubble up to vanna-chat) container.dispatchEvent(event); // Also dispatch directly on the vanna-chat element as backup const vannaChat = container.closest('vanna-chat'); if (vannaChat) { vannaChat.dispatchEvent(new CustomEvent('artifact-opened', { detail: eventDetail, bubbles: true, cancelable: true })); } // Handle default behavior if not prevented and user triggered if (!this.defaultPrevented && trigger === 'user-action') { this.handleDefaultAction(component); } // Return whether we should render in chat (true if default not prevented) return !this.defaultPrevented; } private generateStandaloneHTML(component: RichComponent): string { const { content, title, dependencies = [] } = component.data; let dependenciesHTML = ''; // Add common CDN links for dependencies if (dependencies.includes('d3')) { dependenciesHTML += '\n'; } if (dependencies.includes('plotly')) { dependenciesHTML += '\n'; } if (dependencies.includes('three') || dependencies.includes('threejs')) { dependenciesHTML += '\n'; } return ` ${title || 'Artifact'} ${dependenciesHTML}
    ${content}
    `; } private handleDefaultAction(component: RichComponent): void { // Default action: open in new window const newWindow = window.open('', '_blank', 'width=800,height=600'); if (newWindow) { newWindow.document.write(this.generateStandaloneHTML(component)); newWindow.document.close(); } } private openFullscreen(component: RichComponent): void { // Create fullscreen overlay const overlay = document.createElement('div'); overlay.className = 'artifact-fullscreen-overlay'; overlay.innerHTML = `

    ${component.data.title || 'Artifact'}

    `; // Add styles overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: white; z-index: 10000; display: flex; flex-direction: column; `; const header = overlay.querySelector('.fullscreen-header') as HTMLElement; header.style.cssText = ` padding: 16px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; `; const content = overlay.querySelector('.fullscreen-content') as HTMLElement; content.style.cssText = ` flex: 1; padding: 16px; `; const iframe = overlay.querySelector('.fullscreen-iframe') as HTMLIFrameElement; iframe.style.cssText = ` width: 100%; height: 100%; border: none; `; // Close button functionality const closeBtn = overlay.querySelector('.close-fullscreen') as HTMLButtonElement; closeBtn.addEventListener('click', () => { document.body.removeChild(overlay); }); // Escape key to close const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { document.body.removeChild(overlay); document.removeEventListener('keydown', handleEscape); } }; document.addEventListener('keydown', handleEscape); document.body.appendChild(overlay); } private openEditor(_component: RichComponent): void { // Placeholder for future editor implementation } private escapeHtml(html: string): string { const div = document.createElement('div'); div.textContent = html; return div.innerHTML.replace(/"/g, '"'); } } // User message component renderer export class UserMessageComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const messageEl = document.createElement('vanna-message'); messageEl.setAttribute('theme', 'light'); // Could be made dynamic messageEl.dataset.componentId = component.id; // Set properties for vanna-message (messageEl as any).content = component.data.content || ''; (messageEl as any).type = 'user'; (messageEl as any).timestamp = Date.parse(component.timestamp); return messageEl; } } // Assistant message component renderer export class AssistantMessageComponentRenderer extends BaseComponentRenderer { render(component: RichComponent): HTMLElement { const messageEl = document.createElement('vanna-message'); messageEl.setAttribute('theme', 'light'); // Could be made dynamic messageEl.dataset.componentId = component.id; // Set properties for vanna-message (messageEl as any).content = component.data.content || ''; (messageEl as any).type = 'assistant'; (messageEl as any).timestamp = Date.parse(component.timestamp); return messageEl; } } // Component registry for managing all component types export class ComponentRegistry { private renderers: Map = new Map(); constructor() { // Register primitive component renderers (domain-agnostic) this.register('status_card', new StatusCardComponentRenderer()); this.register('progress_display', new ProgressDisplayComponentRenderer()); this.register('log_viewer', new LogViewerComponentRenderer()); this.register('badge', new BadgeComponentRenderer()); this.register('icon_text', new IconTextComponentRenderer()); // Register existing component renderers this.register('card', new CardComponentRenderer()); this.register('task_list', new TaskListComponentRenderer()); this.register('progress_bar', new ProgressBarComponentRenderer()); this.register('notification', new NotificationComponentRenderer()); this.register('status_indicator', new StatusIndicatorComponentRenderer()); this.register('text', new TextComponentRenderer()); this.register('dataframe', new DataFrameComponentRenderer()); this.register('chart', new ChartComponentRenderer()); // Register interactive component renderers this.register('button', new ButtonComponentRenderer()); this.register('button_group', new ButtonGroupComponentRenderer()); // Register artifact component renderer this.register('artifact', new ArtifactComponentRenderer()); // Register message component renderers this.register('user-message', new UserMessageComponentRenderer()); this.register('assistant-message', new AssistantMessageComponentRenderer()); } register(type: string, renderer: ComponentRenderer): void { this.renderers.set(type, renderer); } render(component: RichComponent): HTMLElement { // Check if this is a component that should use web components const webComponentTag = this.getWebComponentTag(component.type); if (webComponentTag) { return this.renderWebComponent(webComponentTag, component); } // Use the old renderer system for other components const renderer = this.renderers.get(component.type); if (!renderer) { return this.renderFallback(component); } return renderer.render(component); } private getWebComponentTag(type: string): string | null { const mapping: Record = { 'card': 'rich-card', 'task_list': 'rich-task-list', 'progress_bar': 'rich-progress-bar', // We'll add more mappings as we convert other components }; return mapping[type] || null; } private renderWebComponent(tagName: string, component: RichComponent): HTMLElement { const element = document.createElement(tagName) as any; // Set properties based on component data Object.keys(component.data).forEach(key => { if (key === 'actions' && Array.isArray(component.data[key])) { element.actions = component.data[key]; } else { element[key] = component.data[key]; } }); // Set theme to match the parent VannaChat theme element.setAttribute('theme', this.getCurrentTheme()); return element; } private getCurrentTheme(): string { // Try to get theme from the parent VannaChat component const vannaChat = document.querySelector('vanna-chat'); if (vannaChat) { return vannaChat.getAttribute('theme') || 'dark'; } return 'dark'; } update(element: HTMLElement, component: RichComponent, updates?: Record): void { const renderer = this.renderers.get(component.type); if (renderer) { renderer.update(element, component, updates); } } remove(element: HTMLElement): void { element.remove(); } private renderFallback(component: RichComponent): HTMLElement { const container = document.createElement('div'); container.className = 'rich-component rich-fallback'; container.dataset.componentId = component.id; container.innerHTML = `
    Unknown Component: ${component.type}
    ${JSON.stringify(component.data, null, 2)}
    `; return container; } } // Component manager for handling component lifecycle export class ComponentManager { private components: Map = new Map(); private elements: Map = new Map(); private registry: ComponentRegistry = new ComponentRegistry(); private container: HTMLElement; private readonly sharedFields = new Set([ 'id', 'type', 'lifecycle', 'layout', 'theme', 'children', 'timestamp', 'visible', 'interactive', ]); constructor(container: HTMLElement) { this.container = container; ensureRichComponentStyles(this.container); } processUpdate(update: ComponentUpdate): void { // Handle UI state updates with special processing if (update.component && this.isUIStateUpdate(update.component)) { this.processUIStateUpdate(update.component); return; } switch (update.operation) { case 'create': this.createComponent(update); break; case 'update': this.updateComponent(update); break; case 'replace': this.replaceComponent(update); break; case 'remove': this.removeComponent(update); break; } } private createComponent(update: ComponentUpdate): void { if (!update.component) return; const component = this.normalizeComponent(update.component); const element = this.registry.render(component); this.components.set(component.id, component); this.elements.set(component.id, element); // Determine where to place the component this.positionComponent(element); } private updateComponent(update: ComponentUpdate): void { if (!update.component) return; const element = this.elements.get(update.target_id); if (element) { const component = this.normalizeComponent(update.component); this.registry.update(element, component, update.updates); this.components.set(update.target_id, component); } } private replaceComponent(update: ComponentUpdate): void { if (!update.component) return; const oldElement = this.elements.get(update.target_id); if (oldElement) { const component = this.normalizeComponent(update.component); const newElement = this.registry.render(component); oldElement.parentNode?.replaceChild(newElement, oldElement); this.elements.set(component.id, newElement); this.components.set(component.id, component); // Clean up old references if ID changed if (update.target_id !== component.id) { this.elements.delete(update.target_id); this.components.delete(update.target_id); } } } private removeComponent(update: ComponentUpdate): void { const element = this.elements.get(update.target_id); if (element) { element.remove(); this.elements.delete(update.target_id); this.components.delete(update.target_id); } } private positionComponent(element: HTMLElement): void { // Always append to container this.container.appendChild(element); // Trigger scroll to bottom in parent chat component this.triggerScroll(); } private triggerScroll(): void { // Find the parent vanna-chat component and trigger its scroll method const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && typeof vannaChat.scrollToLastMessage === 'function') { // Use requestAnimationFrame to wait for DOM update requestAnimationFrame(() => { requestAnimationFrame(() => { vannaChat.scrollToLastMessage(); }); }); } } clear(): void { this.components.clear(); this.elements.clear(); this.container.innerHTML = ''; ensureRichComponentStyles(this.container); } getComponent(id: string): RichComponent | undefined { return this.components.get(id); } getAllComponents(): RichComponent[] { return Array.from(this.components.values()); } private normalizeComponent(component: RichComponent): RichComponent { const data = { ...(component.data ?? {}) }; for (const [key, value] of Object.entries(component as Record)) { if (this.sharedFields.has(key) || key === 'data') continue; data[key] = value; } if (component.data && Object.keys(component.data).length === Object.keys(data).length) { return component; } return { ...component, data, }; } private isUIStateUpdate(component: RichComponent): boolean { return component.type === 'status_bar_update' || component.type === 'task_tracker_update' || component.type === 'chat_input_update'; } private processUIStateUpdate(component: RichComponent): void { switch (component.type) { case 'status_bar_update': this.updateStatusBar(component); break; case 'task_tracker_update': this.updateTaskTracker(component); break; case 'chat_input_update': this.updateChatInput(component); break; } } private updateStatusBar(component: RichComponent): void { // Find the status bar component - first try shadow DOM, then document let statusBar: HTMLElement | null = null; // Look for vanna-chat and search within its shadow root const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && vannaChat.shadowRoot) { statusBar = vannaChat.shadowRoot.querySelector('vanna-status-bar') as HTMLElement | null; } // Fallback to document search if (!statusBar) { statusBar = document.querySelector('vanna-status-bar') as HTMLElement | null; } if (statusBar) { const { status, message, detail } = component.data || {}; // Set properties directly on the Lit component (statusBar as any).status = status; (statusBar as any).message = message || ''; (statusBar as any).detail = detail || ''; } } private updateTaskTracker(component: RichComponent): void { // Find the progress tracker component - first try shadow DOM, then document let progressTracker = null; // Look for vanna-chat and search within its shadow root const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && vannaChat.shadowRoot) { progressTracker = vannaChat.shadowRoot.querySelector('vanna-progress-tracker'); } // Fallback to document search if (!progressTracker) { progressTracker = document.querySelector('vanna-progress-tracker'); } if (!progressTracker) return; const { operation, task, task_id, status, detail } = component.data || {}; switch (operation) { case 'add_task': if (task && progressTracker.addItem) { progressTracker.addItem(task.title || task.text, task.description || task.detail, task.id); } break; case 'update_task': if (task_id && progressTracker.updateItem) { progressTracker.updateItem(task_id, status, detail); } break; case 'remove_task': if (task_id && progressTracker.removeItem) { progressTracker.removeItem(task_id); } break; case 'clear_tasks': if (progressTracker.clear) { progressTracker.clear(); } break; } } private updateChatInput(component: RichComponent): void { // Find the chat input element - first try shadow DOM, then document let chatInput = null; // Look for vanna-chat and search within its shadow root const vannaChat = document.querySelector('vanna-chat') as any; if (vannaChat && vannaChat.shadowRoot) { chatInput = vannaChat.shadowRoot.querySelector('textarea.message-input, input.message-input'); } // Fallback to document search with multiple selectors if (!chatInput) { chatInput = document.querySelector('textarea[data-testid="message-input"], input[type="text"].message-input, .message-input input, .message-input textarea'); } if (!chatInput) return; const { placeholder, disabled, value, focus } = component.data || {}; if (placeholder !== undefined) { chatInput.placeholder = placeholder; } if (disabled !== undefined) { chatInput.disabled = disabled; } if (value !== undefined) { chatInput.value = value; } if (focus !== undefined) { if (focus) { chatInput.focus(); } else { chatInput.blur(); } } } }