diff --git "a/public/app.js" "b/public/app.js" new file mode 100644--- /dev/null +++ "b/public/app.js" @@ -0,0 +1,9180 @@ +/** + * Rox AI - Production-Ready Professional Chat Application + * @fileoverview Ultra-optimized chat interface with proper type declarations and validation + * @version 3.9.2 + * @author Rox AI Technologies + * @license MIT + * + * ╔══════════════════════════════════════════════════════════════════════════════╗ + * ║ TABLE OF CONTENTS ║ + * ╠══════════════════════════════════════════════════════════════════════════════╣ + * ║ ║ + * ║ SECTION LINE (approx) ║ + * ║ ─────────────────────────────────────────────────────────────────────── ║ + * ║ ║ + * ║ 1. DEVELOPER TOOLS PROTECTION .......................... ~70 ║ + * ║ - Context menu disable ║ + * ║ - Keyboard shortcuts block ║ + * ║ - Security warnings ║ + * ║ ║ + * ║ 2. TYPE DEFINITIONS & CONSTANTS ........................ ~130 ║ + * ║ - JSDoc typedefs (Model, Message, Conversation, etc.) ║ + * ║ - LANGUAGE_NAMES, HTML_ESCAPE_MAP, DIALOG_ICONS ║ + * ║ - Storage keys and limits ║ + * ║ ║ + * ║ 3. ROXAI CLASS ........................................ ~260 ║ + * ║ - Constructor & state initialization ║ + * ║ - DOM element references ║ + * ║ ║ + * ║ 4. HOT RELOAD SYSTEM .................................. ~360 ║ + * ║ - Version checking ║ + * ║ - Update dialog ║ + * ║ - Cache clearing ║ + * ║ ║ + * ║ 5. UTILITY METHODS .................................... ~660 ║ + * ║ - Error categorization ║ + * ║ - Storage helpers (get/set) ║ + * ║ - debounce ║ + * ║ - escapeHtml, escapeHtmlDisplay ║ + * ║ - sanitizeUrl, generateId ║ + * ║ ║ + * ║ 6. INITIALIZATION ..................................... ~880 ║ + * ║ - _initElements ║ + * ║ - _initTheme ║ + * ║ - _initPWAInstall ║ + * ║ ║ + * ║ 7. MODEL SELECTOR ..................................... ~970 ║ + * ║ - Dropdown toggle ║ + * ║ - Model switching ║ + * ║ ║ + * ║ 8. DIALOG SYSTEM ...................................... ~1060 ║ + * ║ - Custom dialog creation ║ + * ║ - showDialog, closeDialog ║ + * ║ ║ + * ║ 9. MOBILE ACTION SHEET ................................ ~1090 ║ + * ║ - Long-press detection ║ + * ║ - Action sheet UI ║ + * ║ - Touch event handling ║ + * ║ ║ + * ║ 10. EVENT LISTENERS ................................... ~1660 ║ + * ║ - Input handling ║ + * ║ - Button clicks ║ + * ║ - Keyboard shortcuts ║ + * ║ - Global keydown ║ + * ║ ║ + * ║ 11. INPUT HANDLING .................................... ~2040 ║ + * ║ - _handleInput ║ + * ║ - _updateSendButtonState ║ + * ║ - _stopGeneration ║ + * ║ - _autoResize ║ + * ║ - _handleKeyDown ║ + * ║ ║ + * ║ 12. SIDEBAR ........................................... ~2140 ║ + * ║ - Toggle, open, close ║ + * ║ ║ + * ║ 13. FILE HANDLING ..................................... ~2180 ║ + * ║ - File selection ║ + * ║ - Attachment preview ║ + * ║ ║ + * ║ 14. MESSAGING ......................................... ~2270 ║ + * ║ - Message validation ║ + * ║ - sendMessage (main API call) ║ + * ║ - Creator protection ║ + * ║ ║ + * ║ 15. MESSAGE RENDERING ................................. ~2700 ║ + * ║ - _renderMessage ║ + * ║ - _initCodeCopyButtons ║ + * ║ - _attachMessageEvents ║ + * ║ - _toggleListenAloud (TTS) ║ + * ║ - _regenerateResponse ║ + * ║ - _exportToPDF ║ + * ║ - _formatContentForPDF ║ + * ║ ║ + * ║ 16. CONTENT FORMATTING ................................ ~4500 ║ + * ║ - _formatContent (markdown parsing) ║ + * ║ - _formatStreamingContent ║ + * ║ - _formatInlineContent ║ + * ║ - _renderMath (KaTeX) ║ + * ║ - _autoFormatMathExpressions ║ + * ║ - _parseMarkdownTablesWithPlaceholders ║ + * ║ - _parseChartBlocksWithPlaceholders ║ + * ║ ║ + * ║ 17. STREAMING MESSAGE METHODS ......................... ~6160 ║ + * ║ - _renderStreamingMessage ║ + * ║ - _updateStreamingMessage ║ + * ║ - _finalizeStreamingMessage ║ + * ║ - Typing indicator ║ + * ║ ║ + * ║ 18. SCROLLING ......................................... ~6790 ║ + * ║ - _scrollToBottom ║ + * ║ - Smooth scroll initialization ║ + * ║ ║ + * ║ 19. CONVERSATION MANAGEMENT ........................... ~6830 ║ + * ║ - createNewChat �� + * ║ - loadConversation ║ + * ║ - _renderConversations ║ + * ║ ║ + * ║ 20. CONTEXT MENU ...................................... ~6960 ║ + * ║ - Show/hide context menu ║ + * ║ - Context actions ║ + * ║ ║ + * ║ 21. RENAME MODAL ...................................... ~7020 ║ + * ║ - Show rename modal ║ + * ║ - Confirm rename ║ + * ║ - Delete conversation ║ + * ║ ║ + * ║ 22. STORAGE ........................................... ~7110 ║ + * ║ - _loadConversations ║ + * ║ - _saveConversations ║ + * ║ - Validation helpers ║ + * ║ ║ + * ║ 23. DOCUMENTATION ..................................... ~7350 ║ + * ║ - Documentation modal ║ + * ║ - _getDocumentationHTML ║ + * ║ ║ + * ║ 24. MOBILE NAVIGATION SUPPORT ......................... ~7920 ║ + * ║ - PWA detection ║ + * ║ - Back button handling ║ + * ║ - Swipe gestures ║ + * ║ - Keyboard handling ║ + * ║ - Safe area handling ║ + * ║ - Haptic feedback ║ + * ║ ║ + * ║ 25. APPLICATION INITIALIZATION ........................ ~8370 ║ + * ║ - Error handlers ║ + * ║ - DOMContentLoaded ║ + * ║ - Global instance creation ║ + * ║ ║ + * ║ 26. TYPE DECLARATIONS ................................. ~8450 ║ + * ║ - deepFreeze utility ║ + * ║ ║ + * ╚══════════════════════════════════════════════════════════════════════════════╝ + * + * USAGE: Use Ctrl+F (Cmd+F on Mac) to search for section headers like: + * "// ==================== MESSAGING ====================" + * to jump directly to that section. + */ + +'use strict'; + +// ==================== DEVELOPER TOOLS PROTECTION ==================== +(function() { + // Disable right-click context menu + document.addEventListener('contextmenu', function(e) { + e.preventDefault(); + return false; + }); + + // Disable keyboard shortcuts for dev tools + document.addEventListener('keydown', function(e) { + // F12 + if (e.key === 'F12' || e.keyCode === 123) { + e.preventDefault(); + return false; + } + // Ctrl+Shift+I (Dev Tools) + if (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'i' || e.keyCode === 73)) { + e.preventDefault(); + return false; + } + // Ctrl+Shift+J (Console) + if (e.ctrlKey && e.shiftKey && (e.key === 'J' || e.key === 'j' || e.keyCode === 74)) { + e.preventDefault(); + return false; + } + // Ctrl+Shift+C (Inspect Element) + if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.key === 'c' || e.keyCode === 67)) { + e.preventDefault(); + return false; + } + // Ctrl+U (View Source) + if (e.ctrlKey && (e.key === 'U' || e.key === 'u' || e.keyCode === 85)) { + e.preventDefault(); + return false; + } + // Ctrl+S (Save Page) + if (e.ctrlKey && (e.key === 'S' || e.key === 's' || e.keyCode === 83)) { + e.preventDefault(); + return false; + } + }); + + // Disable drag + document.addEventListener('dragstart', function(e) { + e.preventDefault(); + return false; + }); + + // Console security warning (runs once on load, then periodically) + const showSecurityWarning = function() { + console.clear(); + console.log('%c⚠️ Stop!', 'color: red; font-size: 50px; font-weight: bold;'); + console.log('%cThis is a browser feature intended for developers.', 'font-size: 16px;'); + console.log('%cIf someone told you to paste something here, it\'s likely a scam.', 'font-size: 16px; color: #ef4444;'); + }; + + // Show warning on load and periodically (store interval for potential cleanup) + setTimeout(showSecurityWarning, 500); + window._roxSecurityInterval = setInterval(showSecurityWarning, 10000); + + // Cleanup on page unload to prevent memory leaks + window.addEventListener('beforeunload', function() { + if (window._roxSecurityInterval) { + clearInterval(window._roxSecurityInterval); + } + }); +})(); + +/** + * Extend Window interface for roxAI + * @typedef {Window & { roxAI?: RoxAI, _roxSecurityInterval?: number, navigator: Navigator & { standalone?: boolean } }} ExtendedWindow + */ + +/** + * Network Information API connection object + * @typedef {Object} NetworkInformation + * @property {'slow-2g'|'2g'|'3g'|'4g'} effectiveType - Effective connection type + * @property {number} downlink - Downlink speed in Mbps + * @property {number} rtt - Round-trip time in milliseconds + * @property {boolean} saveData - Whether data saver is enabled + * @property {Function} addEventListener - Add event listener + * @property {Function} removeEventListener - Remove event listener + */ + +/** + * Extended Navigator with Network Information API + * @typedef {Navigator & { connection?: NetworkInformation, mozConnection?: NetworkInformation, webkitConnection?: NetworkInformation }} ExtendedNavigator + */ + +/** + * BeforeInstallPromptEvent for PWA installation + * @typedef {Event & { prompt: () => Promise, userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> }} BeforeInstallPromptEvent + */ + +/** + * @typedef {Object} Model + * @property {string} id - Model identifier + * @property {string} name - Display name + * @property {string} desc - Description + */ + +/** + * @typedef {Object} Attachment + * @property {string} name - File name + * @property {string} type - MIME type + * @property {number} size - File size in bytes + */ + +/** + * @typedef {Object} MessageVersion + * @property {string} userContent - User message content for this version + * @property {string} [assistantContent] - Assistant response for this version + * @property {number} timestamp - Version timestamp + * @property {string} [model] - Model name that generated this version's response + */ + +/** + * @typedef {Object} Message + * @property {'user'|'assistant'} role - Message role + * @property {string} content - Message content + * @property {Attachment[]} [attachments] - Optional attachments + * @property {number} timestamp - Unix timestamp + * @property {string} [id] - Unique message ID + * @property {string} [pairId] - ID linking user-assistant message pairs + * @property {MessageVersion[]} [versions] - Edit history versions + * @property {number} [versionIndex] - Current version index + * @property {string} [model] - Model name that generated the response + * @property {boolean} [usedInternet] - Whether internet was used for this response + * @property {string} [internetSource] - Source of internet data (e.g., 'Google News', 'Web Search', 'DuckDuckGo', 'Wikipedia') + */ + +/** + * @typedef {Object} Conversation + * @property {string} id - Unique identifier + * @property {string} title - Conversation title + * @property {Message[]} messages - Array of messages + * @property {number} createdAt - Creation timestamp + */ + +/** + * @typedef {Object} DialogOptions + * @property {'info'|'success'|'warning'|'error'|'confirm'} [type='info'] + * @property {string} title - Dialog title + * @property {string} message - Dialog message + * @property {string} [confirmText='OK'] - Confirm button text + * @property {string} [cancelText='Cancel'] - Cancel button text + * @property {Function} [onConfirm] - Confirm callback + * @property {Function} [onCancel] - Cancel callback + * @property {boolean} [showCancel=true] - Show cancel button + */ + +/** @type {Object} Language name mappings */ +const LANGUAGE_NAMES = Object.freeze({ + 'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'rb': 'ruby', + 'sh': 'bash', 'yml': 'yaml', 'md': 'markdown', 'cs': 'csharp', 'cpp': 'c++', + 'jsx': 'jsx', 'tsx': 'tsx', 'json': 'json', 'html': 'html', 'css': 'css', + 'sql': 'sql', 'go': 'go', 'rs': 'rust', 'php': 'php', 'swift': 'swift', + 'kt': 'kotlin', 'scala': 'scala', 'vue': 'vue', 'svelte': 'svelte', + 'astro': 'astro', 'r': 'r', 'lua': 'lua', 'dart': 'dart', 'zig': 'zig' +}); + +/** @type {Object} HTML escape map */ +const HTML_ESCAPE_MAP = Object.freeze({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' +}); + +/** @type {Object} Dialog icons SVG */ +const DIALOG_ICONS = Object.freeze({ + info: '', + success: '', + warning: '', + error: '', + confirm: '' +}); + +/** + * Mobile breakpoint in pixels + * @constant {number} + */ +const MOBILE_BREAKPOINT = 768; + +/** + * Maximum message length + * @constant {number} + */ +const MAX_MESSAGE_LENGTH = 10000000; + +/** + * Maximum conversations to store + * @constant {number} + */ +const MAX_CONVERSATIONS = 10000; + +/** + * Maximum messages per conversation + * @constant {number} + */ +const MAX_MESSAGES_PER_CONVERSATION = 100000; + +/** + * Storage key for conversations + * @constant {string} + */ +const STORAGE_KEY_CONVERSATIONS = 'roxai_conversations'; + +/** + * Storage key for theme + * @constant {string} + */ +const STORAGE_KEY_THEME = 'roxai_theme'; + +/** + * Storage key for model + * @constant {string} + */ +const STORAGE_KEY_MODEL = 'roxai_model'; + +/** + * Storage key for last version (hot reload tracking) + * @constant {string} + */ +const STORAGE_KEY_LAST_VERSION = 'roxai_last_version'; + +/** + * Storage key for app version + * @constant {string} + */ +const STORAGE_KEY_APP_VERSION = 'roxai_app_version'; + +/** + * Storage key for service worker version + * @constant {string} + */ +const STORAGE_KEY_SW_VERSION = 'roxai_sw_version'; + +/** + * Session storage key for update complete flag + * @constant {string} + */ +const SESSION_KEY_UPDATE_COMPLETE = 'roxai_update_complete'; + +// Animation timing is defined in CSS variables (--ease-out-expo) + +/** + * Main RoxAI Application Class + * @class + */ +class RoxAI { + /** + * Creates a new RoxAI instance + * @constructor + */ + constructor() { + /** @type {Conversation[]} */ + this.conversations = []; + + /** @type {string|null} */ + this.currentConversationId = null; + + /** @type {File[]} */ + this.attachedFiles = []; + + /** @type {boolean} */ + this.isLoading = false; + + /** @type {string} */ + this.currentModel = 'rox'; + + /** @type {AbortController|null} */ + this.requestController = null; + + /** @type {number|null} */ + this._scrollRAF = null; + + // Text-to-speech state + /** @type {SpeechSynthesisUtterance|null} */ + this._currentUtterance = null; + /** @type {HTMLElement|null} */ + this._currentListenBtn = null; + /** @type {HTMLElement|null} */ + this._currentMessageDiv = null; + /** @type {number|null} */ + this._scrollAnimationId = null; + /** @type {Function|null} */ + this._listenScrollCleanup = null; + + // Mobile action sheet state (initialized in _initMobileActionSheet) + /** @type {number|null} */ + this._longPressTimer = null; + /** @type {{x: number, y: number}|null} */ + this._touchStartPos = null; + /** @type {HTMLElement|null} */ + this._longPressTarget = null; + /** @type {Message|null} */ + this._actionSheetMessage = null; + /** @type {boolean} */ + this._isScrolling = false; + + // Mobile navigation state (initialized in _initMobileNavigation) + /** @type {string[]} */ + this._navStack = ['main']; + /** @type {boolean} */ + this._isPWA = false; + + // PWA install prompt + /** @type {BeforeInstallPromptEvent|null} */ + this._deferredPrompt = null; + + // Auto-scroll state - tracks if user has scrolled away during streaming + /** @type {boolean} */ + this._userHasScrolledAway = false; + /** @type {string|null} */ + this._currentStreamingPairId = null; + + // Typing indicator state + /** @type {number|null} */ + this._typingStartTime = null; + /** @type {number|null} */ + this._typingStatusInterval = null; + /** @type {boolean} */ + this._llmConfirmed = false; + + // Haptic feedback state + /** @type {boolean} */ + this._hasHaptics = false; + + // Streaming optimization state + /** @type {string|null} */ + this._pendingStreamContent = null; + /** @type {string|null} */ + this._pendingStreamId = null; + /** @type {number|null} */ + this._streamUpdateRAF = null; + + // Network quality and retry state + /** @type {'excellent'|'good'|'fair'|'poor'|'offline'} */ + this._connectionQuality = 'good'; + /** @type {{content: string, files: File[], timestamp: number}[]} */ + this._offlineQueue = []; + /** @type {number} */ + this._maxRetries = 3; + /** @type {HTMLElement|null} */ + this._offlineIndicator = null; + + /** @type {readonly Model[]} */ + this.models = /** @type {readonly Model[]} */ (Object.freeze([ + { id: 'rox', name: 'Rox Core', desc: 'Fast & reliable for everyday tasks' }, + { id: 'rox-2.1-turbo', name: 'Rox 2.1 Turbo', desc: 'Deep thinking & reasoning' }, + { id: 'rox-3.5-coder', name: 'Rox 3.5 Coder', desc: 'Best for coding & development' }, + { id: 'rox-4.5-turbo', name: 'Rox 4.5 Turbo', desc: 'Advanced reasoning & analysis' }, + { id: 'rox-5-ultra', name: 'Rox 5 Ultra', desc: 'Most powerful flagship model' } + ])); + + // DOM Element references + /** @type {HTMLElement|null} */ this.sidebar = null; + /** @type {HTMLElement|null} */ this.sidebarOverlay = null; + /** @type {HTMLElement|null} */ this.chatList = null; + /** @type {HTMLElement|null} */ this.messages = null; + /** @type {HTMLElement|null} */ this.welcome = null; + /** @type {HTMLTextAreaElement|null} */ this.messageInput = null; + /** @type {HTMLButtonElement|null} */ this.btnSend = null; + /** @type {HTMLButtonElement|null} */ this.btnNewChat = null; + /** @type {HTMLButtonElement|null} */ this.btnToggleSidebar = null; + /** @type {HTMLButtonElement|null} */ this.btnThemeToggle = null; + /** @type {HTMLButtonElement|null} */ this.btnAttach = null; + /** @type {HTMLInputElement|null} */ this.fileInput = null; + /** @type {HTMLElement|null} */ this.attachmentsPreview = null; + /** @type {HTMLElement|null} */ this.contextMenu = null; + /** @type {HTMLElement|null} */ this.renameModal = null; + /** @type {HTMLElement|null} */ this.chatTitle = null; + /** @type {HTMLElement|null} */ this.modelSelector = null; + /** @type {HTMLButtonElement|null} */ this.modelSelectorBtn = null; + /** @type {HTMLElement|null} */ this.modelDropdown = null; + /** @type {HTMLElement|null} */ this.currentModelName = null; + + this._init(); + } + + /** + * Initialize the application + * @private + */ + _init() { + this.conversations = this._loadConversations(); + this.currentModel = this._getStorageItem(STORAGE_KEY_MODEL) || 'rox'; + + this._initElements(); + this._initTheme(); + this._initModelSelector(); + this._initEventListeners(); + this._initSmoothScroll(); + this._renderConversations(); + this._createCustomDialogs(); + this._initMobileActionSheet(); + this._initDocumentation(); + this._initMobileNavigation(); + this._initHotReload(); + } + + // ==================== HOT RELOAD SYSTEM ==================== + + /** + * Initialize hot reload system - polls server for updates + * @private + */ + _initHotReload() { + /** @type {string|null} */ + this._serverVersion = null; + /** @type {boolean} */ + this._updateAvailable = false; + /** @type {number|null} */ + this._hotReloadInterval = null; + /** @type {string|null} */ + this._appVersion = null; + /** @type {string|null} */ + this._newAppVersion = null; + /** @type {boolean} */ + this._updateJustCompleted = false; + /** @type {number} */ + this._lastUpdateCheck = 0; + + // Check if we just completed an update (prevents loop) + const updateCompleteFlag = sessionStorage.getItem(SESSION_KEY_UPDATE_COMPLETE); + const urlParams = new URLSearchParams(window.location.search); + const hasUpdateParams = urlParams.has('_v') || urlParams.has('_nocache') || urlParams.has('_emergency'); + + if (updateCompleteFlag || hasUpdateParams) { + this._updateJustCompleted = true; + // Clear the flag + sessionStorage.removeItem(SESSION_KEY_UPDATE_COMPLETE); + + // Clean URL params without triggering reload + if (hasUpdateParams) { + const cleanUrl = window.location.pathname; + window.history.replaceState({}, document.title, cleanUrl); + console.log('✅ Update complete! URL cleaned.'); + } + + // Don't check for updates for 30 seconds after completing one (reduced from 60s) + console.log('⏸️ Update just completed - skipping update checks for 30s'); + setTimeout(() => { + this._updateJustCompleted = false; + console.log('▶️ Resuming update checks'); + // Immediately check for updates when resuming + this._checkForUpdates(); + }, 30000); + } + + // Listen for messages from service worker + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.type === 'FORCE_RELOAD') { + console.log('🔄 Force reload requested by service worker'); + // Only perform update if we didn't just complete one + if (!this._updateJustCompleted) { + this._performUpdate(); + } + } + if (event.data?.type === 'CACHES_CLEARED') { + console.log(`✅ Service worker cleared ${event.data.count || 'all'} caches`); + } + if (event.data?.type === 'VERSION') { + console.log(`📦 Service worker version: ${event.data.version}`); + } + if (event.data?.type === 'UPDATE_AVAILABLE') { + console.log('🆕 Service worker detected update'); + if (!this._updateAvailable && !this._updateJustCompleted) { + this._updateAvailable = true; + this._showUpdateDialog(); + } + } + if (event.data?.type === 'UPDATE_ACTIVATED') { + console.log(`✅ Service worker v${event.data.version} now active`); + } + }); + + // Check for waiting service worker on load (but not right after update) + navigator.serviceWorker.ready.then((registration) => { + if (registration.waiting && !this._updateJustCompleted) { + console.log('🆕 New service worker waiting - triggering update'); + this._updateAvailable = true; + this._showUpdateDialog(); + } + + // Listen for new service workers + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + if (newWorker) { + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + console.log('🆕 New service worker installed - update available'); + // Don't show dialog if update just completed + if (!this._updateAvailable && !this._updateJustCompleted) { + this._updateAvailable = true; + this._showUpdateDialog(); + } + } + }); + } + }); + + // Force check for SW updates + registration.update().catch(() => {}); + }); + } + + // Check for updates immediately on load (not after update) + if (!this._updateJustCompleted) { + // Small delay to let page load first + setTimeout(() => this._checkForUpdates(), 1000); + } else { + // Just fetch version for display without triggering update + this._fetchVersionForDisplay(); + } + + // Poll every 20 seconds in production, every 5 seconds in development + const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + const pollInterval = isLocalhost ? 5000 : 20000; + + this._hotReloadInterval = window.setInterval(() => { + if (!this._updateJustCompleted && !this._updateAvailable) { + this._checkForUpdates(); + } + }, pollInterval); + + // Also check when tab becomes visible again + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && !this._updateAvailable && !this._updateJustCompleted) { + // Debounce - only check if last check was more than 5 seconds ago + const now = Date.now(); + if (now - this._lastUpdateCheck > 5000) { + this._checkForUpdates(); + } + } + }); + + // Check on focus (user returns to tab) + window.addEventListener('focus', () => { + if (!this._updateAvailable && !this._updateJustCompleted) { + const now = Date.now(); + if (now - this._lastUpdateCheck > 5000) { + this._checkForUpdates(); + } + } + }); + + // Cleanup interval on page unload to prevent memory leaks + window.addEventListener('beforeunload', () => { + if (this._hotReloadInterval) { + clearInterval(this._hotReloadInterval); + this._hotReloadInterval = null; + } + }); + + console.log(`🔄 Hot reload initialized (polling every ${pollInterval / 1000}s)`); + } + + /** + * Fetch version just for display (without triggering update checks) + * @private + */ + async _fetchVersionForDisplay() { + try { + const response = await fetch(`/api/version?_t=${Date.now()}`, { cache: 'no-store' }); + if (!response.ok) return; + + const data = await response.json(); + this._serverVersion = data.version; + this._appVersion = data.appVersion; + this._newAppVersion = data.appVersion; + console.log(`📦 App version: ${data.appVersion} (freshly updated)`); + this._updateVersionDisplay(); + } catch (e) { + console.debug('Version fetch failed:', e.message); + } + } + + /** + * Check server for updates + * @private + */ + async _checkForUpdates() { + // Skip if update already detected or just completed + if (this._updateAvailable || this._updateJustCompleted) return; + + this._lastUpdateCheck = Date.now(); + + try { + // Add cache-busting to ensure we always get fresh version info + const timestamp = Date.now(); + const response = await fetch(`/api/version?_t=${timestamp}`, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + }); + + if (!response.ok) return; + + const data = await response.json(); + const newVersion = data.version; + const newAppVersion = data.appVersion; + + // First check - store the version + if (this._serverVersion === null) { + this._serverVersion = newVersion; + this._appVersion = newAppVersion; + this._newAppVersion = newAppVersion; + console.log(`📦 App version: ${newAppVersion}, Build: ${data.buildTime}`); + // Update version display in UI + this._updateVersionDisplay(); + + // Store in localStorage for comparison across sessions + try { + localStorage.setItem(STORAGE_KEY_LAST_VERSION, newAppVersion); + } catch (e) { /* ignore */ } + return; + } + + // Store the new version for display + this._newAppVersion = newAppVersion; + + // Check 1: Server version changed (server restarted with new code) + if (newVersion !== this._serverVersion) { + this._updateAvailable = true; + console.log('🆕 Server version changed - update available!'); + console.log(` Server: ${this._serverVersion} → ${newVersion}`); + this._showUpdateDialog(this._appVersion, newAppVersion); + return; + } + + // Check 2: App version changed (semantic version bump) + if (newAppVersion && this._appVersion && newAppVersion !== this._appVersion) { + this._updateAvailable = true; + console.log(`🆕 App version changed: ${this._appVersion} → ${newAppVersion}`); + this._showUpdateDialog(this._appVersion, newAppVersion); + return; + } + + // Check 3: Compare with stored version from previous session + try { + const storedVersion = localStorage.getItem(STORAGE_KEY_LAST_VERSION); + if (storedVersion && storedVersion !== newAppVersion) { + this._updateAvailable = true; + console.log(`🆕 Version differs from last session: ${storedVersion} → ${newAppVersion}`); + this._showUpdateDialog(storedVersion, newAppVersion); + localStorage.setItem(STORAGE_KEY_LAST_VERSION, newAppVersion); + return; + } + } catch (e) { /* ignore localStorage errors */ } + + } catch (error) { + // Silently fail - server might be restarting + console.debug('Version check failed:', error.message); + } + } + + /** + * Update version display in UI + * @private + */ + _updateVersionDisplay() { + // Update the version display element (already exists in HTML) + const versionEl = document.getElementById('appVersionDisplay'); + if (versionEl) { + versionEl.innerHTML = `v${this._appVersion || '0.0.0'}`; + versionEl.title = `Rox AI v${this._appVersion}`; + } + } + + /** + * Show update available dialog with version info + * @private + * @param {string} [currentVersion] - Current app version + * @param {string} [newVersion] - New app version available + */ + _showUpdateDialog(currentVersion, newVersion) { + // Create update dialog overlay + const existingDialog = document.getElementById('updateDialogOverlay'); + if (existingDialog) existingDialog.remove(); + + const currentVer = currentVersion || this._appVersion || 'Unknown'; + const newVer = newVersion || this._newAppVersion || 'Latest'; + + const overlay = document.createElement('div'); + overlay.id = 'updateDialogOverlay'; + overlay.className = 'update-dialog-overlay'; + overlay.innerHTML = ` +
+
+ + + + + +
+

Update Available

+
+
+ Current + v${this.escapeHtml(currentVer)} +
+
+ + + +
+
+ New + v${this.escapeHtml(newVer)} +
+
+

A new version of Rox AI is available with the latest features and improvements.

+
+ + +
+
+ `; + + document.body.appendChild(overlay); + + // Show with animation + requestAnimationFrame(() => { + overlay.style.opacity = '1'; + }); + + // Handle button clicks + document.getElementById('updateLaterBtn')?.addEventListener('click', () => { + overlay.remove(); + // Reset flag but check again sooner (30 seconds) + this._updateAvailable = false; + setTimeout(() => this._checkForUpdates(), 30000); + }); + + document.getElementById('updateNowBtn')?.addEventListener('click', () => { + this._performUpdate(); + }); + + // Also allow clicking overlay to dismiss (but will show again) + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + overlay.remove(); + this._updateAvailable = false; + setTimeout(() => this._checkForUpdates(), 30000); + } + }); + } + + /** + * Perform the update - clear ALL caches completely and reload with fresh content + * @private + */ + async _performUpdate() { + const overlay = document.getElementById('updateDialogOverlay'); + const dialog = overlay?.querySelector('.update-dialog'); + + // Show updating UI + if (dialog) { + dialog.innerHTML = ` +
+ + + +
+

Updating...

+

Step 1/5: Preparing update...

