UNPKG

besper-frontend-site-dev-main

Version:

Professional B-esper Frontend Site - Site-wide integration toolkit for full website bot deployment

1,485 lines (1,299 loc) 93.6 kB
// BesperChatWidget component import { getBotOperationsEndpoint, operationsApiCall, } from '../services/centralizedApi.js'; import { getBrowserLanguage, getLocalizedText, getLocalizedCategory, getTranslation, } from '../utils/i18n.js'; import { StyleSanitizer } from '../utils/sanitizer.js'; /** * Professional Chat Widget with Dynamic Styling */ export class BesperChatWidget { constructor(botId, options = {}) { this.botId = botId; this.options = { environment: 'prod', position: 'bottom-right', ...options, }; this.state = { isOpen: false, isMinimized: true, threadId: null, isConnected: false, sessionData: null, styling: null, botConfig: null, typing: false, viewState: 'normal', // Can be 'normal' or 'expanded' userLanguage: getBrowserLanguage(), }; this.messageHistory = []; this.widget = null; this.apiEndpoint = getBotOperationsEndpoint(); this.isInitialized = false; this.autoExpandTimeouts = new Map(); this.outsideClickHandler = null; this.customMessageTemplate = null; this.sanitizer = new StyleSanitizer(); } async init() { try { // Don't show widget until we have session data console.log('[LOADING] Initializing B-esper chat widget...'); // Create session first to get styling data try { await this.createSession(); // Apply configuration and styling from payload if session was successful this.applySessionConfiguration(); } catch (sessionError) { console.warn( '[WARN] Session creation failed, using default configuration:', sessionError ); // Use default configuration if session creation fails this.applyDefaultConfiguration(sessionError); } // Only create widget after we have the session data and styling this.createWidget(); // Add welcome message if available if (this.state.isConnected) { this.addWelcomeMessage(); } else { this.addOfflineMessage(); } // Setup event handlers this.setupEventHandlers(); // Setup outside click handler if enabled this.setupOutsideClickHandler(); // Mobile layout initialization if (this.isMobileDevice()) { // Listen for orientation changes on mobile window.addEventListener('orientationchange', () => { setTimeout(() => { this.adjustMobileLayout(); }, 500); }); // Listen for visual viewport changes for better keyboard handling if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { setTimeout(() => { this.adjustMobileLayout(); }, 100); }); } // Listen for window resize events as fallback window.addEventListener('resize', () => { if (this.isMobileDevice()) { setTimeout(() => { this.adjustMobileLayout(); }, 100); } }); } // Mark as initialized this.isInitialized = true; console.log('[SUCCESS] B-esper chat widget initialized successfully'); return this; } catch (error) { console.error('[ERROR] Failed to initialize B-esper chat widget:', error); throw error; } } async createSession() { try { // Get browser language using the enhanced detection function const browserLanguage = getBrowserLanguage(); const data = await operationsApiCall( 'create_session', 'POST', { bot_id: this.botId, implementation_type: 'widget', browser_language: browserLanguage, }, this.options.environment ); if (!data.success) { throw new Error(data.error || 'Session creation failed'); } // Store all session data this.state.sessionData = data; this.state.threadId = data.thread_id || data.session_token; this.state.isConnected = true; // Extract styling information this.state.styling = data.styling || {}; // Use bot_title from session response instead of hardcoded value this.state.botConfig = { botName: data.bot_name || data.bot_title || 'B-esper Assistant', botTitle: data.bot_title || 'AI Assistant', welcomeMessage: data.welcome_message, dataPolicyUrl: data.data_policy_url, logoUrl: data.logo || data.styling?.logo_url, // Use logo from response (base64 or URL) }; console.log('[SUCCESS] Session created:', this.state.threadId); console.log('🎨 Styling received:', this.state.styling); } catch (error) { console.error('[ERROR] Session creation failed:', error); // Fallback to demo mode with basic styling this.state.threadId = 'demo-' + Date.now(); this.state.isConnected = false; this.state.styling = { primary_color: '#5897de', secondary_color: '#022d54', user_message_color: '#5897de', bot_message_color: '#f0f4f8', font_family: 'Segoe UI', }; this.state.botConfig = { botName: 'Demo Assistant', botTitle: 'Demo Mode', welcomeMessage: 'Demo mode: API connection failed. This is a demo interface.', dataPolicyUrl: null, logoUrl: null, }; console.warn('[WARN] Running in demo mode'); } } createWidget() { // Check if container option is provided for embedded mode if (this.options.container) { this.createEmbeddedWidget(); } else { this.createFloatingWidget(); } // Inject styles based on session styling this.injectStyles(); } createFloatingWidget() { // Create floating widget container const widgetContainer = document.createElement('div'); widgetContainer.id = 'besper-chat-widget'; widgetContainer.className = 'besper-widget-container'; // Apply positioning for floating widget this.positionWidget(widgetContainer); // Create widget HTML with proper structure widgetContainer.innerHTML = this.getWidgetHTML(); // Add to page document.body.appendChild(widgetContainer); this.widget = widgetContainer; } createEmbeddedWidget() { // Find target container let targetContainer; if (typeof this.options.container === 'string') { targetContainer = document.getElementById(this.options.container) || document.querySelector(this.options.container); } else if (this.options.container instanceof HTMLElement) { targetContainer = this.options.container; } if (!targetContainer) { console.error('[ERROR] Container not found:', this.options.container); throw new Error(`Container not found: ${this.options.container}`); } // Create embedded chat container const widgetContainer = document.createElement('div'); widgetContainer.id = 'besper-chat-widget'; widgetContainer.className = 'besper-widget-container besper-embedded'; // Apply embedded styling (no positioning, full width/height) widgetContainer.style.position = 'relative'; widgetContainer.style.width = '100%'; widgetContainer.style.height = '100%'; widgetContainer.style.minHeight = '400px'; // Create embedded widget HTML (no floating button, chat always open) widgetContainer.innerHTML = this.getEmbeddedWidgetHTML(); // Add to target container targetContainer.appendChild(widgetContainer); this.widget = widgetContainer; // Set state for embedded mode this.state.isOpen = true; this.state.isMinimized = false; } positionWidget(container) { const position = this.options.position || 'bottom-right'; container.style.position = 'fixed'; container.style.zIndex = '9999'; switch (position) { case 'bottom-right': container.style.bottom = '20px'; container.style.right = '20px'; break; case 'bottom-left': container.style.bottom = '20px'; container.style.left = '20px'; break; case 'top-right': container.style.top = '20px'; container.style.right = '20px'; break; case 'top-left': container.style.top = '20px'; container.style.left = '20px'; break; } } getWidgetHTML() { const config = this.state.botConfig; const tooltips = getLocalizedCategory('tooltips', this.state.userLanguage); const placeholder = getTranslation( 'typeYourMessage', this.state.userLanguage ); return ` <div class="besper-widget ${this.state.isOpen ? 'open' : 'minimized'}"> <!-- Minimized Widget Button --> <div class="besper-widget-button" id="besper-widget-btn"> <div class="besper-widget-icon"> ${ config.logoUrl ? `<img src="${config.logoUrl}" alt="Bot Logo" class="besper-widget-logo">` : `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"></path> </svg>` } </div> </div> <!-- Chat Interface --> <div class="besper-chat-container" id="besper-chat-container"> <!-- Header (logo removed from here as per requirement) --> <div class="besper-chat-header"> <div class="besper-chat-header-left"> <div class="besper-chat-title-group"> <div class="besper-chat-title">${config.botTitle}</div> </div> </div> <div class="besper-chat-header-right"> <div class="besper-chat-actions"> ${ config.dataPolicyUrl ? ` <button type="button" class="besper-action-btn" id="besper-data-policy-btn" title="${tooltips.dataPolicy}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/> </svg> </button> ` : '' } <button type="button" class="besper-action-btn" id="besper-expand-btn" title="${tooltips.expandView}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/> </svg> </button> <button type="button" class="besper-action-btn" id="besper-download-btn" title="${tooltips.downloadConversation}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7,10 12,15 17,10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> </button> <button type="button" class="besper-action-btn" id="besper-restart-btn" title="${tooltips.restartConversation}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M1 4v6h6"/> <path d="M23 20v-6h-6"/> <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/> <path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/> </svg> </button> <button type="button" class="besper-action-btn" id="besper-delete-btn" title="${tooltips.deleteConversation}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M3 6h18"/> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/> </svg> </button> <button type="button" class="besper-action-btn" id="besper-minimize-btn" title="${tooltips.minimizeChat}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M6 9l6 6 6-6"/> </svg> </button> </div> </div> </div> <!-- Messages Area --> <div class="besper-chat-messages" id="besper-messages"> <!-- Messages and typing indicator will be dynamically added here --> </div> <!-- Input Area --> <div class="besper-chat-input-container"> <form class="besper-chat-input-form" id="besper-chat-form"> <div class="besper-input-wrapper"> <textarea class="besper-chat-input" id="besper-message-input" placeholder="${placeholder}" rows="1" maxlength="2000" ></textarea> </div> <button type="submit" class="besper-chat-send-btn" id="besper-send-btn"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="22" y1="2" x2="11" y2="13"></line> <polygon points="22,2 15,22 11,13 2,9 22,2"></polygon> </svg> </button> </form> </div> </div> </div> `; } getEmbeddedWidgetHTML() { const config = this.state.botConfig; const tooltips = getLocalizedCategory('tooltips', this.state.userLanguage); const placeholder = getLocalizedText( 'typeYourMessage', 'en', this.state.userLanguage ); return ` <div class="besper-widget embedded open"> <!-- Chat Interface (no floating button for embedded mode) --> <div class="besper-chat-container" id="besper-chat-container"> <!-- Header --> <div class="besper-chat-header"> <div class="besper-chat-header-left"> <div class="besper-chat-title-group"> ${ config.logoUrl ? `<div class="besper-chat-logo"> <img src="${config.logoUrl}" alt="Bot Logo" class="besper-chat-logo-img"> </div>` : '' } <div class="besper-chat-title">${config.botTitle}</div> </div> </div> <div class="besper-chat-header-right"> <div class="besper-chat-actions"> ${ config.dataPolicyUrl ? ` <button type="button" class="besper-action-btn" id="besper-data-policy-btn" title="${tooltips.dataPolicy}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/> </svg> </button> ` : '' } <button type="button" class="besper-action-btn" id="besper-expand-btn" title="${tooltips.expandView}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/> </svg> </button> <button type="button" class="besper-action-btn" id="besper-download-btn" title="${tooltips.downloadConversation}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7,10 12,15 17,10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> </button> <button type="button" class="besper-action-btn" id="besper-restart-btn" title="${tooltips.restartConversation}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M1 4v6h6"/> <path d="M23 20v-6h-6"/> <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/> <path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/> </svg> </button> <button type="button" class="besper-action-btn" id="besper-delete-btn" title="${tooltips.deleteConversation}"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M3 6h18"/> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/> </svg> </button> </div> </div> </div> <!-- Messages Area --> <div class="besper-chat-messages" id="besper-messages"> <!-- Messages and typing indicator will be dynamically added here --> </div> <!-- Input Area --> <div class="besper-chat-input-container"> <form class="besper-chat-input-form" id="besper-chat-form"> <div class="besper-input-wrapper"> <textarea class="besper-chat-input" id="besper-message-input" placeholder="${placeholder}" rows="1" maxlength="2000" ></textarea> </div> <button type="submit" class="besper-chat-send-btn" id="besper-send-btn"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="22" y1="2" x2="11" y2="13"></line> <polygon points="22,2 15,22 11,13 2,9 22,2"></polygon> </svg> </button> </form> </div> </div> </div> `; } setupEventHandlers() { const widgetBtn = this.widget.querySelector('#besper-widget-btn'); const minimizeBtn = this.widget.querySelector('#besper-minimize-btn'); const sendBtn = this.widget.querySelector('#besper-send-btn'); const messageInput = this.widget.querySelector('#besper-message-input'); const chatForm = this.widget.querySelector('#besper-chat-form'); // Action buttons const dataPolicyBtn = this.widget.querySelector('#besper-data-policy-btn'); const expandBtn = this.widget.querySelector('#besper-expand-btn'); const downloadBtn = this.widget.querySelector('#besper-download-btn'); const restartBtn = this.widget.querySelector('#besper-restart-btn'); const deleteBtn = this.widget.querySelector('#besper-delete-btn'); // Widget toggle (only for floating mode) if (widgetBtn) { widgetBtn.addEventListener('click', () => this.toggleWidget()); } // Minimize button (only for floating mode) if (minimizeBtn) { minimizeBtn.addEventListener('click', () => this.minimizeWidget()); } // Message sending sendBtn?.addEventListener('click', e => { e.preventDefault(); this.sendMessage(); }); chatForm?.addEventListener('submit', e => { e.preventDefault(); this.sendMessage(); }); messageInput?.addEventListener('keypress', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); // Auto-resize textarea messageInput?.addEventListener('input', () => { this.autoResizeTextarea(messageInput); }); // Mobile-specific input handling if (this.isMobileDevice()) { this.setupMobileInputHandling(messageInput); } // Action button handlers dataPolicyBtn?.addEventListener('click', () => this.handleDataPolicy()); expandBtn?.addEventListener('click', () => this.handleExpand()); downloadBtn?.addEventListener('click', () => this.handleDownload()); restartBtn?.addEventListener('click', () => this.handleRestart()); deleteBtn?.addEventListener('click', () => this.handleDelete()); // Setup resize listener for table overflow rechecking this.setupResizeListener(); // Setup outside click handler for closing widget this.setupOutsideClickHandler(); // Global handler for warning clicks window.handleTableWarningClick = (event, messageId) => { // Cancel auto-expand if clicking during countdown if (this.autoExpandTimeouts.has(messageId)) { clearTimeout(this.autoExpandTimeouts.get(messageId)); this.autoExpandTimeouts.delete(messageId); } this.toggleExpandedView(); }; } setupOutsideClickHandler() { // Only enable for floating widget and if configured if (this.options.container || !this.state.styling?.close_on_outside_click) { return; } this.outsideClickHandler = event => { // Don't close if widget is not open or if clicking inside the widget if (!this.state.isOpen || this.widget.contains(event.target)) { return; } // Close the widget this.minimizeWidget(); }; // Add with small delay to prevent immediate closure setTimeout(() => { document.addEventListener('click', this.outsideClickHandler); }, 100); } removeOutsideClickHandler() { if (this.outsideClickHandler) { document.removeEventListener('click', this.outsideClickHandler); this.outsideClickHandler = null; } } autoResizeTextarea(textarea) { textarea.style.height = 'auto'; const maxHeight = 120; const newHeight = Math.min(textarea.scrollHeight, maxHeight); textarea.style.height = newHeight + 'px'; } // Mobile device detection isMobileDevice() { return ( window.innerWidth <= 480 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ) ); } // Simplified mobile input handling without complex keyboard positioning setupMobileInputHandling(messageInput) { if (!messageInput || !this.isMobileDevice()) return; const inputContainer = this.widget.querySelector( '.besper-chat-input-container' ); const messagesContainer = this.widget.querySelector( '.besper-chat-messages' ); if (!inputContainer || !messagesContainer) { console.warn( '[WARN] Mobile scroll prevention setup failed - missing elements' ); return; } // Simple input handling - just ensure messages scroll to bottom when input is focused messageInput.addEventListener('focus', () => { setTimeout(() => { this.scrollToBottom(); }, 300); // Delay to account for keyboard animation }); // Store minimal cleanup function this.mobileCleanup = () => { // No complex cleanup needed with the simplified approach }; } adjustMobileLayout() { if (!this.isMobileDevice()) return; const messagesContainer = this.widget.querySelector( '.besper-chat-messages' ); const chatContainer = this.widget.querySelector('.besper-chat-container'); if (messagesContainer && chatContainer) { // Ensure messages container is scrollable messagesContainer.style.overflowY = 'auto'; messagesContainer.style.webkitOverflowScrolling = 'touch'; // Maintain scroll position if user was at bottom const isAtBottom = messagesContainer.scrollTop + messagesContainer.clientHeight >= messagesContainer.scrollHeight - 10; if (isAtBottom) { setTimeout(() => { this.scrollToBottom(); }, 100); } } } setupResizeListener() { let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { // Recheck all visible messages for table overflow (skip on mobile) if (this.widget && !this.isMobileDevice()) { this.widget .querySelectorAll('.besper-message.besper-bot') .forEach(message => { const messageId = message.id; if (messageId) { this.checkTableOverflow(messageId); } }); } }, 250); }); } toggleWidget() { this.state.isOpen = !this.state.isOpen; const widget = this.widget.querySelector('.besper-widget'); if (this.state.isOpen) { widget.classList.remove('minimized'); widget.classList.add('open'); // Focus input setTimeout(() => { this.widget.querySelector('#besper-message-input')?.focus(); }, 100); } else { widget.classList.remove('open'); widget.classList.add('minimized'); } } minimizeWidget() { this.state.isOpen = false; const widget = this.widget.querySelector('.besper-widget'); widget.classList.remove('open'); widget.classList.add('minimized'); } addWelcomeMessage() { const welcomeMessage = this.state.botConfig.welcomeMessage; if (welcomeMessage) { this.addMessage(welcomeMessage, 'bot'); } } addOfflineMessage() { const offlineMessage = this.state.botConfig?.welcomeMessage || 'Chat is currently offline. Please try again later.'; this.addMessage(offlineMessage, 'bot'); } addMessage(content, sender, timestamp = null) { const messagesContainer = this.widget.querySelector('#besper-messages'); if (!messagesContainer) return; const messageId = 'msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); const messageTime = timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }); const config = this.state.botConfig; const formattedContent = this.formatMessage(content, sender); // Check if content has tables for overflow warning (skip on mobile) const hasTable = formattedContent.includes('<table'); const overflowWarningHTML = hasTable && sender === 'bot' && !this.isMobileDevice() ? ` <div class="besper-table-overflow-warning" id="overflowWarning-${messageId}" onclick="window.handleTableWarningClick && window.handleTableWarningClick(event, '${messageId}')"> <div class="besper-warning-icon"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/> </svg> </div> <div class="besper-warning-content"> <div class="besper-warning-text">Optimize view for better readability</div> <div class="besper-warning-subtext">Expanding chat area for wider tables</div> </div> </div> ` : ''; const messageHTML = ` <div class="besper-message besper-${sender}" id="${messageId}"> <div class="besper-message-avatar besper-${sender}" ${ sender === 'bot' && !config.logoUrl ? 'style="display: none;"' : '' }> ${ sender === 'bot' ? config.logoUrl ? `<img src="${config.logoUrl}" alt="Bot Logo" class="besper-avatar-image">` : '' : 'U' } </div> <div class="besper-message-content" id="bubble-${messageId}"> ${overflowWarningHTML} ${formattedContent} <div class="besper-message-time">${messageTime}</div> </div> </div> `; messagesContainer.insertAdjacentHTML('beforeend', messageHTML); // Store message in history this.messageHistory.push({ id: messageId, content, sender, timestamp: timestamp || new Date().toISOString(), }); // Check for table overflow after a short delay to ensure rendering (skip on mobile) if (sender === 'bot' && hasTable && !this.isMobileDevice()) { setTimeout(() => this.checkTableOverflow(messageId), 100); } // Scroll to bottom this.scrollToBottom(); } formatMessage(content, sender = 'bot') { // Use custom template if available and sender is bot if (this.customMessageTemplate && sender === 'bot') { const template = this.customMessageTemplate.replace( '{{message}}', content ); return this.sanitizer.sanitizeHTML(template); } // Enhanced message formatting with table support and markdown parsing return this.parseMarkdown(content); } parseMarkdown(text) { // Escape HTML first but preserve the structure text = text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;'); // Process tables BEFORE code blocks to prevent conflicts const lines = text.split('\n'); const result = []; let i = 0; let inCodeBlock = false; while (i < lines.length) { // Check for code blocks first if (lines[i].trim().startsWith('```')) { if (!inCodeBlock) { // Starting a code block inCodeBlock = true; const langMatch = lines[i].match(/```(\w*)/); const lang = langMatch ? langMatch[1] : ''; // Special handling for markdown code blocks that contain tables if (lang === 'markdown') { // Look ahead to see if there are tables in this markdown block const markdownBlockLines = []; let j = i + 1; let foundClosing = false; // Collect all lines until closing ``` while (j < lines.length) { if (lines[j].trim() === '```') { foundClosing = true; break; } markdownBlockLines.push(lines[j]); j++; } if (foundClosing) { // Check if the markdown block contains tables const hasTable = markdownBlockLines.some( line => line.trim().startsWith('|') && line.trim().endsWith('|') ) && markdownBlockLines.some(line => line.trim().match(/^\|[\s\-:|]+\|$/) ); if (hasTable) { // Parse the markdown content as actual markdown (recursive call with clean content) const markdownContent = markdownBlockLines.join('\n'); const parsedMarkdown = this.parseMarkdown(markdownContent); result.push(parsedMarkdown); i = j + 1; // Skip past the closing ``` inCodeBlock = false; continue; } } } // Regular code block handling result.push(`<pre><code class="language-${lang}">`); } else { // Ending a code block inCodeBlock = false; result.push('</code></pre>'); } i++; continue; } // If we're in a code block, just add the line if (inCodeBlock) { result.push(lines[i]); i++; continue; } // Check if this line starts a table (and we're NOT in a code block) if ( lines[i].trim().startsWith('|') && lines[i].trim().endsWith('|') && i + 1 < lines.length && lines[i + 1].trim().match(/^\|[\s\-:|]+\|$/) ) { // Found a table, collect all table lines const tableLines = []; let j = i; while ( j < lines.length && lines[j].trim().startsWith('|') && lines[j].trim().endsWith('|') ) { tableLines.push(lines[j]); j++; } // Parse the table if (tableLines.length >= 2) { // Extract headers const headerCells = tableLines[0] .split('|') .slice(1, -1) .map(h => { let headerContent = h.trim(); // Apply markdown formatting to headers headerContent = headerContent .replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>') .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*([^*\s][^*]*[^*\s])\*/g, '<em>$1</em>') .replace(/___(.+?)___/g, '<strong><em>$1</em></strong>') .replace(/__(.+?)__/g, '<strong>$1</strong>') .replace(/_([^_\s][^_]*[^_\s])_/g, '<em>$1</em>'); return headerContent; }); // Extract alignments const alignments = tableLines[1] .split('|') .slice(1, -1) .map(s => { s = s.trim(); if (s.startsWith(':') && s.endsWith(':')) return 'center'; if (s.endsWith(':')) return 'right'; return 'left'; }); // Build table with wrapper let table = '<div class="besper-table-wrapper">'; table += '<table class="besper-markdown-table">'; table += '<thead><tr>'; headerCells.forEach((header, idx) => { table += `<th style="text-align: ${alignments[idx] || 'left'}">${header}</th>`; }); table += '</tr></thead><tbody>'; // Process body rows for (let k = 2; k < tableLines.length; k++) { const cells = tableLines[k] .split('|') .slice(1, -1) .map(c => { let cellContent = c.trim(); // Apply markdown formatting cellContent = cellContent .replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>') .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*([^*\s][^*]*[^*\s])\*/g, '<em>$1</em>') .replace(/___(.+?)___/g, '<strong><em>$1</em></strong>') .replace(/__(.+?)__/g, '<strong>$1</strong>') .replace(/_([^_\s][^_]*[^_\s])_/g, '<em>$1</em>'); return cellContent; }); if (cells.length > 0) { table += '<tr>'; cells.forEach((cell, idx) => { table += `<td style="text-align: ${alignments[idx] || 'left'}">${cell}</td>`; }); table += '</tr>'; } } table += '</tbody></table></div>'; result.push(table); } i = j; } else { // Process other markdown elements let line = lines[i]; // Headers line = line.replace(/^#### (.+)$/, '<div class="besper-h4">$1</div>'); line = line.replace(/^### (.+)$/, '<div class="besper-h3">$1</div>'); line = line.replace(/^## (.+)$/, '<div class="besper-h2">$1</div>'); line = line.replace(/^# (.+)$/, '<div class="besper-h1">$1</div>'); // Bold and italic line = line.replace( /\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>' ); line = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); line = line.replace(/\*([^*\s][^*]*[^*\s])\*/g, '<em>$1</em>'); line = line.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>'); line = line.replace(/__(.+?)__/g, '<strong>$1</strong>'); line = line.replace(/_([^_\s][^_]*[^_\s])_/g, '<em>$1</em>'); // Inline code (only if not already in a code block) line = line.replace( /`([^`]+)`/g, '<span class="besper-code">$1</span>' ); // Lists if (line.match(/^[*-] .+$/)) { line = '<li class="besper-li">' + line.substring(2) + '</li>'; } else if (line.match(/^\d+\. .+$/)) { line = '<li class="besper-li">' + line.replace(/^\d+\. /, '') + '</li>'; } // Links line = line.replace( /\[([^\]]+)\]\(([^)]+)\)/g, '<a class="besper-link" href="$2" target="_blank">$1</a>' ); result.push(line); i++; } } // Join lines and handle lists text = result.join('\n'); // Wrap consecutive li elements in ul text = text.replace( /(<li class="besper-li">.*<\/li>\n?)+/g, function (match) { return '<ul class="besper-ul">' + match + '</ul>'; } ); // Wrap text in paragraphs where appropriate const finalLines = text.split('\n'); const finalResult = []; let inBlock = false; finalLines.forEach(line => { if (line.match(/^<(div|table|pre|ul|ol|h[1-6]|li)/)) { inBlock = true; finalResult.push(line); } else if (line.match(/<\/(div|table|pre|ul|ol|h[1-6])>$/)) { inBlock = false; finalResult.push(line); } else if (!inBlock && line.trim() && !line.match(/^<.*>$/)) { finalResult.push('<div class="besper-p">' + line + '</div>'); } else { finalResult.push(line); } }); return finalResult.join('\n'); } // Table Overflow Detection and Layout Optimization Functions // Check for table overflow in a specific message checkTableOverflow(messageId) { const messageElement = this.widget.querySelector(`#${messageId}`); if (!messageElement) return; const tables = messageElement.querySelectorAll('.besper-table-wrapper'); tables.forEach((wrapper, index) => { const table = wrapper.querySelector('table'); if (!table) return; // Force a reflow to get accurate measurements wrapper.style.overflow = 'visible'; const tableWidth = table.scrollWidth; const wrapperWidth = wrapper.clientWidth; wrapper.style.overflow = 'auto'; if (tableWidth > wrapperWidth) { console.log( `Table overflow detected in message ${messageId}, table ${index}` ); this.showOverflowWarning(messageId, wrapper); } }); } // Show overflow warning with auto-expand showOverflowWarning(messageId, tableWrapper) { const warning = this.widget.querySelector(`#overflowWarning-${messageId}`); if (!warning) return; warning.classList.add('visible'); warning.classList.add('auto-expanding'); // Highlight the table briefly tableWrapper.classList.add('highlighting'); setTimeout(() => tableWrapper.classList.remove('highlighting'), 1000); // Clear any existing timeout for this message if (this.autoExpandTimeouts.has(messageId)) { clearTimeout(this.autoExpandTimeouts.get(messageId)); } // Auto-expand after 1.5 seconds const timeoutId = setTimeout(() => { if (this.state.viewState === 'normal') { this.toggleExpandedView(); this.updateWarningState(messageId); } this.autoExpandTimeouts.delete(messageId); }, 1500); this.autoExpandTimeouts.set(messageId, timeoutId); } // Toggle between normal and expanded view states toggleExpandedView() { const chatContainer = this.widget.querySelector('.besper-chat-container'); if (!chatContainer) return; // Add transitioning class for smooth animation chatContainer.classList.add('transitioning'); // Clear all state classes first chatContainer.classList.remove('expanded'); // Toggle state if (this.state.viewState === 'normal') { this.state.viewState = 'expanded'; chatContainer.classList.add('expanded'); } else { this.state.viewState = 'normal'; // Already cleared classes above, so we're back to normal state } // Update expand button appearance this.updateExpandButton(); // Update all warning states this.widget .querySelectorAll('.besper-table-overflow-warning') .forEach(warning => { this.updateWarningStateElement(warning); }); // Remove transitioning class after animation setTimeout(() => { chatContainer.classList.remove('transitioning'); }, 400); } // Update warning state updateWarningState(messageId) { const warning = this.widget.querySelector(`#overflowWarning-${messageId}`); this.updateWarningStateElement(warning); } updateWarningStateElement(warning) { if (!warning) return; const warningText = warning.querySelector('.besper-warning-text'); if (!warningText) return; if (this.state.viewState === 'normal') { warningText.textContent = 'Optimize view for better readability (Click to expand)'; warning.classList.remove('expanded'); } else { // viewState === 'expanded' warningText.textContent = 'Optimized view active - more space for text (Click to return to normal)'; warning.classList.add('expanded'); } } scrollToBottom() { const messagesContainer = this.widget.querySelector('#besper-messages'); if (messagesContainer) { // Use smooth scrolling for better UX, especially when keyboard state changes messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth', }); } } showTypingIndicator() { const messagesContainer = this.widget.querySelector('#besper-messages'); if (!messagesContainer) return; // Remove any existing typing indicator this.hideTypingIndicator(); const config = this.state.botConfig; const typingDiv = document.createElement('div'); typingDiv.className = 'besper-typing-indicator'; typingDiv.id = 'besper-typing-indicator'; typingDiv.innerHTML = ` <div class="besper-message-avatar besper-bot" ${ !config.logoUrl ? 'style="display: none;"' : '' }> ${ config.logoUrl ? `<img src="${config.logoUrl}" alt="Bot Logo" class="besper-avatar-image">` : '' } </div> <div class="besper-typing-bubble"> <div class="besper-typing-dot"></div> <div class="besper-typing-dot"></div> <div class="besper-typing-dot"></div> </div> `; messagesContainer.appendChild(typingDiv); this.scrollToBottom(); } hideTypingIndicator() { const typingIndicator = this.widget.querySelector( '#besper-typing-indicator' ); if (typingIndicator) { typingIndicator.remove(); } } async sendMessage() { const input = this.widget.querySelector('#besper-message-input'); const message = input.value.trim(); if (!message || this.state.typing) return; // Add user message this.addMessage(message, 'user'); input.value = ''; this.autoResizeTextarea(input); // Show typing indicator this.state.typing = true; this.showTypingIndicator(); try { const response = await this.generateResponse(message); this.hideTypingIndicator(); this.addMessage(response, 'bot'); } catch (error) { this.hideTypingIndicator(); console.error('Message send error:', error); this.addMessage( 'Sorry, I encountered an error. Please try again.', 'bot' ); } finally { this.state.typing = false; } } async generateResponse(message) { if (!this.state.isConnected) { return `Demo response to: "${message}". This is a demo mode.`; } try { // Get browser language for response generation using the enhanced detection function const browserLanguage = getBrowserLanguage(); // Use thread_id if available, otherwise fallback to session_token for legacy/demo const threadId = this.state.threadId; if (!threadId) { throw new Error('Missing thread_id for response generation'); } const data = await operationsApiCall( 'generate_response', 'POST', { thread_id: threadId, // <-- Use thread_id as required by API bot_id: this.botId, message, browser_language: browserLanguage, }, this.options.environment ); if (!data.success) { throw new Error(data.error || 'Response generation failed'); } return ( data.content || data.response || 'I apologize, but I could not generate a response.' ); } catch (error) { console.error('Response generation failed:', error); return `I'm having trouble connecting right now. Please try again later.`; } } // Action button handlers handleDataPolicy() { const dataPolicyUrl = this.state.botConfig.dataPolicyUrl; if (dataPolicyUrl) { window.open(dataPolicyUrl, '_blank'); } } handleExpand() { this.toggleExpandedView(); this.updateExpandButton(); } // Update expand button appearance and tooltip based on current state updateExpandButton() { const expandBtn = this.widget.querySelector('#besper-expand-btn'); if (!expandBtn) return; const svg = expandBtn.querySelector('svg'); if (this.state.viewState === 'normal') { expandBtn.title = 'Optimize view for better readability'; svg.innerHTML = '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>'; } else { // viewState === 'expanded' expandBtn.title = 'Return to normal view'; svg.innerHTML = '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>'; } } async handleDownload() { if (!this.state.isConnected || !this.state.threadId) { console.warn('Cannot download: not connected or missing thread_id'); return; } try { // Use centralized endpoint for download const endpoint = `${getBotOperationsEndpoint()}/download_conversation`; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ thread_id: this.state.threadId, bot_id: this.botId, format: 'txt', }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `conversation-${new Date().toISOString().split('T')[0]}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Download failed:', error); } } async handleRestart() { try { // Show restart animation feedback this.showActionFeedback('restart', 'Restarting conversation...'); // Handle restart client-side by creating a new session await this.createSession(); // Clear messages and add welcome message this.clearMessages(); this.addWelcomeMessage(); // Show success feedback this.showActionFeedback('restart', 'Conversation restarted!', 'success'); console.log('Conversation restarted successfully'); } catch (error) { console.error('Restart failed:', error); this.showActionFeedback( 'restart', 'Failed to restart conversation', 'error' ); } } async handleDelete() { if (!this.state.isConnected || !this.state.threadId) { console.warn('Cannot delete: not connected or missing thread_id'); // Show feedback and clear messages locally this.showActionFeedback('delete', 'Clearing conversation...'); this.clearMessages(); this.addWelcomeMessage(); this.showActionFeedback('delete', 'Conversation cleared!', 'success'); return; } try { // Show delete animation feedback this.showActionFeedback('delete', 'Deleting conversation...'); const result = await operationsApiCall( 'delete_conversation', 'POST', { thread_id: this.state.threadId, bot_id: this.botId, }, this.options.environment ); if (!result.success) { throw new Error(result.error || 'Delete operation failed'); } // Show success feedback this.showActionFeedback('delete', 'Conversation deleted!', 'success'); console.log('Conversation deleted successfully'); } catch (error) { console.error('Delete failed:', error); this.showActionFeedback( 'delete', 'Failed to delete conversation', 'error' ); } finally { // Always clear messages locally regardless of API result this.clearMessages(); this.addWelcomeMessage(); } } clearMessages() { const messagesContainer = this.widget.querySelector('#besper-messages'); if (messagesContainer) { messagesContainer.innerHTML = ''; } this.messageHistory = []; } showActionFeedback(action, message, type = 'loading') { // Create or update feedback element let feedback = this.widget.querySelector('.besper-action-feedback'); if (!feedback) { feedback = document.createElement('div'); feedback.className = 'besper-action-feedback'; const messagesContainer = this.widget.querySelector('#besper-messages'); if (messagesContainer) { messagesContainer.appendChild(feedback); } } // Set feedback content based on type let icon = ''; if (type === 'loading') { icon = '<div class="besper-spinner"></div>'; } else if (type === 'success') { icon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'; } else if (type === 'error') { icon = '<sv