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
JavaScript
// 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// 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