+ `; + + // Add spin animation if not present + if (!document.getElementById('spinAnimation')) { + const style = document.createElement('style'); + style.id = 'spinAnimation'; + style.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'; + document.head.appendChild(style); + } + } + + const updateStatus = (msg) => { + const el = document.getElementById('updateStatus'); + if (el) el.textContent = msg; + console.log(`🔄 ${msg}`); + }; + + try { + // Step 1: Tell service worker to force update (clear caches + unregister) + updateStatus('Step 1/5: Notifying service worker...'); + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: 'FORCE_UPDATE' }); + // Also try the string version for compatibility + navigator.serviceWorker.controller.postMessage('forceUpdate'); + } + await new Promise(r => setTimeout(r, 100)); + + // Step 2: Clear ALL caches from window.caches API + updateStatus('Step 2/5: Clearing browser caches...'); + if ('caches' in window) { + const cacheNames = await caches.keys(); + if (cacheNames.length > 0) { + console.log(`🗑️ Found ${cacheNames.length} caches to clear:`, cacheNames); + await Promise.all(cacheNames.map(async (name) => { + try { + await caches.delete(name); + console.log(` ✓ Deleted cache: ${name}`); + } catch (e) { + console.warn(` ✗ Failed to delete cache: ${name}`, e); + } + })); + } + // Verify caches are cleared + const remaining = await caches.keys(); + if (remaining.length > 0) { + console.warn('⚠️ Some caches remain:', remaining); + // Try again + await Promise.all(remaining.map(name => caches.delete(name))); + } + } + await new Promise(r => setTimeout(r, 100)); + + // Step 3: Unregister ALL service workers + updateStatus('Step 3/5: Unregistering service workers...'); + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + if (registrations.length > 0) { + console.log(`🗑️ Unregistering ${registrations.length} service workers`); + await Promise.all(registrations.map(async (reg) => { + try { + const success = await reg.unregister(); + console.log(` ${success ? '✓' : '✗'} Unregistered: ${reg.scope}`); + } catch (e) { + console.warn(` ✗ Failed to unregister:`, e); + } + })); + } + } + await new Promise(r => setTimeout(r, 100)); + + // Step 4: Clear storage (but preserve update flag) + updateStatus('Step 4/5: Clearing local storage...'); + try { + // Clear version tracking + localStorage.removeItem(STORAGE_KEY_APP_VERSION); + localStorage.removeItem(STORAGE_KEY_SW_VERSION); + // Set flag BEFORE clearing session storage to mark update complete + sessionStorage.setItem(SESSION_KEY_UPDATE_COMPLETE, 'true'); + console.log('✅ Storage cleared, update flag set'); + } catch (e) { + console.warn('⚠️ Storage clear failed:', e); + } + + // Step 5: Force hard reload with cache bypass + updateStatus('Step 5/5: Reloading with fresh content...'); + await new Promise(r => setTimeout(r, 300)); + + // Build clean URL with cache-busting parameter + const cleanUrl = new URL(window.location.origin + window.location.pathname); + cleanUrl.searchParams.set('_v', Date.now().toString()); + + // Prevent any further update checks during reload + this._updateJustCompleted = true; + this._updateAvailable = false; + + // Use location.replace for clean navigation + console.log('🔄 Reloading to:', cleanUrl.toString()); + window.location.replace(cleanUrl.toString()); + + } catch (error) { + console.error('❌ Update failed:', error); + updateStatus('Update failed. Forcing reload...'); + + // Set flag before emergency reload + try { + sessionStorage.setItem(SESSION_KEY_UPDATE_COMPLETE, 'true'); + } catch (e) { /* ignore */ } + + // Emergency fallback - just reload with cache bust + setTimeout(() => { + window.location.href = window.location.pathname + '?_emergency=' + Date.now(); + }, 500); + } + } + + // ==================== UTILITY METHODS ==================== + + /** + * Categorize error for better user feedback + * @private + * @param {unknown} error - The error to categorize + * @returns {{title: string, message: string, canRetry: boolean}} + */ + _categorizeError(error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Offline + if (!navigator.onLine) { + return { + title: 'No Internet Connection', + message: 'You appear to be offline. Please check your connection and try again.', + canRetry: true + }; + } + + // Timeout + if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { + return { + title: 'Request Timeout', + message: 'The request took too long. The server might be busy. Try again?', + canRetry: true + }; + } + + // Rate limit + if (errorMessage.includes('429') || errorMessage.includes('Too many')) { + return { + title: 'Too Many Requests', + message: 'Please wait a moment before sending another message.', + canRetry: false + }; + } + + // Server error + if (errorMessage.includes('500') || errorMessage.includes('502') || errorMessage.includes('503')) { + return { + title: 'Server Error', + message: 'The server is having issues. Please try again in a moment.', + canRetry: true + }; + } + + // Network error + if (errorMessage.includes('fetch') || errorMessage.includes('network') || errorMessage.includes('Failed to fetch')) { + return { + title: 'Connection Error', + message: 'Could not connect to the server. Please check your connection.', + canRetry: true + }; + } + + // Default + return { + title: 'Something Went Wrong', + message: 'Failed to send message. Please try again.', + canRetry: true + }; + } + + /** + * Safely get item from localStorage + * @private + * @param {string} key - Storage key + * @returns {string|null} + */ + _getStorageItem(key) { + try { + return localStorage.getItem(key); + } catch (e) { + console.warn(`Failed to read from localStorage: ${key}`, e); + return null; + } + } + + /** + * Safely set item in localStorage + * @private + * @param {string} key - Storage key + * @param {string} value - Value to store + * @returns {boolean} Success status + */ + _setStorageItem(key, value) { + try { + localStorage.setItem(key, value); + return true; + } catch (e) { + console.error(`Failed to write to localStorage: ${key}`, e); + return false; + } + } + + /** + * Debounce function execution + * @template {(...args: any[]) => any} T + * @param {T} func - Function to debounce + * @param {number} wait - Wait time in ms + * @returns {(...args: Parameters) => void} + */ + debounce(func, wait) { + /** @type {ReturnType|undefined} */ + let timeout; + return (/** @type {Parameters} */ ...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; + } + + /** + * Escape HTML special characters + * @param {string} text - Text to escape + * @returns {string} + */ + escapeHtml(text) { + if (typeof text !== 'string') return ''; + return text.replace(/[&<>"']/g, (m) => HTML_ESCAPE_MAP[m] || m); + } + + /** + * Escape HTML for display text (only escapes < and > for XSS protection, preserves & and quotes) + * @param {string} text - Text to escape + * @returns {string} + */ + escapeHtmlDisplay(text) { + if (typeof text !== 'string') return ''; + return text.replace(//g, '>'); + } + + /** + * Close incomplete markdown structures (code blocks, bold, italic, math, etc.) for partial responses + * @param {string} content - Partial content that may have unclosed structures + * @returns {string} Content with closed structures + */ + _closeIncompleteMarkdown(content) { + if (typeof content !== 'string') return ''; + + // Count code block fences (```) + const codeBlockMatches = content.match(/```/g); + const codeBlockCount = codeBlockMatches ? codeBlockMatches.length : 0; + + // If odd number of ```, we have an unclosed code block + if (codeBlockCount % 2 !== 0) { + content += '\n```'; + } + + // Count inline code backticks (single `) - but not triple backticks + // Remove triple backticks first to count only single backticks + const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '').replace(/```[\s\S]*$/g, ''); + const inlineCodeMatches = withoutCodeBlocks.match(/`/g); + const inlineCodeCount = inlineCodeMatches ? inlineCodeMatches.length : 0; + + // If odd number of backticks, close the inline code + if (inlineCodeCount % 2 !== 0) { + content += '`'; + } + + // Close incomplete bold markers (**) + const boldMatches = withoutCodeBlocks.match(/\*\*/g); + const boldCount = boldMatches ? boldMatches.length : 0; + if (boldCount % 2 !== 0) { + content += '**'; + } + + // Close incomplete italic markers (*) - after removing bold + const withoutBold = withoutCodeBlocks.replace(/\*\*[^*]*\*\*/g, ''); + const italicMatches = withoutBold.match(/(? m.id === this.currentModel); + return modelInfo?.name || 'Rox Core'; + } + + // ==================== INITIALIZATION ==================== + + /** + * Initialize DOM element references + * @private + */ + _initElements() { + this.sidebar = document.getElementById('sidebar'); + this.sidebarOverlay = document.getElementById('sidebarOverlay'); + this.chatList = document.getElementById('chatList'); + this.messages = document.getElementById('messages'); + this.welcome = document.getElementById('welcome'); + this.messageInput = /** @type {HTMLTextAreaElement} */ (document.getElementById('messageInput')); + this.btnSend = /** @type {HTMLButtonElement} */ (document.getElementById('btnSend')); + this.btnNewChat = /** @type {HTMLButtonElement} */ (document.getElementById('btnNewChat')); + this.btnToggleSidebar = /** @type {HTMLButtonElement} */ (document.getElementById('btnToggleSidebar')); + this.btnThemeToggle = /** @type {HTMLButtonElement} */ (document.getElementById('btnThemeToggle')); + this.btnAttach = /** @type {HTMLButtonElement} */ (document.getElementById('btnAttach')); + this.fileInput = /** @type {HTMLInputElement} */ (document.getElementById('fileInput')); + this.attachmentsPreview = document.getElementById('attachmentsPreview'); + this.contextMenu = document.getElementById('contextMenu'); + this.renameModal = document.getElementById('renameModal'); + this.chatTitle = document.getElementById('chatTitle'); + } + + /** + * Initialize theme from storage or detect from device preference + * @private + */ + _initTheme() { + const savedTheme = this._getStorageItem(STORAGE_KEY_THEME); + + // If no saved theme, detect from device preference + let theme; + if (savedTheme) { + theme = savedTheme; + } else { + // Check device preference using media query + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + theme = prefersDark ? 'dark' : 'light'; + } + + document.documentElement.setAttribute('data-theme', theme); + this._updateThemeIcon(theme); + + // Listen for device theme changes (when user changes system theme) + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + // Only auto-switch if user hasn't manually set a preference + const userPreference = this._getStorageItem(STORAGE_KEY_THEME); + if (!userPreference) { + const newTheme = e.matches ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', newTheme); + this._updateThemeIcon(newTheme); + } + }); + } + + /** + * Toggle between light and dark theme + * Manual toggle saves preference to override auto-detection + */ + toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + const newTheme = current === 'light' ? 'dark' : 'light'; + + // Add transitioning class for smooth theme change + document.documentElement.setAttribute('data-theme-transitioning', ''); + document.documentElement.setAttribute('data-theme', newTheme); + // Save user preference to override auto-detection + this._setStorageItem(STORAGE_KEY_THEME, newTheme); + this._updateThemeIcon(newTheme); + + // Remove transitioning class after animation + setTimeout(() => { + document.documentElement.removeAttribute('data-theme-transitioning'); + }, 200); + + this.showToast(`Switched to ${newTheme} mode`, 'success', 2000); + } + + /** + * Update theme toggle icon + * @private + * @param {string} theme - Current theme + */ + _updateThemeIcon(theme) { + if (!this.btnThemeToggle) return; + const sun = this.btnThemeToggle.querySelector('.icon-sun'); + const moon = this.btnThemeToggle.querySelector('.icon-moon'); + if (sun instanceof HTMLElement && moon instanceof HTMLElement) { + sun.style.display = theme === 'light' ? 'none' : 'block'; + moon.style.display = theme === 'light' ? 'block' : 'none'; + } + } + + // ==================== MODEL SELECTOR ==================== + + /** + * Initialize model selector dropdown + * @private + */ + _initModelSelector() { + this.modelSelector = document.getElementById('modelSelector'); + this.modelSelectorBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modelSelectorBtn')); + this.modelDropdown = document.getElementById('modelDropdown'); + this.currentModelName = document.getElementById('currentModelName'); + + this._setModel(this.currentModel, false); + + this.modelSelectorBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + this._toggleModelDropdown(); + }); + + document.querySelectorAll('.model-option').forEach((option) => { + option.addEventListener('click', () => { + const modelId = option instanceof HTMLElement ? option.dataset.model : null; + const modelName = option instanceof HTMLElement ? option.dataset.name : null; + if (modelId) { + this._setModel(modelId, true, modelName || undefined); + this._closeModelDropdown(); + } + }); + }); + + document.addEventListener('click', (e) => { + if (this.modelSelector && !this.modelSelector.contains(/** @type {Node} */ (e.target))) { + this._closeModelDropdown(); + } + }); + } + + /** + * Toggle model dropdown visibility + * @private + */ + _toggleModelDropdown() { + this.modelSelector?.classList.toggle('open'); + } + + /** + * Close model dropdown + * @private + */ + _closeModelDropdown() { + this.modelSelector?.classList.remove('open'); + } + + /** + * Set current model + * @private + * @param {string} modelId - Model ID + * @param {boolean} [showToast=false] - Show toast notification + * @param {string} [modelName] - Optional model name override + */ + _setModel(modelId, showToast = false, modelName) { + if (!modelId || typeof modelId !== 'string') return; + + this.currentModel = modelId; + this._setStorageItem(STORAGE_KEY_MODEL, modelId); + + const model = this.models.find((m) => m.id === modelId); + if (this.currentModelName) { + this.currentModelName.textContent = modelName || model?.name || 'Rox Core'; + } + + document.querySelectorAll('.model-option').forEach((option) => { + if (option instanceof HTMLElement) { + option.classList.toggle('active', option.dataset.model === modelId); + } + }); + + if (showToast) { + this.showToast(`Switched to ${modelName || model?.name || 'Rox Core'}`, 'success', 2000); + } + } + + /** + * Get current model ID + * @returns {string} + */ + getCurrentModel() { + return this.currentModel; + } + + // ==================== DIALOG SYSTEM ==================== + + /** + * Create custom dialog elements + * @private + */ + _createCustomDialogs() { + if (document.getElementById('customDialogOverlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'customDialogOverlay'; + overlay.className = 'custom-dialog-overlay'; + overlay.innerHTML = ` +
+
+

+

+
+
+ `; + document.body.appendChild(overlay); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) this.closeDialog(); + }); + } + + // ==================== MOBILE ACTION SHEET ==================== + + /** + * Initialize mobile action sheet for long-press on messages + * @private + */ + _initMobileActionSheet() { + // Create action sheet overlay + if (document.getElementById('mobileActionSheetOverlay')) return; + + // Check if this is a touch-capable device (or mobile viewport for testing) + const isTouchDevice = ('ontouchstart' in window) || + (navigator.maxTouchPoints > 0) || + // @ts-ignore + (navigator.msMaxTouchPoints > 0) || + window.innerWidth <= 768; // Also enable on mobile viewport for testing + + if (!isTouchDevice) return; + + const overlay = document.createElement('div'); + overlay.id = 'mobileActionSheetOverlay'; + overlay.className = 'mobile-action-sheet-overlay'; + overlay.innerHTML = ` +
+
+
Actions
+
+ +
+ `; + document.body.appendChild(overlay); + + // Close on overlay click + overlay.addEventListener('click', (e) => { + if (e.target === overlay) this._closeMobileActionSheet(); + }); + + // Close on cancel button + document.getElementById('actionSheetCancel')?.addEventListener('click', () => { + this._closeMobileActionSheet(); + }); + + // Initialize long-press state + /** @type {number|null} */ + this._longPressTimer = null; + /** @type {{x: number, y: number}|null} */ + this._touchStartPos = null; + /** @type {HTMLElement|null} */ + this._longPressTarget = null; + /** @type {Message|null} */ + this._actionSheetMessage = null; + /** @type {boolean} */ + this._isScrolling = false; + + // Long press threshold in ms + const LONG_PRESS_DURATION = 500; + // Movement threshold in pixels (to detect scrolling) + const MOVE_THRESHOLD = 10; + + // Add touch listeners to chat container + const chatContainer = document.getElementById('chatContainer'); + if (!chatContainer) return; + + // Prevent default context menu on long press + chatContainer.addEventListener('contextmenu', (e) => { + const target = /** @type {HTMLElement} */ (e.target); + if (target.closest('.message')) { + e.preventDefault(); + } + }); + + chatContainer.addEventListener('touchstart', (e) => { + const target = /** @type {HTMLElement} */ (e.target); + const messageEl = /** @type {HTMLElement|null} */ (target.closest('.message')); + if (!messageEl) { + return; + } + + const touch = e.touches[0]; + this._touchStartPos = { x: touch.clientX, y: touch.clientY }; + this._longPressTarget = messageEl; + this._isScrolling = false; + + // Start long press timer + this._longPressTimer = window.setTimeout(() => { + if (!this._isScrolling && this._longPressTarget) { + // Trigger haptic feedback if available + if (navigator.vibrate) { + navigator.vibrate(50); + } + + // Add visual feedback + this._longPressTarget.classList.add('long-press-active'); + + // Get message data and show action sheet + this._showMobileActionSheet(this._longPressTarget); + } + }, LONG_PRESS_DURATION); + }, { passive: true }); + + chatContainer.addEventListener('touchmove', (e) => { + if (!this._touchStartPos) return; + + const touch = e.touches[0]; + const deltaX = Math.abs(touch.clientX - this._touchStartPos.x); + const deltaY = Math.abs(touch.clientY - this._touchStartPos.y); + + // If moved beyond threshold, cancel long press (user is scrolling) + if (deltaX > MOVE_THRESHOLD || deltaY > MOVE_THRESHOLD) { + this._isScrolling = true; + this._cancelLongPress(); + } + }, { passive: true }); + + chatContainer.addEventListener('touchend', () => { + // Clear the timer - if it hasn't fired yet, cancel it + if (this._longPressTimer) { + clearTimeout(this._longPressTimer); + this._longPressTimer = null; + } + // Only remove visual feedback if action sheet is NOT showing + const overlay = document.getElementById('mobileActionSheetOverlay'); + const isActionSheetVisible = overlay && overlay.classList.contains('show'); + if (this._longPressTarget && !isActionSheetVisible) { + this._longPressTarget.classList.remove('long-press-active'); + } + this._touchStartPos = null; + }, { passive: true }); + + chatContainer.addEventListener('touchcancel', () => { + this._cancelLongPress(); + }, { passive: true }); + + // Also cancel on scroll event (backup) + chatContainer.addEventListener('scroll', () => { + this._isScrolling = true; + this._cancelLongPress(); + }, { passive: true }); + } + + /** + * Cancel long press timer and reset state + * @private + */ + _cancelLongPress() { + if (this._longPressTimer) { + clearTimeout(this._longPressTimer); + this._longPressTimer = null; + } + if (this._longPressTarget) { + this._longPressTarget.classList.remove('long-press-active'); + } + this._touchStartPos = null; + } + + /** + * Show mobile action sheet for a message + * @private + * @param {HTMLElement} messageEl - The message element + */ + _showMobileActionSheet(messageEl) { + const overlay = document.getElementById('mobileActionSheetOverlay'); + const content = document.getElementById('actionSheetContent'); + const title = document.getElementById('actionSheetTitle'); + + if (!overlay || !content || !title) return; + + // Get message data + const messageId = messageEl.dataset.messageId; + const pairId = messageEl.dataset.pairId; + const msgIndex = messageEl.dataset.index; + const isUser = messageEl.classList.contains('user'); + const isAssistant = messageEl.classList.contains('assistant'); + + // Find the message in conversation + const conversation = this.conversations.find((c) => c.id === this.currentConversationId); + if (!conversation) return; + + // Try to find message by ID first, then by index + let message = messageId + ? conversation.messages.find((m) => m.id === messageId) + : null; + + if (!message && msgIndex !== undefined) { + message = conversation.messages[parseInt(msgIndex, 10)]; + } + + // Fallback: get content directly from DOM + if (!message) { + const contentEl = messageEl.querySelector('.message-content'); + const textContent = contentEl?.textContent || ''; + message = /** @type {Message} */ ({ + role: isUser ? 'user' : 'assistant', + content: textContent, + id: messageId, + pairId: pairId, + timestamp: Date.now() + }); + } + + this._actionSheetMessage = message; + + // Set title based on message type + title.textContent = isUser ? 'Message Actions' : 'Response Actions'; + + // Build action items based on message type + let actionsHtml = ''; + + if (isUser) { + // User message actions: Copy, Edit + actionsHtml = ` + + + `; + } else if (isAssistant) { + // Assistant message actions: Copy, Regenerate, Listen Aloud, Export PDF + actionsHtml = ` + + + +
+ + `; + } + + content.innerHTML = actionsHtml; + + // Add click handlers to action items + content.querySelectorAll('.mobile-action-sheet-item').forEach((item) => { + item.addEventListener('click', (e) => { + const target = /** @type {HTMLElement} */ (e.currentTarget); + const action = target.dataset.action; + this._handleMobileAction(action, messageEl, message); + }); + }); + + // Show overlay + overlay.classList.add('show'); + + // Push navigation state for back button handling + if (this._isPWA) { + history.pushState({ nav: 'action-sheet' }, '', window.location.href); + } + + // Remove long press visual feedback after a short delay for smooth transition + setTimeout(() => { + messageEl.classList.remove('long-press-active'); + }, 150); + } + + /** + * Close mobile action sheet + * @private + */ + _closeMobileActionSheet() { + const overlay = document.getElementById('mobileActionSheetOverlay'); + if (overlay) { + overlay.classList.remove('show'); + } + // Remove visual feedback from any message that has it + document.querySelectorAll('.message.long-press-active').forEach((el) => { + el.classList.remove('long-press-active'); + }); + this._actionSheetMessage = null; + this._longPressTarget = null; + } + + /** + * Handle mobile action sheet action + * @private + * @param {string|undefined} action - The action to perform + * @param {HTMLElement} messageEl - The message element + * @param {Message} message - The message data + */ + async _handleMobileAction(action, messageEl, message) { + this._closeMobileActionSheet(); + + switch (action) { + case 'copy': + try { + await navigator.clipboard.writeText(message.content); + this.showToast('Copied to clipboard', 'success', 2000); + } catch (err) { + console.error('Failed to copy:', err); + this.showToast('Failed to copy', 'error'); + } + break; + + case 'edit': + this._openEditModal(message); + break; + + case 'regenerate': + if (message && message.pairId) { + this._regenerateResponse(message.pairId); + } else { + this.showToast('Cannot regenerate this message', 'warning'); + } + break; + + case 'listen': + this._toggleListenAloud(messageEl, message.content); + break; + + case 'pdf': + this._exportToPDF(message); + break; + } + } + + /** + * Show custom dialog + * @param {DialogOptions} options - Dialog options + */ + showDialog(options) { + const { + type = 'info', + title = '', + message = '', + confirmText = 'OK', + cancelText = 'Cancel', + onConfirm, + onCancel, + showCancel = true + } = options; + + const overlay = document.getElementById('customDialogOverlay'); + const dialog = document.getElementById('customDialog'); + const iconEl = document.getElementById('dialogIcon'); + const titleEl = document.getElementById('dialogTitle'); + const messageEl = document.getElementById('dialogMessage'); + const actionsEl = document.getElementById('dialogActions'); + + if (!overlay || !dialog || !iconEl || !titleEl || !messageEl || !actionsEl) { + console.error('Dialog elements not found'); + return; + } + + iconEl.innerHTML = DIALOG_ICONS[type] || DIALOG_ICONS.info; + iconEl.className = `custom-dialog-icon ${type}`; + iconEl.style.animation = 'bounceIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)'; + + titleEl.textContent = title; + messageEl.textContent = message; + + let actionsHtml = ''; + if (showCancel) { + actionsHtml += ``; + } + actionsHtml += ``; + actionsEl.innerHTML = actionsHtml; + + overlay.classList.add('show'); + requestAnimationFrame(() => { + requestAnimationFrame(() => dialog.classList.add('show')); + }); + + // Push navigation state for back button handling + if (this._isPWA) { + history.pushState({ nav: 'dialog' }, '', window.location.href); + } + + const confirmBtn = document.getElementById('dialogConfirm'); + const cancelBtn = document.getElementById('dialogCancel'); + + /** @type {Function} */ + let cleanup; + + const handleConfirm = () => { + dialog.style.animation = 'scaleOut 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards'; + setTimeout(() => { + this.closeDialog(); + cleanup(); + if (typeof onConfirm === 'function') onConfirm(); + }, 250); + }; + + const handleCancel = () => { + dialog.style.animation = 'scaleOut 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards'; + setTimeout(() => { + this.closeDialog(); + cleanup(); + if (typeof onCancel === 'function') onCancel(); + }, 250); + }; + + /** @param {KeyboardEvent} e */ + const handleKeydown = (e) => { + if (e.key === 'Escape' && showCancel) { + e.preventDefault(); + handleCancel(); + } else if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleConfirm(); + } + }; + + cleanup = () => { + confirmBtn?.removeEventListener('click', handleConfirm); + cancelBtn?.removeEventListener('click', handleCancel); + document.removeEventListener('keydown', handleKeydown); + }; + + confirmBtn?.addEventListener('click', handleConfirm); + cancelBtn?.addEventListener('click', handleCancel); + document.addEventListener('keydown', handleKeydown); + + setTimeout(() => confirmBtn?.focus(), 100); + } + + /** + * Close dialog + */ + closeDialog() { + const overlay = document.getElementById('customDialogOverlay'); + const dialog = document.getElementById('customDialog'); + + if (dialog) { + dialog.style.animation = 'scaleOut 0.2s ease forwards'; + dialog.classList.remove('show'); + } + + setTimeout(() => { + overlay?.classList.remove('show'); + if (dialog) dialog.style.animation = ''; + }, 200); + } + + /** + * Show toast notification + * @param {string} message - Toast message + * @param {'info'|'success'|'warning'|'error'} [type='info'] - Toast type + * @param {number} [duration=3000] - Duration in ms + */ + showToast(message, type = 'info', duration = 3000) { + if (!message || typeof message !== 'string') return; + + let container = document.getElementById('toastContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'toastContainer'; + container.className = 'toast-container'; + document.body.appendChild(container); + } + + // Limit max toasts to prevent spam + const existingToasts = container.querySelectorAll('.toast'); + if (existingToasts.length >= 5) { + existingToasts[0].remove(); + } + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.innerHTML = ` + ${this.escapeHtml(message)} + + `; + + container.appendChild(toast); + + // Simple fade-in + requestAnimationFrame(() => toast.classList.add('show')); + + /** @type {ReturnType|null} */ + let timeoutId = null; + + const removeToast = () => { + if (timeoutId) clearTimeout(timeoutId); + toast.classList.remove('show'); + setTimeout(() => { + if (toast.parentNode) toast.remove(); + }, 200); + }; + + // Click anywhere on toast to dismiss + toast.addEventListener('click', removeToast); + const closeBtn = toast.querySelector('.toast-close'); + closeBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + removeToast(); + }); + timeoutId = setTimeout(removeToast, duration); + } + + /** + * Show keyboard shortcuts dialog + */ + showKeyboardShortcuts() { + const shortcuts = [ + { keys: 'Ctrl/Cmd + K', action: 'Focus message input' }, + { keys: 'Ctrl/Cmd + N', action: 'New chat' }, + { keys: 'Ctrl/Cmd + B', action: 'Toggle sidebar' }, + { keys: 'Ctrl/Cmd + /', action: 'Show shortcuts' }, + { keys: 'Enter', action: 'Send message (desktop)' }, + { keys: 'Shift + Enter', action: 'New line' }, + { keys: 'Esc', action: 'Close dialogs / Stop generation' } + ]; + + const shortcutsHtml = shortcuts.map((s) => + `
+ ${this.escapeHtml(s.action)} + ${this.escapeHtml(s.keys)} +
` + ).join(''); + + this.showDialog({ + type: 'info', + title: 'Keyboard Shortcuts', + message: '', + confirmText: 'Got it', + showCancel: false + }); + + setTimeout(() => { + const messageEl = document.getElementById('dialogMessage'); + if (messageEl) { + messageEl.innerHTML = shortcutsHtml; + messageEl.style.textAlign = 'left'; + } + }, 10); + } + + // ==================== EVENT LISTENERS ==================== + + /** + * Initialize all event listeners + * @private + */ + _initEventListeners() { + // Input handling + this.messageInput?.addEventListener('input', () => this._handleInput()); + this.messageInput?.addEventListener('keydown', (e) => this._handleKeyDown(e)); + + // Button clicks - handle both send and stop + this.btnSend?.addEventListener('click', () => { + if (this.isLoading && this.requestController) { + this._stopGeneration(); + } else { + this.sendMessage(); + } + }); + this.btnNewChat?.addEventListener('click', () => this.createNewChat()); + this.btnToggleSidebar?.addEventListener('click', () => this._toggleSidebar()); + this.btnThemeToggle?.addEventListener('click', () => this.toggleTheme()); + this.btnAttach?.addEventListener('click', () => this.fileInput?.click()); + + // PWA Install button + this._initPWAInstall(); + + // File input + this.fileInput?.addEventListener('change', (e) => this._handleFileSelect(e)); + + // Suggestions + document.querySelectorAll('.suggestion-card').forEach((card) => { + card.addEventListener('click', (e) => { + const target = e.currentTarget; + if (target instanceof HTMLElement && this.messageInput) { + const prompt = target.dataset.prompt; + if (prompt) { + this.messageInput.value = prompt; + this._handleInput(); + this.messageInput.focus(); + } + } + }); + }); + + // Context menu + document.addEventListener('click', (e) => { + if (this.contextMenu && !this.contextMenu.contains(/** @type {Node} */ (e.target))) { + this._hideContextMenu(); + } + }); + + document.querySelectorAll('.context-menu-item').forEach((item) => { + item.addEventListener('click', (e) => { + const target = e.currentTarget; + if (target instanceof HTMLElement) { + this._handleContextAction(target.dataset.action || ''); + } + }); + }); + + // Rename modal + document.getElementById('btnCancelRename')?.addEventListener('click', () => { + this.renameModal?.classList.remove('show'); + }); + + document.getElementById('btnConfirmRename')?.addEventListener('click', () => { + this._confirmRename(); + }); + + document.getElementById('renameInput')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') this._confirmRename(); + if (e.key === 'Escape') this.renameModal?.classList.remove('show'); + }); + + // Sidebar overlay + this.sidebarOverlay?.addEventListener('click', () => this._closeSidebar()); + + // Window resize + window.addEventListener('resize', /** @type {EventListener} */ (this.debounce(() => { + if (window.innerWidth > MOBILE_BREAKPOINT) { + this.sidebarOverlay?.classList.remove('show'); + this.sidebar?.classList.remove('open'); + } + }, 100))); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => this._handleGlobalKeydown(e)); + + // Event delegation for code copy buttons (works during streaming) + document.addEventListener('click', async (e) => { + const target = /** @type {HTMLElement} */ (e.target); + const copyBtn = target.closest('.code-copy-btn'); + if (copyBtn && copyBtn instanceof HTMLElement) { + const codeBlock = copyBtn.closest('.code-block'); + const codeEl = codeBlock?.querySelector('.code-content code'); + const code = codeEl?.textContent || ''; + + try { + await navigator.clipboard.writeText(code); + copyBtn.classList.add('copied'); + copyBtn.innerHTML = `Copied!`; + + setTimeout(() => { + copyBtn.classList.remove('copied'); + copyBtn.innerHTML = `Copy`; + }, 2000); + } catch (err) { + console.error('Copy failed:', err); + this.showToast('Failed to copy code', 'error'); + } + } + }); + + // Offline/Online detection + this._initConnectionStatus(); + } + + /** + * Initialize connection status monitoring with network quality detection + * @private + */ + _initConnectionStatus() { + this._offlineIndicator = document.getElementById('offlineIndicator'); + + /** + * Update connection status display + */ + const updateConnectionStatus = () => { + if (this._offlineIndicator) { + this._offlineIndicator.style.display = navigator.onLine ? 'none' : 'inline-flex'; + } + this._connectionQuality = navigator.onLine ? this._detectConnectionQuality() : 'offline'; + }; + + // Initial check + updateConnectionStatus(); + + // Listen for connection changes + window.addEventListener('online', () => { + updateConnectionStatus(); + this.showToast('Back online', 'success', 2000); + // Process any queued messages + this._processOfflineQueue(); + }); + + window.addEventListener('offline', () => { + updateConnectionStatus(); + this.showToast('You are offline', 'warning', 3000); + }); + + // Monitor Network Information API if available + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + if (connection) { + connection.addEventListener('change', () => { + this._connectionQuality = this._detectConnectionQuality(); + this._onConnectionQualityChange(); + }); + } + + // Periodic connection quality check (every 30 seconds when active) + this._startConnectionMonitor(); + } + + /** + * Detect connection quality using Network Information API + * @private + * @returns {'excellent'|'good'|'fair'|'poor'|'offline'} + */ + _detectConnectionQuality() { + if (!navigator.onLine) return 'offline'; + + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + if (!connection) return 'good'; // Default if API not available + + const effectiveType = connection.effectiveType; + const downlink = connection.downlink; // Mbps + const rtt = connection.rtt; // Round-trip time in ms + + // Determine quality based on effective type and metrics + if (effectiveType === '4g' && downlink >= 5 && rtt < 100) { + return 'excellent'; + } else if (effectiveType === '4g' || (effectiveType === '3g' && downlink >= 1.5)) { + return 'good'; + } else if (effectiveType === '3g' || (effectiveType === '2g' && downlink >= 0.5)) { + return 'fair'; + } else { + return 'poor'; + } + } + + /** + * Handle connection quality changes + * @private + */ + _onConnectionQualityChange() { + const quality = this._connectionQuality; + + if (quality === 'poor') { + this.showToast('Slow connection detected', 'warning', 3000); + } else if (quality === 'fair' && this.isLoading) { + this.showToast('Connection may be slow', 'info', 2000); + } + } + + /** + * Start periodic connection monitoring + * @private + */ + _startConnectionMonitor() { + // Check connection quality periodically when page is visible + let monitorInterval = null; + + const startMonitoring = () => { + if (monitorInterval) return; + monitorInterval = setInterval(() => { + if (navigator.onLine) { + const newQuality = this._detectConnectionQuality(); + if (newQuality !== this._connectionQuality) { + this._connectionQuality = newQuality; + this._onConnectionQualityChange(); + } + } + }, 30000); // Check every 30 seconds + }; + + const stopMonitoring = () => { + if (monitorInterval) { + clearInterval(monitorInterval); + monitorInterval = null; + } + }; + + // Only monitor when page is visible + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopMonitoring(); + } else { + startMonitoring(); + } + }); + + if (!document.hidden) { + startMonitoring(); + } + } + + /** + * Get adaptive timeout based on connection quality + * @private + * @returns {number} Timeout in milliseconds + */ + _getAdaptiveTimeout() { + const baseTimeout = 180000; // 3 minutes base + + switch (this._connectionQuality) { + case 'excellent': return baseTimeout; + case 'good': return baseTimeout; + case 'fair': return baseTimeout * 1.5; // 4.5 minutes + case 'poor': return baseTimeout * 2; // 6 minutes + default: return baseTimeout; + } + } + + /** + * Queue message for sending when back online + * @private + * @param {string} content - Message content + * @param {File[]} files - Attached files + */ + _queueOfflineMessage(content, files) { + // Limit queue size to prevent memory issues + if (this._offlineQueue.length >= 10) { + this.showToast('Offline queue full. Please wait for connection.', 'warning'); + return false; + } + + this._offlineQueue.push({ + content, + files: [...files], + timestamp: Date.now() + }); + + this.showToast('Message queued. Will send when online.', 'info', 3000); + return true; + } + + /** + * Process queued offline messages + * @private + */ + async _processOfflineQueue() { + if (this._offlineQueue.length === 0 || !navigator.onLine) return; + + this.showToast(`Sending ${this._offlineQueue.length} queued message(s)...`, 'info', 2000); + + // Process queue one at a time + while (this._offlineQueue.length > 0 && navigator.onLine) { + const queued = this._offlineQueue.shift(); + if (!queued) continue; + + // Skip messages older than 1 hour + if (Date.now() - queued.timestamp > 3600000) { + continue; + } + + try { + // Set input and files, then send + if (this.messageInput) { + this.messageInput.value = queued.content; + } + this.attachedFiles = queued.files; + this._renderAttachments(); + + // Wait for previous send to complete + await this.sendMessage(); + + // Small delay between queued messages + await new Promise(r => setTimeout(r, 500)); + } catch (err) { + // Re-queue failed message for retry + this._offlineQueue.unshift(queued); + this.showToast('Failed to send queued message', 'error'); + break; + } + } + } + + /** + * Retry failed request with exponential backoff + * @private + * @param {Function} requestFn - Function that returns a Promise + * @param {number} [maxRetries=3] - Maximum retry attempts + * @returns {Promise} + */ + async _retryWithBackoff(requestFn, maxRetries = 3) { + let lastError; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await requestFn(); + } catch (error) { + lastError = error; + + // Don't retry on user abort + if (error instanceof Error && error.name === 'AbortError') { + throw error; + } + + // Don't retry on client errors (4xx) + if (error instanceof Error && error.message.includes('HTTP 4')) { + throw error; + } + + if (attempt < maxRetries) { + // Exponential backoff: 1s, 2s, 4s + const delay = Math.pow(2, attempt) * 1000; + this.showToast(`Retrying in ${delay / 1000}s... (${attempt + 1}/${maxRetries})`, 'info', delay); + await new Promise(r => setTimeout(r, delay)); + } + } + } + + throw lastError; + } + + /** + * Initialize PWA install functionality + * @private + */ + _initPWAInstall() { + /** @type {BeforeInstallPromptEvent|null} */ + this._deferredPrompt = null; + + const installBtn = document.getElementById('btnInstallPWA'); + if (!installBtn) return; + + // Check if already installed as PWA + const isStandalone = window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone === true || + document.referrer.includes('android-app://'); + + if (isStandalone) { + // Already installed, hide the button + installBtn.style.display = 'none'; + return; + } + + // Listen for the beforeinstallprompt event + window.addEventListener('beforeinstallprompt', (e) => { + // Prevent the mini-infobar from appearing on mobile + e.preventDefault(); + // Store the event for later use + this._deferredPrompt = /** @type {BeforeInstallPromptEvent} */ (e); + // Show the install button + installBtn.style.display = 'flex'; + console.log('💾 PWA install prompt ready'); + }); + + // Handle install button click + installBtn.addEventListener('click', async () => { + if (!this._deferredPrompt) { + // No deferred prompt - show manual instructions + this._showManualInstallInstructions(); + return; + } + + // Show the install prompt + this._deferredPrompt.prompt(); + + // Wait for the user's response + const { outcome } = await this._deferredPrompt.userChoice; + console.log(`💾 PWA install outcome: ${outcome}`); + + if (outcome === 'accepted') { + this.showToast('Installing Rox AI...', 'success', 3000); + installBtn.style.display = 'none'; + } + + // Clear the deferred prompt + this._deferredPrompt = null; + }); + + // Listen for successful installation + window.addEventListener('appinstalled', () => { + console.log('✅ PWA installed successfully'); + this.showToast('Rox AI installed successfully!', 'success', 3000); + installBtn.style.display = 'none'; + this._deferredPrompt = null; + }); + } + + /** + * Show manual install instructions for browsers that don't support beforeinstallprompt + * @private + */ + _showManualInstallInstructions() { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor); + const isEdge = /Edg/.test(navigator.userAgent); + const isFirefox = /Firefox/.test(navigator.userAgent); + + let instructions = ''; + + if (isIOS) { + instructions = ` + Install on iOS:
+ 1. Tap the Share button (square with arrow)
+ 2. Scroll down and tap "Add to Home Screen"
+ 3. Tap "Add" to install + `; + } else if (isSafari) { + instructions = ` + Install on Safari:
+ 1. Click File menu
+ 2. Select "Add to Dock" or use the Share menu
+ 3. Click "Add" to install + `; + } else if (isChrome || isEdge) { + instructions = ` + Install on ${isEdge ? 'Edge' : 'Chrome'}:
+ 1. Click the install icon (⊕) in the address bar
+ 2. Or click ⋮ Menu → Install Rox AI
+ 3. Click "Install" to add to your device + `; + } else if (isFirefox) { + instructions = ` + Install on Firefox:
+ Firefox doesn't fully support PWA installation.
+ Try using Chrome or Edge for the best experience. + `; + } else { + instructions = ` + Install Rox AI:
+ Look for an install icon in your browser's address bar,
+ or check your browser's menu for an "Install" or "Add to Home Screen" option. + `; + } + + this.showDialog({ + type: 'info', + title: '📱 Install Rox AI', + message: instructions, + confirmText: 'Got it', + showCancel: false + }); + } + + /** + * Handle global keyboard shortcuts + * @private + * @param {KeyboardEvent} e + */ + _handleGlobalKeydown(e) { + const isModifier = e.ctrlKey || e.metaKey; + + if (e.key === 'Escape') { + // Cancel ongoing request if loading + if (this.isLoading && this.requestController) { + this.requestController.abort(); + this.showToast('Request cancelled', 'info', 2000); + } + this._hideContextMenu(); + this.renameModal?.classList.remove('show'); + this.closeDialog(); + this._closeModelDropdown(); + this._closeMobileActionSheet(); + return; + } + + if (isModifier && e.key === 'k') { + e.preventDefault(); + this.messageInput?.focus(); + } else if (isModifier && e.key === 'n') { + e.preventDefault(); + this.createNewChat(); + } else if (isModifier && e.key === 'b') { + e.preventDefault(); + this._toggleSidebar(); + } else if (isModifier && e.key === '/') { + e.preventDefault(); + this.showKeyboardShortcuts(); + } + } + + /** + * Initialize smooth scroll handling - optimized for performance + * @private + */ + _initSmoothScroll() { + const chatContainer = document.getElementById('chatContainer'); + + if (chatContainer) { + // Detect ANY manual scroll interaction (wheel, touch, keyboard, scrollbar drag) + // Once user scrolls during loading, stop auto-scroll until streaming ends + + // Wheel scroll (mouse/trackpad) - ANY upward scroll stops auto-scroll + chatContainer.addEventListener('wheel', (e) => { + if (this.isLoading) { + if (e.deltaY < 0) { + // User is scrolling UP - they want to read previous content + this._userHasScrolledAway = true; + } + // Don't interfere with scroll - let user scroll freely + } + }, { passive: true }); + + // Touch scroll (mobile) - detect upward swipe + let touchStartY = 0; + chatContainer.addEventListener('touchstart', (e) => { + touchStartY = e.touches[0].clientY; + }, { passive: true }); + + chatContainer.addEventListener('touchmove', (e) => { + if (this.isLoading) { + const touchY = e.touches[0].clientY; + const deltaY = touchY - touchStartY; + if (deltaY > 5) { + // User is scrolling UP (finger moving down) - they want to read previous content + this._userHasScrolledAway = true; + } + // Don't interfere - let user scroll freely + } + }, { passive: true }); + + // Keyboard scroll (Page Up, Arrow Up, Home) + chatContainer.addEventListener('keydown', (e) => { + if (this.isLoading && ['PageUp', 'ArrowUp', 'Home'].includes(e.key)) { + this._userHasScrolledAway = true; + } + }); + + // Scrollbar drag - detect when user scrolls away from bottom + let lastScrollTop = 0; + chatContainer.addEventListener('scroll', () => { + if (this.isLoading) { + const currentScrollTop = chatContainer.scrollTop; + const scrollHeight = chatContainer.scrollHeight; + const clientHeight = chatContainer.clientHeight; + const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight; + + // If user scrolled UP (scrollTop decreased) and not near bottom + if (currentScrollTop < lastScrollTop - 5 && distanceFromBottom > 150) { + this._userHasScrolledAway = true; + } + + // If user manually scrolled back to bottom, resume auto-scroll + if (distanceFromBottom < 50 && this._userHasScrolledAway) { + this._userHasScrolledAway = false; + } + + lastScrollTop = currentScrollTop; + } + }, { passive: true }); + } + } + + // ==================== INPUT HANDLING ==================== + + /** + * Handle input changes + * @private + */ + _handleInput() { + const hasContent = (this.messageInput?.value.trim() !== '') || (this.attachedFiles.length > 0); + + if (this.btnSend) { + // When loading, button should always be enabled (for stop functionality) + if (this.isLoading) { + this.btnSend.disabled = false; + } else { + this.btnSend.disabled = !hasContent; + } + } + + this._autoResize(); + } + + /** + * Update send button to show stop state + * @private + * @param {boolean} isGenerating + */ + _updateSendButtonState(isGenerating) { + if (!this.btnSend) return; + + if (isGenerating) { + this.btnSend.classList.add('generating'); + this.btnSend.disabled = false; + this.btnSend.title = 'Stop generating'; + // Change to stop icon + this.btnSend.innerHTML = ` + + + + `; + } else { + this.btnSend.classList.remove('generating'); + this.btnSend.title = 'Send message'; + // Change back to send icon + this.btnSend.innerHTML = ` + + + + `; + this._handleInput(); // Re-evaluate disabled state + } + } + + /** + * Stop the current generation + * @private + */ + _stopGeneration() { + if (this.requestController) { + this.requestController.abort(); + this.requestController = null; + } + + this._removeTypingIndicator(); + this.isLoading = false; + this._updateSendButtonState(false); + this._currentStreamingPairId = null; + this._userHasScrolledAway = false; + + this.showToast('Generation stopped', 'info', 2000); + } + + /** + * Auto-resize textarea + * @private + */ + _autoResize() { + if (!this.messageInput) return; + this.messageInput.style.height = 'auto'; + this.messageInput.style.height = `${Math.min(this.messageInput.scrollHeight, 200)}px`; + } + + /** + * Handle keydown in message input + * @private + * @param {KeyboardEvent} e + */ + _handleKeyDown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + // On mobile devices, Enter creates a new line (user must tap send button) + // On desktop, Enter sends the message + const isMobile = window.innerWidth <= MOBILE_BREAKPOINT || + ('ontouchstart' in window) || + (navigator.maxTouchPoints > 0); + + if (isMobile) { + // Allow default behavior (new line) on mobile + return; + } + + // Desktop: send message on Enter + e.preventDefault(); + this.sendMessage(); + } + } + + // ==================== SIDEBAR ==================== + + /** + * Toggle sidebar visibility + * @private + */ + _toggleSidebar() { + if (window.innerWidth <= MOBILE_BREAKPOINT) { + const isOpen = this.sidebar?.classList.toggle('open'); + this.sidebarOverlay?.classList.toggle('show', isOpen); + + // Update navigation state for back button handling + if (isOpen) { + document.querySelector('.app')?.setAttribute('data-nav-state', 'sidebar-open'); + if (this._isPWA) { + history.pushState({ nav: 'sidebar' }, '', window.location.href); + } + } else { + document.querySelector('.app')?.removeAttribute('data-nav-state'); + } + } else { + this.sidebar?.classList.toggle('collapsed'); + } + } + + /** + * Close sidebar + * @private + */ + _closeSidebar() { + this.sidebar?.classList.remove('open'); + this.sidebarOverlay?.classList.remove('show'); + document.querySelector('.app')?.removeAttribute('data-nav-state'); + } + + // ==================== FILE HANDLING ==================== + + /** + * Handle file selection + * @private + * @param {Event} e + */ + _handleFileSelect(e) { + const target = e.target; + if (!(target instanceof HTMLInputElement) || !target.files) return; + + const files = Array.from(target.files); + + for (const file of files) { + this.attachedFiles.push(file); + } + + if (files.length > 0) { + this.showToast(`${files.length} file${files.length > 1 ? 's' : ''} attached`, 'success', 2000); + } + + this._renderAttachments(); + this._handleInput(); + target.value = ''; + } + + /** + * Render attachment previews + * @private + */ + _renderAttachments() { + if (!this.attachmentsPreview) return; + this.attachmentsPreview.innerHTML = ''; + + this.attachedFiles.forEach((file, index) => { + const preview = document.createElement('div'); + preview.className = 'attachment-preview'; + preview.style.opacity = '0'; + preview.style.transform = 'scale(0.8) translateY(-10px)'; + preview.innerHTML = ` + + + + + ${this.escapeHtml(file.name)} + + `; + + const removeBtn = preview.querySelector('.attachment-preview-remove'); + removeBtn?.addEventListener('click', () => { + preview.style.transition = 'all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)'; + preview.style.opacity = '0'; + preview.style.transform = 'scale(0.7) translateX(-20px)'; + setTimeout(() => { + this.attachedFiles.splice(index, 1); + this._renderAttachments(); + this._handleInput(); + this.showToast('File removed', 'info', 1500); + }, 250); + }); + + if (this.attachmentsPreview) { + this.attachmentsPreview.appendChild(preview); + } + + requestAnimationFrame(() => { + setTimeout(() => { + preview.style.transition = 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)'; + preview.style.opacity = '1'; + preview.style.transform = 'scale(1) translateY(0)'; + }, index * 50); + }); + }); + } + + // ==================== MESSAGING ==================== + + /** + * Validate message content + * @private + * @param {string} content - Message content to validate + * @returns {{valid: boolean, error?: string}} Validation result + */ + _validateMessage(content) { + if (typeof content !== 'string') { + return { valid: false, error: 'Invalid message format' }; + } + + const trimmed = content.trim(); + + if (trimmed.length > MAX_MESSAGE_LENGTH) { + return { valid: false, error: `Message too long (max ${MAX_MESSAGE_LENGTH.toLocaleString()} characters)` }; + } + + // Check for potentially malicious content patterns + if (/^javascript:/i.test(trimmed) || /^data:/i.test(trimmed)) { + return { valid: false, error: 'Invalid message content' }; + } + + return { valid: true }; + } + + /** + * Send message to API + * @async + * @returns {Promise} + */ + async sendMessage() { + const rawContent = this.messageInput?.value || ''; + const content = rawContent.trim(); + + // Validate input - prevent double-sends + if ((!content && this.attachedFiles.length === 0) || this.isLoading) { + return; + } + + // Immediately set loading to prevent double-clicks + this.isLoading = true; + this._updateSendButtonState(true); + + // Validate message length + const validation = this._validateMessage(content); + if (!validation.valid) { + this.isLoading = false; + this._updateSendButtonState(false); + this.showToast(validation.error || 'Invalid message', 'warning'); + return; + } + + // Check if offline - queue message for later + if (!navigator.onLine) { + this.isLoading = false; + this._updateSendButtonState(false); + const queued = this._queueOfflineMessage(content, this.attachedFiles); + if (queued) { + // Clear input after queuing + if (this.messageInput) { + this.messageInput.value = ''; + this.messageInput.style.height = 'auto'; + } + this.attachedFiles = []; + this._renderAttachments(); + this._handleInput(); + } + return; + } + + // Warn on poor connection + if (this._connectionQuality === 'poor') { + this.showToast('Slow connection - response may take longer', 'info', 3000); + } + + // Create conversation if needed + if (!this.currentConversationId) { + this.createNewChat(); + } + + const conversation = this.conversations.find((c) => c.id === this.currentConversationId); + if (!conversation) { + this.isLoading = false; + this._updateSendButtonState(false); + this.showToast('Failed to find conversation', 'error'); + return; + } + + // Check conversation message limit + if (conversation.messages.length >= MAX_MESSAGES_PER_CONVERSATION) { + this.isLoading = false; + this._updateSendButtonState(false); + this.showDialog({ + type: 'warning', + title: 'Conversation Limit', + message: 'This conversation has reached the maximum message limit. Please start a new chat.', + confirmText: 'OK', + showCancel: false + }); + return; + } + + // Hide welcome screen + if (this.welcome) { + this.welcome.style.transition = 'all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)'; + this.welcome.style.opacity = '0'; + this.welcome.style.transform = 'translateY(-30px) scale(0.95)'; + setTimeout(() => { + if (this.welcome) this.welcome.style.display = 'none'; + }, 250); + } + + // Store files before clearing + const filesToSend = [...this.attachedFiles]; + + // Create unique pair ID for linking user and assistant messages + const pairId = this._generateId(); + + // Create user message with version support + /** @type {Message} */ + const userMessage = { + role: 'user', + content: content, + attachments: filesToSend.map((f) => ({ name: f.name, type: f.type, size: f.size })), + timestamp: Date.now(), + id: this._generateId(), + pairId: pairId, + versions: [{ userContent: content, timestamp: Date.now() }], + versionIndex: 0 + }; + + conversation.messages.push(userMessage); + this._renderMessage(userMessage, conversation.messages.length - 1); + + // Clear input + if (this.messageInput) { + this.messageInput.value = ''; + this.messageInput.style.height = 'auto'; + + // Dismiss keyboard on mobile devices after sending + if (window.innerWidth <= MOBILE_BREAKPOINT) { + this.messageInput.blur(); + } + } + this.attachedFiles = []; + this._renderAttachments(); + this._handleInput(); + + // Show typing indicator (isLoading already set at start) + this._showTypingIndicator(); + + // Create abort controller + this.requestController = new AbortController(); + + // Declare assistantId outside try block so it's accessible in catch + let assistantId = ''; + // Track streaming response content for partial save on cancel + let streamingFullResponse = ''; + // Track if internet was used for this response + let usedInternet = false; + let internetSource = ''; + + try { + const formData = new FormData(); + formData.append('message', content); + formData.append('model', this.currentModel); + formData.append('chatId', this.currentConversationId || ''); + // Send full conversation history for complete context + formData.append('conversationHistory', JSON.stringify(conversation.messages)); + filesToSend.forEach((file) => formData.append('files', file)); + + const response = await fetch('/api/chat', { + method: 'POST', + body: formData, + signal: this.requestController.signal + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Keep typing indicator until first chunk arrives for better UX + // It will be removed when we receive the first actual content chunk + + // Create assistant message placeholder for streaming + assistantId = this._generateId(); + // Get model display name + const modelName = this._getCurrentModelName(); + /** @type {Message} */ + const assistantMessage = { + role: 'assistant', + content: '', + timestamp: Date.now(), + id: assistantId, + pairId: pairId, + versions: userMessage.versions, + versionIndex: 0, + model: modelName + }; + + conversation.messages.push(assistantMessage); + const msgIndex = conversation.messages.length - 1; + // Don't render streaming message yet - wait for first chunk + + // Process streaming response + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + let receivedFirstChunk = false; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value, { stream: true }); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + let data; + try { + const jsonStr = line.slice(6).trim(); + if (!jsonStr) continue; + data = JSON.parse(jsonStr); + } catch (jsonErr) { + // Skip invalid JSON lines + continue; + } + // Handle thinking indicator - shows immediately when server starts processing + if (data.thinking && !receivedFirstChunk) { + // Server confirmed it's processing - start the detailed status timer + this._startTypingStatusTimer(); + } + // Capture internet usage flag from thinking event (always check) + if (data.thinking) { + if (data.usedInternet) { + usedInternet = true; + internetSource = data.internetSource || 'Web'; + } + continue; + } + // Handle truncation notification - content was too large and was trimmed + if (data.truncated && data.info) { + // Show a brief notification to user + this._showTruncationNotice(data.info); + continue; + } + // Handle heartbeat - keeps connection alive, confirms LLM is still working + if (data.heartbeat) { + // Update status to show connection is alive + this._onHeartbeatReceived(); + continue; + } + if (data.chunk) { + if (!receivedFirstChunk) { + receivedFirstChunk = true; + // Remove typing indicator and show streaming message on first real chunk + this._removeTypingIndicator(); + this._renderStreamingMessage(assistantMessage, msgIndex); + } + fullResponse += data.chunk; + streamingFullResponse = fullResponse; // Track for partial save on cancel + this._updateStreamingMessage(assistantId, fullResponse); + } + if (data.done) { + fullResponse = data.response || fullResponse; + // Capture internet usage flag from done event + if (data.usedInternet) { + usedInternet = true; + internetSource = data.internetSource || 'Web'; + } + // Store file content in user message for conversation history + // This allows follow-up questions to have access to file content + if (data.fileContent && userMessage) { + userMessage.content = content + data.fileContent; + } + } + if (data.error) { + throw new Error(data.response || 'Stream error'); + } + } + } + } + } + + // Ensure typing indicator is removed even if no chunks were received + if (!receivedFirstChunk) { + this._removeTypingIndicator(); + this._renderStreamingMessage(assistantMessage, msgIndex); + } + + // Finalize the message + assistantMessage.content = fullResponse || 'No response received.'; + + // Fallback: detect internet usage from content if flag wasn't captured + if (!usedInternet && fullResponse.includes('LIVE INTERNET SEARCH RESULTS')) { + usedInternet = true; + internetSource = internetSource || 'Web Search'; + } + + if (usedInternet) { + assistantMessage.usedInternet = true; + assistantMessage.internetSource = internetSource; + } + if (userMessage.versions && userMessage.versions[0]) { + userMessage.versions[0].assistantContent = assistantMessage.content; + userMessage.versions[0].model = assistantMessage.model; + } + + // Re-render with final content and action buttons + this._finalizeStreamingMessage(assistantId, assistantMessage, msgIndex, usedInternet, internetSource); + + // Update title on first exchange + if (conversation.messages.length === 2 && content) { + conversation.title = content.substring(0, 40) + (content.length > 40 ? '...' : ''); + this._renderConversations(); + } + + this._saveConversations(); + + } catch (error) { + this._removeTypingIndicator(); + + // Handle user cancellation - keep partial response if any content was received + if (error instanceof Error && error.name === 'AbortError') { + if (streamingFullResponse && assistantId) { + // Keep the partial response - finalize it instead of removing + const assistantIdx = conversation.messages.findIndex(m => m.id === assistantId); + if (assistantIdx !== -1) { + const assistantMessage = conversation.messages[assistantIdx]; + // Close any incomplete markdown structures (code blocks, etc.) + const closedContent = this._closeIncompleteMarkdown(streamingFullResponse); + assistantMessage.content = closedContent + '\n\n*(Response stopped by user)*'; + + // Update user message version with partial response + if (userMessage.versions && userMessage.versions[0]) { + userMessage.versions[0].assistantContent = assistantMessage.content; + userMessage.versions[0].model = assistantMessage.model; + } + + // Finalize the streaming message with partial content + this._finalizeStreamingMessage(assistantId, assistantMessage, assistantIdx); + this._saveConversations(); + } + this.showToast('Response stopped', 'info', 2000); + } else { + // No content received yet - remove the empty message + if (assistantId) { + const streamingEl = document.getElementById(`streaming-${assistantId}`); + if (streamingEl) streamingEl.remove(); + const assistantIdx = conversation.messages.findIndex(m => m.id === assistantId); + if (assistantIdx !== -1) { + conversation.messages.splice(assistantIdx, 1); + } + } + this.showToast('Request cancelled', 'info', 2000); + } + return; + } + + // Remove streaming message if it exists (for non-cancel errors) + if (assistantId) { + const streamingEl = document.getElementById(`streaming-${assistantId}`); + if (streamingEl) { + streamingEl.remove(); + // Remove the assistant message from conversation since it failed + const assistantIdx = conversation.messages.findIndex(m => m.id === assistantId); + if (assistantIdx !== -1) { + conversation.messages.splice(assistantIdx, 1); + } + } + } + + console.error('Send message error:', error); + + // Determine specific error type for better user feedback + const errorInfo = this._categorizeError(error); + + this.showDialog({ + type: 'error', + title: errorInfo.title, + message: errorInfo.message, + confirmText: errorInfo.canRetry ? 'Retry' : 'OK', + showCancel: errorInfo.canRetry, + cancelText: 'Cancel', + onConfirm: errorInfo.canRetry ? () => { + // Retry the message + if (this.messageInput) { + this.messageInput.value = content; + this._handleInput(); + } + // Remove the user message that was added + const userMsgIdx = conversation.messages.findIndex(m => m.id === userMessage.id); + if (userMsgIdx !== -1) { + conversation.messages.splice(userMsgIdx, 1); + // Remove from DOM + const userMsgEl = document.querySelector(`[data-message-id="${userMessage.id}"]`); + if (userMsgEl) userMsgEl.remove(); + } + this._saveConversations(); + // Auto-retry after a short delay + setTimeout(() => this.sendMessage(), 500); + } : undefined + }); + + // Store error in user message version + const errorModelName = this._getCurrentModelName(); + if (userMessage.versions && userMessage.versions[0]) { + userMessage.versions[0].assistantContent = '⚠️ Sorry, there was an error processing your request. Please try again.'; + userMessage.versions[0].model = errorModelName; + } + + /** @type {Message} */ + const errorMessage = { + role: 'assistant', + content: '⚠️ Sorry, there was an error processing your request. Please try again.', + timestamp: Date.now(), + id: this._generateId(), + pairId: pairId, + versions: userMessage.versions, + versionIndex: 0, + model: errorModelName + }; + conversation.messages.push(errorMessage); + this._renderMessage(errorMessage, conversation.messages.length - 1); + + } finally { + this.isLoading = false; + this._updateSendButtonState(false); + this.requestController = null; + this._currentStreamingPairId = null; + this._userHasScrolledAway = false; + } + } + + // ==================== MESSAGE RENDERING ==================== + + /** + * Render a message with animation + * @private + * @param {Message} message + * @param {number} [msgIndex] - Index in conversation + */ + _renderMessage(message, msgIndex) { + if (!this.messages) return; + + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${message.role}`; + messageDiv.style.opacity = '0'; + messageDiv.style.transform = message.role === 'user' + ? 'translateX(30px) scale(0.95)' + : 'translateX(-30px) scale(0.95)'; + + if (message.id) messageDiv.dataset.messageId = message.id; + if (message.pairId) messageDiv.dataset.pairId = message.pairId; + if (typeof msgIndex === 'number') messageDiv.dataset.index = String(msgIndex); + + // Avatar: User gets letter, Assistant gets animated logo + const avatarContent = message.role === 'user' + ? 'U' + : ``; + + let attachmentsHtml = ''; + if (message.attachments && message.attachments.length > 0) { + attachmentsHtml = ` +
+ ${message.attachments.map((att) => ` +
+
+ + + + +
+ ${this.escapeHtml(att.name)} +
+ `).join('')} +
+ `; + } + + // Edit button for user messages + let editBtnHtml = ''; + if (message.role === 'user' && message.id) { + editBtnHtml = ` + + `; + } + + // Version navigation for assistant messages + let versionNavHtml = ''; + if (message.role === 'assistant' && message.versions && message.versions.length > 1) { + const curVer = message.versionIndex || 0; + const totalVer = message.versions.length; + versionNavHtml = ` +
+ + ${curVer + 1} / ${totalVer} + +
+ `; + } + + // Action buttons for assistant messages (copy, regenerate, PDF export, listen aloud) + let actionBtnsHtml = ''; + if (message.role === 'assistant' && message.pairId) { + actionBtnsHtml = ` +
+ + + + +
+ `; + } + + // Message indicators container (model + internet source) + let indicatorsHtml = ''; + if (message.role === 'assistant') { + let indicatorContent = ''; + if (message.model) { + indicatorContent += `${this.escapeHtml(message.model)}`; + } + // Check for internet usage - either from stored flag or content detection + let showInternetIndicator = message.usedInternet && message.internetSource; + let internetSourceToShow = message.internetSource || 'Web Search'; + if (!showInternetIndicator && message.content && message.content.includes('LIVE INTERNET SEARCH RESULTS')) { + showInternetIndicator = true; + } + if (showInternetIndicator) { + indicatorContent += `Live data from ${this.escapeHtml(internetSourceToShow)}`; + } + if (indicatorContent) { + indicatorsHtml = `
${indicatorContent}
`; + } + } + + messageDiv.innerHTML = ` +
${avatarContent}
+
+ ${indicatorsHtml} + ${attachmentsHtml} +
${this._formatContent(message.content)}
+ ${editBtnHtml} + ${versionNavHtml} + ${actionBtnsHtml} +
+ `; + + this.messages.appendChild(messageDiv); + + // Attach event listeners + this._attachMessageEvents(messageDiv, message); + + // Simple fade-in animation - optimized + requestAnimationFrame(() => { + messageDiv.style.transition = 'opacity 0.2s ease-out, transform 0.2s ease-out'; + messageDiv.style.opacity = '1'; + messageDiv.style.transform = 'translateX(0)'; + }); + + this._initCodeCopyButtons(messageDiv); + this._scrollToBottom(); + } + + /** + * Render message instantly without animation (for loading conversations) + * @private + * @param {Message} message + * @param {number} [msgIndex] + */ + _renderMessageInstant(message, msgIndex) { + if (!this.messages) return; + + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${message.role}`; + + if (message.id) messageDiv.dataset.messageId = message.id; + if (message.pairId) messageDiv.dataset.pairId = message.pairId; + if (typeof msgIndex === 'number') messageDiv.dataset.index = String(msgIndex); + + // Avatar: User gets letter, Assistant gets animated logo + const avatarContent = message.role === 'user' + ? 'U' + : ``; + + let attachmentsHtml = ''; + if (message.attachments && message.attachments.length > 0) { + attachmentsHtml = ` +
+ ${message.attachments.map((att) => ` +
+
+ + + + +
+ ${this.escapeHtml(att.name)} +
+ `).join('')} +
+ `; + } + + // Edit button for user messages + let editBtnHtml = ''; + if (message.role === 'user' && message.id) { + editBtnHtml = ` + + `; + } + + // Version navigation for assistant messages + let versionNavHtml = ''; + if (message.role === 'assistant' && message.versions && message.versions.length > 1) { + const curVer = message.versionIndex || 0; + const totalVer = message.versions.length; + versionNavHtml = ` +
+ + ${curVer + 1} / ${totalVer} + +
+ `; + } + + // Action buttons for assistant messages (copy, regenerate, PDF export, listen aloud) + let actionBtnsHtml = ''; + if (message.role === 'assistant' && message.pairId) { + actionBtnsHtml = ` +
+ + + + +
+ `; + } + + // Message indicators container (model + internet source) + let indicatorsHtml = ''; + if (message.role === 'assistant') { + let indicatorContent = ''; + if (message.model) { + indicatorContent += `${this.escapeHtml(message.model)}`; + } + // Check for internet usage - either from stored flag or content detection + let showInternetIndicator = message.usedInternet && message.internetSource; + let internetSourceToShow = message.internetSource || 'Web Search'; + if (!showInternetIndicator && message.content && message.content.includes('LIVE INTERNET SEARCH RESULTS')) { + showInternetIndicator = true; + } + if (showInternetIndicator) { + indicatorContent += `Live data from ${this.escapeHtml(internetSourceToShow)}`; + } + if (indicatorContent) { + indicatorsHtml = `
${indicatorContent}
`; + } + } + + messageDiv.innerHTML = ` +
${avatarContent}
+
+ ${indicatorsHtml} + ${attachmentsHtml} +
${this._formatContent(message.content)}
+ ${editBtnHtml} + ${versionNavHtml} + ${actionBtnsHtml} +
+ `; + + this.messages.appendChild(messageDiv); + this._attachMessageEvents(messageDiv, message); + this._initCodeCopyButtons(messageDiv); + } + + /** + * Initialize code copy buttons in a container + * @private + * @param {HTMLElement} container + */ + _initCodeCopyButtons(container) { + container.querySelectorAll('.code-copy-btn').forEach((btn) => { + btn.addEventListener('click', async () => { + const codeBlock = btn.closest('.code-block'); + const codeEl = codeBlock?.querySelector('.code-content code'); + const code = codeEl?.textContent || ''; + + try { + await navigator.clipboard.writeText(code); + btn.classList.add('copied'); + btn.innerHTML = `Copied!`; + + setTimeout(() => { + btn.classList.remove('copied'); + btn.innerHTML = `Copy`; + }, 2000); + } catch (err) { + console.error('Copy failed:', err); + this.showToast('Failed to copy code', 'error'); + } + }); + }); + } + + /** + * Attach event listeners to message elements + * @private + * @param {HTMLElement} messageDiv + * @param {Message} message + */ + _attachMessageEvents(messageDiv, message) { + // Edit button click + const editBtn = messageDiv.querySelector('.msg-edit-btn'); + if (editBtn) { + editBtn.addEventListener('click', () => this._openEditModal(message)); + } + + // Version navigation + messageDiv.querySelectorAll('.version-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + const target = e.currentTarget; + if (target instanceof HTMLElement) { + const pairId = target.dataset.pairId; + const dir = target.dataset.dir; + if (pairId && dir) { + this._switchVersion(pairId, dir === 'next' ? 1 : -1); + } + } + }); + }); + + // Copy response button + const copyBtn = messageDiv.querySelector('.copy-response-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', async () => { + const content = message.content || ''; + try { + await navigator.clipboard.writeText(content); + const span = copyBtn.querySelector('span'); + if (span) { + const originalText = span.textContent; + span.textContent = 'Copied!'; + copyBtn.classList.add('copied'); + setTimeout(() => { + span.textContent = originalText; + copyBtn.classList.remove('copied'); + }, 2000); + } + this.showToast('Response copied to clipboard', 'success', 2000); + } catch (err) { + console.error('Failed to copy:', err); + this.showToast('Failed to copy response', 'error'); + } + }); + } + + // Regenerate button + const regenBtn = messageDiv.querySelector('.regenerate-btn'); + if (regenBtn) { + regenBtn.addEventListener('click', () => { + const pairId = message.pairId; + if (pairId) { + this._regenerateResponse(pairId); + } + }); + } + + // PDF Export button + const pdfBtn = messageDiv.querySelector('.pdf-export-btn'); + if (pdfBtn) { + pdfBtn.addEventListener('click', () => { + this._exportToPDF(message); + }); + } + + // Listen aloud button + const listenBtn = messageDiv.querySelector('.listen-aloud-btn'); + if (listenBtn) { + listenBtn.addEventListener('click', () => { + this._toggleListenAloud(messageDiv, message.content); + }); + } + } + + /** + * Toggle text-to-speech for a message with auto-scroll + * @private + * @param {HTMLElement} messageDiv - The message container element + * @param {string} content - The text content to read + */ + _toggleListenAloud(messageDiv, content) { + const listenBtn = messageDiv.querySelector('.listen-aloud-btn'); + const listenIcon = listenBtn?.querySelector('.listen-icon'); + const stopIcon = listenBtn?.querySelector('.stop-icon'); + const isPlaying = listenBtn?.classList.contains('playing'); + + // If already playing this message, stop it + if (isPlaying) { + this._stopListenAloud(); + return; + } + + // Stop any other playing audio first + this._stopListenAloud(); + + // Check for speech synthesis support + if (!('speechSynthesis' in window)) { + this.showToast('Text-to-speech is not supported in your browser', 'error'); + return; + } + + // Strip HTML and code blocks for cleaner reading + const textToRead = this._prepareTextForSpeech(content); + + if (!textToRead.trim()) { + this.showToast('No text content to read', 'warning'); + return; + } + + // Create utterance + const utterance = new SpeechSynthesisUtterance(textToRead); + utterance.rate = 1.0; + utterance.pitch = 1.0; + utterance.volume = 1.0; + + // Get a good voice (voices may load async) + const setVoice = () => { + const voices = speechSynthesis.getVoices(); + const preferredVoice = voices.find(v => v.lang.startsWith('en') && v.name.includes('Google')) + || voices.find(v => v.lang.startsWith('en')) + || voices[0]; + if (preferredVoice) utterance.voice = preferredVoice; + }; + setVoice(); + // Some browsers load voices async + if (speechSynthesis.onvoiceschanged !== undefined) { + speechSynthesis.onvoiceschanged = setVoice; + } + + // Store reference for stopping + this._currentUtterance = utterance; + this._currentListenBtn = /** @type {HTMLElement|null} */ (listenBtn); + this._currentMessageDiv = messageDiv; + + // Update UI to playing state + listenBtn?.classList.add('playing'); + + // Auto-scroll setup - smoothly scroll through the message content as it's being read + // Disabled on mobile devices to prevent disorienting scroll behavior + const chatContainer = document.getElementById('chatContainer'); + const messageContent = messageDiv.querySelector('.message-content'); + /** @type {number|null} */ + let scrollAnimationId = null; + const isMobile = window.innerWidth <= MOBILE_BREAKPOINT; + + // Track if user has manually scrolled away (allows free scrolling) + let userHasScrolledAway = false; + let lastAutoScrollTop = 0; + + // Calculate reading speed and scroll parameters (desktop only) + if (chatContainer && messageContent && !isMobile) { + // Estimate reading duration based on text length (avg ~150 words/min for TTS) + const wordCount = textToRead.split(/\s+/).length; + const estimatedDuration = (wordCount / 150) * 60 * 1000; // in ms + + const messageTop = messageDiv.offsetTop; + const messageHeight = messageDiv.offsetHeight; + const containerHeight = chatContainer.clientHeight; + + // Start position: message top with some padding + const scrollStart = Math.max(0, messageTop - 50); + // End position: scroll to show the bottom of the message + const scrollEnd = Math.max(scrollStart, messageTop + messageHeight - containerHeight + 100); + const scrollDistance = scrollEnd - scrollStart; + + // Scroll to start position first + chatContainer.scrollTo({ top: scrollStart, behavior: 'smooth' }); + lastAutoScrollTop = scrollStart; + + const startTime = Date.now(); + let isAutoScrolling = false; + + // Detect ANY user scroll interaction to give them free scroll control + const handleUserScroll = (e) => { + if (!this._currentUtterance) return; + + // Any wheel event = user wants control + if (e.type === 'wheel') { + userHasScrolledAway = true; + return; + } + + // Touch scroll detection + if (e.type === 'touchstart') { + // User touched the screen, they might want to scroll + userHasScrolledAway = true; + } + }; + + // Detect scroll position changes - if user scrolls in any direction, give them control + const handleScrollChange = () => { + if (!this._currentUtterance || userHasScrolledAway) return; + + // If we're not in the middle of auto-scrolling and scroll position changed significantly + if (!isAutoScrolling) { + const currentScroll = chatContainer.scrollTop; + const diff = Math.abs(currentScroll - lastAutoScrollTop); + // If scroll changed by more than 20px and we didn't cause it, user is scrolling + if (diff > 20) { + userHasScrolledAway = true; + } + } + }; + + // Keyboard scroll detection (arrow keys, page up/down, etc.) + const handleKeyScroll = (e) => { + if (!this._currentUtterance) return; + const scrollKeys = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ']; + if (scrollKeys.includes(e.key)) { + userHasScrolledAway = true; + } + }; + + // Add scroll detection listeners + chatContainer.addEventListener('wheel', handleUserScroll, { passive: true }); + chatContainer.addEventListener('touchstart', handleUserScroll, { passive: true }); + chatContainer.addEventListener('scroll', handleScrollChange, { passive: true }); + document.addEventListener('keydown', handleKeyScroll, { passive: true }); + + // Store cleanup function + this._listenScrollCleanup = () => { + chatContainer.removeEventListener('wheel', handleUserScroll); + chatContainer.removeEventListener('touchstart', handleUserScroll); + chatContainer.removeEventListener('scroll', handleScrollChange); + document.removeEventListener('keydown', handleKeyScroll); + }; + + // Animate scroll following the reading progress + const animateScroll = () => { + if (!this._currentUtterance || !('speechSynthesis' in window) || !speechSynthesis.speaking) { + return; + } + + // Skip auto-scroll if user has scrolled away (free scroll mode) + if (userHasScrolledAway) { + // Continue animation loop but don't scroll - user has full control + scrollAnimationId = requestAnimationFrame(animateScroll); + return; + } + + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / estimatedDuration, 1); + + // Smooth easing function + const easeProgress = progress < 0.5 + ? 2 * progress * progress + : 1 - Math.pow(-2 * progress + 2, 2) / 2; + + const targetScroll = scrollStart + (scrollDistance * easeProgress); + + // Mark that we're auto-scrolling so we don't trigger user scroll detection + isAutoScrolling = true; + + // Only scroll if we need to move down + if (targetScroll > chatContainer.scrollTop) { + chatContainer.scrollTop = targetScroll; + lastAutoScrollTop = targetScroll; + } + + // Reset auto-scrolling flag after a brief delay + setTimeout(() => { isAutoScrolling = false; }, 50); + + if (progress < 1 && this._currentUtterance) { + scrollAnimationId = requestAnimationFrame(animateScroll); + } + }; + + // Start animation after a brief delay + setTimeout(() => { + if (this._currentUtterance) { + scrollAnimationId = requestAnimationFrame(animateScroll); + } + }, 300); + + // Store reference for cleanup + this._scrollAnimationId = scrollAnimationId; + } + + // Handle speech events + utterance.onend = () => { + if (scrollAnimationId) cancelAnimationFrame(scrollAnimationId); + if (this._scrollAnimationId) cancelAnimationFrame(this._scrollAnimationId); + this._resetListenAloudUI(); + }; + + utterance.onerror = (e) => { + if (scrollAnimationId) cancelAnimationFrame(scrollAnimationId); + if (this._scrollAnimationId) cancelAnimationFrame(this._scrollAnimationId); + if (e.error !== 'canceled') { + this.showToast('Speech synthesis error', 'error'); + } + this._resetListenAloudUI(); + }; + + // Start speaking + speechSynthesis.speak(utterance); + this.showToast('Reading aloud...', 'info', 2000); + } + + /** + * Stop any currently playing speech + * @private + */ + _stopListenAloud() { + if ('speechSynthesis' in window && speechSynthesis.speaking) { + speechSynthesis.cancel(); + } + this._resetListenAloudUI(); + } + + /** + * Reset the listen aloud button UI + * @private + */ + _resetListenAloudUI() { + // Cancel any ongoing scroll animation + if (this._scrollAnimationId) { + cancelAnimationFrame(this._scrollAnimationId); + this._scrollAnimationId = null; + } + + // Clean up scroll event listeners + if (this._listenScrollCleanup) { + this._listenScrollCleanup(); + this._listenScrollCleanup = null; + } + + if (this._currentListenBtn) { + this._currentListenBtn.classList.remove('playing'); + } + this._currentUtterance = null; + this._currentListenBtn = null; + this._currentMessageDiv = null; + } + + /** + * Prepare text content for speech synthesis + * @private + * @param {string} content - Raw content with possible markdown/HTML + * @returns {string} Clean text for reading + */ + _prepareTextForSpeech(content) { + let text = content; + // Remove code blocks + text = text.replace(/```[\s\S]*?```/g, ' code block '); + // Remove inline code + text = text.replace(/`[^`]+`/g, ' code '); + // Remove HTML tags + text = text.replace(/<[^>]+>/g, ' '); + // Remove markdown links but keep text + text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + // Remove markdown formatting + text = text.replace(/[*_~#]+/g, ''); + // Clean up whitespace + text = text.replace(/\s+/g, ' ').trim(); + return text; + } + + /** + * Regenerate response for a message pair + * @private + * @param {string} pairId - The pair ID linking user and assistant messages + */ + async _regenerateResponse(pairId) { + if (this.isLoading) { + this.showToast('Please wait for the current request to complete', 'warning'); + return; + } + + const conversation = this.conversations.find((c) => c.id === this.currentConversationId); + if (!conversation) return; + + // Find user and assistant messages by pairId + const userMsg = conversation.messages.find((m) => m.pairId === pairId && m.role === 'user'); + const assistantMsg = conversation.messages.find((m) => m.pairId === pairId && m.role === 'assistant'); + + if (!userMsg || !assistantMsg) { + this.showToast('Could not find message to regenerate', 'error'); + return; + } + + // Initialize versions if needed (include model from existing assistant message) + if (!userMsg.versions) { + userMsg.versions = [{ + userContent: userMsg.content, + assistantContent: assistantMsg.content, + timestamp: userMsg.timestamp, + model: assistantMsg.model || 'Rox Core' + }]; + userMsg.versionIndex = 0; + } + if (!assistantMsg.versions) { + assistantMsg.versions = userMsg.versions; + assistantMsg.versionIndex = 0; + } + + // Show loading on the specific message being regenerated (not at bottom) + this.isLoading = true; + this._userHasScrolledAway = false; // Reset scroll state for regeneration + this._currentStreamingPairId = pairId; // Track which message is being regenerated + this._showRegeneratingIndicator(pairId); + + try { + // Get history up to this message pair (excluding the current pair) + // This provides proper context for regeneration + const userMsgIndex = conversation.messages.indexOf(userMsg); + const historyUpTo = conversation.messages.slice(0, userMsgIndex).map(msg => ({ + role: msg.role, + content: msg.content + })); + + const formData = new FormData(); + formData.append('message', userMsg.content); + formData.append('model', this.currentModel); + formData.append('chatId', this.currentConversationId || ''); + formData.append('conversationHistory', JSON.stringify(historyUpTo)); + + this.requestController = new AbortController(); + + // Track streaming response for partial save on cancel + let streamingNewResponse = ''; + + const response = await fetch('/api/chat', { + method: 'POST', + body: formData, + signal: this.requestController.signal + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + // Handle streaming response + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let newResponse = ''; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value, { stream: true }); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + let data; + try { + data = JSON.parse(line.slice(6)); + } catch (jsonErr) { + // Skip invalid JSON + continue; + } + // Skip thinking/heartbeat events + if (data.thinking || data.heartbeat) continue; + if (data.chunk) { + newResponse += data.chunk; + } + if (data.done) { + newResponse = data.response || newResponse; + } + if (data.error) { + throw new Error(data.response || 'Stream error'); + } + } + } + } + } + + newResponse = newResponse || 'No response received.'; + + // Get current model name for display + const modelName = this._getCurrentModelName(); + + // Add new version with model info for version navigation + const newVersion = { + userContent: userMsg.content, + assistantContent: newResponse, + timestamp: Date.now(), + model: modelName + }; + + userMsg.versions.push(newVersion); + userMsg.versionIndex = userMsg.versions.length - 1; + + assistantMsg.versions = userMsg.versions; + assistantMsg.versionIndex = userMsg.versions.length - 1; + assistantMsg.content = newResponse; + assistantMsg.model = modelName; + + this._saveConversations(); + // Re-render and scroll to the regenerated message, not to bottom + this._reRenderConversation(pairId); + this.showToast(`Regenerated with ${modelName}`, 'success', 2000); + + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + // Keep partial response if any content was received + if (streamingNewResponse) { + const modelName = this._getCurrentModelName(); + + // Close any incomplete markdown structures + const closedContent = this._closeIncompleteMarkdown(streamingNewResponse); + + // Add new version with partial response + const newVersion = { + userContent: userMsg.content, + assistantContent: closedContent + '\n\n*(Response stopped by user)*', + timestamp: Date.now(), + model: modelName + }; + + userMsg.versions.push(newVersion); + userMsg.versionIndex = userMsg.versions.length - 1; + + assistantMsg.versions = userMsg.versions; + assistantMsg.versionIndex = userMsg.versions.length - 1; + assistantMsg.content = closedContent + '\n\n*(Response stopped by user)*'; + assistantMsg.model = modelName; + + this._saveConversations(); + this._reRenderConversation(pairId); + this.showToast('Response stopped', 'info', 2000); + } else { + this.showToast('Request cancelled', 'info', 2000); + } + // Don't return early - let finally block clean up + } else { + console.error('Regenerate error:', error); + this.showToast('Failed to regenerate response', 'error'); + } + } finally { + this.isLoading = false; + this.requestController = null; + this._currentStreamingPairId = null; + this._removeRegeneratingIndicator(pairId); + } + } + + /** + * Export response content to a professionally styled PDF + * @private + * @param {string} content - The message content to export + */ + /** + * Export message content to PDF + * @param {Message|string} messageOrContent - The message object or content string to export + */ + _exportToPDF(messageOrContent) { + // Handle both message object and plain string for backward compatibility + let content, model, usedInternet, internetSource; + + if (typeof messageOrContent === 'string') { + content = messageOrContent; + model = null; + usedInternet = false; + internetSource = null; + } else if (messageOrContent && typeof messageOrContent === 'object') { + content = messageOrContent.content; + model = messageOrContent.model || null; + usedInternet = messageOrContent.usedInternet || false; + internetSource = messageOrContent.internetSource || null; + } else { + this.showToast('No content to export', 'warning'); + return; + } + + if (!content || typeof content !== 'string') { + this.showToast('No content to export', 'warning'); + return; + } + + const now = new Date(); + const dateStr = now.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + const timeStr = now.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); + + // Format content for PDF (convert markdown to HTML) + const formattedContent = this._formatContentForPDF(content); + + // Create professional PDF HTML template + const pdfHtml = ` + + + + + + + + + + + + + + + + + + + +
+
+ + Rox AI +
+
+
${dateStr} at ${timeStr}
+ ${model ? `
Model: ${this.escapeHtml(model)}
` : ''} + ${usedInternet ? `
📡 Data Source: ${this.escapeHtml(internetSource || 'Internet')}
` : ''} + AI-Generated Response +
+
+ +
+ ${formattedContent} +
+ +
+ + ${usedInternet ? `` : ''} +
+ + +`; + + // Create hidden iframe for printing (avoids about:blank and browser headers) + let existingFrame = document.getElementById('pdfPrintFrame'); + if (existingFrame) { + existingFrame.remove(); + } + + /** @type {HTMLIFrameElement} */ + const printFrame = document.createElement('iframe'); + printFrame.id = 'pdfPrintFrame'; + printFrame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'; + document.body.appendChild(printFrame); + + const frameWindow = printFrame.contentWindow; + if (!frameWindow) { + this.showToast('Failed to create print frame', 'error'); + return; + } + + const doc = frameWindow.document; + doc.open(); + doc.write(pdfHtml); + doc.close(); + + // Wait for content to load then print (only once) + setTimeout(() => { + try { + frameWindow.focus(); + frameWindow.print(); + } catch (e) { + // Fallback to window.open if iframe print fails + const printWindow = window.open('', '_blank', 'width=800,height=600'); + if (printWindow) { + printWindow.document.write(pdfHtml); + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => printWindow.print(), 300); + } + } + }, 500); + + this.showToast('PDF export ready - uncheck "Headers and footers" in print options', 'success', 5000); + } + + /** + * Format content specifically for PDF export + * @private + * @param {string} content - Raw content to format + * @returns {string} HTML formatted content + */ + _formatContentForPDF(content) { + if (!content || typeof content !== 'string') return ''; + + let formatted = content; + + // Remove internet search indicator lines (they look ugly in the PDF) + formatted = formatted.replace(/^🌐\s*Searching for.*?\.\.\.?\s*$/gm, ''); + formatted = formatted.replace(/^🌐\s*LIVE INTERNET SEARCH RESULTS:?\s*$/gm, ''); + // Also remove any leading/trailing whitespace and extra blank lines created by removal + formatted = formatted.replace(/^\s*\n+/g, '').replace(/\n{3,}/g, '\n\n'); + + // FIRST: Fix common formatting issues in raw content before any processing + // Fix numbered lists that are missing the dot or space + // Handle "1AI" -> "1. AI", "2Con" -> "2. Con" (number directly followed by uppercase letter) + formatted = formatted.replace(/^(\d+)([A-Z][a-zA-Z])/gm, '$1. $2'); + // Handle "1**text**" -> "1. **text**" (number directly followed by bold markdown) + formatted = formatted.replace(/^(\d+)(\*\*[^*]+\*\*)/gm, '$1. $2'); + // Handle "1text" -> "1. text" (number directly followed by any letter) + formatted = formatted.replace(/^(\d+)([a-zA-Z])/gm, '$1. $2'); + // Handle "1.text" -> "1. text" (dot but no space) + formatted = formatted.replace(/^(\d+)\.([^\s])/gm, '$1. $2'); + // Handle "1)" -> "1. " (parenthesis style to dot style) + formatted = formatted.replace(/^(\d+)\)\s*/gm, '$1. '); + // Fix bullet points without space "- text" is fine, but "-text" needs space + formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1'); + + // CRITICAL: Then, aggressively strip ALL HTML tags (with or without attributes) to plain text/markdown + // This handles cases where the stored content already has styled HTML from rendering + // Use a loop to handle nested tags + let prevStrip = ''; + let maxIterations = 10; + while (prevStrip !== formatted && maxIterations-- > 0) { + prevStrip = formatted; + // Strip strong/b tags with ANY attributes - convert to markdown **bold** + formatted = formatted.replace(/]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/b>/gi, '**$1**'); + // Strip em/i tags - convert to markdown *italic* + formatted = formatted.replace(/]*>([\s\S]*?)<\/em>/gi, '*$1*'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/i>/gi, '*$1*'); + // Strip u tags - convert to markdown _underline_ + formatted = formatted.replace(/]*>([\s\S]*?)<\/u>/gi, '_$1_'); + // Strip span tags - just extract content + formatted = formatted.replace(/]*>([\s\S]*?)<\/span>/gi, '$1'); + // Strip div tags - extract content with newline + formatted = formatted.replace(/]*>([\s\S]*?)<\/div>/gi, '$1\n'); + // Strip p tags + formatted = formatted.replace(/]*>([\s\S]*?)<\/p>/gi, '$1\n\n'); + // Strip br tags + formatted = formatted.replace(/]*\/?>/gi, '\n'); + // Strip heading tags - convert to markdown + formatted = formatted.replace(/]*>([\s\S]*?)<\/h1>/gi, '# $1\n'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/h2>/gi, '## $1\n'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/h3>/gi, '### $1\n'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/h4>/gi, '#### $1\n'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/h5>/gi, '##### $1\n'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/h6>/gi, '###### $1\n'); + // Strip anchor tags - extract text + formatted = formatted.replace(/]*>([\s\S]*?)<\/a>/gi, '$1'); + // Strip list items - convert to markdown with proper spacing + formatted = formatted.replace(/]*>([\s\S]*?)<\/li>/gi, '- $1\n'); + // Remove ul/ol tags + formatted = formatted.replace(/<\/?(?:ul|ol)[^>]*>/gi, ''); + // Strip blockquote - convert to markdown + formatted = formatted.replace(/]*>([\s\S]*?)<\/blockquote>/gi, '> $1\n'); + // Strip hr + formatted = formatted.replace(/]*\/?>/gi, '\n---\n'); + // Strip code tags (not in pre) - convert to markdown + formatted = formatted.replace(/]*>([\s\S]*?)<\/code>/gi, '`$1`'); + } + + // Also handle escaped HTML entities with attributes + formatted = formatted.replace(/<strong[^&]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + formatted = formatted.replace(/<b[^&]*>([\s\S]*?)<\/b>/gi, '**$1**'); + formatted = formatted.replace(/<em[^&]*>([\s\S]*?)<\/em>/gi, '*$1*'); + formatted = formatted.replace(/<i[^&]*>([\s\S]*?)<\/i>/gi, '*$1*'); + formatted = formatted.replace(/<u[^&]*>([\s\S]*?)<\/u>/gi, '_$1_'); + formatted = formatted.replace(/<span[^&]*>([\s\S]*?)<\/span>/gi, '$1'); + formatted = formatted.replace(/<br[^&]*\/?>/gi, '\n'); + formatted = formatted.replace(/<p[^&]*>([\s\S]*?)<\/p>/gi, '$1\n\n'); + + // Clean up any remaining self-closing tags (but not our placeholders) + formatted = formatted.replace(/<[a-z][^>]*\/>/gi, ''); + // Remove any remaining opening/closing tags that weren't caught (but preserve content) + // Be careful not to remove too aggressively - only remove tags we know about + formatted = formatted.replace(/<\/?(?:font|center|marquee|blink|nobr|wbr|s|strike|del|ins|mark|small|big|sub|sup|abbr|acronym|cite|dfn|kbd|samp|var|tt|pre)[^>]*>/gi, ''); + + // Fix malformed numbered lists where number is directly attached to text + // These are additional catches for edge cases not handled earlier + // Handle "1**Bold**" -> "1. **Bold**" (number followed by bold) + formatted = formatted.replace(/^(\d+)(\*\*)/gm, '$1. $2'); + // Handle "1-" or "2-" patterns that should be "1. " (number-dash to number-dot) + formatted = formatted.replace(/^(\d+)-\s*/gm, '$1. '); + + // Clean up multiple newlines and spaces + formatted = formatted.replace(/\n{3,}/g, '\n\n'); + formatted = formatted.replace(/ +/g, ' '); + + // Auto-format math expressions (before protecting math blocks) + formatted = this._autoFormatMathExpressions(formatted); + + // Store math blocks temporarily for special PDF handling + const mathBlocks = []; + const inlineMathPdf = []; + + // Protect display math blocks: $$ ... $$ or \[ ... \] + formatted = formatted.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math) => { + const placeholder = `__PDF_MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push(math.trim()); + return placeholder; + }); + formatted = formatted.replace(/\\\[([\s\S]*?)\\\]/g, (_match, math) => { + const placeholder = `__PDF_MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push(math.trim()); + return placeholder; + }); + + // Protect inline math: $ ... $ or \( ... \) + formatted = formatted.replace(/(? { + const placeholder = `__PDF_INLINE_MATH_${inlineMathPdf.length}__`; + inlineMathPdf.push(math.trim()); + return placeholder; + }); + // Handle \( ... \) - use a more permissive pattern that handles nested parens + formatted = formatted.replace(/\\\((.+?)\\\)/gs, (_match, math) => { + const placeholder = `__PDF_INLINE_MATH_${inlineMathPdf.length}__`; + inlineMathPdf.push(math.trim()); + return placeholder; + }); + + // Store code blocks temporarily + /** @type {string[]} */ + const codeBlocks = []; + formatted = formatted.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => { + const placeholder = `__PDF_CODE_BLOCK_${codeBlocks.length}__`; + const language = lang ? lang.toUpperCase() : 'CODE'; + codeBlocks.push(`
${this.escapeHtml(language)}
${this.escapeHtml(code.trim())}
`); + return placeholder; + }); + + // Store inline codes + /** @type {string[]} */ + const inlineCodes = []; + formatted = formatted.replace(/`([^`]+)`/g, (_match, code) => { + const placeholder = `__PDF_INLINE_CODE_${inlineCodes.length}__`; + inlineCodes.push(`${this.escapeHtml(code)}`); + return placeholder; + }); + + // Headings - process from h6 to h1 with PDF-optimized styling + formatted = formatted.replace(/^###### (.+)$/gm, (_, text) => `
${this._formatInlineContent(text)}
`); + formatted = formatted.replace(/^##### (.+)$/gm, (_, text) => `
${this._formatInlineContent(text)}
`); + formatted = formatted.replace(/^#### (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + formatted = formatted.replace(/^### (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + formatted = formatted.replace(/^## (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + formatted = formatted.replace(/^# (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + + // Bold, Italic, and Underline with PDF styling + formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, text) => `${this.escapeHtml(text)}`); + formatted = formatted.replace(/(? `${this.escapeHtml(text)}`); + // Skip placeholders when processing underlines + formatted = formatted.replace(/(? { + if (/__/.test(match)) return match; + return `${this.escapeHtml(text)}`; + }); + + // Ordered lists with styled numbers - ensure proper spacing between number and text + // Match "1. text", "1.text", or even "1text" patterns (after our earlier fixes) + formatted = formatted.replace(/^(\d+)\.\s*(.+)$/gm, (_, num, text) => `
${num}${this._formatInlineContent(text.trim())}
`); + + // Unordered lists with bullet points (•) + // Handle both "- text" and "-text" formats with proper spacing + formatted = formatted.replace(/^[-*•]\s*(.+)$/gm, (_, text) => `
${this._formatInlineContent(text.trim())}
`); + + // Blockquotes with styled design + formatted = formatted.replace(/^> (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + formatted = formatted.replace(/<\/blockquote>\n]*>/g, ''); + + // Tables for PDF + formatted = this._parseMarkdownTablesForPDF(formatted); + + // Horizontal rule with gradient styling + formatted = formatted.replace(/^---$/gm, '
'); + formatted = formatted.replace(/^\*\*\*$/gm, '
'); + formatted = formatted.replace(/^___$/gm, '
'); + + // Links with styling + formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Paragraphs + const lines = formatted.split('\n'); + /** @type {string[]} */ + const result = []; + /** @type {string[]} */ + let paragraphContent = []; + + for (const line of lines) { + const trimmed = line.trim(); + const isBlockElement = /^<(h[1-6]|div|blockquote|hr|pre|table|__PDF)/.test(trimmed) || + /<\/(h[1-6]|div|blockquote|pre|table)>$/.test(trimmed); + + if (isBlockElement || trimmed === '') { + if (paragraphContent.length > 0) { + result.push('

' + paragraphContent.join('
') + '

'); + paragraphContent = []; + } + if (trimmed !== '') { + result.push(line); + } + } else { + paragraphContent.push(trimmed); + } + } + + if (paragraphContent.length > 0) { + result.push('

' + paragraphContent.join('
') + '

'); + } + + formatted = result.join('\n'); + + // Restore inline codes + inlineCodes.forEach((code, i) => { + formatted = formatted.replace(new RegExp(`__PDF_INLINE_CODE_${i}__`, 'g'), code); + }); + + // Restore code blocks + codeBlocks.forEach((block, i) => { + formatted = formatted.replace(new RegExp(`__PDF_CODE_BLOCK_${i}__`, 'g'), block); + }); + + // Restore math blocks with PDF-friendly rendering + mathBlocks.forEach((math, i) => { + const rendered = this._renderMathForPDF(math, true); + formatted = formatted.replace(new RegExp(`__PDF_MATH_BLOCK_${i}__`, 'g'), rendered); + }); + + // Restore inline math + inlineMathPdf.forEach((math, i) => { + const rendered = this._renderMathForPDF(math, false); + formatted = formatted.replace(new RegExp(`__PDF_INLINE_MATH_${i}__`, 'g'), rendered); + }); + + // Clean up + formatted = formatted.replace(/]*>
<\/p>/g, ''); + formatted = formatted.replace(/]*>\s*<\/p>/g, ''); + + // Final safety cleanup: strip any remaining raw HTML tags that weren't caught at the start + // These would be tags that somehow survived - just extract their content + let finalPrev = ''; + let finalIterations = 5; + while (finalPrev !== formatted && finalIterations-- > 0) { + finalPrev = formatted; + // For any remaining styled HTML tags, just extract the content (don't re-style) + // Match tags that have style attributes but aren't our properly styled ones + // Pattern: content where content doesn't contain nested tags + formatted = formatted.replace(/([^<]*)<\/strong>/gi, '$1'); + formatted = formatted.replace(/([^<]*)<\/em>/gi, '$1'); + // Strip any OTHER styled strong/em/etc tags (not our standard ones) - extract content + formatted = formatted.replace(/([^<]*)<\/strong>/gi, '$1'); + formatted = formatted.replace(/([^<]*)<\/em>/gi, '$1'); + formatted = formatted.replace(/]*>([^<]*)<\/b>/gi, '$1'); + formatted = formatted.replace(/]*>([^<]*)<\/i>/gi, '$1'); + formatted = formatted.replace(/]*>([^<]*)<\/u>/gi, '$1'); + formatted = formatted.replace(/]*>([^<]*)<\/span>/gi, '$1'); + } + + // Wrap headings with their immediately following code blocks to keep them together + formatted = formatted.replace( + /(]*>[\s\S]*?<\/h[1-6]>)(\s*)(
[\s\S]*?<\/pre><\/div>)/g, + '
$1$2$3
' + ); + + // Also wrap heading + paragraph + code block patterns + formatted = formatted.replace( + /(]*>[\s\S]*?<\/h[1-6]>)(\s*

[\s\S]*?<\/p>\s*)(

[\s\S]*?<\/pre><\/div>)/g, + '
$1$2$3
' + ); + + return formatted; + } + + /** + * Parse markdown tables for PDF + * @private + * @param {string} content + * @returns {string} + */ + _parseMarkdownTablesForPDF(content) { + if (!content) return content; + const tableRegex = /(?:^|\n)((?:\|[^\n]+\|\n)+)/g; + return content.replace(tableRegex, (match, tableContent) => { + const lines = tableContent.trim().split('\n').filter(l => l.trim()); + if (lines.length < 2) return match; + const sep = lines[1]; + if (!/^\|[\s:|-]+\|$/.test(sep)) return match; + const aligns = sep.split('|').filter(c => c.trim()).map(c => { + const t = c.trim(); + if (t.startsWith(':') && t.endsWith(':')) return 'center'; + if (t.endsWith(':')) return 'right'; + return 'left'; + }); + const headers = lines[0].split('|').filter(c => c.trim()); + let html = ''; + headers.forEach((h, i) => { + html += ``; + }); + html += ''; + for (let i = 2; i < lines.length; i++) { + const cells = lines[i].match(/\|([^|]*)/g)?.map(c => c.slice(1)) || []; + const rowBg = (i - 2) % 2 === 0 ? '#ffffff' : '#f8fafc'; + html += ``; + cells.forEach((c, j) => { + if (c !== undefined) html += ``; + }); + html += ''; + } + html += '
${this._formatInlineContent(h.trim())}
${this._formatInlineContent(c.trim())}
'; + return '\n' + html + '\n'; + }); + } + + /** + * Render math for PDF with Unicode fallback + * @private + * @param {string} math - LaTeX math string + * @param {boolean} displayMode - Whether to render in display mode + * @returns {string} HTML for PDF + */ + _renderMathForPDF(math, displayMode = false) { + if (!math) return ''; + + // Convert LaTeX to Unicode for PDF + let rendered = math + // Handle \text{} - extract text content and format nicely + .replace(/\\text\{([^}]+)\}/g, (_, text) => text.replace(/_/g, ' ')) + .replace(/\\textit\{([^}]+)\}/g, '$1') + .replace(/\\textbf\{([^}]+)\}/g, '$1') + .replace(/\\mathrm\{([^}]+)\}/g, '$1') + .replace(/\\mathit\{([^}]+)\}/g, '$1') + .replace(/\\mathbf\{([^}]+)\}/g, '$1') + // Remove \left and \right (they're just sizing hints) + .replace(/\\left\s*/g, '') + .replace(/\\right\s*/g, '') + .replace(/\\big\s*/g, '') + .replace(/\\Big\s*/g, '') + .replace(/\\bigg\s*/g, '') + .replace(/\\Bigg\s*/g, '') + // Fractions - handle nested ones first + .replace(/\\frac\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g, '($1)/($2)') + .replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') + .replace(/\\dfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') + .replace(/\\tfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') + // Square roots with optional index + .replace(/\\sqrt\[([^\]]+)\]\{([^}]+)\}/g, (_, n, x) => n === '3' ? `∛(${x})` : n === '4' ? `∜(${x})` : `${n}√(${x})`) + .replace(/\\sqrt\{([^}]+)\}/g, '√($1)') + .replace(/\\sqrt(\d+)/g, '√$1') + // Integrals and sums with limits + .replace(/\\int_\{([^}]+)\}\^\{([^}]+)\}/g, '∫[$1→$2]') + .replace(/\\int_([^_\s\^]+)\^([^\s]+)/g, '∫[$1→$2]') + .replace(/\\int/g, '∫') + .replace(/\\oint/g, '∮') + .replace(/\\sum_\{([^}]+)\}\^\{([^}]+)\}/g, 'Σ[$1→$2]') + .replace(/\\sum/g, 'Σ') + .replace(/\\prod_\{([^}]+)\}\^\{([^}]+)\}/g, '∏[$1→$2]') + .replace(/\\prod/g, '∏') + .replace(/\\lim_\{([^}]+)\}/g, 'lim[$1]') + .replace(/\\lim/g, 'lim') + // Calculus + .replace(/\\partial/g, '∂') + .replace(/\\nabla/g, '∇') + .replace(/\\infty/g, '∞') + // Greek letters (lowercase) + .replace(/\\alpha/g, 'α').replace(/\\beta/g, 'β').replace(/\\gamma/g, 'γ') + .replace(/\\delta/g, 'δ').replace(/\\epsilon/g, 'ε').replace(/\\varepsilon/g, 'ε') + .replace(/\\zeta/g, 'ζ').replace(/\\eta/g, 'η').replace(/\\theta/g, 'θ') + .replace(/\\vartheta/g, 'ϑ') + .replace(/\\iota/g, 'ι').replace(/\\kappa/g, 'κ').replace(/\\lambda/g, 'λ') + .replace(/\\mu/g, 'μ').replace(/\\nu/g, 'ν').replace(/\\xi/g, 'ξ') + .replace(/\\pi/g, 'π').replace(/\\varpi/g, 'ϖ') + .replace(/\\rho/g, 'ρ').replace(/\\varrho/g, 'ϱ') + .replace(/\\sigma/g, 'σ').replace(/\\varsigma/g, 'ς') + .replace(/\\tau/g, 'τ').replace(/\\upsilon/g, 'υ').replace(/\\phi/g, 'φ') + .replace(/\\varphi/g, 'ϕ') + .replace(/\\chi/g, 'χ').replace(/\\psi/g, 'ψ').replace(/\\omega/g, 'ω') + // Greek letters (uppercase) + .replace(/\\Alpha/g, 'Α').replace(/\\Beta/g, 'Β').replace(/\\Gamma/g, 'Γ') + .replace(/\\Delta/g, 'Δ').replace(/\\Epsilon/g, 'Ε').replace(/\\Zeta/g, 'Ζ') + .replace(/\\Eta/g, 'Η').replace(/\\Theta/g, 'Θ').replace(/\\Iota/g, 'Ι') + .replace(/\\Kappa/g, 'Κ').replace(/\\Lambda/g, 'Λ').replace(/\\Mu/g, 'Μ') + .replace(/\\Nu/g, 'Ν').replace(/\\Xi/g, 'Ξ').replace(/\\Pi/g, 'Π') + .replace(/\\Rho/g, 'Ρ').replace(/\\Sigma/g, 'Σ').replace(/\\Tau/g, 'Τ') + .replace(/\\Upsilon/g, 'Υ').replace(/\\Phi/g, 'Φ').replace(/\\Chi/g, 'Χ') + .replace(/\\Psi/g, 'Ψ').replace(/\\Omega/g, 'Ω') + // Math operators + .replace(/\\times/g, '×').replace(/\\div/g, '÷').replace(/\\pm/g, '±') + .replace(/\\mp/g, '∓').replace(/\\cdot/g, '·').replace(/\\ast/g, '∗') + .replace(/\\star/g, '⋆').replace(/\\circ/g, '∘') + .replace(/\\bullet/g, '•').replace(/\\oplus/g, '⊕').replace(/\\otimes/g, '⊗') + // Comparisons + .replace(/\\leq/g, '≤').replace(/\\geq/g, '≥').replace(/\\neq/g, '≠') + .replace(/\\le/g, '≤').replace(/\\ge/g, '≥').replace(/\\ne/g, '≠') + .replace(/\\approx/g, '≈').replace(/\\equiv/g, '≡').replace(/\\sim/g, '∼') + .replace(/\\simeq/g, '≃').replace(/\\cong/g, '≅') + .replace(/\\propto/g, '∝').replace(/\\ll/g, '≪').replace(/\\gg/g, '≫') + // Arrows + .replace(/\\rightarrow/g, '→').replace(/\\leftarrow/g, '←') + .replace(/\\Rightarrow/g, '⇒').replace(/\\Leftarrow/g, '⇐') + .replace(/\\leftrightarrow/g, '↔').replace(/\\Leftrightarrow/g, '⇔') + .replace(/\\uparrow/g, '↑').replace(/\\downarrow/g, '↓') + .replace(/\\to/g, '→').replace(/\\gets/g, '←') + .replace(/\\mapsto/g, '↦').replace(/\\longmapsto/g, '⟼') + // Set theory + .replace(/\\forall/g, '∀').replace(/\\exists/g, '∃').replace(/\\nexists/g, '∄') + .replace(/\\in/g, '∈').replace(/\\notin/g, '∉').replace(/\\ni/g, '∋') + .replace(/\\subset/g, '⊂').replace(/\\supset/g, '⊃') + .replace(/\\subseteq/g, '⊆').replace(/\\supseteq/g, '⊇') + .replace(/\\cup/g, '∪').replace(/\\cap/g, '∩') + .replace(/\\emptyset/g, '∅').replace(/\\varnothing/g, '∅') + .replace(/\\setminus/g, '∖') + // Logic + .replace(/\\land/g, '∧').replace(/\\lor/g, '∨').replace(/\\lnot/g, '¬') + .replace(/\\neg/g, '¬').replace(/\\wedge/g, '∧').replace(/\\vee/g, '∨') + // Misc symbols + .replace(/\\angle/g, '∠').replace(/\\triangle/g, '△') + .replace(/\\perp/g, '⊥').replace(/\\parallel/g, '∥') + .replace(/\\therefore/g, '∴').replace(/\\because/g, '∵') + .replace(/\\ldots/g, '…').replace(/\\cdots/g, '⋯').replace(/\\vdots/g, '⋮') + .replace(/\\ddots/g, '⋱') + .replace(/\\prime/g, '′').replace(/\\degree/g, '°') + // Spacing commands (remove them) + .replace(/\\,/g, ' ').replace(/\\;/g, ' ').replace(/\\:/g, ' ') + .replace(/\\!/g, '').replace(/\\quad/g, ' ').replace(/\\qquad/g, ' ') + .replace(/\\ /g, ' ').replace(/\\hspace\{[^}]*\}/g, ' ') + // Remove remaining LaTeX commands but keep their content if in braces + .replace(/\\[a-zA-Z]+\{([^}]*)\}/g, '$1') + .replace(/\\[a-zA-Z]+/g, '') + // Clean up braces first + .replace(/\{([^{}]*)\}/g, '$1'); + + // Now handle superscripts and subscripts with a function that processes them properly + const superscriptMap = { + '0':'⁰','1':'¹','2':'²','3':'³','4':'⁴','5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹', + '+':'⁺','-':'⁻','=':'⁼','(':'⁽',')':'⁾', + 'n':'ⁿ','i':'ⁱ','x':'ˣ','y':'ʸ','a':'ᵃ','b':'ᵇ','c':'ᶜ','d':'ᵈ','e':'ᵉ', + 'f':'ᶠ','g':'ᵍ','h':'ʰ','j':'ʲ','k':'ᵏ','l':'ˡ','m':'ᵐ','o':'ᵒ','p':'ᵖ', + 'r':'ʳ','s':'ˢ','t':'ᵗ','u':'ᵘ','v':'ᵛ','w':'ʷ','z':'ᶻ' + }; + const subscriptMap = { + '0':'₀','1':'₁','2':'₂','3':'₃','4':'₄','5':'₅','6':'₆','7':'₇','8':'₈','9':'₉', + '+':'₊','-':'₋','=':'₌','(':'₍',')':'₎', + 'a':'ₐ','e':'ₑ','h':'ₕ','i':'ᵢ','j':'ⱼ','k':'ₖ','l':'ₗ','m':'ₘ','n':'ₙ', + 'o':'ₒ','p':'ₚ','r':'ᵣ','s':'ₛ','t':'ₜ','u':'ᵤ','v':'ᵥ','x':'ₓ' + }; + + // Convert superscripts - handle ^{...} and ^x patterns + // Process multiple times to handle nested cases + for (let i = 0; i < 5; i++) { + rendered = rendered.replace(/\^(\{[^{}]+\})/g, (_, exp) => { + const content = exp.slice(1, -1); // Remove braces + return content.split('').map(c => superscriptMap[c] || superscriptMap[c.toLowerCase()] || c).join(''); + }); + rendered = rendered.replace(/\^([0-9a-zA-Z+\-])/g, (_, c) => { + return superscriptMap[c] || superscriptMap[c.toLowerCase()] || `^${c}`; + }); + } + + // Convert subscripts - handle _{...} and _x patterns + for (let i = 0; i < 5; i++) { + rendered = rendered.replace(/_(\{[^{}]+\})/g, (_, exp) => { + const content = exp.slice(1, -1); // Remove braces + return content.split('').map(c => subscriptMap[c] || subscriptMap[c.toLowerCase()] || c).join(''); + }); + rendered = rendered.replace(/_([0-9a-zA-Z])/g, (_, c) => { + return subscriptMap[c] || subscriptMap[c.toLowerCase()] || `_${c}`; + }); + } + + // Final cleanup of any remaining braces + rendered = rendered.replace(/[{}]/g, ''); + + if (displayMode) { + return `
${this.escapeHtml(rendered)}
`; + } + return `${this.escapeHtml(rendered)}`; + } + + /** + * Open edit modal for a user message + * @private + * @param {Message} message + */ + _openEditModal(message) { + // Create modal if not exists + let modal = document.getElementById('editMsgModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'editMsgModal'; + modal.className = 'modal'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + const modalRef = modal; + document.getElementById('cancelEditBtn')?.addEventListener('click', () => { + modalRef.classList.remove('show'); + }); + + modalRef.addEventListener('click', (e) => { + if (e.target === modalRef) modalRef.classList.remove('show'); + }); + } + + if (!modal) return; + + const input = /** @type {HTMLTextAreaElement} */ (document.getElementById('editMsgInput')); + if (!input) return; + + modal.dataset.messageId = message.id || ''; + modal.dataset.pairId = message.pairId || ''; + modal.classList.add('show'); + + // Setup save handler (clone button to remove old listeners) + const saveBtn = document.getElementById('saveEditBtn'); + const newSaveBtn = saveBtn?.cloneNode(true); + if (saveBtn && newSaveBtn && saveBtn.parentNode) { + saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); + newSaveBtn.addEventListener('click', () => this._saveEdit()); + } + + // Clone input to remove old keydown listeners, then re-add + const newInput = /** @type {HTMLTextAreaElement} */ (input.cloneNode(true)); + if (input.parentNode) { + input.parentNode.replaceChild(newInput, input); + newInput.value = message.content; + + // Re-focus after clone + setTimeout(() => { + newInput.focus(); + newInput.setSelectionRange(newInput.value.length, newInput.value.length); + }, 100); + + // Ctrl+Enter to save, Escape to close + const modalForKeydown = modal; + newInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + this._saveEdit(); + } else if (e.key === 'Escape') { + modalForKeydown.classList.remove('show'); + } + }); + } + } + + /** + * Save edited message and regenerate response + * @private + */ + async _saveEdit() { + const modal = document.getElementById('editMsgModal'); + const input = /** @type {HTMLTextAreaElement} */ (document.getElementById('editMsgInput')); + if (!modal || !input) return; + + const newContent = input.value.trim(); + const pairId = modal.dataset.pairId; + + if (!newContent || !pairId) { + this.showToast('Please enter a message', 'warning'); + return; + } + + modal.classList.remove('show'); + + const conversation = this.conversations.find((c) => c.id === this.currentConversationId); + if (!conversation) return; + + // Find user and assistant messages by pairId + const userMsg = conversation.messages.find((m) => m.pairId === pairId && m.role === 'user'); + const assistantMsg = conversation.messages.find((m) => m.pairId === pairId && m.role === 'assistant'); + + if (!userMsg || !assistantMsg) return; + + // Initialize versions if needed (include model from existing assistant message) + if (!userMsg.versions) { + userMsg.versions = [{ + userContent: userMsg.content, + assistantContent: assistantMsg.content, + timestamp: userMsg.timestamp, + model: assistantMsg.model || 'Rox Core' + }]; + userMsg.versionIndex = 0; + } + if (!assistantMsg.versions) { + assistantMsg.versions = userMsg.versions; + assistantMsg.versionIndex = 0; + } + + // Show loading on the specific message being edited (not at bottom) + this.isLoading = true; + this._showRegeneratingIndicator(pairId); + + try { + // Get history up to this message pair (excluding the current pair) + const userMsgIndex = conversation.messages.indexOf(userMsg); + const historyUpTo = conversation.messages.slice(0, userMsgIndex).map(msg => ({ + role: msg.role, + content: msg.content + })); + + const formData = new FormData(); + formData.append('message', newContent); + formData.append('model', this.currentModel); + formData.append('chatId', this.currentConversationId || ''); + formData.append('conversationHistory', JSON.stringify(historyUpTo)); + + this.requestController = new AbortController(); + + // Track streaming response for partial save on cancel + let streamingNewResponse = ''; + + const response = await fetch('/api/chat', { + method: 'POST', + body: formData, + signal: this.requestController.signal + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + // Handle streaming response + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let newResponse = ''; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value, { stream: true }); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + let data; + try { + data = JSON.parse(line.slice(6)); + } catch (jsonErr) { + // Skip invalid JSON + continue; + } + // Skip thinking/heartbeat events + if (data.thinking || data.heartbeat) continue; + if (data.chunk) { + newResponse += data.chunk; + streamingNewResponse = newResponse; // Track for partial save + } + if (data.done) { + newResponse = data.response || newResponse; + } + if (data.error) { + throw new Error(data.response || 'Stream error'); + } + } + } + } + } + + newResponse = newResponse || 'No response received.'; + + // Get current model name for display + const modelName = this._getCurrentModelName(); + + // Add new version with model info for version navigation + const newVersion = { + userContent: newContent, + assistantContent: newResponse, + timestamp: Date.now(), + model: modelName + }; + + userMsg.versions.push(newVersion); + userMsg.versionIndex = userMsg.versions.length - 1; + userMsg.content = newContent; + + assistantMsg.versions = userMsg.versions; + assistantMsg.versionIndex = userMsg.versionIndex; + assistantMsg.content = newResponse; + assistantMsg.model = modelName; + + this._saveConversations(); + // Re-render and scroll to the edited message, not to bottom + this._reRenderConversation(pairId); + this.showToast(`Edited & regenerated with ${modelName}`, 'success'); + + } catch (error) { + this._removeRegeneratingIndicator(pairId); + if (error instanceof Error && error.name === 'AbortError') { + // Keep partial response if any content was received + if (streamingNewResponse) { + const modelName = this._getCurrentModelName(); + + // Close any incomplete markdown structures + const closedContent = this._closeIncompleteMarkdown(streamingNewResponse); + + // Add new version with partial response + const newVersion = { + userContent: newContent, + assistantContent: closedContent + '\n\n*(Response stopped by user)*', + timestamp: Date.now(), + model: modelName + }; + + userMsg.versions.push(newVersion); + userMsg.versionIndex = userMsg.versions.length - 1; + userMsg.content = newContent; + + assistantMsg.versions = userMsg.versions; + assistantMsg.versionIndex = userMsg.versions.length - 1; + assistantMsg.content = closedContent + '\n\n*(Response stopped by user)*'; + assistantMsg.model = modelName; + + this._saveConversations(); + this._reRenderConversation(pairId); + this.showToast('Response stopped', 'info', 2000); + } else { + this.showToast('Request cancelled', 'info', 2000); + } + } else { + console.error('Edit error:', error); + this.showToast('Failed to regenerate response', 'error'); + } + } finally { + this.isLoading = false; + this.requestController = null; + this._removeRegeneratingIndicator(pairId); + } + } + + /** + * Switch between message versions + * @private + * @param {string} pairId + * @param {number} delta - 1 for next, -1 for prev + */ + _switchVersion(pairId, delta) { + const conversation = this.conversations.find((c) => c.id === this.currentConversationId); + if (!conversation) return; + + const userMsg = conversation.messages.find((m) => m.pairId === pairId && m.role === 'user'); + const assistantMsg = conversation.messages.find((m) => m.pairId === pairId && m.role === 'assistant'); + + if (!userMsg || !assistantMsg || !userMsg.versions) return; + + const currentIdx = userMsg.versionIndex || 0; + const newIdx = currentIdx + delta; + + if (newIdx < 0 || newIdx >= userMsg.versions.length) return; + + const version = userMsg.versions[newIdx]; + + userMsg.versionIndex = newIdx; + userMsg.content = version.userContent; + + assistantMsg.versionIndex = newIdx; + assistantMsg.content = version.assistantContent || ''; + // Update model label to show which LLM generated this version + if (version.model) { + assistantMsg.model = version.model; + } + + this._saveConversations(); + this._reRenderConversation(pairId); + } + + /** + * Re-render current conversation + * @private + * @param {string} [scrollToPairId] - Optional pairId to scroll to instead of bottom + */ + _reRenderConversation(scrollToPairId) { + if (!this.messages) return; + + const conversation = this.conversations.find((c) => c.id === this.currentConversationId); + if (!conversation) return; + + this.messages.innerHTML = ''; + conversation.messages.forEach((msg, idx) => { + this._renderMessageInstant(msg, idx); + }); + + // If scrollToPairId is provided, scroll to that message pair instead of bottom + if (scrollToPairId) { + // First try to find the user message (start of the pair) + let targetMessage = this.messages.querySelector(`[data-pair-id="${scrollToPairId}"].user`); + // Fall back to assistant message if user message not found + if (!targetMessage) { + targetMessage = this.messages.querySelector(`[data-pair-id="${scrollToPairId}"].assistant`); + } + if (targetMessage) { + const chatContainer = document.getElementById('chatContainer'); + if (chatContainer) { + // Use setTimeout to ensure DOM is fully rendered + setTimeout(() => { + const messageTop = /** @type {HTMLElement} */ (targetMessage).offsetTop; + // Scroll to the start of the message pair with some padding + chatContainer.scrollTo({ + top: Math.max(0, messageTop - 20), + behavior: 'smooth' + }); + }, 50); + return; + } + } + } + + this._scrollToBottom(); + } + + /** + * Auto-format math expressions that aren't wrapped in $ delimiters + * Converts patterns like x^6, x_n, 1e6 to proper Unicode and styles math symbols + * @private + * @param {string} content + * @returns {string} + */ + _autoFormatMathExpressions(content) { + if (!content || typeof content !== 'string') return content; + + let formatted = content; + + // Superscript map for common characters + const superscripts = { + '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', + '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', + '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾', + 'n': 'ⁿ', 'i': 'ⁱ', 'x': 'ˣ', 'y': 'ʸ', 'a': 'ᵃ', + 'b': 'ᵇ', 'c': 'ᶜ', 'd': 'ᵈ', 'e': 'ᵉ', 'f': 'ᶠ', + 'g': 'ᵍ', 'h': 'ʰ', 'j': 'ʲ', 'k': 'ᵏ', 'l': 'ˡ', + 'm': 'ᵐ', 'o': 'ᵒ', 'p': 'ᵖ', 'r': 'ʳ', 's': 'ˢ', + 't': 'ᵗ', 'u': 'ᵘ', 'v': 'ᵛ', 'w': 'ʷ', 'z': 'ᶻ' + }; + + // Subscript map for common characters + const subscripts = { + '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', + '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉', + '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎', + 'a': 'ₐ', 'e': 'ₑ', 'h': 'ₕ', 'i': 'ᵢ', 'j': 'ⱼ', + 'k': 'ₖ', 'l': 'ₗ', 'm': 'ₘ', 'n': 'ₙ', 'o': 'ₒ', + 'p': 'ₚ', 'r': 'ᵣ', 's': 'ₛ', 't': 'ₜ', 'u': 'ᵤ', + 'v': 'ᵥ', 'x': 'ₓ' + }; + + // Convert superscript patterns: x^2, r^6, n^{10}, etc. + // Only convert in math-like contexts (single letter variable followed by ^) + // Skip patterns that look like file paths, URLs, or code (e.g., file^backup, 2^32) + formatted = formatted.replace(/\b([a-zA-Z])\^(\{([^}]+)\}|([0-9]+))\b/g, (match, base, _, bracedExp, plainExp) => { + const exp = bracedExp || plainExp; + const converted = exp.split('').map(c => superscripts[c] || superscripts[c.toLowerCase()] || c).join(''); + // Check if all characters were converted + const allConverted = exp.split('').every(c => superscripts[c] || superscripts[c.toLowerCase()]); + if (allConverted) { + return base + converted; + } + return match; // Keep original if not all convertible + }); + + // Convert subscript patterns: x_1, a_n, x_{10}, etc. + // Only for single letter variables followed by subscript (math context) + // Skip placeholders like __CODE_BLOCK__ and snake_case identifiers + formatted = formatted.replace(/\b([a-zA-Z])_(\{([^}]+)\}|([0-9]))\b/g, (match, base, _, bracedSub, plainSub) => { + // Skip if this looks like a placeholder or snake_case + if (match.includes('__')) return match; + const sub = bracedSub || plainSub; + const converted = sub.split('').map(c => subscripts[c] || subscripts[c.toLowerCase()] || c).join(''); + // Check if all characters were converted + const allConverted = sub.split('').every(c => subscripts[c] || subscripts[c.toLowerCase()]); + if (allConverted) { + return base + converted; + } + return match; + }); + + // Convert scientific notation: 1e6 → 1×10⁶, 2.5e-3 → 2.5×10⁻³ + // Only standalone numbers, not in identifiers like "file1e6name" + formatted = formatted.replace(/\b(\d+\.?\d*)[eE]([+-]?\d+)\b/g, (_, num, exp) => { + const expConverted = exp.split('').map(c => superscripts[c] || c).join(''); + return `${num}×10${expConverted}`; + }); + + // Style common math symbols that AI outputs directly + // These are already Unicode but we can style them for consistency + const mathSymbols = [ + '≈', '≠', '≤', '≥', '±', '∓', '×', '÷', '·', + '→', '←', '↔', '⇒', '⇐', '⇔', '↑', '↓', + '∞', '∝', '∂', '∇', '∫', '∑', '∏', + '∈', '∉', '⊂', '⊃', '⊆', '⊇', '∪', '∩', '∅', + '∀', '∃', '∧', '∨', '¬', + 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', + 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ρ', + 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', + 'Γ', 'Δ', 'Θ', 'Λ', 'Ξ', 'Π', 'Σ', 'Φ', 'Ψ', 'Ω', + '√', '∛', '∜' + ]; + + // Create a regex pattern for all math symbols + const symbolPattern = new RegExp(`([${mathSymbols.join('')}])`, 'g'); + + // Only wrap standalone symbols (not already in HTML tags) + formatted = formatted.replace(symbolPattern, '$1'); + + // Clean up any double-wrapped symbols + formatted = formatted.replace(/([^<]+)<\/span><\/span>/g, '$1'); + + return formatted; + } + + /** + * Format message content with markdown-like syntax + * @private + * @param {string} content + * @returns {string} + */ + _formatContent(content) { + if (!content || typeof content !== 'string') return ''; + + let formatted = content; + + // Remove internet search indicator lines (they look ugly in the response) + formatted = formatted.replace(/^🌐\s*Searching for.*?\.\.\.?\s*$/gm, ''); + formatted = formatted.replace(/^🌐\s*LIVE INTERNET SEARCH RESULTS:?\s*$/gm, ''); + // Also remove any leading/trailing whitespace and extra blank lines created by removal + formatted = formatted.replace(/^\s*\n+/g, '').replace(/\n{3,}/g, '\n\n'); + + // Fix malformed numbered lists where number is directly attached to text (e.g., "1**Bold**" -> "1. **Bold**") + formatted = formatted.replace(/^(\d+)(\*\*)/gm, '$1. $2'); + formatted = formatted.replace(/^(\d+)([A-Za-z])/gm, '$1. $2'); + // Also handle "1-text" and "1)text" patterns + formatted = formatted.replace(/^(\d+)-([A-Za-z])/gm, '$1. $2'); + formatted = formatted.replace(/^(\d+)\)([A-Za-z])/gm, '$1. $2'); + + // Store math blocks temporarily to protect them from other processing + const mathBlocks = []; + const inlineMath = []; + + // Protect display math blocks: $$ ... $$ or \[ ... \] + formatted = formatted.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math) => { + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: math.trim(), display: true }); + return placeholder; + }); + formatted = formatted.replace(/\\\[([\s\S]*?)\\\]/g, (_match, math) => { + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: math.trim(), display: true }); + return placeholder; + }); + + // Protect inline math: $ ... $ or \( ... \) + formatted = formatted.replace(/\$([^\$\n]+?)\$/g, (_match, math) => { + const placeholder = `__INLINE_MATH_${inlineMath.length}__`; + inlineMath.push({ math: math.trim(), display: false }); + return placeholder; + }); + // Handle \( ... \) - use a more permissive pattern that handles nested parens + formatted = formatted.replace(/\\\((.+?)\\\)/gs, (_match, math) => { + const placeholder = `__INLINE_MATH_${inlineMath.length}__`; + inlineMath.push({ math: math.trim(), display: false }); + return placeholder; + }); + + // Store code blocks temporarily - detect LaTeX inside code blocks and render as math + // IMPORTANT: Extract code blocks BEFORE auto-formatting to prevent x^2 conversion in code + const codeBlocks = []; + formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => { + const trimmedCode = code.trim(); + const language = lang ? lang.toLowerCase() : ''; + + // Check if this is actually LaTeX/math content inside a code block + // Only treat as LaTeX if EXPLICITLY marked as latex/tex/math language + // OR if no language specified AND has clear LaTeX commands (not just patterns that could be code) + const hasLaTeXCommands = /\\(?:frac|sqrt|sum|int|lim|prod|infty|alpha|beta|gamma|delta|epsilon|theta|pi|sigma|omega|phi|psi|lambda|mu|nu|rho|tau|eta|zeta|xi|kappa|chi|pm|mp|times|div|cdot|leq|geq|neq|le|ge|ne|approx|equiv|sim|propto|subset|supset|subseteq|supseteq|cup|cap|in|notin|forall|exists|partial|nabla|vec|hat|bar|dot|ddot|text|mathrm|mathbf|mathit|mathbb|mathcal|left|right|big|Big|bigg|Bigg|begin|end|quad|qquad|rightarrow|leftarrow|Rightarrow|Leftarrow|to|gets)\b/.test(trimmedCode); + + // Check for code-like patterns that should NOT be treated as LaTeX + const hasCodePatterns = /(?:function|const|let|var|return|import|export|class|def|if|else|for|while|switch|case|try|catch|=>|===|!==|\|\||&&|console\.|print\(|System\.|public\s|private\s|void\s)/.test(trimmedCode); + + // Only treat as LaTeX if: + // 1. Explicitly marked as latex/tex/math, OR + // 2. No language AND has LaTeX commands AND no code patterns + const isLaTeX = (language === 'latex' || language === 'tex' || language === 'math') || + ((language === '' || language === 'plaintext') && hasLaTeXCommands && !hasCodePatterns); + + if (isLaTeX) { + // Render as math block instead of code block + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: trimmedCode, display: true }); + return placeholder; + } + + const displayLang = lang ? (LANGUAGE_NAMES[language] || language) : 'plaintext'; + const escapedCode = this.escapeHtml(trimmedCode); + const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; + codeBlocks.push(`
${displayLang}
${escapedCode}
`); + return placeholder; + }); + + // Inline code - extract BEFORE auto-formatting + const inlineCodes = []; + formatted = formatted.replace(/`([^`]+)`/g, (_match, code) => { + const placeholder = `__INLINE_CODE_${inlineCodes.length}__`; + inlineCodes.push(`${this.escapeHtml(code)}`); + return placeholder; + }); + + // NOW apply auto-format math expressions AFTER code blocks are protected + formatted = this._autoFormatMathExpressions(formatted); + + // IMPORTANT: Parse tables FIRST and store as placeholders to protect from further processing + const tablePlaceholders = []; + formatted = this._parseMarkdownTablesWithPlaceholders(formatted, tablePlaceholders); + + // Parse charts and store as placeholders + const chartPlaceholders = []; + formatted = this._parseChartBlocksWithPlaceholders(formatted, chartPlaceholders); + + // Convert HTML tags to markdown (handle both raw and escaped HTML) + // First handle escaped HTML entities with ANY attributes (most common from AI output) + formatted = formatted.replace(/<h([1-6])[^&]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => `${'#'.repeat(parseInt(level))} ${text.trim()}`); + formatted = formatted.replace(/<strong[^&]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + formatted = formatted.replace(/<b[^&]*>([\s\S]*?)<\/b>/gi, '**$1**'); + formatted = formatted.replace(/<em[^&]*>([\s\S]*?)<\/em>/gi, '*$1*'); + formatted = formatted.replace(/<i[^&]*>([\s\S]*?)<\/i>/gi, '*$1*'); + formatted = formatted.replace(/<u[^&]*>([\s\S]*?)<\/u>/gi, '_$1_'); + formatted = formatted.replace(/<br[^&]*\/?>/gi, '\n'); + formatted = formatted.replace(/<p[^&]*>([\s\S]*?)<\/p>/gi, '$1\n\n'); + formatted = formatted.replace(/<li[^&]*>([\s\S]*?)<\/li>/gi, '- $1'); + formatted = formatted.replace(/<(?:ul|ol)[^&]*>|<\/(?:ul|ol)>/gi, ''); + formatted = formatted.replace(/<hr[^&]*\/?>/gi, '\n---\n'); + formatted = formatted.replace(/<blockquote[^&]*>([\s\S]*?)<\/blockquote>/gi, '> $1'); + formatted = formatted.replace(/<span[^&]*>([\s\S]*?)<\/span>/gi, '$1'); + formatted = formatted.replace(/<div[^&]*>([\s\S]*?)<\/div>/gi, '$1\n'); + formatted = formatted.replace(/<a[^&]*>([\s\S]*?)<\/a>/gi, '$1'); + + // Then handle raw HTML tags (including those with style/class attributes) + let prev = ''; + let maxIter = 10; + while (prev !== formatted && maxIter-- > 0) { + prev = formatted; + // Handle tags with ANY attributes (style, class, etc.) + formatted = formatted.replace(/]*>([\s\S]*?)<\/h\1>/gi, (_, level, text) => `${'#'.repeat(parseInt(level))} ${text.trim()}`); + formatted = formatted.replace(/]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/b>/gi, '**$1**'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/em>/gi, '*$1*'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/i>/gi, '*$1*'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/u>/gi, '_$1_'); + formatted = formatted.replace(/]*\/?>/gi, '\n'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/p>/gi, '$1\n\n'); + // Handle span/div tags - just extract content + formatted = formatted.replace(/]*>([\s\S]*?)<\/span>/gi, '$1'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/div>/gi, '$1\n'); + // Handle anchor tags - extract text + formatted = formatted.replace(/]*>([\s\S]*?)<\/a>/gi, '$1'); + } + + // Headings + formatted = formatted.replace(/^###### (.+)$/gm, (_, text) => `
${this._formatInlineContent(text)}
`); + formatted = formatted.replace(/^##### (.+)$/gm, (_, text) => `
${this._formatInlineContent(text)}
`); + formatted = formatted.replace(/^#### (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + formatted = formatted.replace(/^### (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + formatted = formatted.replace(/^## (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + formatted = formatted.replace(/^# (.+)$/gm, (_, text) => `

${this._formatInlineContent(text)}

`); + + // Bold, Italic, and Underline (use display-safe escaping to preserve quotes) + formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, text) => `${this.escapeHtmlDisplay(text)}`); + // Italic - use negative lookbehind/lookahead to avoid matching inside bold markers + formatted = formatted.replace(/(? `${this.escapeHtmlDisplay(text)}`); + // Skip placeholders like __CODE_BLOCK_0__ when processing underlines + formatted = formatted.replace(/(? { + if (/__/.test(match)) return match; + return `${this.escapeHtmlDisplay(text)}`; + }); + + // Lists + formatted = formatted.replace(/^(\d+)\. (.+)$/gm, (_, num, text) => `
  • ${this._formatInlineContent(text)}
  • `); + formatted = formatted.replace(/((?:
  • [\s\S]*?<\/li>\n?)+)/g, '
      $1
    '); + formatted = formatted.replace(/<\/ol>\n
      /g, ''); + formatted = formatted.replace(/ data-num="\d+"/g, ''); + + formatted = formatted.replace(/^[-*] (.+)$/gm, (_, text) => `${this._formatInlineContent(text)}`); + formatted = formatted.replace(/((?:[\s\S]*?<\/uli>\n?)+)/g, '
        $1
      '); + formatted = formatted.replace(/<\/ul>\n
        /g, ''); + formatted = formatted.replace(//g, '
      • '); + formatted = formatted.replace(/<\/uli>/g, '
      • '); + + // Blockquotes + formatted = formatted.replace(/^> (.+)$/gm, (_, text) => `
        ${this._formatInlineContent(text)}
        `); + formatted = formatted.replace(/<\/blockquote>\n
        /g, '
        '); + + // Horizontal rule (multiple formats) + formatted = formatted.replace(/^---$/gm, '
        '); + formatted = formatted.replace(/^\*\*\*$/gm, '
        '); + formatted = formatted.replace(/^___$/gm, '
        '); + + // Links (keep escapeHtml for URLs for security) + formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { + const safeUrl = this.sanitizeUrl(url); + if (!safeUrl) return this.escapeHtmlDisplay(text); + return `${this.escapeHtmlDisplay(text)}`; + }); + + // Paragraphs + const lines = formatted.split('\n'); + let result = []; + let paragraphContent = []; + + for (const line of lines) { + const trimmed = line.trim(); + const isBlockElement = /^<(h[1-6]|ul|ol|li|blockquote|hr|div|pre|__CODE|__TABLE|__CHART|__MATH)/.test(trimmed) || + /<\/(h[1-6]|ul|ol|blockquote|div|pre)>$/.test(trimmed); + + if (isBlockElement || trimmed === '') { + if (paragraphContent.length > 0) { + result.push('

        ' + paragraphContent.join('
        ') + '

        '); + paragraphContent = []; + } + if (trimmed !== '') { + result.push(line); + } + } else { + paragraphContent.push(trimmed); + } + } + + if (paragraphContent.length > 0) { + result.push('

        ' + paragraphContent.join('
        ') + '

        '); + } + + formatted = result.join('\n'); + + // Restore codes using regex for reliable replacement (handles multiple occurrences) + inlineCodes.forEach((code, i) => { + formatted = formatted.replace(new RegExp(`__INLINE_CODE_${i}__`, 'g'), code); + }); + codeBlocks.forEach((block, i) => { + formatted = formatted.replace(new RegExp(`__CODE_BLOCK_${i}__`, 'g'), block); + }); + + // Restore tables + tablePlaceholders.forEach((table, i) => { + formatted = formatted.replace(new RegExp(`__TABLE_BLOCK_${i}__`, 'g'), table); + }); + + // Restore charts + chartPlaceholders.forEach((chart, i) => { + formatted = formatted.replace(new RegExp(`__CHART_BLOCK_${i}__`, 'g'), chart); + }); + + // Restore math blocks with KaTeX rendering + mathBlocks.forEach((item, i) => { + const rendered = this._renderMath(item.math, true); + formatted = formatted.replace(new RegExp(`__MATH_BLOCK_${i}__`, 'g'), rendered); + }); + + // Restore inline math with KaTeX rendering + inlineMath.forEach((item, i) => { + const rendered = this._renderMath(item.math, false); + formatted = formatted.replace(new RegExp(`__INLINE_MATH_${i}__`, 'g'), rendered); + }); + + // Clean up + formatted = formatted.replace(/


        <\/p>/g, ''); + formatted = formatted.replace(/

        \s*<\/p>/g, ''); + + // Final pass for any remaining escaped HTML entities (comprehensive) + formatted = formatted.replace(/<\s*h([1-6])\s*>([\s\S]*?)<\s*\/\s*h\1\s*>/gi, (_, level, text) => `${text.trim()}`); + formatted = formatted.replace(/<\s*strong\s*>([\s\S]*?)<\s*\/\s*strong\s*>/gi, '$1'); + formatted = formatted.replace(/<\s*b\s*>([\s\S]*?)<\s*\/\s*b\s*>/gi, '$1'); + formatted = formatted.replace(/<\s*em\s*>([\s\S]*?)<\s*\/\s*em\s*>/gi, '$1'); + formatted = formatted.replace(/<\s*i\s*>([\s\S]*?)<\s*\/\s*i\s*>/gi, '$1'); + formatted = formatted.replace(/<\s*u\s*>([\s\S]*?)<\s*\/\s*u\s*>/gi, '$1'); + formatted = formatted.replace(/<\s*br\s*\/?>/gi, '
        '); + formatted = formatted.replace(/<\s*hr\s*\/?>/gi, '


        '); + + return formatted; + } + + /** + * Render LaTeX math using KaTeX + * @private + * @param {string} math - LaTeX math string + * @param {boolean} displayMode - Whether to render in display mode (block) or inline + * @returns {string} Rendered HTML + */ + _renderMath(math, displayMode = false) { + if (!math) return ''; + + try { + // Check if KaTeX is available + if (typeof katex !== 'undefined') { + const html = katex.renderToString(math, { + displayMode: displayMode, + throwOnError: false, + errorColor: '#cc0000', + strict: false, + trust: true, + macros: { + "\\R": "\\mathbb{R}", + "\\N": "\\mathbb{N}", + "\\Z": "\\mathbb{Z}", + "\\Q": "\\mathbb{Q}", + "\\C": "\\mathbb{C}" + } + }); + + if (displayMode) { + return `
        ${html}
        `; + } + return `${html}`; + } + } catch (e) { + console.warn('KaTeX rendering failed:', e); + } + + // Fallback: convert common LaTeX to Unicode + let fallback = math + // Handle \text{} - extract text content + .replace(/\\text\{([^}]+)\}/g, (_, text) => text.replace(/_/g, ' ')) + .replace(/\\textit\{([^}]+)\}/g, '$1') + .replace(/\\textbf\{([^}]+)\}/g, '$1') + .replace(/\\mathrm\{([^}]+)\}/g, '$1') + .replace(/\\mathit\{([^}]+)\}/g, '$1') + .replace(/\\mathbf\{([^}]+)\}/g, '$1') + // Remove \left and \right (sizing hints) + .replace(/\\left\s*/g, '') + .replace(/\\right\s*/g, '') + .replace(/\\big\s*/g, '') + .replace(/\\Big\s*/g, '') + .replace(/\\bigg\s*/g, '') + .replace(/\\Bigg\s*/g, '') + // Fractions + .replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') + .replace(/\\dfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)') + .replace(/\\sqrt\{([^}]+)\}/g, '√($1)') + .replace(/\\sqrt(\d+)/g, '√$1') + .replace(/\\int_\{?([^}\s]+)\}?\^\{?([^}\s]+)\}?/g, '∫[$1→$2]') + .replace(/\\int/g, '∫') + .replace(/\\sum_\{?([^}\s]+)\}?\^\{?([^}\s]+)\}?/g, 'Σ[$1→$2]') + .replace(/\\sum/g, 'Σ') + .replace(/\\prod/g, '∏') + .replace(/\\partial/g, '∂') + .replace(/\\nabla/g, '∇') + .replace(/\\infty/g, '∞') + .replace(/\\alpha/g, 'α').replace(/\\beta/g, 'β').replace(/\\gamma/g, 'γ') + .replace(/\\delta/g, 'δ').replace(/\\Delta/g, 'Δ') + .replace(/\\epsilon/g, 'ε').replace(/\\varepsilon/g, 'ε') + .replace(/\\theta/g, 'θ').replace(/\\Theta/g, 'Θ') + .replace(/\\lambda/g, 'λ').replace(/\\Lambda/g, 'Λ') + .replace(/\\mu/g, 'μ').replace(/\\nu/g, 'ν') + .replace(/\\pi/g, 'π').replace(/\\Pi/g, 'Π') + .replace(/\\sigma/g, 'σ').replace(/\\Sigma/g, 'Σ') + .replace(/\\omega/g, 'ω').replace(/\\Omega/g, 'Ω') + .replace(/\\phi/g, 'φ').replace(/\\Phi/g, 'Φ') + .replace(/\\psi/g, 'ψ').replace(/\\Psi/g, 'Ψ') + .replace(/\\rho/g, 'ρ').replace(/\\tau/g, 'τ') + .replace(/\\eta/g, 'η').replace(/\\zeta/g, 'ζ') + .replace(/\\xi/g, 'ξ').replace(/\\Xi/g, 'Ξ') + .replace(/\\kappa/g, 'κ').replace(/\\iota/g, 'ι') + .replace(/\\chi/g, 'χ').replace(/\\upsilon/g, 'υ') + .replace(/\\times/g, '×').replace(/\\div/g, '÷').replace(/\\pm/g, '±') + .replace(/\\mp/g, '∓').replace(/\\cdot/g, '·') + .replace(/\\leq/g, '≤').replace(/\\geq/g, '≥').replace(/\\neq/g, '≠') + .replace(/\\le/g, '≤').replace(/\\ge/g, '≥').replace(/\\ne/g, '≠') + .replace(/\\approx/g, '≈').replace(/\\equiv/g, '≡') + .replace(/\\propto/g, '∝').replace(/\\sim/g, '∼') + .replace(/\\rightarrow/g, '→').replace(/\\leftarrow/g, '←') + .replace(/\\Rightarrow/g, '⇒').replace(/\\Leftarrow/g, '⇐') + .replace(/\\leftrightarrow/g, '↔').replace(/\\Leftrightarrow/g, '⇔') + .replace(/\\to/g, '→').replace(/\\gets/g, '←') + .replace(/\\forall/g, '∀').replace(/\\exists/g, '∃') + .replace(/\\in/g, '∈').replace(/\\notin/g, '∉') + .replace(/\\subset/g, '⊂').replace(/\\supset/g, '⊃') + .replace(/\\subseteq/g, '⊆').replace(/\\supseteq/g, '⊇') + .replace(/\\cup/g, '∪').replace(/\\cap/g, '∩') + .replace(/\\emptyset/g, '∅').replace(/\\varnothing/g, '∅') + .replace(/\\land/g, '∧').replace(/\\lor/g, '∨').replace(/\\neg/g, '¬') + .replace(/\\angle/g, '∠').replace(/\\perp/g, '⊥').replace(/\\parallel/g, '∥') + .replace(/\\ldots/g, '…').replace(/\\cdots/g, '⋯') + // Spacing + .replace(/\\,/g, ' ').replace(/\\;/g, ' ').replace(/\\:/g, ' ') + .replace(/\\!/g, '').replace(/\\quad/g, ' ').replace(/\\qquad/g, ' ') + .replace(/\\ /g, ' ') + // Remove remaining LaTeX commands but keep content in braces + .replace(/\\[a-zA-Z]+\{([^}]*)\}/g, '$1') + .replace(/\\[a-zA-Z]+/g, '') + .replace(/\{([^{}]*)\}/g, '$1'); + + // Handle superscripts and subscripts with iterative processing + const superscriptMap = { + '0':'⁰','1':'¹','2':'²','3':'³','4':'⁴','5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹', + '+':'⁺','-':'⁻','=':'⁼','(':'⁽',')':'⁾', + 'n':'ⁿ','i':'ⁱ','x':'ˣ','y':'ʸ','a':'ᵃ','b':'ᵇ','c':'ᶜ','d':'ᵈ','e':'ᵉ', + 'f':'ᶠ','g':'ᵍ','h':'ʰ','j':'ʲ','k':'ᵏ','l':'ˡ','m':'ᵐ','o':'ᵒ','p':'ᵖ', + 'r':'ʳ','s':'ˢ','t':'ᵗ','u':'ᵘ','v':'ᵛ','w':'ʷ','z':'ᶻ' + }; + const subscriptMap = { + '0':'₀','1':'₁','2':'₂','3':'₃','4':'₄','5':'₅','6':'₆','7':'₇','8':'₈','9':'₉', + '+':'₊','-':'₋','=':'₌','(':'₍',')':'₎', + 'a':'ₐ','e':'ₑ','h':'ₕ','i':'ᵢ','j':'ⱼ','k':'ₖ','l':'ₗ','m':'ₘ','n':'ₙ', + 'o':'ₒ','p':'ₚ','r':'ᵣ','s':'ₛ','t':'ₜ','u':'ᵤ','v':'ᵥ','x':'ₓ' + }; + + // Process superscripts and subscripts iteratively + for (let i = 0; i < 5; i++) { + fallback = fallback.replace(/\^(\{[^{}]+\})/g, (_, exp) => { + const content = exp.slice(1, -1); + return content.split('').map(c => superscriptMap[c] || superscriptMap[c.toLowerCase()] || c).join(''); + }); + fallback = fallback.replace(/\^([0-9a-zA-Z+\-])/g, (_, c) => { + return superscriptMap[c] || superscriptMap[c.toLowerCase()] || `^${c}`; + }); + fallback = fallback.replace(/_(\{[^{}]+\})/g, (_, exp) => { + const content = exp.slice(1, -1); + return content.split('').map(c => subscriptMap[c] || subscriptMap[c.toLowerCase()] || c).join(''); + }); + fallback = fallback.replace(/_([0-9a-zA-Z])/g, (_, c) => { + return subscriptMap[c] || subscriptMap[c.toLowerCase()] || `_${c}`; + }); + } + + // Final cleanup + fallback = fallback.replace(/[{}]/g, ''); + + if (displayMode) { + return `
        ${this.escapeHtmlDisplay(fallback)}
        `; + } + return `${this.escapeHtmlDisplay(fallback)}`; + } + + /** + * Format inline content (bold, italic) while escaping other HTML + * @private + * @param {string} text - Text to format + * @returns {string} Formatted HTML + */ + _formatInlineContent(text) { + if (!text) return ''; + let result = text; + + // Convert HTML tags to markdown (handle tags with ANY attributes including style) + let prev = ''; + let maxIter = 10; + + // First handle escaped HTML entities with attributes + result = result.replace(/<strong[^&]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + result = result.replace(/<b[^&]*>([\s\S]*?)<\/b>/gi, '**$1**'); + result = result.replace(/<em[^&]*>([\s\S]*?)<\/em>/gi, '*$1*'); + result = result.replace(/<i[^&]*>([\s\S]*?)<\/i>/gi, '*$1*'); + result = result.replace(/<u[^&]*>([\s\S]*?)<\/u>/gi, '_$1_'); + result = result.replace(/<span[^&]*>([\s\S]*?)<\/span>/gi, '$1'); + + // Then handle raw HTML tags (including those with style/class attributes) + while (prev !== result && maxIter-- > 0) { + prev = result; + // Match tags with ANY attributes (style, class, etc.) + result = result.replace(/]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + result = result.replace(/]*>([\s\S]*?)<\/b>/gi, '**$1**'); + result = result.replace(/]*>([\s\S]*?)<\/em>/gi, '*$1*'); + result = result.replace(/]*>([\s\S]*?)<\/i>/gi, '*$1*'); + result = result.replace(/]*>([\s\S]*?)<\/u>/gi, '_$1_'); + result = result.replace(/]*>([\s\S]*?)<\/span>/gi, '$1'); + } + + // Store bold/italic with unique markers + const bolds = []; + const italics = []; + const boldMarker = '\u0000BOLD\u0000'; + const italicMarker = '\u0000ITALIC\u0000'; + const underlineMarker = '\u0000UNDERLINE\u0000'; + + result = result.replace(/\*\*([^*]+)\*\*/g, (_, content) => { + bolds.push(content); + return `${boldMarker}${bolds.length - 1}${boldMarker}`; + }); + + result = result.replace(/(? { + italics.push(content); + return `${italicMarker}${italics.length - 1}${italicMarker}`; + }); + + // Handle underline with _text_ (but not placeholders like __CODE_BLOCK_0__) + const underlines = []; + result = result.replace(/(? { + // Skip if this looks like a placeholder + if (/__/.test(match)) return match; + underlines.push(content); + return `${underlineMarker}${underlines.length - 1}${underlineMarker}`; + }); + + // Escape only < and > for XSS protection, preserve & and quotes for readability + result = this.escapeHtmlDisplay(result); + + // Restore bold with proper HTML (use regex for reliable replacement) + bolds.forEach((content, i) => { + result = result.replace(new RegExp(`${boldMarker}${i}${boldMarker}`, 'g'), `${this.escapeHtmlDisplay(content)}`); + }); + + // Restore italic with proper HTML + italics.forEach((content, i) => { + result = result.replace(new RegExp(`${italicMarker}${i}${italicMarker}`, 'g'), `${this.escapeHtmlDisplay(content)}`); + }); + + // Restore underline with proper HTML + underlines.forEach((content, i) => { + result = result.replace(new RegExp(`${underlineMarker}${i}${underlineMarker}`, 'g'), `${this.escapeHtmlDisplay(content)}`); + }); + + return result; + } + + /** + * Parse markdown tables into HTML + * @private + * @param {string} content + * @returns {string} + */ + _parseMarkdownTablesWithPlaceholders(content, placeholders) { + if (!content) return content; + + // Split content into lines for processing + const lines = content.split('\n'); + const result = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Check if this line looks like a table header (starts with |) + if (line.trim().startsWith('|') && line.trim().endsWith('|')) { + // Check if next line is a separator (|---|---|) + const nextLine = lines[i + 1]; + if (nextLine && /^\|[\s:|-]+\|$/.test(nextLine.trim())) { + // This is a table! Parse it + const tableLines = [line]; + let j = i + 1; + + // Collect all table lines (separator + data rows) + while (j < lines.length && lines[j].trim().startsWith('|')) { + tableLines.push(lines[j]); + j++; + } + + // Convert table to HTML + const tableHtml = this._convertTableToHtml(tableLines); + if (tableHtml) { + const placeholder = `__TABLE_BLOCK_${placeholders.length}__`; + placeholders.push(tableHtml); + result.push(placeholder); + i = j; + continue; + } + } + } + + result.push(line); + i++; + } + + return result.join('\n'); + } + + /** + * Convert table lines to HTML + * @private + * @param {string[]} lines - Table lines including header, separator, and data rows + * @returns {string|null} HTML table or null if invalid + */ + _convertTableToHtml(lines) { + if (lines.length < 2) return null; + + // Parse header + const headerLine = lines[0].trim(); + const headerCells = headerLine.split('|').filter((c, i, arr) => i > 0 && i < arr.length - 1); + if (headerCells.length === 0) return null; + + // Parse separator for alignments + const sepLine = lines[1].trim(); + const sepCells = sepLine.split('|').filter((c, i, arr) => i > 0 && i < arr.length - 1); + const aligns = sepCells.map(c => { + const t = c.trim(); + if (t.startsWith(':') && t.endsWith(':')) return 'center'; + if (t.endsWith(':')) return 'right'; + return 'left'; + }); + + // Build HTML + let html = '
        '; + headerCells.forEach((h, i) => { + const align = aligns[i] || 'left'; + html += ``; + }); + html += ''; + + // Parse data rows (skip header and separator) + for (let i = 2; i < lines.length; i++) { + const rowLine = lines[i].trim(); + if (!rowLine.startsWith('|')) continue; + + let cells = rowLine.split('|').filter((c, idx, arr) => idx > 0 && idx < arr.length - 1); + if (cells.length === 0) continue; + + // Pad cells to match header count to prevent misalignment + while (cells.length < headerCells.length) { + cells.push(''); + } + + html += ''; + // Only render cells up to header count to prevent extra columns + for (let j = 0; j < headerCells.length; j++) { + const align = aligns[j] || 'left'; + const cellContent = cells[j] !== undefined ? cells[j].trim() : ''; + html += ``; + } + html += ''; + } + + html += '
        ${this._formatInlineContent(h.trim())}
        ${this._formatInlineContent(cellContent)}
        '; + return html; + } + + /** + * Parse chart blocks and store in placeholders array + * @private + * @param {string} content + * @param {string[]} placeholders - Array to store chart HTML + * @returns {string} + */ + _parseChartBlocksWithPlaceholders(content, placeholders) { + if (!content) return content; + let idx = 0; + return content.replace(/```chart\n([\s\S]*?)```/g, (match, data) => { + try { + const cfg = this._parseChartConfig(data.trim()); + if (!cfg) return match; + const id = `chart-${Date.now()}-${idx++}`; + setTimeout(() => this._renderChart(id, cfg), 0); + const title = cfg.title ? `
        ${this.escapeHtml(cfg.title)}
        ` : ''; + const chartHtml = `
        ${title}
        `; + const placeholder = `__CHART_BLOCK_${placeholders.length}__`; + placeholders.push(chartHtml); + return placeholder; + } catch (e) { return match; } + }); + } + + /** + * Parse chart config from text + * @private + * @param {string} data + * @returns {Object|null} + */ + _parseChartConfig(data) { + const lines = data.split('\n').filter(l => l.trim()); + if (!lines.length) return null; + const cfg = { type: 'bar', title: '', labels: [], datasets: [] }; + const colors = ['#3eb489','#d97bb3','#667eea','#f59e0b','#ef4444','#a855f7','#14b8a6','#fbbf24']; + for (const line of lines) { + const t = line.trim(); + if (t.toLowerCase().startsWith('type:')) { cfg.type = t.slice(5).trim().toLowerCase(); continue; } + if (t.toLowerCase().startsWith('title:')) { cfg.title = t.slice(6).trim(); continue; } + if (t.toLowerCase().startsWith('labels:')) { cfg.labels = t.slice(7).trim().split(',').map(l => l.trim()); continue; } + if (t.includes(':')) { + const [name, vals] = t.split(':').map(s => s.trim()); + const values = vals.split(',').map(v => parseFloat(v.trim())).filter(v => !isNaN(v)); + if (values.length) { + const ci = cfg.datasets.length; + cfg.datasets.push({ label: name, data: values, bg: colors[ci % colors.length] + '99', border: colors[ci % colors.length] }); + } + } + } + if (!cfg.labels.length && cfg.datasets.length) { + const max = Math.max(...cfg.datasets.map(d => d.data.length)); + cfg.labels = Array.from({ length: max }, (_, i) => `${i + 1}`); + } + return cfg.datasets.length ? cfg : null; + } + + /** + * Render chart on canvas + * @private + * @param {string} id + * @param {Object} cfg + */ + _renderChart(id, cfg) { + const canvas = document.getElementById(id); + if (!canvas || !(canvas instanceof HTMLCanvasElement)) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const container = canvas.parentElement; + const w = container?.clientWidth || 400, h = 250; + const dpr = window.devicePixelRatio || 1; + canvas.width = w * dpr; canvas.height = h * dpr; + canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; + ctx.scale(dpr, dpr); + const pad = { t: 20, r: 20, b: 40, l: 50 }; + const cw = w - pad.l - pad.r, ch = h - pad.t - pad.b; + const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + const txtCol = isDark ? '#a0b4c4' : '#475569'; + const gridCol = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; + const allVals = cfg.datasets.flatMap(d => d.data); + const maxV = Math.max(...allVals) * 1.1, minV = Math.min(0, Math.min(...allVals)); + ctx.clearRect(0, 0, w, h); + if (cfg.type === 'pie' || cfg.type === 'doughnut') { + this._drawPieChart(ctx, cfg, w, h, isDark); + } else if (cfg.type === 'line' || cfg.type === 'area') { + this._drawLineChart(ctx, cfg, pad, cw, ch, maxV, minV, txtCol, gridCol); + } else { + this._drawBarChart(ctx, cfg, pad, cw, ch, maxV, minV, txtCol, gridCol); + } + const legend = document.getElementById(`${id}-legend`); + if (legend) this._renderChartLegend(legend, cfg); + } + + /** @private */ + _drawBarChart(ctx, cfg, pad, cw, ch, maxV, minV, txtCol, gridCol) { + ctx.strokeStyle = gridCol; ctx.lineWidth = 1; + for (let i = 0; i <= 5; i++) { + const y = pad.t + (ch / 5) * i; + ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + cw, y); ctx.stroke(); + ctx.fillStyle = txtCol; ctx.font = '11px system-ui'; ctx.textAlign = 'right'; + ctx.fillText((maxV - ((maxV - minV) / 5) * i).toFixed(0), pad.l - 8, y + 4); + } + const gw = cw / cfg.labels.length, bw = (gw * 0.8) / cfg.datasets.length, bg = gw * 0.1; + cfg.datasets.forEach((ds, di) => { + ds.data.forEach((v, i) => { + const x = pad.l + bg + i * gw + di * bw; + const bh = ((v - minV) / (maxV - minV)) * ch; + ctx.fillStyle = ds.bg; + ctx.beginPath(); ctx.roundRect(x, pad.t + ch - bh, bw - 2, bh, [3, 3, 0, 0]); ctx.fill(); + }); + }); + ctx.fillStyle = txtCol; ctx.font = '11px system-ui'; ctx.textAlign = 'center'; + cfg.labels.forEach((l, i) => ctx.fillText(l, pad.l + gw / 2 + i * gw, pad.t + ch + 20)); + } + + /** @private */ + _drawLineChart(ctx, cfg, pad, cw, ch, maxV, minV, txtCol, gridCol) { + ctx.strokeStyle = gridCol; ctx.lineWidth = 1; + for (let i = 0; i <= 5; i++) { + const y = pad.t + (ch / 5) * i; + ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + cw, y); ctx.stroke(); + ctx.fillStyle = txtCol; ctx.font = '11px system-ui'; ctx.textAlign = 'right'; + ctx.fillText((maxV - ((maxV - minV) / 5) * i).toFixed(0), pad.l - 8, y + 4); + } + const gap = cw / (cfg.labels.length - 1 || 1); + cfg.datasets.forEach(ds => { + const pts = ds.data.map((v, i) => ({ x: pad.l + i * gap, y: pad.t + ch - ((v - minV) / (maxV - minV)) * ch })); + if (cfg.type === 'area') { + ctx.fillStyle = ds.bg; ctx.beginPath(); ctx.moveTo(pts[0].x, pad.t + ch); + pts.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(pts[pts.length - 1].x, pad.t + ch); ctx.closePath(); ctx.fill(); + } + ctx.strokeStyle = ds.border; ctx.lineWidth = 2; ctx.beginPath(); + pts.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)); ctx.stroke(); + pts.forEach(p => { ctx.fillStyle = ds.border; ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.fill(); }); + }); + ctx.fillStyle = txtCol; ctx.font = '11px system-ui'; ctx.textAlign = 'center'; + cfg.labels.forEach((l, i) => ctx.fillText(l, pad.l + i * gap, pad.t + ch + 20)); + } + + /** @private */ + _drawPieChart(ctx, cfg, w, h, isDark) { + const cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 - 30; + const ds = cfg.datasets[0]; if (!ds) return; + const total = ds.data.reduce((a, b) => a + b, 0); + let angle = -Math.PI / 2; + const colors = ['#3eb489','#d97bb3','#667eea','#f59e0b','#ef4444','#a855f7','#14b8a6','#fbbf24']; + ds.data.forEach((v, i) => { + const slice = (v / total) * Math.PI * 2; + ctx.fillStyle = colors[i % colors.length]; ctx.beginPath(); ctx.moveTo(cx, cy); + ctx.arc(cx, cy, r, angle, angle + slice); ctx.closePath(); ctx.fill(); + if (cfg.type === 'doughnut') { + ctx.fillStyle = isDark ? '#152232' : '#ffffff'; + ctx.beginPath(); ctx.arc(cx, cy, r * 0.6, 0, Math.PI * 2); ctx.fill(); + } + const pct = ((v / total) * 100).toFixed(1); + if (parseFloat(pct) > 5) { + const mid = angle + slice / 2, lx = cx + Math.cos(mid) * r * 0.7, ly = cy + Math.sin(mid) * r * 0.7; + ctx.fillStyle = '#fff'; ctx.font = 'bold 12px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(`${pct}%`, lx, ly); + } + angle += slice; + }); + } + + /** @private */ + _renderChartLegend(el, cfg) { + const colors = ['#3eb489','#d97bb3','#667eea','#f59e0b','#ef4444','#a855f7','#14b8a6','#fbbf24']; + const isPie = cfg.type === 'pie' || cfg.type === 'doughnut'; + if (isPie) { + el.innerHTML = cfg.labels.map((l, i) => `
        ${this.escapeHtml(l)}
        `).join(''); + } else { + el.innerHTML = cfg.datasets.map(ds => `
        ${this.escapeHtml(ds.label)}
        `).join(''); + } + } + + /** + * Show typing indicator with status updates + * @private + */ + _showTypingIndicator() { + if (!this.messages) return; + + // Don't start timer yet - wait for server confirmation + this._typingStartTime = null; + this._llmConfirmed = false; + + const indicator = document.createElement('div'); + indicator.className = 'message assistant'; + indicator.id = 'typing-indicator'; + indicator.style.opacity = '0'; + indicator.style.transform = 'translateX(-30px) scale(0.9)'; + indicator.innerHTML = ` + +
        +
        +
        +
        +
        +
        +
        +
        Connecting...
        +
        +
        + `; + this.messages.appendChild(indicator); + + // Simple fade-in + requestAnimationFrame(() => { + indicator.style.transition = 'opacity 0.15s ease-out'; + indicator.style.opacity = '1'; + indicator.style.transform = 'translateX(0)'; + }); + + this._scrollToBottom(); + } + + /** + * Start the typing status timer - called when server confirms LLM is processing + * @private + */ + _startTypingStatusTimer() { + if (this._llmConfirmed) return; // Already started + + this._llmConfirmed = true; + this._typingStartTime = Date.now(); + + // Update status immediately + const statusEl = document.getElementById('typingStatus'); + if (statusEl) { + statusEl.textContent = 'AI is thinking...'; + } + + // Start the timer for elapsed updates + this._typingStatusInterval = window.setInterval(() => { + this._updateTypingStatus(); + }, 1000); + } + + /** + * Called when heartbeat is received - confirms connection is alive + * @private + */ + _onHeartbeatReceived() { + // If we haven't started the timer yet, start it now + // (heartbeat means server is processing) + if (!this._llmConfirmed) { + this._startTypingStatusTimer(); + } + } + + /** + * Show truncation notice when content was too large and was trimmed + * @param {string} info - Truncation info message + * @private + */ + _showTruncationNotice(info) { + // Update typing status to show truncation info briefly + const statusEl = document.getElementById('typingStatus'); + if (statusEl) { + statusEl.textContent = info; + // Reset to normal status after 3 seconds + setTimeout(() => { + if (this._llmConfirmed && statusEl) { + this._updateTypingStatus(); + } + }, 3000); + } + } + + /** + * Update typing status with elapsed time and status messages + * @private + */ + _updateTypingStatus() { + const statusEl = document.getElementById('typingStatus'); + if (!statusEl || !this._typingStartTime) return; + + const elapsed = Math.floor((Date.now() - this._typingStartTime) / 1000); + + // Different status messages based on elapsed time + let statusText = ''; + if (elapsed < 3) { + statusText = 'AI is thinking...'; + } else if (elapsed < 8) { + statusText = `Processing your request... (${elapsed}s)`; + } else if (elapsed < 15) { + statusText = `Working on it... (${elapsed}s)`; + } else if (elapsed < 30) { + statusText = `Still working, complex question... (${elapsed}s)`; + } else if (elapsed < 60) { + statusText = `Deep thinking in progress... (${elapsed}s)`; + } else { + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + statusText = `Almost there... (${mins}m ${secs}s)`; + } + + statusEl.textContent = statusText; + } + + /** + * Remove typing indicator + * @private + */ + _removeTypingIndicator() { + // Clear the status update interval + if (this._typingStatusInterval) { + clearInterval(this._typingStatusInterval); + this._typingStatusInterval = null; + } + this._typingStartTime = null; + this._llmConfirmed = false; + + const indicator = document.getElementById('typing-indicator'); + if (indicator) { + indicator.style.transition = 'opacity 0.15s ease-out'; + indicator.style.opacity = '0'; + setTimeout(() => indicator.remove(), 150); + } + } + + /** + * Show regenerating indicator on a specific message (in-place, no scroll) + * @private + * @param {string} pairId - The pair ID of the message being regenerated + */ + _showRegeneratingIndicator(pairId) { + if (!this.messages) return; + + // Find the assistant message element with this pairId + const assistantMessage = this.messages.querySelector(`.message.assistant[data-pair-id="${pairId}"]`); + if (!assistantMessage) return; + + // Add regenerating class for styling + assistantMessage.classList.add('regenerating'); + + // Find the message content and replace with loading animation + const messageContent = assistantMessage.querySelector('.message-content'); + if (messageContent) { + // Store original content for potential restoration + assistantMessage.setAttribute('data-original-content', messageContent.innerHTML); + + // Replace with regenerating animation + messageContent.innerHTML = ` +
        +
        + + + + + +
        + Regenerating response... +
        + `; + } + + // Hide action buttons while regenerating + const actionBtns = assistantMessage.querySelector('.msg-actions'); + if (actionBtns instanceof HTMLElement) { + actionBtns.style.display = 'none'; + } + + // Scroll to the START of the message pair (user message) immediately + const userMessage = this.messages.querySelector(`.message.user[data-pair-id="${pairId}"]`); + const targetMessage = userMessage || assistantMessage; + const chatContainer = document.getElementById('chatContainer'); + if (chatContainer && targetMessage) { + const messageTop = /** @type {HTMLElement} */ (targetMessage).offsetTop; + chatContainer.scrollTo({ + top: Math.max(0, messageTop - 20), + behavior: 'smooth' + }); + } + } + + /** + * Remove regenerating indicator from a specific message + * @private + * @param {string} pairId - The pair ID of the message + */ + _removeRegeneratingIndicator(pairId) { + if (!this.messages) return; + + const assistantMessage = this.messages.querySelector(`.message.assistant[data-pair-id="${pairId}"]`); + if (!assistantMessage) return; + + assistantMessage.classList.remove('regenerating'); + + // Restore action buttons + const actionBtns = assistantMessage.querySelector('.msg-actions'); + if (actionBtns instanceof HTMLElement) { + actionBtns.style.display = ''; + } + } + + // ==================== STREAMING MESSAGE METHODS ==================== + + /** + * Render a streaming message placeholder + * @private + * @param {Message} message + * @param {number} msgIndex + */ + _renderStreamingMessage(message, msgIndex) { + if (!this.messages) return; + + // Reset scroll state for new streaming message + this._userHasScrolledAway = false; + this._currentStreamingPairId = message.pairId || null; + + const messageDiv = document.createElement('div'); + messageDiv.className = 'message assistant streaming'; + messageDiv.id = `streaming-${message.id}`; + messageDiv.dataset.messageId = message.id || ''; + messageDiv.dataset.pairId = message.pairId || ''; + messageDiv.dataset.index = String(msgIndex); + messageDiv.style.opacity = '0'; + messageDiv.style.transform = 'translateX(-30px) scale(0.95)'; + + messageDiv.innerHTML = ` + +
        +
        +
        + `; + + this.messages.appendChild(messageDiv); + + // Simple fade-in + requestAnimationFrame(() => { + messageDiv.style.transition = 'opacity 0.2s ease-out'; + messageDiv.style.opacity = '1'; + messageDiv.style.transform = 'translateX(0)'; + }); + + this._scrollToBottom(); + } + + /** + * Update streaming message content - optimized with batching and RAF + * @private + * @param {string} messageId + * @param {string} content + */ + _updateStreamingMessage(messageId, content) { + const messageDiv = document.getElementById(`streaming-${messageId}`); + if (!messageDiv) return; + + // Batch content updates - store pending content + this._pendingStreamContent = content; + this._pendingStreamId = messageId; + + // Use RAF batching to prevent layout thrashing + if (!this._streamUpdateRAF) { + this._streamUpdateRAF = requestAnimationFrame(() => { + this._flushStreamUpdate(); + }); + } + } + + /** + * Flush pending stream updates - batched DOM update + * @private + */ + _flushStreamUpdate() { + this._streamUpdateRAF = null; + + if (!this._pendingStreamContent || !this._pendingStreamId) return; + + const messageDiv = document.getElementById(`streaming-${this._pendingStreamId}`); + if (!messageDiv) return; + + const contentEl = messageDiv.querySelector('.streaming-content'); + if (contentEl) { + // Use streaming-aware formatter that handles incomplete markdown + contentEl.innerHTML = this._formatStreamingContent(this._pendingStreamContent); + } + + // Clear pending content + this._pendingStreamContent = null; + + // Only auto-scroll if user hasn't manually scrolled away + if (!this._userHasScrolledAway) { + const chatContainer = document.getElementById('chatContainer'); + if (chatContainer) { + // Check if user is near the bottom before auto-scrolling + const distanceFromBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight; + + // Only auto-scroll if already near bottom (within 200px) + if (distanceFromBottom < 200) { + // Throttle scroll - only scroll every other frame for smoother performance + if (!this._scrollRAF) { + this._scrollRAF = requestAnimationFrame(() => { + this._scrollToBottom(true); + this._scrollRAF = null; + }); + } + } + } + } + } + + /** + * Format content for streaming display - handles incomplete markdown gracefully + * @private + * @param {string} content + * @returns {string} + */ + _formatStreamingContent(content) { + if (!content || typeof content !== 'string') return ''; + + let formatted = content; + + // Remove internet search indicator lines (they look ugly in the response) + formatted = formatted.replace(/^🌐\s*Searching for.*?\.\.\.?\s*$/gm, ''); + formatted = formatted.replace(/^🌐\s*LIVE INTERNET SEARCH RESULTS:?\s*$/gm, ''); + // Also remove any leading/trailing whitespace and extra blank lines created by removal + formatted = formatted.replace(/^\s*\n+/g, '').replace(/\n{3,}/g, '\n\n'); + + // Fix malformed numbered lists where number is directly attached to text (e.g., "1**Bold**" -> "1. **Bold**") + formatted = formatted.replace(/^(\d+)(\*\*)/gm, '$1. $2'); + formatted = formatted.replace(/^(\d+)([A-Za-z])/gm, '$1. $2'); + // Also handle "1-text" and "1)text" patterns + formatted = formatted.replace(/^(\d+)-([A-Za-z])/gm, '$1. $2'); + formatted = formatted.replace(/^(\d+)\)([A-Za-z])/gm, '$1. $2'); + + // Store math blocks temporarily to protect them from other processing + const mathBlocks = []; + const inlineMath = []; + + // Protect completed display math blocks: $$ ... $$ + formatted = formatted.replace(/\$\$([\s\S]*?)\$\$/g, (_match, math) => { + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: math.trim(), display: true, complete: true }); + return placeholder; + }); + + // Protect completed display math: \[ ... \] + formatted = formatted.replace(/\\\[([\s\S]*?)\\\]/g, (_match, math) => { + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: math.trim(), display: true, complete: true }); + return placeholder; + }); + + // Handle incomplete display math $$ ... (streaming) + const incompleteDisplayMath = formatted.match(/\$\$([^$]*)$/); + if (incompleteDisplayMath) { + const beforeMath = formatted.substring(0, formatted.lastIndexOf('$$')); + const math = incompleteDisplayMath[1]; + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: math.trim(), display: true, complete: false }); + formatted = beforeMath + placeholder; + } + + // Protect completed inline math: $ ... $ + formatted = formatted.replace(/\$([^\$\n]+?)\$/g, (_match, math) => { + const placeholder = `__INLINE_MATH_${inlineMath.length}__`; + inlineMath.push({ math: math.trim(), display: false, complete: true }); + return placeholder; + }); + + // Protect completed inline math: \( ... \) - use permissive pattern for nested parens + formatted = formatted.replace(/\\\((.+?)\\\)/gs, (_match, math) => { + const placeholder = `__INLINE_MATH_${inlineMath.length}__`; + inlineMath.push({ math: math.trim(), display: false, complete: true }); + return placeholder; + }); + + // Clean HTML tags first - convert to markdown equivalents (handle tags with ANY attributes) + let prev = ''; + let maxIter = 10; + while (prev !== formatted && maxIter-- > 0) { + prev = formatted; + // Handle tags with style/class attributes + formatted = formatted.replace(/]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/b>/gi, '**$1**'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/em>/gi, '*$1*'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/i>/gi, '*$1*'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/u>/gi, '_$1_'); + formatted = formatted.replace(/]*>([\s\S]*?)<\/span>/gi, '$1'); + formatted = formatted.replace(/]*\/?>/gi, '\n'); + } + // Handle escaped HTML entities with attributes + formatted = formatted.replace(/<strong[^&]*>([\s\S]*?)<\/strong>/gi, '**$1**'); + formatted = formatted.replace(/<b[^&]*>([\s\S]*?)<\/b>/gi, '**$1**'); + formatted = formatted.replace(/<em[^&]*>([\s\S]*?)<\/em>/gi, '*$1*'); + formatted = formatted.replace(/<i[^&]*>([\s\S]*?)<\/i>/gi, '*$1*'); + formatted = formatted.replace(/<u[^&]*>([\s\S]*?)<\/u>/gi, '_$1_'); + formatted = formatted.replace(/<span[^&]*>([\s\S]*?)<\/span>/gi, '$1'); + formatted = formatted.replace(/<br[^&]*\/?>/gi, '\n'); + + // Store completed code blocks - detect LaTeX inside code blocks and render as math + // IMPORTANT: Extract code blocks BEFORE auto-formatting to prevent x^2 conversion in code + const codeBlocks = []; + formatted = formatted.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => { + const trimmedCode = code.trim(); + const language = lang ? lang.toLowerCase() : ''; + + // Check if this is actually LaTeX/math content inside a code block + // Only treat as LaTeX if EXPLICITLY marked as latex/tex/math language + // OR if no language specified AND has clear LaTeX commands (not just patterns that could be code) + const hasLaTeXCommands = /\\(?:frac|sqrt|sum|int|lim|prod|infty|alpha|beta|gamma|delta|epsilon|theta|pi|sigma|omega|phi|psi|lambda|mu|nu|rho|tau|eta|zeta|xi|kappa|chi|pm|mp|times|div|cdot|leq|geq|neq|le|ge|ne|approx|equiv|sim|propto|subset|supset|subseteq|supseteq|cup|cap|in|notin|forall|exists|partial|nabla|vec|hat|bar|dot|ddot|text|mathrm|mathbf|mathit|mathbb|mathcal|left|right|big|Big|bigg|Bigg|begin|end|quad|qquad|rightarrow|leftarrow|Rightarrow|Leftarrow|to|gets)\b/.test(trimmedCode); + + // Check for code-like patterns that should NOT be treated as LaTeX + const hasCodePatterns = /(?:function|const|let|var|return|import|export|class|def|if|else|for|while|switch|case|try|catch|=>|===|!==|\|\||&&|console\.|print\(|System\.|public\s|private\s|void\s)/.test(trimmedCode); + + // Only treat as LaTeX if: + // 1. Explicitly marked as latex/tex/math, OR + // 2. No language AND has LaTeX commands AND no code patterns + const isLaTeX = (language === 'latex' || language === 'tex' || language === 'math') || + ((language === '' || language === 'plaintext') && hasLaTeXCommands && !hasCodePatterns); + + if (isLaTeX) { + // Render as math block instead of code block + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: trimmedCode, display: true, complete: true }); + return placeholder; + } + + const displayLang = lang ? (LANGUAGE_NAMES[language] || language) : 'plaintext'; + const escapedCode = this.escapeHtml(trimmedCode); + const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; + codeBlocks.push(`
        ${displayLang}
        ${escapedCode}
        `); + return placeholder; + }); + + // Handle incomplete code blocks (streaming) + const incompleteCodeMatch = formatted.match(/```(\w*)\n?([\s\S]*)$/); + if (incompleteCodeMatch) { + // Check if this is actually incomplete (not ending with ```) + const lastTripleBacktick = formatted.lastIndexOf('```'); + const afterLastBacktick = formatted.substring(lastTripleBacktick + 3); + // If there's content after ``` and no closing ```, it's incomplete + if (afterLastBacktick.length > 0 && !afterLastBacktick.includes('```')) { + const beforeCode = formatted.substring(0, lastTripleBacktick); + const lang = incompleteCodeMatch[1] || ''; + const code = incompleteCodeMatch[2] || ''; + const language = lang ? lang.toLowerCase() : ''; + + // Check if this incomplete block looks like LaTeX + const hasLaTeXCommands = /\\(?:frac|sqrt|sum|int|lim|prod|infty|alpha|beta|gamma|delta|epsilon|theta|pi|sigma|omega|phi|psi|lambda|mu|nu|rho|tau|eta|zeta|xi|kappa|chi|pm|mp|times|div|cdot|leq|geq|neq|le|ge|ne|approx|equiv|sim|propto|subset|supset|subseteq|supseteq|cup|cap|in|notin|forall|exists|partial|nabla|vec|hat|bar|dot|ddot|text|mathrm|mathbf|mathit|mathbb|mathcal|left|right|big|Big|bigg|Bigg|begin|end|quad|qquad|rightarrow|leftarrow|Rightarrow|Leftarrow|to|gets)\b/.test(code); + const hasCodePatterns = /(?:function|const|let|var|return|import|export|class|def|if|else|for|while|switch|case|try|catch|=>|===|!==|\|\||&&|console\.|print\(|System\.|public\s|private\s|void\s)/.test(code); + const isLaTeX = (language === 'latex' || language === 'tex' || language === 'math') || + ((language === '' || language === 'plaintext') && hasLaTeXCommands && !hasCodePatterns); + + if (isLaTeX) { + // Render as streaming math block + const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`; + mathBlocks.push({ math: code.trim(), display: true, complete: false }); + formatted = beforeCode + placeholder; + } else { + const displayLang = lang ? (LANGUAGE_NAMES[language] || language) : 'plaintext'; + const escapedCode = this.escapeHtml(code); + const streamingCodeBlock = `
        ${displayLang}
        ${escapedCode}
        `; + formatted = beforeCode + `__CODE_BLOCK_${codeBlocks.length}__`; + codeBlocks.push(streamingCodeBlock); + } + } + } + + // Store inline codes - extract BEFORE auto-formatting + const inlineCodes = []; + formatted = formatted.replace(/`([^`\n]+)`/g, (_match, code) => { + const placeholder = `__INLINE_CODE_${inlineCodes.length}__`; + inlineCodes.push(`${this.escapeHtml(code)}`); + return placeholder; + }); + + // Handle incomplete inline code + const incompleteInlineCode = formatted.match(/`([^`\n]*)$/); + if (incompleteInlineCode) { + formatted = formatted.replace(/`([^`\n]*)$/, `${this.escapeHtml(incompleteInlineCode[1])}|`); + } + + // NOW apply auto-format math expressions AFTER code blocks are protected + formatted = this._autoFormatMathExpressions(formatted); + + // IMPORTANT: Parse tables and charts FIRST before other formatting + formatted = this._parseStreamingTables(formatted); + formatted = this._parseStreamingCharts(formatted); + + // Headings + formatted = formatted.replace(/^###### (.+)$/gm, (_, text) => `
        ${this._formatInlineContent(text)}
        `); + formatted = formatted.replace(/^##### (.+)$/gm, (_, text) => `
        ${this._formatInlineContent(text)}
        `); + formatted = formatted.replace(/^#### (.+)$/gm, (_, text) => `

        ${this._formatInlineContent(text)}

        `); + formatted = formatted.replace(/^### (.+)$/gm, (_, text) => `

        ${this._formatInlineContent(text)}

        `); + formatted = formatted.replace(/^## (.+)$/gm, (_, text) => `

        ${this._formatInlineContent(text)}

        `); + formatted = formatted.replace(/^# (.+)$/gm, (_, text) => `

        ${this._formatInlineContent(text)}

        `); + + // Bold (use display-safe escaping to preserve quotes) + formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, text) => `${this.escapeHtmlDisplay(text)}`); + // Handle incomplete bold (streaming) - only if there's actual content + formatted = formatted.replace(/\*\*([^*]+)$/, (_, text) => text.trim() ? `${this.escapeHtmlDisplay(text)}` : '**' + text); + + // Italic (complete only to avoid conflicts) + formatted = formatted.replace(/(? `${this.escapeHtmlDisplay(text)}`); + + // Lists - ordered + formatted = formatted.replace(/^(\d+)\. (.+)$/gm, (_, num, text) => `${this._formatInlineContent(text)}`); + formatted = formatted.replace(/((?:]*>[\s\S]*?<\/oli>\n?)+)/g, (match) => { + if (match.includes('__CODE_BLOCK_') || match.includes('__MATH_BLOCK_')) return match; + return '
          ' + match + '
        '; + }); + formatted = formatted.replace(/<\/ol>\n
          /g, ''); + formatted = formatted.replace(/]*>/g, '
        1. '); + formatted = formatted.replace(/<\/oli>/g, '
        2. '); + + // Lists - unordered + formatted = formatted.replace(/^[-*] (.+)$/gm, (_, text) => `${this._formatInlineContent(text)}`); + formatted = formatted.replace(/((?:[\s\S]*?<\/uli>\n?)+)/g, (match) => { + if (match.includes('__CODE_BLOCK_') || match.includes('__MATH_BLOCK_')) return match; + return '
            ' + match + '
          '; + }); + formatted = formatted.replace(/<\/ul>\n
            /g, ''); + formatted = formatted.replace(//g, '
          • '); + formatted = formatted.replace(/<\/uli>/g, '
          • '); + + // Blockquotes + formatted = formatted.replace(/^> (.+)$/gm, (_, text) => `
            ${this._formatInlineContent(text)}
            `); + formatted = formatted.replace(/<\/blockquote>\n
            /g, '
            '); + + // Horizontal rule (multiple formats) + formatted = formatted.replace(/^---$/gm, '
            '); + formatted = formatted.replace(/^\*\*\*$/gm, '
            '); + formatted = formatted.replace(/^___$/gm, '
            '); + + // Underline (but not placeholders) + formatted = formatted.replace(/(? { + if (/__/.test(match)) return match; + return `${this.escapeHtmlDisplay(text)}`; + }); + + // Links (keep escapeHtml for URLs for security) + formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { + const safeUrl = this.sanitizeUrl(url); + if (!safeUrl) return this.escapeHtmlDisplay(text); + return `${this.escapeHtmlDisplay(text)}`; + }); + + // Convert newlines to breaks for paragraphs + const lines = formatted.split('\n'); + const result = []; + let paragraphContent = []; + + for (const line of lines) { + const trimmed = line.trim(); + const isBlockElement = /^<(h[1-6]|ul|ol|li|blockquote|hr|div|table|__CODE|__MATH|__TABLE)/.test(trimmed) || + /<\/(h[1-6]|ul|ol|blockquote|div|table)>$/.test(trimmed); + + if (isBlockElement || trimmed === '') { + if (paragraphContent.length > 0) { + result.push('

            ' + paragraphContent.join('
            ') + '

            '); + paragraphContent = []; + } + if (trimmed !== '') { + result.push(line); + } + } else { + paragraphContent.push(trimmed); + } + } + + if (paragraphContent.length > 0) { + result.push('

            ' + paragraphContent.join('
            ') + '

            '); + } + + formatted = result.join('\n'); + + // Restore codes using regex for reliable replacement + inlineCodes.forEach((code, i) => { + formatted = formatted.replace(new RegExp(`__INLINE_CODE_${i}__`, 'g'), code); + }); + codeBlocks.forEach((block, i) => { + formatted = formatted.replace(new RegExp(`__CODE_BLOCK_${i}__`, 'g'), block); + }); + + // Restore math blocks with KaTeX rendering + mathBlocks.forEach((item, i) => { + const rendered = this._renderStreamingMath(item.math, true, item.complete); + formatted = formatted.replace(new RegExp(`__MATH_BLOCK_${i}__`, 'g'), rendered); + }); + + // Restore inline math with KaTeX rendering + inlineMath.forEach((item, i) => { + const rendered = this._renderStreamingMath(item.math, false, item.complete); + formatted = formatted.replace(new RegExp(`__INLINE_MATH_${i}__`, 'g'), rendered); + }); + + // Clean up empty elements and extra whitespace + formatted = formatted.replace(/

            <\/p>/g, ''); + formatted = formatted.replace(/

            \s*<\/p>/g, ''); + formatted = formatted.replace(/


            <\/p>/g, ''); + formatted = formatted.replace(/

            \s*
            \s*<\/p>/g, ''); + formatted = formatted.replace(/(
            \s*)+$/g, ''); + formatted = formatted.replace(/^\s*\n/gm, ''); + + // Final cleanup for any remaining escaped entities + formatted = formatted.replace(/<strong>([^&]*)<\/strong>/gi, '$1'); + formatted = formatted.replace(/<b>([^&]*)<\/b>/gi, '$1'); + formatted = formatted.replace(/<em>([^&]*)<\/em>/gi, '$1'); + formatted = formatted.replace(/<i>([^&]*)<\/i>/gi, '$1'); + formatted = formatted.replace(/<u>([^&]*)<\/u>/gi, '$1'); + + return formatted; + } + + /** + * Render math for streaming - handles incomplete math gracefully + * @private + * @param {string} math - LaTeX math string + * @param {boolean} displayMode - Whether to render in display mode + * @param {boolean} complete - Whether the math block is complete + * @returns {string} Rendered HTML + */ + _renderStreamingMath(math, displayMode = false, complete = true) { + if (!math) return ''; + + try { + // Check if KaTeX is available + if (typeof katex !== 'undefined') { + const html = katex.renderToString(math, { + displayMode: displayMode, + throwOnError: false, + errorColor: '#cc0000', + strict: false, + trust: true, + macros: { + "\\R": "\\mathbb{R}", + "\\N": "\\mathbb{N}", + "\\Z": "\\mathbb{Z}", + "\\Q": "\\mathbb{Q}", + "\\C": "\\mathbb{C}" + } + }); + + if (displayMode) { + const streamingClass = complete ? '' : ' streaming-math'; + return `

            ${html}${complete ? '' : '|'}
            `; + } + return `${html}`; + } + } catch (e) { + // KaTeX failed, use fallback + } + + // Fallback rendering + return this._renderMath(math, displayMode); + } + + /** + * Parse tables during streaming - handles incomplete tables gracefully + * @private + * @param {string} content + * @returns {string} + */ + _parseStreamingTables(content) { + if (!content) return content; + + // Use the same line-by-line parsing as _parseMarkdownTables + const lines = content.split('\n'); + const result = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Check if this line looks like a table header (starts and ends with |) + if (line.trim().startsWith('|') && line.trim().endsWith('|')) { + // Check if next line is a separator (|---|---|) + const nextLine = lines[i + 1]; + if (nextLine && /^\|[\s:|-]+\|$/.test(nextLine.trim())) { + // This is a table! Parse it + const tableLines = [line]; + let j = i + 1; + + // Collect all table lines (separator + data rows) + while (j < lines.length && lines[j].trim().startsWith('|')) { + tableLines.push(lines[j]); + j++; + } + + // Convert table to HTML + const tableHtml = this._convertTableToHtml(tableLines); + if (tableHtml) { + result.push(tableHtml.replace('table-wrapper', 'table-wrapper streaming-table')); + i = j; + continue; + } + } + // Check if this might be an incomplete table (header without separator yet) + else if (!nextLine || !nextLine.trim().startsWith('|')) { + // Could be incomplete table being streamed + const headerCells = line.split('|').filter((c, idx, arr) => idx > 0 && idx < arr.length - 1); + if (headerCells.length >= 2) { + let html = '
            '; + headerCells.forEach(h => { + html += ``; + }); + html += '
            ${this._formatInlineContent(h.trim())}
            Loading table...
            '; + result.push(html); + i++; + continue; + } + } + } + + result.push(line); + i++; + } + + return result.join('\n'); + } + + /** + * Parse charts during streaming - handles incomplete chart blocks gracefully + * @private + * @param {string} content + * @returns {string} + */ + _parseStreamingCharts(content) { + if (!content) return content; + + // Handle complete chart blocks + let idx = 0; + content = content.replace(/```chart\n([\s\S]*?)```/g, (match, data) => { + try { + const cfg = this._parseChartConfig(data.trim()); + if (!cfg) return match; + const id = `chart-${Date.now()}-${idx++}`; + setTimeout(() => this._renderChart(id, cfg), 0); + const title = cfg.title ? `
            ${this.escapeHtmlDisplay(cfg.title)}
            ` : ''; + return `
            ${title}
            `; + } catch (e) { return match; } + }); + + // Handle incomplete chart blocks (chart being streamed) + const incompleteChartMatch = content.match(/```chart\n([\s\S]*)$/); + if (incompleteChartMatch && !content.endsWith('```')) { + const beforeChart = content.substring(0, content.lastIndexOf('```chart')); + const partialData = incompleteChartMatch[1] || ''; + + // Try to extract title if available + let title = 'Chart'; + const titleMatch = partialData.match(/title:\s*(.+)/i); + if (titleMatch) { + title = titleMatch[1].trim(); + } + + // Try to extract type if available + let chartType = 'chart'; + const typeMatch = partialData.match(/type:\s*(\w+)/i); + if (typeMatch) { + chartType = typeMatch[1].trim(); + } + + const html = `
            +
            ${this.escapeHtmlDisplay(title)}
            +
            +
            + + + + +
            + Creating ${chartType}... + +
            +
            `; + + return beforeChart + html; + } + + return content; + } + + /** + * Finalize streaming message with full content and action buttons + * @private + * @param {string} messageId + * @param {Message} message + * @param {number} msgIndex + * @param {boolean} [usedInternet=false] - Whether internet was used for this response + * @param {string} [internetSource=''] - Source of internet data + */ + _finalizeStreamingMessage(messageId, message, msgIndex, usedInternet = false, internetSource = '') { + const streamingDiv = document.getElementById(`streaming-${messageId}`); + if (!streamingDiv) return; + + // Fallback: detect internet usage from message content if flag wasn't passed + if (!usedInternet && message.content && message.content.includes('LIVE INTERNET SEARCH RESULTS')) { + usedInternet = true; + internetSource = internetSource || 'Web Search'; + } + + // Cancel any pending stream updates + if (this._streamUpdateRAF) { + cancelAnimationFrame(this._streamUpdateRAF); + this._streamUpdateRAF = null; + } + this._pendingStreamContent = null; + this._pendingStreamId = null; + + // Remove streaming class + streamingDiv.classList.remove('streaming'); + streamingDiv.id = ''; + + // Add model indicator at the top of the bubble + const bubble = streamingDiv.querySelector('.message-bubble'); + if (bubble && message.model) { + // Create a container for indicators + const indicatorContainer = document.createElement('div'); + indicatorContainer.className = 'message-indicators'; + + // Add model indicator + const modelIndicator = document.createElement('span'); + modelIndicator.className = 'model-indicator'; + modelIndicator.textContent = message.model; + indicatorContainer.appendChild(modelIndicator); + + // Add internet indicator if internet was used + // Check for internet usage - either from flag or content detection + let showInternetIndicator = usedInternet; + let internetSourceToShow = internetSource || 'Web Search'; + if (!showInternetIndicator && message.content && message.content.includes('LIVE INTERNET SEARCH RESULTS')) { + showInternetIndicator = true; + } + if (showInternetIndicator) { + const internetIndicator = document.createElement('span'); + internetIndicator.className = 'internet-indicator'; + internetIndicator.innerHTML = `Live data from ${internetSourceToShow}`; + internetIndicator.title = `This response includes live data fetched from ${internetSourceToShow}`; + indicatorContainer.appendChild(internetIndicator); + } + + bubble.insertBefore(indicatorContainer, bubble.firstChild); + } + + // Update content with formatted version + const contentEl = streamingDiv.querySelector('.message-content'); + if (contentEl) { + contentEl.classList.remove('streaming-content'); + contentEl.innerHTML = this._formatContent(message.content); + } + + // Add action buttons + if (bubble && message.pairId) { + const actionsHtml = ` +
            + + + + +
            + `; + bubble.insertAdjacentHTML('beforeend', actionsHtml); + } + + // Attach event listeners + this._attachMessageEvents(streamingDiv, message); + this._initCodeCopyButtons(streamingDiv); + this._scrollToBottom(); + } + + // ==================== SCROLLING ==================== + + /** + * Scroll chat to bottom - optimized for performance + * @private + * @param {boolean} [instant=false] + */ + _scrollToBottom(instant = false) { + const container = document.getElementById('chatContainer'); + if (!container) return; + + // Cancel any pending scroll + if (this._scrollRAF) { + cancelAnimationFrame(this._scrollRAF); + this._scrollRAF = null; + } + + if (instant) { + container.scrollTop = container.scrollHeight; + } else { + // Use native smooth scroll for better performance + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth' + }); + } + } + + /** + * Smooth scroll to target position - simplified + * @private + * @param {HTMLElement} element + * @param {number} target + * @param {number} [duration=150] + */ + _smoothScrollTo(element, target, duration = 150) { + // Use native smooth scroll + element.scrollTo({ + top: target, + behavior: 'smooth' + }); + } + + // ==================== CONVERSATION MANAGEMENT ==================== + + /** + * Create a new chat conversation + */ + createNewChat() { + /** @type {Conversation} */ + const conversation = { + id: this._generateId(), + title: 'New chat', + messages: [], + createdAt: Date.now() + }; + + this.conversations.unshift(conversation); + this.currentConversationId = conversation.id; + + if (this.messages) this.messages.innerHTML = ''; + if (this.welcome) { + this.welcome.style.display = 'flex'; + this.welcome.style.opacity = '1'; + this.welcome.style.transform = 'none'; + } + if (this.chatTitle) this.chatTitle.textContent = 'Rox AI'; + + this._renderConversations(); + this._saveConversations(); + + if (window.innerWidth <= MOBILE_BREAKPOINT) { + this._closeSidebar(); + } + + this.messageInput?.focus(); + } + + /** + * Load a conversation by ID + * @param {string} id + */ + loadConversation(id) { + if (!id || typeof id !== 'string') return; + + const conversation = this.conversations.find((c) => c.id === id); + if (!conversation) { + this.showToast('Conversation not found', 'error'); + return; + } + + if (this.currentConversationId === id) return; + + this.currentConversationId = id; + + if (this.messages) { + this.messages.innerHTML = ''; + conversation.messages.forEach((msg, idx) => { + this._renderMessageInstant(msg, idx); + }); + } + + if (this.welcome) { + this.welcome.style.display = 'none'; + } + + if (this.chatTitle) { + this.chatTitle.textContent = conversation.title; + } + + this._renderConversations(); + + if (window.innerWidth <= MOBILE_BREAKPOINT) { + this._closeSidebar(); + } + + this.messageInput?.focus(); + this._scrollToBottom(true); + } + + /** + * Render conversations list + * @private + */ + _renderConversations() { + if (!this.chatList) return; + + this.chatList.innerHTML = ''; + + if (this.conversations.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.style.cssText = 'padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;'; + emptyState.innerHTML = ` + + + +

            No conversations yet

            +

            Start a new chat to begin

            + `; + if (this.chatList) { + this.chatList.appendChild(emptyState); + } + return; + } + + this.conversations.forEach((conv) => { + const item = document.createElement('div'); + item.className = 'chat-item' + (conv.id === this.currentConversationId ? ' active' : ''); + + item.innerHTML = ` +
            ${this.escapeHtml(conv.title)}
            + + `; + + item.addEventListener('click', (e) => { + if ((e.target instanceof Element) && e.target.closest('.chat-item-menu')) return; + this.loadConversation(conv.id); + }); + + const menuBtn = item.querySelector('.chat-item-menu'); + menuBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + this._showContextMenu(e, conv.id); + }); + + if (this.chatList) { + this.chatList.appendChild(item); + } + }); + } + + // ==================== CONTEXT MENU ==================== + + /** + * Show context menu + * @private + * @param {Event} e + * @param {string} conversationId + */ + _showContextMenu(e, conversationId) { + e.preventDefault(); + if (!this.contextMenu) return; + + this.contextMenu.dataset.conversationId = conversationId; + + const menuWidth = 150; + const menuHeight = 80; + + /** @type {number} */ + let x = 0; + /** @type {number} */ + let y = 0; + + if (e instanceof MouseEvent) { + x = e.pageX; + y = e.pageY; + } + + if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 10; + if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 10; + + this.contextMenu.style.left = `${x}px`; + this.contextMenu.style.top = `${y}px`; + this.contextMenu.classList.add('show'); + } + + /** + * Hide context menu + * @private + */ + _hideContextMenu() { + this.contextMenu?.classList.remove('show'); + } + + /** + * Handle context menu action + * @private + * @param {string} action + */ + _handleContextAction(action) { + const conversationId = this.contextMenu?.dataset.conversationId; + this._hideContextMenu(); + + if (!conversationId) return; + + if (action === 'rename') { + this._showRenameModal(conversationId); + } else if (action === 'delete') { + this._deleteConversation(conversationId); + } + } + + // ==================== RENAME MODAL ==================== + + /** + * Show rename modal + * @private + * @param {string} conversationId + */ + _showRenameModal(conversationId) { + const conversation = this.conversations.find((c) => c.id === conversationId); + if (!conversation || !this.renameModal) return; + + this.renameModal.dataset.conversationId = conversationId; + const input = /** @type {HTMLInputElement} */ (document.getElementById('renameInput')); + + if (input) { + input.value = conversation.title; + this.renameModal.classList.add('show'); + setTimeout(() => { + input.focus(); + input.select(); + }, 100); + } + } + + /** + * Confirm rename action + * @private + */ + _confirmRename() { + const conversationId = this.renameModal?.dataset.conversationId; + const input = /** @type {HTMLInputElement} */ (document.getElementById('renameInput')); + const newTitle = input?.value.trim(); + + if (!newTitle) { + this.showToast('Please enter a valid name', 'warning'); + return; + } + + const conversation = this.conversations.find((c) => c.id === conversationId); + if (conversation) { + conversation.title = newTitle; + if (conversationId === this.currentConversationId && this.chatTitle) { + this.chatTitle.textContent = newTitle; + } + this._renderConversations(); + this._saveConversations(); + this.showToast('Chat renamed successfully', 'success'); + } + + this.renameModal?.classList.remove('show'); + } + + /** + * Delete conversation with confirmation + * @private + * @param {string} conversationId + */ + _deleteConversation(conversationId) { + const conversation = this.conversations.find((c) => c.id === conversationId); + if (!conversation) return; + + this.showDialog({ + type: 'warning', + title: 'Delete Chat', + message: `Are you sure you want to delete "${conversation.title}"? This action cannot be undone.`, + confirmText: 'Delete', + cancelText: 'Cancel', + onConfirm: () => { + this.conversations = this.conversations.filter((c) => c.id !== conversationId); + + if (conversationId === this.currentConversationId) { + this.currentConversationId = null; + if (this.messages) this.messages.innerHTML = ''; + if (this.welcome) { + this.welcome.style.display = 'flex'; + this.welcome.style.opacity = '1'; + this.welcome.style.transform = 'none'; + } + if (this.chatTitle) this.chatTitle.textContent = 'Rox AI'; + } + + this._renderConversations(); + this._saveConversations(); + this.showToast('Chat deleted', 'success'); + } + }); + } + + // ==================== STORAGE ==================== + + /** + * Validate a single conversation object + * @private + * @param {unknown} conv - Conversation to validate + * @returns {conv is Conversation} Whether the conversation is valid + */ + _isValidConversation(conv) { + if (!conv || typeof conv !== 'object') return false; + + const c = /** @type {Record} */ (conv); + + return ( + typeof c.id === 'string' && c.id.length > 0 && + typeof c.title === 'string' && + Array.isArray(c.messages) && + typeof c.createdAt === 'number' + ); + } + + /** + * Validate a single message object + * @private + * @param {unknown} msg - Message to validate + * @returns {msg is Message} Whether the message is valid + */ + _isValidMessage(msg) { + if (!msg || typeof msg !== 'object') return false; + + const m = /** @type {Record} */ (msg); + + return ( + (m.role === 'user' || m.role === 'assistant') && + typeof m.content === 'string' && + typeof m.timestamp === 'number' + ); + } + + /** + * Load conversations from storage with validation + * @private + * @returns {Conversation[]} + */ + _loadConversations() { + try { + const saved = this._getStorageItem(STORAGE_KEY_CONVERSATIONS); + if (!saved) return []; + + /** @type {unknown} */ + let parsed; + try { + parsed = JSON.parse(saved); + } catch (parseError) { + console.error('Error parsing conversations JSON:', parseError); + return []; + } + + if (!Array.isArray(parsed)) return []; + + // Validate and sanitize loaded data + const validConversations = parsed + .filter((conv) => this._isValidConversation(conv)) + .map((conv) => ({ + ...conv, + // Sanitize title + title: String(conv.title).slice(0, 100), + // Validate messages + messages: Array.isArray(conv.messages) + ? conv.messages.filter((msg) => this._isValidMessage(msg)) + : [] + })) + .slice(0, MAX_CONVERSATIONS); // Limit total conversations + + return /** @type {Conversation[]} */ (validConversations); + } catch (e) { + console.error('Error loading conversations:', e); + return []; + } + } + + /** + * Save conversations to storage with size management + * @private + * @returns {boolean} Success status + */ + _saveConversations() { + try { + // Limit conversations before saving + if (this.conversations.length > MAX_CONVERSATIONS) { + // Remove oldest conversations + this.conversations = this.conversations + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, MAX_CONVERSATIONS); + } + + // Limit messages per conversation + this.conversations.forEach((conv) => { + if (conv.messages.length > MAX_MESSAGES_PER_CONVERSATION) { + conv.messages = conv.messages.slice(-MAX_MESSAGES_PER_CONVERSATION); + } + }); + + const data = JSON.stringify(this.conversations); + + // Check storage size (5MB limit for localStorage) + if (data.length > 4 * 1024 * 1024) { + console.warn('Storage size approaching limit, removing old conversations'); + this.conversations = this.conversations.slice(0, Math.floor(this.conversations.length / 2)); + return this._saveConversations(); + } + + if (!this._setStorageItem(STORAGE_KEY_CONVERSATIONS, data)) { + this.showToast('Failed to save conversations', 'error'); + return false; + } + + return true; + } catch (e) { + console.error('Error saving conversations:', e); + this.showToast('Failed to save conversations', 'error'); + return false; + } + } + + /** + * Clear all conversations with confirmation + * @returns {void} + */ + clearAllConversations() { + this.showDialog({ + type: 'warning', + title: 'Clear All Chats', + message: 'Are you sure you want to delete all conversations? This action cannot be undone.', + confirmText: 'Clear All', + cancelText: 'Cancel', + onConfirm: () => { + this.conversations = []; + this.currentConversationId = null; + if (this.messages) this.messages.innerHTML = ''; + if (this.welcome) { + this.welcome.style.display = 'flex'; + this.welcome.style.opacity = '1'; + this.welcome.style.transform = 'none'; + } + if (this.chatTitle) this.chatTitle.textContent = 'Rox AI'; + this._renderConversations(); + this._saveConversations(); + this.showToast('All chats cleared', 'success'); + } + }); + } + + // ==================== DOCUMENTATION ==================== + + /** + * Initialize documentation modal + * @private + */ + _initDocumentation() { + const openDocsLink = document.getElementById('openDocsLink'); + const docsOverlay = document.getElementById('docsModalOverlay'); + const docsClose = document.getElementById('docsModalClose'); + const docsContent = document.getElementById('docsModalContent'); + + if (openDocsLink && docsOverlay && docsContent) { + // Inject documentation content + docsContent.innerHTML = this._getDocumentationHTML(); + + // Open documentation + openDocsLink.addEventListener('click', (e) => { + e.preventDefault(); + docsOverlay.classList.add('show'); + document.body.style.overflow = 'hidden'; + }); + + // Close documentation + docsClose?.addEventListener('click', () => { + docsOverlay.classList.remove('show'); + document.body.style.overflow = ''; + }); + + // Close on overlay click + docsOverlay.addEventListener('click', (e) => { + if (e.target === docsOverlay) { + docsOverlay.classList.remove('show'); + document.body.style.overflow = ''; + } + }); + + // Close on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && docsOverlay.classList.contains('show')) { + docsOverlay.classList.remove('show'); + document.body.style.overflow = ''; + } + }); + } + } + + /** + * Get documentation HTML content + * @private + * @returns {string} + */ + _getDocumentationHTML() { + return ` + +
            +

            + + + + + About Rox AI +

            +

            + Rox AI is a cutting-edge artificial intelligence platform developed by Rox AI Technologies. + Our mission is to democratize access to powerful AI capabilities through our proprietary suite of standalone AI models, + each meticulously engineered from the ground up to deliver exceptional performance across diverse use cases. +

            +

            + Unlike conventional AI solutions that rely on third-party infrastructure, Rox AI models are + independently developed and optimized by our world-class research team. + This approach ensures unparalleled control over model behavior, safety protocols, and continuous improvement cycles. +

            +

            + Our platform represents years of dedicated research in natural language processing, deep learning architectures, + and human-AI interaction design. Every Rox AI model undergoes rigorous testing and refinement to meet the highest + standards of accuracy, reliability, and user experience. +

            +
            + + +
            +

            + + + + + Founder & Creator +

            +
            +
            MF
            +
            +

            Mohammad Faiz

            +

            + Founder, CEO & Chief AI Architect at Rox AI Technologies. Mohammad Faiz is a visionary technologist + and AI researcher who founded Rox AI with the goal of creating accessible, powerful, and ethical AI systems. + Under his leadership, Rox AI has developed a comprehensive suite of proprietary AI models that serve + millions of users worldwide. His expertise spans deep learning, natural language processing, + and large-scale distributed systems. +

            +
            +
            +
            + + +
            +

            + + + + + + Rox AI Model Family +

            +

            + We have 5 different standalone AI models, each developed from scratch by Rox AI. Pick the one that fits what you need! +

            + + +
            +
            +
            + + + + + + + + + + + Rox Core +
            + Default +
            +

            + Your everyday AI helper. Fast, reliable, and great for most tasks. + Ask questions, get help with writing, brainstorm ideas, or just chat. + This is the best choice for quick answers and general help. +

            +
            +
            +
            Parameters
            +
            405B
            +
            +
            +
            Context
            +
            128K
            +
            +
            +
            Speed
            +
            Fast
            +
            +
            +
            Best For
            +
            Daily Use
            +
            +
            +
            + + +
            +
            +
            + + + + + + + + + + + Rox 2.1 Turbo +
            + Deep Thinker +
            +

            + This model thinks step by step before answering. Great for math problems, + logic puzzles, and tricky questions. It takes a bit longer but gives you + really well thought out answers. +

            +
            +
            +
            Parameters
            +
            671B
            +
            +
            +
            Context
            +
            128K
            +
            +
            +
            Thinking
            +
            Deep
            +
            +
            +
            Best For
            +
            Hard Problems
            +
            +
            +
            + + +
            +
            +
            + + + + + + + + + + + Rox 3.5 Coder +
            + Code Expert +
            +

            + Built for programmers and developers. Write code, fix bugs, explain how things work, + or learn new programming languages. Knows 100+ coding languages and can help with + any coding project. +

            +
            +
            +
            Parameters
            +
            480B
            +
            +
            +
            Active
            +
            35B
            +
            +
            +
            Languages
            +
            100+
            +
            +
            +
            Best For
            +
            Coding
            +
            +
            +
            + + +
            +
            +
            + + + + + + + + + + + Rox 4.5 Turbo +
            + Advanced +
            +

            + A very powerful model with deep thinking abilities. Great for complex analysis, + research tasks, and when you need high quality answers. Excellent balance of + speed and intelligence. +

            +
            +
            +
            Parameters
            +
            685B
            +
            +
            +
            Context
            +
            128K
            +
            +
            +
            Thinking
            +
            Deep
            +
            +
            +
            Best For
            +
            Analysis
            +
            +
            +
            + + +
            +
            +
            + + + + + + + + + + + Rox 5 Ultra +
            + Flagship +
            +

            + The most powerful AI in the Rox family. Trained on a massive 14.8 TRILLION datasets, + making it incredibly knowledgeable and capable. Features superior intelligence, advanced reasoning, + and deep thinking capabilities. Use this for research, complex analysis, creative writing, + and when you need the absolute best quality responses. This is our crown jewel. +

            +
            +
            +
            Training
            +
            14.8T+
            +
            +
            +
            Context
            +
            128K
            +
            +
            +
            Thinking
            +
            Ultimate
            +
            +
            +
            Best For
            +
            Everything
            +
            +
            +
            +
            + + +
            +

            + + + + + + + Which Model Should I Use? +

            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
            ModelGood ForSpeed
            Rox CoreQuick questions, everyday help⚡ Fast
            Rox 2.1 TurboMath, logic, step-by-step thinking🧠 Thoughtful
            Rox 3.5 CoderWriting code, fixing bugs💻 Very Fast
            Rox 4.5 TurboComplex analysis, research🔥 Powerful
            Rox 5 UltraUltimate AI, best quality🏆 Supreme
            +
            + + +
            +

            + + + + Core Capabilities +

            +
              +
            • Natural Language Understanding: Advanced comprehension of context, nuance, and intent across 50+ languages with cultural awareness.
            • +
            • Code Generation & Analysis: Write, debug, explain, and optimize code in 100+ programming languages with best practices and syntax highlighting.
            • +
            • Creative Writing: Generate stories, articles, essays, marketing copy, and creative content with customizable tone and style.
            • +
            • Mathematical Reasoning: Solve complex mathematical problems with step-by-step explanations and beautiful LaTeX rendering.
            • +
            • Document Analysis: Upload and analyze PDFs, Word documents, Excel spreadsheets, PowerPoint presentations, and more.
            • +
            • Image Understanding: Analyze uploaded images using Rox Vision for descriptions, OCR, and visual Q&A.
            • +
            • Research Assistance: Summarize documents, answer questions, and provide comprehensive research support.
            • +
            • Data Visualization: Create charts and graphs using simple chart blocks (bar, line, area, pie, doughnut).
            • +
            • Multilingual Support: Communicate fluently in 50+ languages including Hindi, Spanish, French, German, Chinese, Japanese, Arabic, and more.
            • +
            +
            + + +
            +

            + + + + Safety & Ethics +

            +

            + At Rox AI, safety and ethical AI development are foundational principles. Our models are designed with multiple layers + of safety measures to ensure responsible AI usage: +

            +
              +
            • Content Filtering: Advanced systems to prevent generation of harmful, illegal, or inappropriate content.
            • +
            • Bias Mitigation: Continuous efforts to identify and reduce biases in model outputs.
            • +
            • Privacy Protection: No storage of conversation data; all interactions are processed securely.
            • +
            • Transparency: Clear communication about AI limitations and capabilities.
            • +
            • Human Oversight: Designed to augment human capabilities, not replace human judgment.
            • +
            +
            + + +
            +

            + + + + + + Important Information +

            +
            +
            + + + + + Please Note +
            +
            + While Rox AI models are highly capable, they may occasionally produce inaccurate or incomplete information. + Always verify important facts, especially for critical decisions involving health, legal, financial, or safety matters. + Rox AI is designed to assist and augment human capabilities, not to replace professional expertise or judgment. +
            +
            +
              +
            • Knowledge Cutoff: Models have training data cutoffs and may not have information about very recent events.
            • +
            • Real-time Awareness: Rox AI knows the current date and time (IST) but cannot access live internet data or news.
            • +
            • Verification Recommended: Always verify critical information from authoritative sources.
            • +
            • Context Handling: Very long conversations are automatically managed to fit within model limits.
            • +
            • Large File Support: Large files are intelligently truncated while preserving the most important content.
            • +
            • Privacy First: Conversations are stored locally on your device, not on external servers.
            • +
            • Not a Replacement: AI assistance should complement, not replace, professional advice in specialized fields.
            • +
            +
            + + +
            +

            + + + + + + + + + + + + + Technical Specifications +

            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
            SpecificationDetails
            Maximum Context Length128,000 tokens (varies by model)
            Supported Languages50+ languages including English, Spanish, French, German, Chinese, Japanese, Arabic, Hindi, and more
            Programming Languages100+ including Python, JavaScript, TypeScript, Java, C++, Go, Rust, and more
            File Upload SupportPDF, DOCX, XLSX, PPTX, RTF, images (JPG, PNG, GIF, WebP), and 50+ text/code file types
            Image SupportRox Vision analyzes and understands uploaded images across all models
            Response FormatRich markdown with code highlighting, tables, charts, and math rendering (KaTeX)
            PDF ExportExport any response to professionally formatted PDF documents
            Text-to-SpeechListen to AI responses with built-in text-to-speech functionality
            +
            + + +
            +

            + + + + + Platform Features +

            +
              +
            • Multi-Model Selection: Switch between 5 specialized AI models based on your task needs.
            • +
            • File Processing: Upload and analyze PDFs, Word documents, Excel spreadsheets, PowerPoint presentations, images, and code files.
            • +
            • Image Understanding: Upload images for AI analysis and description using Rox Vision.
            • +
            • Code Highlighting: Beautiful syntax highlighting for 100+ programming languages with one-click copy.
            • +
            • Math Rendering: Full LaTeX math support with KaTeX for equations and formulas.
            • +
            • Chart Generation: Create bar, line, area, pie, and doughnut charts using simple chart blocks.
            • +
            • Table Formatting: Automatic markdown table rendering with alignment support.
            • +
            • PDF Export: Export any AI response to a professionally styled PDF document.
            • +
            • Text-to-Speech: Listen to responses with auto-scrolling text highlighting.
            • +
            • Dark/Light Theme: Choose your preferred visual theme for comfortable viewing.
            • +
            • PWA Support: Install as a native app on mobile and desktop devices.
            • +
            • Offline Capable: Basic functionality available even without internet connection.
            • +
            • Conversation History: All chats are saved locally and can be renamed or deleted.
            • +
            • Message Editing: Edit your messages and regenerate AI responses with version history.
            • +
            • Smart Truncation: Large files are intelligently truncated to fit within model context limits.
            • +
            +
            + + +
            +

            + + + + + Model Release History +

            + +
              +
            • Rox 5 Ultra (December 2025): The most powerful flagship model in the Rox AI family. Trained on a massive 14.8 TRILLION datasets. Standalone model with advanced reasoning capabilities, deep thinking, and superior intelligence. Features transparent reasoning chains for complex problem-solving. Our ultimate model for research, analysis, creative writing, and the most demanding tasks.
            • +
            • Rox 4.5 Turbo (November 2025): Advanced standalone model with 685 billion parameters. Features deep thinking capabilities with transparent reasoning chains. 128K context window with state-of-the-art performance across all benchmarks. Excels at complex reasoning, creative writing, and multilingual tasks.
            • +
            • Rox 3.5 Coder (September 2025): Specialized standalone coding model with 480 billion total parameters (35 billion active). Trained on extensive proprietary code corpus covering 100+ programming languages. Features advanced code completion, debugging assistance, and architectural understanding.
            • +
            • Rox 2.1 Turbo (July 2025): Enhanced standalone model with 671 billion parameters using proprietary Mixture-of-Experts architecture. Features deep thinking capabilities with transparent reasoning chains. Optimized for speed while maintaining high accuracy.
            • +
            • Rox Core (March 2025): Foundation standalone model with 405 billion parameters. Fast and efficient for everyday tasks. The starting point for the entire Rox AI family with excellent general-purpose capabilities.
            • +
            + +

            + All Rox AI models are standalone, proprietary models developed from scratch by Rox AI Technologies. They are not built on or derived from any third-party AI models. +

            + +

            🆕 Rox Vision - Now Live!

            +

            + Rox Vision is our dedicated vision-language model that powers image understanding across all Rox LLM models. + Upload any image and get detailed analysis, descriptions, OCR, and visual Q&A capabilities. +

            +
              +
            • Universal Integration: All Rox models (Core, 2.1 Turbo, 3.5 Coder, 4.5 Turbo, 5 Ultra) use Rox Vision for image processing.
            • +
            • Advanced Understanding: Scene analysis, object detection, text extraction (OCR), and visual reasoning.
            • +
            • Format Support: JPG, PNG, GIF, WebP, and BMP images.
            • +
            + +

            Upcoming Models

            +
              +
            • Rox 6 (Q3 2026): Next-generation model with enhanced multimodal capabilities and expanded context window. Currently in early development.
            • +
            +
            + + +
            +

            + + + + + Contact & Support +

            +

            + For questions, feedback, or support inquiries, please reach out to the Rox AI team. + We are committed to continuously improving our platform and value your input. +

            +

            + Rox AI Technologies: Building the future of artificial intelligence, one conversation at a time. +

            +

            + © ${new Date().getFullYear()} Rox AI Technologies. All rights reserved. Founded by Mohammad Faiz. +

            +
            + `; + } + + // ==================== MOBILE NAVIGATION SUPPORT ==================== + + /** + * Initialize mobile navigation for native-like experience on Android & iOS + * @private + */ + _initMobileNavigation() { + // Navigation state stack for back button handling + /** @type {string[]} */ + this._navStack = ['main']; + + // Detect if running as PWA + this._isPWA = window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone === true || + document.referrer.includes('android-app://'); + + // Hide install button in PWA mode (user already has the app installed) + if (this._isPWA) { + const installBtn = document.getElementById('btnInstallPWA'); + if (installBtn) { + installBtn.style.display = 'none'; + } + } + + // Initialize all mobile navigation features + this._initBackButtonHandler(); + this._initSwipeGestures(); + this._initKeyboardHandling(); + this._initSafeAreaHandling(); + this._initHapticFeedback(); + this._initPullToRefresh(); + + // Set up visual viewport handling for iOS keyboard + if ('visualViewport' in window) { + this._initVisualViewport(); + } + + console.log('📱 Mobile navigation initialized', { isPWA: this._isPWA }); + } + + /** + * Initialize Android back button / gesture handling + * @private + */ + _initBackButtonHandler() { + // Push initial state + if (this._isPWA) { + history.replaceState({ nav: 'main' }, '', window.location.href); + } + + // Handle popstate (back button/gesture) + window.addEventListener('popstate', (e) => { + e.preventDefault(); + this._handleBackNavigation(); + }); + + // Handle hardware back button on Android + document.addEventListener('backbutton', (e) => { + e.preventDefault(); + this._handleBackNavigation(); + }); + } + + /** + * Handle back navigation logic + * @private + */ + _handleBackNavigation() { + // Check what's currently open and close it + const docsOverlay = document.getElementById('docsModalOverlay'); + const actionSheet = document.getElementById('mobileActionSheetOverlay'); + const dialogOverlay = document.getElementById('customDialogOverlay'); + const renameModal = this.renameModal; + const contextMenu = this.contextMenu; + + // Priority order: dialogs > action sheets > modals > sidebar > exit + if (dialogOverlay?.classList.contains('show')) { + this.closeDialog(); + this._pushNavState('main'); + return; + } + + if (actionSheet?.classList.contains('show')) { + this._closeMobileActionSheet(); + this._pushNavState('main'); + return; + } + + if (docsOverlay?.classList.contains('show')) { + docsOverlay.classList.remove('show'); + document.body.style.overflow = ''; + this._pushNavState('main'); + return; + } + + if (renameModal?.classList.contains('show')) { + renameModal.classList.remove('show'); + this._pushNavState('main'); + return; + } + + if (contextMenu?.classList.contains('show')) { + this._hideContextMenu(); + this._pushNavState('main'); + return; + } + + if (this.modelSelector?.classList.contains('open')) { + this._closeModelDropdown(); + this._pushNavState('main'); + return; + } + + // Close sidebar on mobile + if (window.innerWidth <= MOBILE_BREAKPOINT && this.sidebar?.classList.contains('open')) { + this._closeSidebar(); + this._pushNavState('main'); + return; + } + + // If nothing to close, allow default back behavior or minimize app + if (this._isPWA) { + // In PWA mode, show exit confirmation + this.showDialog({ + type: 'confirm', + title: 'Exit App', + message: 'Do you want to exit Rox AI?', + confirmText: 'Exit', + cancelText: 'Stay', + onConfirm: () => { + // Try to close the app (works on some platforms) + window.close(); + // Fallback: navigate away + history.back(); + } + }); + } + } + + /** + * Push navigation state for back button handling + * @private + * @param {string} state - Navigation state name + */ + _pushNavState(state) { + if (this._isPWA) { + history.pushState({ nav: state }, '', window.location.href); + } + this._navStack.push(state); + } + + /** + * Initialize swipe gestures for iOS-like navigation + * @private + */ + _initSwipeGestures() { + const app = document.querySelector('.app'); + if (!app) return; + + /** @type {{x: number, y: number, time: number}|null} */ + let touchStart = null; + /** @type {{x: number, y: number}|null} */ + let touchCurrent = null; + let isSwipingFromEdge = false; + const EDGE_THRESHOLD = 30; // pixels from edge to trigger swipe + const SWIPE_THRESHOLD = 80; // minimum swipe distance + const VELOCITY_THRESHOLD = 0.3; // minimum velocity + + app.addEventListener('touchstart', (e) => { + const touch = e.touches[0]; + touchStart = { x: touch.clientX, y: touch.clientY, time: Date.now() }; + touchCurrent = { x: touch.clientX, y: touch.clientY }; + + // Check if starting from left edge (for opening sidebar) + isSwipingFromEdge = touch.clientX < EDGE_THRESHOLD; + }, { passive: true }); + + app.addEventListener('touchmove', (e) => { + if (!touchStart) return; + + const touch = e.touches[0]; + touchCurrent = { x: touch.clientX, y: touch.clientY }; + + const deltaX = touchCurrent.x - touchStart.x; + const deltaY = Math.abs(touchCurrent.y - touchStart.y); + + // Only handle horizontal swipes + if (deltaY > Math.abs(deltaX)) { + isSwipingFromEdge = false; + return; + } + + // Visual feedback for edge swipe + if (isSwipingFromEdge && deltaX > 20 && window.innerWidth <= MOBILE_BREAKPOINT) { + const progress = Math.min(deltaX / SWIPE_THRESHOLD, 1); + this._showSwipeIndicator(progress); + } + }, { passive: true }); + + app.addEventListener('touchend', () => { + if (!touchStart || !touchCurrent) { + touchStart = null; + touchCurrent = null; + isSwipingFromEdge = false; + this._hideSwipeIndicator(); + return; + } + + const deltaX = touchCurrent.x - touchStart.x; + const deltaY = Math.abs(touchCurrent.y - touchStart.y); + const deltaTime = Date.now() - touchStart.time; + const velocity = Math.abs(deltaX) / deltaTime; + + // Check for valid horizontal swipe + if (Math.abs(deltaX) > SWIPE_THRESHOLD && deltaY < Math.abs(deltaX) * 0.5) { + if (velocity > VELOCITY_THRESHOLD || Math.abs(deltaX) > SWIPE_THRESHOLD * 1.5) { + if (deltaX > 0 && isSwipingFromEdge && window.innerWidth <= MOBILE_BREAKPOINT) { + // Swipe right from edge - open sidebar + this._openSidebar(); + this._triggerHaptic('light'); + } else if (deltaX < 0 && this.sidebar?.classList.contains('open')) { + // Swipe left - close sidebar + this._closeSidebar(); + this._triggerHaptic('light'); + } + } + } + + touchStart = null; + touchCurrent = null; + isSwipingFromEdge = false; + this._hideSwipeIndicator(); + }, { passive: true }); + + app.addEventListener('touchcancel', () => { + touchStart = null; + touchCurrent = null; + isSwipingFromEdge = false; + this._hideSwipeIndicator(); + }, { passive: true }); + } + + /** + * Show swipe indicator during edge swipe + * @private + * @param {number} progress - Swipe progress (0-1) + */ + _showSwipeIndicator(progress) { + let indicator = document.getElementById('swipeIndicator'); + if (!indicator) { + indicator = document.createElement('div'); + indicator.id = 'swipeIndicator'; + indicator.className = 'back-gesture-indicator'; + indicator.innerHTML = ` + + + + `; + document.body.appendChild(indicator); + } + + indicator.style.transform = `translateY(-50%) translateX(${progress * 40 - 20}px)`; + indicator.style.opacity = String(progress); + } + + /** + * Hide swipe indicator + * @private + */ + _hideSwipeIndicator() { + const indicator = document.getElementById('swipeIndicator'); + if (indicator) { + indicator.style.opacity = '0'; + indicator.style.transform = 'translateY(-50%) translateX(-100%)'; + } + } + + /** + * Open sidebar with animation + * @private + */ + _openSidebar() { + this.sidebar?.classList.add('open'); + this.sidebar?.classList.remove('collapsed'); + this.sidebarOverlay?.classList.add('show'); + document.querySelector('.app')?.setAttribute('data-nav-state', 'sidebar-open'); + this._pushNavState('sidebar'); + } + + /** + * Initialize keyboard handling for virtual keyboard + * @private + */ + _initKeyboardHandling() { + const app = document.querySelector('.app'); + const inputArea = document.querySelector('.input-area'); + + if (!app || !inputArea) return; + + // Detect keyboard open/close via focus events + this.messageInput?.addEventListener('focus', () => { + app.classList.add('keyboard-open'); + + // Scroll to bottom when keyboard opens + setTimeout(() => { + this._scrollToBottom(true); + }, 300); + }); + + this.messageInput?.addEventListener('blur', () => { + // Delay to prevent flicker during keyboard transitions + setTimeout(() => { + if (document.activeElement !== this.messageInput) { + app.classList.remove('keyboard-open'); + } + }, 100); + }); + } + + /** + * Initialize Visual Viewport API for iOS keyboard handling + * @private + */ + _initVisualViewport() { + const viewport = window.visualViewport; + if (!viewport) return; + + const inputArea = document.querySelector('.input-area'); + if (!inputArea) return; + + let initialHeight = viewport.height; + + viewport.addEventListener('resize', () => { + const heightDiff = initialHeight - viewport.height; + + // Keyboard is likely open if height decreased significantly + if (heightDiff > 100) { + // Adjust input area position for iOS + /** @type {HTMLElement} */ (inputArea).style.transform = `translateY(${-heightDiff}px)`; + document.querySelector('.app')?.classList.add('keyboard-open'); + } else { + /** @type {HTMLElement} */ (inputArea).style.transform = ''; + document.querySelector('.app')?.classList.remove('keyboard-open'); + } + }); + + viewport.addEventListener('scroll', () => { + // Keep input area visible during scroll + if (viewport.offsetTop > 0) { + /** @type {HTMLElement} */ (inputArea).style.transform = `translateY(${-viewport.offsetTop}px)`; + } + }); + } + + /** + * Initialize safe area handling + * @private + */ + _initSafeAreaHandling() { + // Update CSS custom properties with actual safe area values + const updateSafeAreas = () => { + const root = document.documentElement; + const computedStyle = getComputedStyle(root); + + // Get actual safe area values + const safeTop = computedStyle.getPropertyValue('--safe-area-top') || '0px'; + const safeBottom = computedStyle.getPropertyValue('--safe-area-bottom') || '0px'; + + // Apply to elements that need it + root.style.setProperty('--actual-safe-top', safeTop); + root.style.setProperty('--actual-safe-bottom', safeBottom); + }; + + // Update on orientation change + window.addEventListener('orientationchange', () => { + setTimeout(updateSafeAreas, 100); + }); + + // Initial update + updateSafeAreas(); + } + + /** + * Initialize haptic feedback support + * @private + */ + _initHapticFeedback() { + // Check for haptic support + this._hasHaptics = 'vibrate' in navigator; + + // Add haptic feedback to interactive elements + const interactiveElements = document.querySelectorAll( + '.btn-send, .btn-new-chat, .btn-icon, .btn-attach, .chat-item, .suggestion-card, .msg-action-btn' + ); + + interactiveElements.forEach((el) => { + el.addEventListener('touchstart', () => { + this._triggerHaptic('light'); + }, { passive: true }); + }); + } + + /** + * Trigger haptic feedback + * @private + * @param {'light'|'medium'|'heavy'} intensity - Haptic intensity + */ + _triggerHaptic(intensity) { + if (!this._hasHaptics) return; + + const patterns = { + light: [10], + medium: [20], + heavy: [30, 10, 30] + }; + + try { + navigator.vibrate(patterns[intensity] || patterns.light); + } catch (e) { + // Haptics not supported or blocked + } + } + + /** + * Initialize pull-to-refresh prevention (PWA handles refresh differently) + * @private + */ + _initPullToRefresh() { + // Prevent default pull-to-refresh on the main container + const main = document.querySelector('.main'); + if (!main) return; + + let touchStartY = 0; + + main.addEventListener('touchstart', (e) => { + touchStartY = e.touches[0].clientY; + }, { passive: true }); + + main.addEventListener('touchmove', (e) => { + const chatContainer = document.getElementById('chatContainer'); + if (!chatContainer) return; + + const touchY = e.touches[0].clientY; + const scrollTop = chatContainer.scrollTop; + + // Prevent pull-to-refresh when at top and pulling down + if (scrollTop <= 0 && touchY > touchStartY) { + // Only prevent if it's a significant pull + if (touchY - touchStartY > 10) { + e.preventDefault(); + } + } + }, { passive: false }); + } + +} + +// ==================== APPLICATION INITIALIZATION ==================== + +/** + * Global error handler for uncaught errors + * @param {ErrorEvent} event - Error event + */ +window.addEventListener('error', (event) => { + // Ignore errors from extensions, cross-origin scripts, or service workers + if (!event.filename || + event.filename.includes('extension') || + event.filename.includes('chrome-extension') || + event.filename.includes('moz-extension') || + !event.filename.includes(window.location.origin)) { + return; + } + + console.error('Uncaught error:', event.error); + // Only show toast for actual app errors, not network/SW errors + const win = /** @type {ExtendedWindow} */ (window); + if (win.roxAI && typeof win.roxAI.showToast === 'function' && event.error) { + win.roxAI.showToast('An unexpected error occurred', 'error'); + } +}); + +/** + * Global handler for unhandled promise rejections + * @param {PromiseRejectionEvent} event - Rejection event + */ +window.addEventListener('unhandledrejection', (event) => { + // Ignore network errors, fetch failures, and service worker errors + const reason = event.reason; + if (reason instanceof TypeError && reason.message?.includes('fetch')) { + event.preventDefault(); + return; + } + if (reason?.name === 'AbortError' || reason?.message?.includes('aborted')) { + event.preventDefault(); + return; + } + // Ignore service worker related errors + if (reason?.message?.includes('ServiceWorker') || reason?.message?.includes('service worker')) { + event.preventDefault(); + return; + } + + console.error('Unhandled promise rejection:', reason); + event.preventDefault(); + + // Only show toast for actual app errors + const win = /** @type {ExtendedWindow} */ (window); + if (win.roxAI && typeof win.roxAI.showToast === 'function' && reason && reason.message) { + win.roxAI.showToast('An unexpected error occurred', 'error'); + } +}); + +/** + * Initialize application when DOM is ready + */ +document.addEventListener('DOMContentLoaded', () => { + try { + // Create global instance + const win = /** @type {ExtendedWindow} */ (window); + win.roxAI = new RoxAI(); + + // Hide loading screen immediately + const loadingScreen = document.getElementById('loading-screen'); + if (loadingScreen) { + loadingScreen.classList.add('hidden'); + setTimeout(() => loadingScreen.remove(), 300); + } + + // Performance logging using modern API + if (window.performance && typeof window.performance.now === 'function') { + const loadTime = Math.round(window.performance.now()); + console.log(`⚡ Rox AI loaded in ${loadTime}ms`); + } + + console.log('✅ Rox AI initialized successfully'); + + } catch (initError) { + console.error('❌ Failed to initialize Rox AI:', initError); + + // Show error to user + const loadingScreen = document.getElementById('loading-screen'); + if (loadingScreen) { + loadingScreen.innerHTML = ` +
            +

            Failed to load Rox AI

            +

            Please refresh the page or try again later.

            + +
            + `; + } + } +});