/**
* 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 = `
${content}
${actions && actions.length > 0 ? `
${actions.map((action: any) => `
${action.label}
`).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 = `
${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 = `
`;
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) => `
${action.label}
`).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 = `
`;
}
let actionsHTML = '';
if (searchable || exportable || filterable) {
actionsHTML = `
${searchable ? `
` : ''}
${exportable ? `
📥 Exporter
📄 PDF
` : ''}
`;
}
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) => `
${col}
${sortable ? ' ' : ''}
`).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 `${formattedValue} `;
}).join('')}
`).join('')}
${hasMoreRows ? `
Affichage de ${max_rows_displayed} sur ${row_count} lignes
` : ''}
`;
} else {
tableHTML = `
`;
}
// 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, '')
.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 = `
${description ? `
${description}
` : ''}
${hasMetadata ? `
Paramètres
${this.renderMetadataTable(metadata)}
` : ''}
${actions.length > 0 ? `
${actions.map((action: any) => `
${action.label}
`).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 `
`;
}
private formatMetadataValue(value: any): string {
if (value === null) {
return 'null ';
}
if (value === undefined) {
return 'undefined ';
}
if (typeof value === 'boolean') {
return `${value} `;
}
if (typeof value === 'number') {
return `${value} `;
}
if (typeof value === 'string') {
return `${this.escapeHtml(value)} `;
}
if (Array.isArray(value) || typeof value === 'object') {
return `${JSON.stringify(value, null, 2)} `;
}
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 = `
${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 = `
${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 = `📄 PDF `;
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 = `
`;
// 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 = `
`;
// 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 = `
${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();
}
}
}
}