Files
aivanov_database/aivanov_project/vanna/frontends/webcomponent/src/components/rich-component-system.ts
2026-03-05 01:20:15 +01:00

2081 lines
67 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, any>;
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<ArtifactOpenedEventDetail>;
}
}
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<string, any>;
position?: any;
timestamp: string;
batch_id?: string;
}
// Component renderer interface
export interface ComponentRenderer {
render(component: RichComponent): HTMLElement;
update(element: HTMLElement, component: RichComponent, updates?: Record<string, any>): 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<string, any>): 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 = `
<div class="card-header ${collapsible ? 'collapsible' : ''}">
${icon ? `<span class="card-icon">${icon}</span>` : ''}
<div class="card-title-section">
<h3 class="card-title">${title}</h3>
${subtitle ? `<p class="card-subtitle">${subtitle}</p>` : ''}
</div>
${status ? `<span class="card-status status-${status}">${status}</span>` : ''}
${collapsible ? `<button class="card-toggle">${collapsed ? '▶' : '▼'}</button>` : ''}
</div>
<div class="card-content ${collapsed ? 'collapsed' : ''}">
${content}
</div>
${actions && actions.length > 0 ? `
<div class="card-actions">
${actions.map((action: any) => `
<button class="card-action ${action.variant || 'secondary'}" data-action="${action.action}">
${action.label}
</button>
`).join('')}
</div>
` : ''}
`;
// 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<HTMLButtonElement>;
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<string, any>): 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 = `
<div class="task-list-header">
<h3 class="task-list-title">${title}</h3>
${show_progress ? `
<div class="task-list-progress">
<span class="progress-text">${completedTasks}/${tasks.length} completed</span>
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
</div>
` : ''}
</div>
<div class="task-list-items">
${tasks.map((task: any) => this.renderTask(task, show_timestamps)).join('')}
</div>
`;
return container;
}
private renderTask(task: any, showTimestamps: boolean): string {
const statusIcon = this.getStatusIcon(task.status);
const progressBar = task.progress !== null && task.progress !== undefined ? `
<div class="task-progress">
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${task.progress * 100}%"></div>
</div>
<span class="task-progress-text">${Math.round(task.progress * 100)}%</span>
</div>
` : '';
return `
<div class="task-item status-${task.status}" data-task-id="${task.id}">
<div class="task-icon">${statusIcon}</div>
<div class="task-content">
<div class="task-title">${task.title}</div>
${task.description ? `<div class="task-description">${task.description}</div>` : ''}
${progressBar}
${showTimestamps && task.created_at ? `
<div class="task-timestamp">
Created: ${new Date(task.created_at).toLocaleString()}
</div>
` : ''}
</div>
</div>
`;
}
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 = `
<div class="progress-header">
${label ? `<span class="progress-label">${label}</span>` : ''}
${show_percentage ? `<span class="progress-percentage">${percentage}%</span>` : ''}
</div>
<div class="progress-track">
<div class="progress-fill ${animated ? 'animated' : ''} ${status ? `status-${status}` : ''}"
style="width: ${percentage}%"></div>
</div>
`;
return container;
}
update(element: HTMLElement, component: RichComponent, updates?: Record<string, any>): 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 ? `
<button class="notification-dismiss" onclick="this.parentElement.remove()">×</button>
` : '';
container.innerHTML = `
<div class="notification-content level-${level}">
${levelIcon ? `<span class="notification-icon">${levelIcon}</span>` : ''}
<div class="notification-body">
${title ? `<div class="notification-title">${title}</div>` : ''}
<div class="notification-message">${message}</div>
</div>
${actions.length > 0 ? `
<div class="notification-actions">
${actions.map((action: any) => `
<button class="notification-action ${action.variant || 'secondary'}" data-action="${action.action}">
${action.label}
</button>
`).join('')}
</div>
` : ''}
${dismissButton}
</div>
`;
// 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 = `
<div class="status-indicator-content status-${status} ${pulseClass}">
<span class="status-icon">${statusIcon}</span>
<span class="status-message">${message}</span>
</div>
`;
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<string, any>): 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 = `
<div class="dataframe-header">
${title ? `<h3 class="dataframe-title">${title}</h3>` : ''}
${description ? `<p class="dataframe-description">${description}</p>` : ''}
<div class="dataframe-meta">
<span class="row-count">${row_count} lignes</span>
<span class="column-count">${column_count} colonnes</span>
</div>
</div>
`;
}
let actionsHTML = '';
if (searchable || exportable || filterable) {
actionsHTML = `
<div class="dataframe-actions">
${searchable ? `
<div class="dataframe-search">
<input type="text" placeholder="Rechercher..." class="search-input">
</div>
` : ''}
${exportable ? `
<button class="export-btn" title="Exporter en CSV">📥 Exporter</button>
<button class="export-pdf-btn" title="Exporter en PDF">📄 PDF</button>
` : ''}
</div>
`;
}
let tableHTML = '';
if (columns.length > 0 && displayedData.length > 0) {
const tableClasses = [
'dataframe-table',
striped ? 'striped' : '',
bordered ? 'bordered' : '',
compact ? 'compact' : ''
].filter(Boolean).join(' ');
tableHTML = `
<div class="dataframe-table-container">
<table class="${tableClasses}">
<thead>
<tr>
${columns.map((col: string) => `
<th class="${sortable ? 'sortable' : ''}" data-column="${col}">
${col}
${sortable ? '<span class="sort-indicator"></span>' : ''}
</th>
`).join('')}
</tr>
</thead>
<tbody>
${displayedData.map((row: any) => `
<tr>
${columns.map((col: string) => {
const value = row[col];
const columnType = column_types[col] || 'string';
const formattedValue = this.formatCellValue(value, columnType);
return `<td class="cell-${columnType}">${formattedValue}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
${hasMoreRows ? `
<div class="dataframe-truncated">
<em>Affichage de ${max_rows_displayed} sur ${row_count} lignes</em>
</div>
` : ''}
</div>
`;
} else {
tableHTML = `
<div class="dataframe-empty">
<p>Aucune donnée à afficher</p>
</div>
`;
}
// Wrap in collapsible <details> element
const summaryText = `Données brutes — ${row_count} lignes, ${column_count} colonnes`;
container.innerHTML = `
<details class="dataframe-collapsible">
<summary class="dataframe-summary">
<span class="dataframe-summary-icon">📊</span>
<span>${summaryText}</span>
</summary>
<div class="dataframe-content">
${headerHTML}
${actionsHTML}
${tableHTML}
</div>
</details>
`;
// Add event listeners
this.attachEventListeners(container, displayedData, columns);
return container;
}
private formatCellValue(value: any, columnType: string): string {
if (value === null || value === undefined) {
return '<em class="null-value">NULL</em>';
}
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 => `
<tr>
${columns.map(col => {
const value = row[col];
const formattedValue = this.formatCellValue(value, 'string');
return `<td>${formattedValue}</td>`;
}).join('')}
</tr>
`).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 => `
<tr>
${columns.map(col => {
const value = row[col];
const formattedValue = this.formatCellValue(value, 'string');
return `<td>${formattedValue}</td>`;
}).join('')}
</tr>
`).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 = `
<pre class="text-code" style="${textStyle}"><code class="language-${code_language}">${this.escapeHtml(content)}</code></pre>
`;
} else if (markdown) {
// Markdown text (simple implementation)
container.innerHTML = `
<div class="text-markdown" style="${textStyle}">${this.renderMarkdown(content)}</div>
`;
} else {
// Plain text
container.innerHTML = `
<div class="text-content" style="${textStyle}">${this.escapeHtml(content)}</div>
`;
}
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, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/^- (.*$)/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[h|u|l])(.+)$/gm, '<p>$1</p>');
}
}
// 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 = `
<div class="status-card-header ${collapsible ? 'collapsible' : ''}">
<div class="status-card-icon">${statusIcon}</div>
<div class="status-card-title-section">
<h3 class="status-card-title">${title}</h3>
<span class="status-card-badge status-${status}">${status}</span>
</div>
${collapsible ? `<button class="status-card-toggle">${collapsed ? '▶' : '▼'}</button>` : ''}
</div>
${description ? `
<div class="status-card-content ${collapsed ? 'collapsed' : ''}">
${description}
</div>
` : ''}
${hasMetadata ? `
<details class="status-card-metadata">
<summary class="status-card-metadata-summary">Paramètres</summary>
<div class="status-card-metadata-content">
${this.renderMetadataTable(metadata)}
</div>
</details>
` : ''}
${actions.length > 0 ? `
<div class="status-card-actions">
${actions.map((action: any) => `
<button class="status-card-action ${action.variant || 'secondary'}" data-action="${action.action}">
${action.label}
</button>
`).join('')}
</div>
` : ''}
`;
// 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, any>): string {
const rows = Object.entries(metadata).map(([key, value]) => {
const formattedValue = this.formatMetadataValue(value);
return `
<tr>
<td class="metadata-key">${this.escapeHtml(key)}</td>
<td class="metadata-value">${formattedValue}</td>
</tr>
`;
}).join('');
return `
<table class="metadata-table">
<thead>
<tr>
<th>Paramètre</th>
<th>Valeur</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
private formatMetadataValue(value: any): string {
if (value === null) {
return '<span class="metadata-null">null</span>';
}
if (value === undefined) {
return '<span class="metadata-undefined">undefined</span>';
}
if (typeof value === 'boolean') {
return `<span class="metadata-boolean">${value}</span>`;
}
if (typeof value === 'number') {
return `<span class="metadata-number">${value}</span>`;
}
if (typeof value === 'string') {
return `<span class="metadata-string">${this.escapeHtml(value)}</span>`;
}
if (Array.isArray(value) || typeof value === 'object') {
return `<pre class="metadata-json">${JSON.stringify(value, null, 2)}</pre>`;
}
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 = `
<div class="progress-display-container">
<div class="progress-display-header">
<span class="progress-display-label">${label}</span>
${show_percentage && !indeterminate ? `<span class="progress-display-percentage">${percentage}%</span>` : ''}
</div>
<div class="progress-display-track">
<div class="progress-display-fill ${animated ? 'animated' : ''} ${status ? `status-${status}` : ''} ${indeterminate ? 'indeterminate' : ''}"
style="width: ${indeterminate ? '100' : percentage}%"></div>
</div>
${description ? `<div class="progress-display-description">${description}</div>` : ''}
</div>
`;
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 = `
<div class="log-viewer-container">
<div class="log-viewer-header">
<h3 class="log-viewer-title">${title}</h3>
${searchable ? `
<div class="log-viewer-search">
<input type="text" placeholder="Search logs..." class="log-search-input">
</div>
` : ''}
</div>
<div class="log-viewer-content ${auto_scroll ? 'auto-scroll' : ''}">
${entries.map((entry: any) => `
<div class="log-entry log-${entry.level}">
${show_timestamps ? `<span class="log-timestamp">${new Date(entry.timestamp).toLocaleTimeString()}</span>` : ''}
<span class="log-level">[${entry.level.toUpperCase()}]</span>
<span class="log-message">${entry.message}</span>
</div>
`).join('')}
</div>
</div>
`;
// 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 ? `<span class="badge-icon">${icon}</span>` : ''}
<span class="badge-text">${text}</span>
`;
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 = `
<span class="icon-text-icon">${icon}</span>
<span class="icon-text-text">${text}</span>
`;
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 = `<span class="button-spinner">⏳</span><span class="button-label">${label}</span>`;
} else if (icon) {
if (icon_position === 'right') {
buttonContent = `<span class="button-label">${label}</span><span class="button-icon">${icon}</span>`;
} else {
buttonContent = `<span class="button-icon">${icon}</span><span class="button-label">${label}</span>`;
}
} else {
buttonContent = `<span class="button-label">${label}</span>`;
}
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<string, any>): 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 = `<span class="button-label">${buttonConfig.label}</span><span class="button-icon">${buttonConfig.icon}</span>`;
} else {
buttonContent = `<span class="button-icon">${buttonConfig.icon}</span><span class="button-label">${buttonConfig.label}</span>`;
}
} else {
buttonContent = `<span class="button-label">${buttonConfig.label}</span>`;
}
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<HTMLButtonElement>;
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<HTMLButtonElement>;
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 = `<button class="export-pdf-btn" title="Exporter en PDF">📄 PDF</button>`;
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 = `
<div class="chart-error">
<p>Format de données du graphique invalide</p>
<pre>${JSON.stringify(component.data, null, 2).substring(0, 200)}...</pre>
</div>
`;
}
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 = `
<div class="artifact-header">
<div class="artifact-meta">
<h3 class="artifact-title">${title || 'Artefact'}</h3>
${description ? `<p class="artifact-description">${description}</p>` : ''}
<span class="artifact-type-badge">${artifact_type}</span>
</div>
<div class="artifact-controls">
${editable ? '<button class="artifact-btn edit-btn" title="Modifier">✏️</button>' : ''}
${fullscreen_capable ? '<button class="artifact-btn fullscreen-btn" title="Plein écran">⛶</button>' : ''}
${external_renderable ? '<button class="artifact-btn external-btn" title="Ouvrir dans une nouvelle fenêtre">🔗</button>' : ''}
</div>
</div>
<div class="artifact-preview">
<iframe class="artifact-iframe" sandbox="allow-scripts allow-same-origin" srcdoc="${this.escapeHtml(content)}"></iframe>
</div>
`;
// 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 = `
<div class="artifact-placeholder">
<div class="placeholder-content">
<span class="placeholder-icon">🎨</span>
<div class="placeholder-text">
<strong>${title || 'Artefact'}</strong> ouvert dans une fenêtre externe
<div class="placeholder-type">${artifact_type}</div>
</div>
<button class="placeholder-reopen" title="Rouvrir">↗</button>
</div>
</div>
`;
// 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 += '<script src="https://d3js.org/d3.v7.min.js"></script>\n';
}
if (dependencies.includes('plotly')) {
dependenciesHTML += '<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>\n';
}
if (dependencies.includes('three') || dependencies.includes('threejs')) {
dependenciesHTML += '<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>\n';
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title || 'Artifact'}</title>
${dependenciesHTML}
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.artifact-container {
width: 100%;
min-height: 100vh;
}
</style>
</head>
<body>
<div class="artifact-container">
${content}
</div>
</body>
</html>`;
}
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 = `
<div class="fullscreen-header">
<h3>${component.data.title || 'Artifact'}</h3>
<button class="close-fullscreen">✕</button>
</div>
<div class="fullscreen-content">
<iframe class="fullscreen-iframe" sandbox="allow-scripts allow-same-origin" srcdoc="${this.escapeHtml(component.data.content)}"></iframe>
</div>
`;
// 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, '&quot;');
}
}
// 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<string, ComponentRenderer> = 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<string, string> = {
'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<string, any>): 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 = `
<div class="fallback-header">
<strong>Unknown Component: ${component.type}</strong>
</div>
<pre class="fallback-data">${JSON.stringify(component.data, null, 2)}</pre>
`;
return container;
}
}
// Component manager for handling component lifecycle
export class ComponentManager {
private components: Map<string, RichComponent> = new Map();
private elements: Map<string, HTMLElement> = 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<string, any>)) {
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();
}
}
}
}