Beta / private /admin /admin.js
Rox-Turbo's picture
Update private/admin/admin.js
19acf2d verified
/**
* Rox AI Admin Panel - Secure Administration Interface
* @version 1.0.0
* @description Production-ready admin panel with JWT auth and exact UI matching
*/
'use strict';
// ==================== CONSTANTS ====================
const API_BASE = '/admin/api';
const TOKEN_KEY = 'rox_admin_token';
const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours
// HTML escape map for XSS prevention
const HTML_ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
// Superscript map for math expressions
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 math expressions
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': 'ₓ'
};
// Language name mappings for code blocks
const LANGUAGE_NAMES = {
'js': 'javascript', 'ts': 'typescript', 'py': 'python', 'rb': 'ruby',
'sh': 'bash', 'yml': 'yaml', 'md': 'markdown', 'cs': 'csharp', 'cpp': 'c++'
};
// ==================== ADMIN PANEL CLASS ====================
class AdminPanel {
constructor() {
this.token = null;
this.currentUser = null;
this.currentChat = null;
this.users = [];
this.chats = [];
this.stats = {};
this.refreshInterval = null;
this._init();
}
_init() {
this._initElements();
this._initEventListeners();
this._initKeyboardShortcuts();
this._checkAuth();
}
_initElements() {
// Login elements
this.loginOverlay = document.getElementById('loginOverlay');
this.loginForm = document.getElementById('loginForm');
this.passwordInput = document.getElementById('passwordInput');
this.loginError = document.getElementById('loginError');
this.loginAttempts = document.getElementById('loginAttempts');
this.loginBtn = document.getElementById('loginBtn');
// App elements
this.app = document.getElementById('app');
this.sidebar = document.getElementById('sidebar');
this.sidebarOverlay = document.getElementById('sidebarOverlay');
this.menuBtn = document.getElementById('menuBtn');
this.mobileMenuBtn = document.getElementById('mobileMenuBtn');
this.sidebarCloseBtn = document.getElementById('sidebarCloseBtn');
// Stats elements
this.totalUsers = document.getElementById('totalUsers');
this.totalChats = document.getElementById('totalChats');
this.todayQueries = document.getElementById('todayQueries');
this.avgResponseTime = document.getElementById('avgResponseTime');
this.totalMessages = document.getElementById('totalMessages');
this.activeSessions = document.getElementById('activeSessions');
this.modelUsageChart = document.getElementById('modelUsageChart');
this.systemHealthChart = document.getElementById('systemHealthChart');
// User list elements
this.userList = document.getElementById('userList');
this.userSearch = document.getElementById('userSearch');
this.selectedUserName = document.getElementById('selectedUserName');
// Chat elements
this.chatsPanel = document.getElementById('chatsPanel');
this.chatsPanelCloseBtn = document.getElementById('chatsPanelCloseBtn');
this.chatsPanelOverlay = document.getElementById('chatsPanelOverlay');
this.chatsList = document.getElementById('chatsList');
this.chatView = document.getElementById('chatView');
this.chatHeader = document.getElementById('chatHeader');
this.chatTitle = document.getElementById('chatTitle');
this.chatMeta = document.getElementById('chatMeta');
this.messagesArea = document.getElementById('messagesArea');
// Action buttons
this.refreshBtn = document.getElementById('refreshBtn');
this.exportBtn = document.getElementById('exportBtn');
this.exportDropdown = document.getElementById('exportDropdown');
this.exportJsonBtn = document.getElementById('exportJsonBtn');
this.exportPdfBtn = document.getElementById('exportPdfBtn');
this.exportUsersPdfBtn = document.getElementById('exportUsersPdfBtn');
this.exportChatJsonBtn = document.getElementById('exportChatJsonBtn');
this.exportChatPdfBtn = document.getElementById('exportChatPdfBtn');
this.clearBtn = document.getElementById('clearBtn');
this.logoutBtn = document.getElementById('btnLogout');
this.lastUpdated = document.getElementById('lastUpdated');
this.lastUpdateTime = null;
// Tabs
this.tabBtns = document.querySelectorAll('.tab-btn');
this.usersTab = document.getElementById('usersTab');
this.statsTab = document.getElementById('statsTab');
// Dialog
this.dialogOverlay = document.getElementById('dialogOverlay');
this.dialogTitle = document.getElementById('dialogTitle');
this.dialogMessage = document.getElementById('dialogMessage');
this.dialogConfirm = document.getElementById('dialogConfirm');
this.dialogCancel = document.getElementById('dialogCancel');
// Help dialog
this.helpOverlay = document.getElementById('helpOverlay');
this.helpBtn = document.getElementById('helpBtn');
this.helpClose = document.getElementById('helpClose');
// Toast container
this.toastContainer = document.getElementById('toastContainer');
// Connection status
this.connectionStatus = document.getElementById('connectionStatus');
this.connectionDot = this.connectionStatus?.querySelector('.connection-dot');
this.connectionText = this.connectionStatus?.querySelector('span');
// Auto-refresh indicator
this.autoRefreshIndicator = document.getElementById('autoRefreshIndicator');
// Search results counter
this.searchResultsCount = document.getElementById('searchResultsCount');
// Chat count badge
this.chatCountBadge = document.getElementById('chatCountBadge');
this.chatCount = document.getElementById('chatCount');
// Breadcrumb
this.breadcrumb = document.getElementById('breadcrumb');
// Performance indicators
this.performanceIndicators = document.getElementById('performanceIndicators');
// Trend elements
this.usersTrend = document.getElementById('usersTrend');
this.chatsTrend = document.getElementById('chatsTrend');
this.todayTrend = document.getElementById('todayTrend');
// Sparkline elements
this.responseTimeSparkline = document.getElementById('responseTimeSparkline');
this.messagesSparkline = document.getElementById('messagesSparkline');
this.activityIndicator = document.getElementById('activityIndicator');
// New enhanced stats elements
this.peakHour = document.getElementById('peakHour');
this.peakHourLabel = document.getElementById('peakHourLabel');
this.onlineUsers = document.getElementById('onlineUsers');
this.avgChatsPerUser = document.getElementById('avgChatsPerUser');
this.serverInfoChart = document.getElementById('serverInfoChart');
this.hourlyUsageChart = document.getElementById('hourlyUsageChart');
// Summary elements
this.summaryTotalUsers = document.getElementById('summaryTotalUsers');
this.summaryOnline = document.getElementById('summaryOnline');
this.summaryToday = document.getElementById('summaryToday');
// Enhanced analytics elements
this.yesterdayQueries = document.getElementById('yesterdayQueries');
this.weekQueries = document.getElementById('weekQueries');
this.monthQueries = document.getElementById('monthQueries');
this.newUsersToday = document.getElementById('newUsersToday');
this.newUsersWeek = document.getElementById('newUsersWeek');
this.avgMessagesPerChat = document.getElementById('avgMessagesPerChat');
this.dailyActivityChart = document.getElementById('dailyActivityChart');
this.browserDistribution = document.getElementById('browserDistribution');
this.osDistribution = document.getElementById('osDistribution');
this.deviceDistribution = document.getElementById('deviceDistribution');
this.languageDistribution = document.getElementById('languageDistribution');
this.refererDistribution = document.getElementById('refererDistribution');
this.countryDistribution = document.getElementById('countryDistribution');
this.screenDistribution = document.getElementById('screenDistribution');
// New enhanced elements
this.realtimeOnline = document.getElementById('realtimeOnline');
this.lastHourQueries = document.getElementById('lastHourQueries');
this.returningUsers = document.getElementById('returningUsers');
this.returningRate = document.getElementById('returningRate');
this.totalPageViews = document.getElementById('totalPageViews');
this.avgPageViews = document.getElementById('avgPageViews');
this.avgSessionDurationEl = document.getElementById('avgSessionDuration');
this.totalSessionsEl = document.getElementById('totalSessions');
this.bounceRateEl = document.getElementById('bounceRate');
this.newUsersMonthEl = document.getElementById('newUsersMonth');
this.totalErrorsEl = document.getElementById('totalErrors');
this.errorRateEl = document.getElementById('errorRate');
this.recentErrorsEl = document.getElementById('recentErrors');
this.totalTokensEl = document.getElementById('totalTokens');
this.topUsersChart = document.getElementById('topUsersChart');
// New enhanced elements
this.avgEngagement = document.getElementById('avgEngagement');
this.avgTimeOnSite = document.getElementById('avgTimeOnSite');
this.mobilePercent = document.getElementById('mobilePercent');
this.avgSessionsPerUser = document.getElementById('avgSessionsPerUser');
this.weeklyHeatmap = document.getElementById('weeklyHeatmap');
// User details panel
this.userDetailsPanel = document.getElementById('userDetailsPanel');
this.userDetailsContent = document.getElementById('userDetailsContent');
this.closeUserDetailsBtn = document.getElementById('closeUserDetails');
}
_initEventListeners() {
// Login form
this.loginForm?.addEventListener('submit', (e) => this._handleLogin(e));
// Logout
this.logoutBtn?.addEventListener('click', () => this._logout());
// Mobile menu
this.menuBtn?.addEventListener('click', () => this._toggleSidebar());
this.mobileMenuBtn?.addEventListener('click', () => this._toggleSidebar());
this.sidebarCloseBtn?.addEventListener('click', () => this._closeSidebar());
this.sidebarOverlay?.addEventListener('click', () => this._closeSidebar());
// Chats panel mobile
this.chatsPanelCloseBtn?.addEventListener('click', () => this._closeChatsPanel());
this.chatsPanelOverlay?.addEventListener('click', () => this._closeChatsPanel());
// Tabs
this.tabBtns.forEach(btn => {
btn.addEventListener('click', () => this._switchTab(btn.dataset.tab));
});
// User search with debouncing
let searchTimeout;
this.userSearch?.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value;
// Visual feedback for search
if (query) {
e.target.style.borderColor = 'var(--accent)';
e.target.style.boxShadow = '0 0 0 3px rgba(62, 180, 137, 0.15)';
} else {
e.target.style.borderColor = '';
e.target.style.boxShadow = '';
}
searchTimeout = setTimeout(() => {
this._filterUsers(query);
}, 300);
});
// Action buttons
this.refreshBtn?.addEventListener('click', () => this._refreshData());
this.exportBtn?.addEventListener('click', (e) => this._toggleExportDropdown(e));
this.exportJsonBtn?.addEventListener('click', () => this._exportJson());
this.exportPdfBtn?.addEventListener('click', () => this._exportStatsPdf());
this.exportUsersPdfBtn?.addEventListener('click', () => this._exportUsersPdf());
this.exportChatJsonBtn?.addEventListener('click', () => this._exportChatJson());
this.exportChatPdfBtn?.addEventListener('click', () => this._exportChatPdf());
this.clearBtn?.addEventListener('click', () => this._confirmClearLogs());
// Help button
this.helpBtn?.addEventListener('click', () => this._showHelp());
this.helpClose?.addEventListener('click', () => this._hideHelp());
// User details panel close
this.closeUserDetailsBtn?.addEventListener('click', () => this._hideUserDetails());
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (this.exportDropdown && !this.exportDropdown.contains(e.target)) {
this.exportDropdown.classList.remove('open');
}
});
// Dialog
this.dialogCancel?.addEventListener('click', () => this._hideDialog());
this.dialogOverlay?.addEventListener('click', (e) => {
if (e.target === this.dialogOverlay) this._hideDialog();
});
this.helpOverlay?.addEventListener('click', (e) => {
if (e.target === this.helpOverlay) this._hideHelp();
});
}
// ==================== AUTHENTICATION ====================
_checkAuth() {
const token = localStorage.getItem(TOKEN_KEY);
if (token && this._isTokenValid(token)) {
this.token = token;
this._showApp();
this._loadData();
} else {
localStorage.removeItem(TOKEN_KEY);
this._showLogin();
}
}
_isTokenValid(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 > Date.now();
} catch {
return false;
}
}
async _handleLogin(e) {
e.preventDefault();
const password = this.passwordInput?.value?.trim();
if (!password) {
this._showLoginError('Please enter a password');
return;
}
this._setLoginLoading(true);
try {
const response = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success && data.token) {
this.token = data.token;
localStorage.setItem(TOKEN_KEY, data.token);
this._showApp();
this._loadData();
this._showToast('Login successful', 'success');
} else {
this._showLoginError(data.error || 'Invalid password');
if (data.attemptsLeft !== undefined) {
this.loginAttempts.textContent = `${data.attemptsLeft} attempts remaining`;
}
if (data.lockoutTime) {
this._showLoginError(`Too many attempts. Try again in ${Math.ceil(data.lockoutTime / 60)} minutes`);
}
}
} catch (err) {
this._showLoginError('Connection error. Please try again.');
} finally {
this._setLoginLoading(false);
}
}
_logout() {
localStorage.removeItem(TOKEN_KEY);
this.token = null;
if (this.refreshInterval) clearInterval(this.refreshInterval);
if (this.lastUpdatedInterval) clearInterval(this.lastUpdatedInterval);
this._showLogin();
this._showToast('Logged out successfully', 'success');
}
_showLogin() {
this.loginOverlay.style.display = 'flex';
this.app.style.display = 'none';
this.passwordInput.value = '';
this.loginError.textContent = '';
this.loginAttempts.textContent = '';
}
_showApp() {
this.loginOverlay.style.display = 'none';
this.app.style.display = 'flex';
// Initialize breadcrumb
this._updateBreadcrumb(['Dashboard']);
// Auto-refresh every 5 seconds for real-time updates
this.refreshInterval = setInterval(() => this._loadData(), 5000);
// Update "last updated" display every second
this.lastUpdatedInterval = setInterval(() => this._updateLastUpdatedDisplay(), 1000);
}
_showLoginError(msg) {
this.loginError.textContent = msg;
}
_setLoginLoading(loading) {
const btnText = this.loginBtn?.querySelector('.btn-text');
const btnLoading = this.loginBtn?.querySelector('.btn-loading');
if (loading) {
btnText.style.display = 'none';
btnLoading.style.display = 'inline-flex';
this.loginBtn.disabled = true;
} else {
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
this.loginBtn.disabled = false;
}
}
// ==================== ENHANCED DATA LOADING ====================
async _loadData() {
try {
// Show loading state with context
this._setLoadingState(true, 'loading');
const [statsRes, usersRes] = await Promise.all([
this._apiGet('/stats'),
this._apiGet('/users')
]);
if (statsRes.success) {
this.stats = statsRes.data;
this._renderStats();
}
if (usersRes.success) {
// Smart update: only re-render if data actually changed
const newUsers = usersRes.data.users || [];
if (this._hasUsersChanged(newUsers)) {
this.users = newUsers;
this._renderUsers();
} else {
// Just update timestamps and status
this._updateUserTimestamps(newUsers);
}
}
// Update last updated time
this.lastUpdateTime = Date.now();
this._updateLastUpdatedDisplay();
// Hide loading state
this._setLoadingState(false);
} catch (err) {
console.error('Failed to load data:', err);
this._setLoadingState(false);
if (err.message === 'Unauthorized') {
this._logout();
} else {
this._showToast('Failed to load data', 'error');
}
}
}
_hasUsersChanged(newUsers) {
if (!this.users || this.users.length !== newUsers.length) return true;
// Quick check for significant changes
for (let i = 0; i < newUsers.length; i++) {
const oldUser = this.users.find(u => u.ip === newUsers[i].ip);
const newUser = newUsers[i];
if (!oldUser || oldUser.chatCount !== newUser.chatCount) {
return true;
}
}
return false;
}
_updateUserTimestamps(newUsers) {
// Update existing user cards with new timestamps without full re-render
newUsers.forEach(newUser => {
const userCard = this.userList?.querySelector(`[data-ip="${this.escapeHtml(newUser.ip)}"]`);
if (userCard) {
const timeElement = userCard.querySelector('.user-time');
const statusElement = userCard.querySelector('.user-status');
if (timeElement) {
timeElement.textContent = this._formatTimeAgo(newUser.lastActivity);
}
if (statusElement) {
statusElement.classList.toggle('online', newUser.isOnline);
statusElement.classList.toggle('status-indicator', newUser.isOnline);
}
}
});
// Update the users array
this.users = newUsers;
}
_setLoadingState(loading, context = 'data') {
const elements = [this.userList, this.modelUsageChart];
elements.forEach(el => {
if (el) {
if (loading) {
el.classList.add('loading-pulse');
// Add contextual loading message
if (context === 'refresh') {
el.style.opacity = '0.7';
}
} else {
el.classList.remove('loading-pulse');
el.style.opacity = '1';
}
}
});
// Update refresh button state with better feedback
if (this.refreshBtn) {
if (loading) {
this.refreshBtn.disabled = true;
this.refreshBtn.classList.add('spinning', 'loading');
this.refreshBtn.querySelector('span').textContent = 'Refreshing...';
} else {
this.refreshBtn.disabled = false;
this.refreshBtn.classList.remove('spinning', 'loading');
this.refreshBtn.querySelector('span').textContent = 'Refresh';
}
}
}
_updateLastUpdatedDisplay() {
if (!this.lastUpdated || !this.lastUpdateTime) return;
const seconds = Math.floor((Date.now() - this.lastUpdateTime) / 1000);
const indicator = document.getElementById('autoRefreshIndicator');
if (seconds < 5) {
this.lastUpdated.textContent = 'Updated just now';
this.lastUpdated.style.color = 'var(--success)';
if (indicator) indicator.style.opacity = '1';
} else if (seconds < 60) {
this.lastUpdated.textContent = `Updated ${seconds}s ago`;
this.lastUpdated.style.color = 'var(--text-secondary)';
if (indicator) indicator.style.opacity = '0.7';
} else {
const mins = Math.floor(seconds / 60);
this.lastUpdated.textContent = `Updated ${mins}m ago`;
this.lastUpdated.style.color = mins > 5 ? 'var(--warning)' : 'var(--text-tertiary)';
if (indicator) indicator.style.opacity = mins > 5 ? '0.5' : '0.6';
}
}
async _loadUserChats(userIp) {
try {
const res = await this._apiGet(`/users/${encodeURIComponent(userIp)}/chats`);
if (res.success) {
this.chats = res.data.chats || [];
this._renderChats();
}
} catch (err) {
console.error('Failed to load chats:', err);
this._showToast('Failed to load chats', 'error');
}
}
async _loadChatMessages(chatId) {
try {
const res = await this._apiGet(`/chats/${encodeURIComponent(chatId)}/messages`);
if (res.success) {
this._currentMessages = res.data.messages || [];
this._renderMessages(this._currentMessages, res.data.chat);
}
} catch (err) {
console.error('Failed to load messages:', err);
this._showToast('Failed to load messages', 'error');
}
}
async _apiGet(endpoint) {
try {
this._updateConnectionStatus('connecting');
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (response.status === 401) {
this._updateConnectionStatus('disconnected');
throw new Error('Unauthorized');
}
if (!response.ok) {
this._updateConnectionStatus('error');
throw new Error(`HTTP ${response.status}`);
}
this._updateConnectionStatus('connected');
return response.json();
} catch (err) {
this._updateConnectionStatus('error');
throw err;
}
}
async _apiPost(endpoint, data) {
try {
this._updateConnectionStatus('connecting');
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify(data)
});
if (response.status === 401) {
this._updateConnectionStatus('disconnected');
throw new Error('Unauthorized');
}
if (!response.ok) {
this._updateConnectionStatus('error');
throw new Error(`HTTP ${response.status}`);
}
this._updateConnectionStatus('connected');
return response.json();
} catch (err) {
this._updateConnectionStatus('error');
throw err;
}
}
_updateConnectionStatus(status) {
if (!this.connectionStatus) return;
// Remove all status classes
this.connectionStatus.classList.remove('disconnected', 'reconnecting', 'error');
switch (status) {
case 'connected':
this.connectionText.textContent = 'Connected';
break;
case 'connecting':
case 'reconnecting':
this.connectionStatus.classList.add('reconnecting');
this.connectionText.textContent = 'Connecting...';
break;
case 'disconnected':
this.connectionStatus.classList.add('disconnected');
this.connectionText.textContent = 'Disconnected';
break;
case 'error':
this.connectionStatus.classList.add('disconnected');
this.connectionText.textContent = 'Connection Error';
break;
}
}
// ==================== RENDERING ====================
_renderStats() {
const s = this.stats;
this.totalUsers.textContent = s.totalUsers || 0;
this.totalChats.textContent = s.totalChats || 0;
this.todayQueries.textContent = s.todayQueries || 0;
// Render trend indicators
this._renderTrendIndicator(this.usersTrend, s.totalUsers, s.previousUsers);
this._renderTrendIndicator(this.chatsTrend, s.totalChats, s.previousChats);
this._renderTrendIndicator(this.todayTrend, s.todayQueries, s.previousTodayQueries);
if (this.avgResponseTime) {
this.avgResponseTime.textContent = `${s.avgResponseTime || 0}ms`;
}
if (this.totalMessages) {
this.totalMessages.textContent = s.totalMessages || 0;
}
if (this.activeSessions) {
this.activeSessions.textContent = s.activeSessions || 0;
}
// New enhanced stats
if (this.peakHour && s.peakHour !== undefined) {
const hour = s.peakHour;
const hourStr = hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`;
this.peakHour.textContent = hourStr;
}
if (this.onlineUsers) {
this.onlineUsers.textContent = s.activeSessions || 0;
}
if (this.avgChatsPerUser) {
this.avgChatsPerUser.textContent = s.avgChatsPerUser || '0';
}
// Enhanced analytics
if (this.yesterdayQueries) {
this.yesterdayQueries.textContent = s.yesterdayQueries || 0;
}
if (this.weekQueries) {
this.weekQueries.textContent = s.weekQueries || 0;
}
if (this.monthQueries) {
this.monthQueries.textContent = s.monthQueries || 0;
}
if (this.newUsersToday) {
this.newUsersToday.textContent = s.newUsersToday || 0;
}
if (this.newUsersWeek) {
this.newUsersWeek.textContent = s.newUsersWeek || 0;
}
if (this.avgMessagesPerChat) {
this.avgMessagesPerChat.textContent = s.avgMessagesPerChat || '0';
}
// Update summary section
if (this.summaryTotalUsers) {
this.summaryTotalUsers.textContent = s.totalUsers || 0;
}
if (this.summaryOnline) {
this.summaryOnline.textContent = s.activeSessions || 0;
}
if (this.summaryToday) {
this.summaryToday.textContent = s.todayQueries || 0;
}
// Render sparklines
this._renderSparkline(this.responseTimeSparkline, s.responseTimeHistory);
this._renderSparkline(this.messagesSparkline, s.messageHistory);
this._renderActivityIndicator(this.activityIndicator, s.activeSessions || 0);
// Render daily activity chart
if (this.dailyActivityChart && s.dailyActivity) {
this._renderDailyActivity(s.dailyActivity);
}
// Render browser and OS distribution
if (this.browserDistribution && s.browserStats) {
this._renderDistribution(this.browserDistribution, s.browserStats, 'browser');
}
if (this.osDistribution && s.osStats) {
this._renderDistribution(this.osDistribution, s.osStats, 'os');
}
if (this.deviceDistribution && s.deviceStats) {
this._renderDistribution(this.deviceDistribution, s.deviceStats, 'device');
}
if (this.languageDistribution && s.languageStats) {
this._renderDistribution(this.languageDistribution, s.languageStats, 'language');
}
if (this.refererDistribution && s.refererStats) {
this._renderDistribution(this.refererDistribution, s.refererStats, 'referer');
}
if (this.countryDistribution && s.countryStats) {
this._renderDistribution(this.countryDistribution, s.countryStats, 'country');
}
if (this.screenDistribution && s.screenStats) {
this._renderDistribution(this.screenDistribution, s.screenStats, 'screen');
}
// Render new real-time metrics
if (this.realtimeOnline) {
this.realtimeOnline.textContent = s.activeSessions || 0;
}
if (this.lastHourQueries) {
this.lastHourQueries.textContent = s.lastHourQueries || 0;
}
if (this.returningUsers) {
this.returningUsers.textContent = s.returningUsers || 0;
}
if (this.returningRate) {
this.returningRate.textContent = (s.returningRate || 0) + '%';
}
// Render engagement metrics
if (this.totalPageViews) {
this.totalPageViews.textContent = this._formatNumber(s.totalPageViews || 0);
}
if (this.avgPageViews) {
this.avgPageViews.textContent = s.avgPageViewsPerUser || 0;
}
if (this.avgSessionDurationEl) {
this.avgSessionDurationEl.textContent = (s.avgSessionDuration || 0) + 'm';
}
if (this.totalSessionsEl) {
this.totalSessionsEl.textContent = this._formatNumber(s.totalSessions || 0);
}
if (this.bounceRateEl) {
this.bounceRateEl.textContent = (s.bounceRate || 0) + '%';
}
if (this.newUsersMonthEl) {
this.newUsersMonthEl.textContent = s.newUsersMonth || 0;
}
// Render error metrics
if (this.totalErrorsEl) {
this.totalErrorsEl.textContent = s.totalErrors || 0;
}
if (this.errorRateEl) {
this.errorRateEl.textContent = (s.errorRate || 0) + '%';
}
if (this.recentErrorsEl) {
this.recentErrorsEl.textContent = s.recentErrors || 0;
}
if (this.totalTokensEl) {
this.totalTokensEl.textContent = this._formatNumber(s.totalTokens || 0);
}
// Render top users
if (this.topUsersChart && s.topUsers) {
this._renderTopUsers(s.topUsers);
}
// Render hourly usage chart
if (this.hourlyUsageChart && s.hourlyUsage) {
this._renderHourlyUsage(s.hourlyUsage, s.peakHour);
}
// Render model usage chart
if (this.modelUsageChart && s.modelUsage) {
this._renderModelUsage(s.modelUsage);
}
// Render system health
if (this.systemHealthChart && s.systemHealth) {
this._renderSystemHealth(s.systemHealth);
}
// Render performance indicators
if (this.performanceIndicators && s.systemHealth) {
this._renderPerformanceIndicators(s.systemHealth);
}
// Render server info
if (this.serverInfoChart && s.systemHealth) {
this._renderServerInfo(s.systemHealth);
}
// Render user insights
this._renderUserInsights(s);
// Render weekly heatmap
if (this.weeklyHeatmap && s.hourlyUsage) {
this._renderWeeklyHeatmap(s.hourlyUsage);
}
}
_renderUserInsights(stats) {
// Calculate average engagement from users
if (this.avgEngagement && this.users.length > 0) {
const totalEngagement = this.users.reduce((sum, u) => sum + (u.engagementScore || 0), 0);
const avgEng = Math.round(totalEngagement / this.users.length);
this.avgEngagement.textContent = avgEng + '%';
}
// Calculate average time on site
if (this.avgTimeOnSite) {
const avgTime = stats.avgSessionDuration || 0;
this.avgTimeOnSite.textContent = avgTime + 'm';
}
// Calculate mobile percentage
if (this.mobilePercent && stats.deviceStats) {
const total = Object.values(stats.deviceStats).reduce((a, b) => a + b, 0) || 1;
const mobile = (stats.deviceStats['Mobile'] || 0) + (stats.deviceStats['Tablet'] || 0);
const mobilePerc = Math.round((mobile / total) * 100);
this.mobilePercent.textContent = mobilePerc + '%';
}
// Calculate average sessions per user
if (this.avgSessionsPerUser && stats.totalUsers > 0) {
const avgSessions = (stats.totalSessions / stats.totalUsers).toFixed(1);
this.avgSessionsPerUser.textContent = avgSessions;
}
}
_renderWeeklyHeatmap(hourlyUsage) {
if (!this.weeklyHeatmap) return;
// Generate mock weekly data based on hourly usage pattern
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const maxVal = Math.max(...(hourlyUsage || []), 1);
let html = '<div class="heatmap-grid">';
// Generate rows for each day
days.forEach((day, dayIndex) => {
html += `<div class="heatmap-row">`;
html += `<div class="heatmap-day-label">${day}</div>`;
// Generate cells for each hour
for (let hour = 0; hour < 24; hour++) {
// Use hourly data with some variation per day
const baseVal = hourlyUsage ? (hourlyUsage[hour] || 0) : 0;
const variation = Math.random() * 0.4 + 0.8; // 0.8 to 1.2
const val = Math.round(baseVal * variation);
const level = Math.min(Math.floor((val / maxVal) * 5), 5);
html += `<div class="heatmap-cell level-${level}" title="${day} ${hour}:00 - ${val} activities"></div>`;
}
html += `</div>`;
});
html += '</div>';
// Add hour labels
html += '<div class="heatmap-hour-labels">';
html += '<div></div>'; // Empty cell for day label column
for (let hour = 0; hour < 24; hour++) {
if (hour % 3 === 0) {
html += `<div class="heatmap-hour-label">${hour}</div>`;
} else {
html += '<div></div>';
}
}
html += '</div>';
// Add legend
html += `
<div class="heatmap-legend">
<span>Less</span>
<div class="heatmap-legend-cell level-0"></div>
<div class="heatmap-legend-cell level-1"></div>
<div class="heatmap-legend-cell level-2"></div>
<div class="heatmap-legend-cell level-3"></div>
<div class="heatmap-legend-cell level-4"></div>
<div class="heatmap-legend-cell level-5"></div>
<span>More</span>
</div>
`;
this.weeklyHeatmap.innerHTML = html;
}
_renderDailyActivity(dailyData) {
if (!this.dailyActivityChart || !dailyData || !Array.isArray(dailyData)) return;
const maxValue = Math.max(...dailyData.map(d => d.count), 1);
let html = '<div class="daily-bars">';
dailyData.forEach(day => {
const height = Math.max((day.count / maxValue) * 100, 5);
html += `
<div class="daily-bar-wrapper">
<div class="daily-bar" style="height: ${height}%">
<span class="daily-bar-value">${day.count}</span>
</div>
<span class="daily-bar-label">${day.date.split(',')[0]}</span>
</div>
`;
});
html += '</div>';
this.dailyActivityChart.innerHTML = html;
}
_renderDistribution(element, data, type) {
if (!element || !data) return;
const total = Object.values(data).reduce((a, b) => a + b, 0) || 1;
const sorted = Object.entries(data).sort(([,a], [,b]) => b - a); // Show all items
const icons = {
browser: {
'Chrome': '🌐', 'Firefox': '🦊', 'Safari': '🧭', 'Edge': '📘',
'Opera': '🔴', 'Brave': '🦁', 'Vivaldi': '🎨', 'Unknown': '❓'
},
os: {
'Windows': '🪟', 'macOS': '🍎', 'Linux': '🐧', 'Android': '🤖',
'iOS': '📱', 'Chrome OS': '💻', 'Ubuntu': '🟠', 'Unknown': '❓'
},
device: {
'Desktop': '🖥️', 'Mobile': '📱', 'Tablet': '📲', 'Unknown': '❓'
},
language: {
'en': '🇺🇸', 'es': '🇪🇸', 'fr': '🇫🇷', 'de': '🇩🇪', 'zh': '🇨🇳',
'ja': '🇯🇵', 'ko': '🇰🇷', 'pt': '🇧🇷', 'ru': '🇷🇺', 'ar': '🇸🇦',
'hi': '🇮🇳', 'it': '🇮🇹', 'Unknown': '🌍'
}
};
const colors = ['#667eea', '#764ba2', '#3eb489', '#f59e0b', '#ef4444', '#06b6d4', '#8b5cf6', '#ec4899'];
if (sorted.length === 0) {
element.innerHTML = '<div class="no-data" style="padding: 12px; text-align: center; color: var(--text-tertiary); font-size: 12px;">No data available</div>';
return;
}
let html = '';
sorted.forEach(([name, count], index) => {
const percent = Math.round((count / total) * 100);
const icon = icons[type]?.[name] || icons[type]?.['Unknown'] || '📊';
const color = colors[index % colors.length];
html += `
<div class="distribution-item">
<span class="distribution-icon">${icon}</span>
<span class="distribution-name" title="${this.escapeHtml(name)}">${this.escapeHtml(name)}</span>
<div class="distribution-bar">
<div class="distribution-fill" style="width: ${percent}%; background: ${color};"></div>
</div>
<span class="distribution-count">${count}</span>
<span class="distribution-percent">${percent}%</span>
</div>
`;
});
element.innerHTML = html;
}
_renderTopUsers(topUsers) {
if (!this.topUsersChart || !topUsers || topUsers.length === 0) {
if (this.topUsersChart) {
this.topUsersChart.innerHTML = '<div class="no-data" style="padding: 20px; text-align: center; color: var(--text-tertiary);">No user data yet</div>';
}
return;
}
let html = '';
topUsers.forEach((user, index) => {
const rankClass = index === 0 ? 'gold' : index === 1 ? 'silver' : index === 2 ? 'bronze' : '';
html += `
<div class="top-user-item">
<div class="top-user-rank ${rankClass}">${index + 1}</div>
<div class="top-user-info">
<div class="top-user-ip">${this.escapeHtml(user.ip)}</div>
<div class="top-user-stats">
<div class="top-user-stat">💬 <span>${user.chats}</span> chats</div>
<div class="top-user-stat">📝 <span>${user.messages}</span> msgs</div>
<div class="top-user-stat">🔄 <span>${user.sessions}</span> sessions</div>
</div>
</div>
</div>
`;
});
this.topUsersChart.innerHTML = html;
}
_formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
_renderTrendIndicator(element, current, previous) {
if (!element) return;
const currentVal = current || 0;
const previousVal = previous || 0;
if (previousVal === 0) {
element.innerHTML = '<div class="trend-arrow neutral"></div><span>--</span>';
element.className = 'stat-trend neutral';
return;
}
const change = currentVal - previousVal;
const percentChange = Math.round((change / previousVal) * 100);
if (change > 0) {
element.innerHTML = `<div class="trend-arrow up"></div><span>+${percentChange}%</span>`;
element.className = 'stat-trend up';
} else if (change < 0) {
element.innerHTML = `<div class="trend-arrow down"></div><span>${percentChange}%</span>`;
element.className = 'stat-trend down';
} else {
element.innerHTML = '<div class="trend-arrow neutral"></div><span>0%</span>';
element.className = 'stat-trend neutral';
}
}
_renderSparkline(element, data) {
if (!element) return;
// Generate mock data if none provided (for demo purposes)
if (!data || !Array.isArray(data) || data.length === 0) {
data = Array.from({length: 20}, () => Math.floor(Math.random() * 100));
}
const maxValue = Math.max(...data, 1);
const bars = data.slice(-20).map(value => {
const height = Math.max((value / maxValue) * 16, 2);
return `<div class="sparkline-bar" style="--height: ${height}px; height: ${height}px;"></div>`;
}).join('');
element.innerHTML = bars;
}
_renderActivityIndicator(element, sessionCount) {
if (!element) return;
const maxDots = 8;
const activeDots = Math.min(Math.max(sessionCount || 0, 0), maxDots);
let html = '';
for (let i = 0; i < maxDots; i++) {
const isActive = i < activeDots;
html += `<div class="activity-dot ${isActive ? 'active' : ''}"></div>`;
}
element.innerHTML = html;
}
_renderPerformanceIndicators(health) {
if (!this.performanceIndicators) return;
if (!health || !health.memoryUsage) {
this.performanceIndicators.innerHTML = `
<div class="perf-indicator">
<div class="perf-dot loading"></div>
<span>Loading...</span>
</div>
`;
return;
}
const memoryPercent = Math.round((health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100);
const uptimeHours = Math.floor((health.uptime || 0) / 3600);
let memoryStatus = 'excellent';
if (memoryPercent > 80) memoryStatus = 'poor';
else if (memoryPercent > 60) memoryStatus = 'fair';
else if (memoryPercent > 40) memoryStatus = 'good';
let uptimeStatus = 'excellent';
if (uptimeHours < 1) uptimeStatus = 'poor';
else if (uptimeHours < 24) uptimeStatus = 'fair';
else if (uptimeHours < 168) uptimeStatus = 'good';
const html = `
<div class="perf-indicator">
<div class="perf-dot ${memoryStatus}"></div>
<span>Memory ${memoryPercent}%</span>
</div>
<div class="perf-indicator">
<div class="perf-dot ${uptimeStatus}"></div>
<span>Uptime ${uptimeHours}h</span>
</div>
`;
this.performanceIndicators.innerHTML = html;
}
_renderServerInfo(health) {
if (!this.serverInfoChart) return;
if (!health) {
this.serverInfoChart.innerHTML = '<div class="no-data">No server info available</div>';
return;
}
const uptimeSeconds = health.uptime || 0;
const uptimeDays = Math.floor(uptimeSeconds / 86400);
const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600);
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
let uptimeStr = '';
if (uptimeDays > 0) uptimeStr += `${uptimeDays}d `;
if (uptimeHours > 0 || uptimeDays > 0) uptimeStr += `${uptimeHours}h `;
uptimeStr += `${uptimeMinutes}m`;
const memoryMB = health.memoryUsage ? Math.round(health.memoryUsage.heapUsed / 1024 / 1024) : 0;
const memoryTotalMB = health.memoryUsage ? Math.round(health.memoryUsage.heapTotal / 1024 / 1024) : 0;
const rssMB = health.memoryUsage ? Math.round(health.memoryUsage.rss / 1024 / 1024) : 0;
const externalMB = health.memoryUsage ? Math.round((health.memoryUsage.external || 0) / 1024 / 1024) : 0;
const html = `
<div class="server-info-item">
<span class="server-info-label">Node.js Version</span>
<span class="server-info-value">${this.escapeHtml(health.nodeVersion || 'Unknown')}</span>
</div>
<div class="server-info-item">
<span class="server-info-label">Server Uptime</span>
<span class="server-info-value">${uptimeStr}</span>
</div>
<div class="server-info-item">
<span class="server-info-label">Heap Memory</span>
<span class="server-info-value">${memoryMB}MB / ${memoryTotalMB}MB</span>
</div>
<div class="server-info-item">
<span class="server-info-label">RSS Memory</span>
<span class="server-info-value">${rssMB}MB</span>
</div>
<div class="server-info-item">
<span class="server-info-label">External Memory</span>
<span class="server-info-value">${externalMB}MB</span>
</div>
<div class="server-info-item">
<span class="server-info-label">Platform</span>
<span class="server-info-value">${this.escapeHtml(health.platform || process?.platform || 'Unknown')}</span>
</div>
`;
this.serverInfoChart.innerHTML = html;
}
_renderHourlyUsage(hourlyData, peakHour) {
if (!this.hourlyUsageChart) return;
if (!hourlyData || !Array.isArray(hourlyData) || hourlyData.length === 0) {
// Generate placeholder data
hourlyData = new Array(24).fill(0);
}
const maxValue = Math.max(...hourlyData, 1);
let barsHtml = '';
for (let i = 0; i < 24; i++) {
const value = hourlyData[i] || 0;
const height = Math.max((value / maxValue) * 100, 2);
const isPeak = i === peakHour;
barsHtml += `<div class="hourly-bar ${isPeak ? 'peak' : ''}" style="height: ${height}%" title="${i}:00 - ${value} requests"></div>`;
}
const html = `
<div class="hourly-bars">${barsHtml}</div>
<div class="hourly-labels">
<span class="hourly-label">12AM</span>
<span class="hourly-label">6AM</span>
<span class="hourly-label">12PM</span>
<span class="hourly-label">6PM</span>
<span class="hourly-label">11PM</span>
</div>
`;
this.hourlyUsageChart.innerHTML = html;
}
_renderModelUsage(usage) {
const total = Object.values(usage).reduce((a, b) => a + b, 0) || 1;
// Sort models by usage count (descending)
const sortedUsage = Object.entries(usage).sort(([,a], [,b]) => b - a);
let html = '';
for (const [model, count] of sortedUsage) {
const percent = Math.round((count / total) * 100);
const barColor = this._getModelColor(model);
html += `
<div class="usage-bar fade-in">
<span class="usage-bar-label" title="${this.escapeHtml(model)}">${this.escapeHtml(model)}</span>
<div class="usage-bar-track">
<div class="usage-bar-fill" style="width: ${percent}%; background: ${barColor}"></div>
</div>
<span class="usage-bar-value">${count} (${percent}%)</span>
</div>
`;
}
this.modelUsageChart.innerHTML = html || '<div class="no-data">No usage data</div>';
}
_getModelColor(model) {
// Assign consistent colors to different models
const colors = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
];
// Simple hash function to assign consistent colors
let hash = 0;
for (let i = 0; i < model.length; i++) {
hash = ((hash << 5) - hash + model.charCodeAt(i)) & 0xffffffff;
}
return colors[Math.abs(hash) % colors.length];
}
_renderSystemHealth(health) {
if (!health) {
this.systemHealthChart.innerHTML = '<div class="no-data">No health data</div>';
return;
}
const memoryMB = Math.round(health.memoryUsage.heapUsed / 1024 / 1024);
const memoryTotalMB = Math.round(health.memoryUsage.heapTotal / 1024 / 1024);
const memoryPercent = Math.round((health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100);
const uptimeHours = Math.floor(health.uptime / 3600);
const uptimeDays = Math.floor(uptimeHours / 24);
let uptimeText = '';
if (uptimeDays > 0) {
uptimeText = `${uptimeDays}d ${uptimeHours % 24}h`;
} else {
uptimeText = `${uptimeHours}h`;
}
const healthColor = memoryPercent > 80 ? '#ef4444' : memoryPercent > 60 ? '#f59e0b' : '#10b981';
const html = `
<div class="health-metrics fade-in">
<div class="health-metric">
<div class="health-metric-label">Memory Usage</div>
<div class="health-metric-value">${memoryMB}MB / ${memoryTotalMB}MB</div>
<div class="health-progress">
<div class="health-progress-fill" style="width: ${memoryPercent}%; background: ${healthColor}"></div>
</div>
<div class="health-metric-percent">${memoryPercent}%</div>
</div>
<div class="health-metric">
<div class="health-metric-label">Uptime</div>
<div class="health-metric-value">${uptimeText}</div>
</div>
<div class="health-metric">
<div class="health-metric-label">Node.js Version</div>
<div class="health-metric-value">${health.nodeVersion}</div>
</div>
</div>
`;
this.systemHealthChart.innerHTML = html;
}
_renderUsers() {
if (!this.users.length) {
this.userList.innerHTML = `
<div class="no-data enhanced-empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87"/>
<path d="M16 3.13a4 4 0 010 7.75"/>
</svg>
<h3>No Users Yet</h3>
<p>Users will appear here when they start chatting with Rox AI</p>
<button class="btn btn-secondary btn-sm" onclick="window.open('/', '_blank')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Open Chat Interface
</button>
</div>
`;
return;
}
// Sort users by activity (online first, then by last activity)
const sortedUsers = [...this.users].sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1;
if (!a.isOnline && b.isOnline) return 1;
return (b.lastActivity || 0) - (a.lastActivity || 0);
});
// Virtual scrolling for large lists (>100 users)
if (sortedUsers.length > 100) {
this._renderUsersVirtual(sortedUsers);
return;
}
let html = '';
for (const user of sortedUsers) {
html += this._renderUserCard(user);
}
this.userList.innerHTML = html;
this._attachUserEventListeners();
}
_renderUserCard(user) {
const isActive = this.currentUser === user.ip;
const isOnline = user.isOnline ? 'online' : '';
const timeAgo = this._formatTimeAgo(user.lastActivity);
const firstSeenDate = user.firstSeen ? new Date(user.firstSeen).toLocaleDateString() : 'Unknown';
const engagementScore = user.engagementScore || 0;
const engagementClass = engagementScore >= 70 ? 'high' : engagementScore >= 40 ? 'medium' : 'low';
// User type badge
const userType = user.userType || 'New';
const userTypeClass = userType === 'Power User' ? 'power' : userType === 'Returning' ? 'returning' : userType === 'Dormant' ? 'dormant' : 'new';
// Bounce status
const bounceStatus = user.bounceStatus || ((user.pageViews || 1) === 1 ? 'Bounced' : 'Engaged');
const bounceClass = bounceStatus === 'Bounced' ? 'bounced' : 'engaged';
return `
<div class="user-card ${isActive ? 'active' : ''} fade-in" data-ip="${this.escapeHtml(user.ip)}" tabindex="0" role="button" aria-label="Select user ${user.ip}">
<div class="user-card-header">
<span class="user-status ${isOnline} ${isOnline ? 'status-indicator' : ''}"></span>
<span class="user-ip" title="Full IP Address: ${this.escapeHtml(user.ip)}">${this.escapeHtml(user.ip)}</span>
<span class="user-type-badge ${userTypeClass}">${userType}</span>
<span class="user-time">${timeAgo}</span>
</div>
<div class="user-meta">
<span class="user-chat-count">${user.chatCount || 0} chats</span>
<span class="user-badge messages">${user.totalMessages || 0} msgs</span>
<span class="user-badge visits">👁 ${user.visits || 1}</span>
${user.avgResponseTime ? `<span class="user-badge response-time" title="Avg Response Time">⚡ ${user.avgResponseTime}ms</span>` : ''}
<span class="user-badge bounce ${bounceClass}" title="Bounce Status">${bounceStatus === 'Bounced' ? '🚪' : '✅'} ${bounceStatus}</span>
</div>
<div class="user-device-info">
${user.device?.browser ? `<span class="user-badge browser" title="${user.userAgentRaw ? this.escapeHtml(user.userAgentRaw) : 'Browser: ' + this.escapeHtml(user.device.fullBrowser || user.device.browser)}">${this.escapeHtml(user.device.fullBrowser || user.device.browser)}</span>` : ''}
${user.device?.os ? `<span class="user-badge os" title="Operating System">${this.escapeHtml(user.device.fullOs || user.device.os)}</span>` : ''}
${user.device?.device ? `<span class="user-badge device" title="Device Type">${this.escapeHtml(user.device.device)}</span>` : ''}
</div>
<div class="user-card-extra">
${user.country ? `<span class="user-extra-badge country" title="Country">🌍 ${this.escapeHtml(user.country)}</span>` : ''}
${user.city ? `<span class="user-extra-badge city" title="City">📍 ${this.escapeHtml(user.city)}</span>` : ''}
${user.screenResolution ? `<span class="user-extra-badge screen" title="Screen Resolution">📐 ${this.escapeHtml(user.screenResolution)}</span>` : ''}
${user.totalTokensUsed ? `<span class="user-extra-badge tokens" title="Tokens Used">🪙 ${this._formatNumber(user.totalTokensUsed)}</span>` : ''}
${user.favoriteModel ? `<span class="user-extra-badge model" title="Favorite Model">⭐ ${this.escapeHtml(user.favoriteModel)}</span>` : ''}
${user.errorCount > 0 ? `<span class="user-extra-badge error" title="Errors">❌ ${user.errorCount}</span>` : ''}
${user.colorScheme ? `<span class="user-extra-badge theme" title="Color Scheme">${user.colorScheme === 'dark' ? '🌙' : '☀️'} ${this.escapeHtml(user.colorScheme)}</span>` : ''}
${user.doNotTrack ? `<span class="user-extra-badge dnt" title="Do Not Track">🛡️ DNT</span>` : ''}
</div>
<div class="user-details">
<span class="user-detail-item" title="First seen">🆕 ${firstSeenDate}</span>
<span class="user-detail-item" title="Sessions">🔄 ${user.sessionCount || 1}</span>
${user.avgSessionDuration ? `<span class="user-detail-item" title="Avg Session Duration">⏱️ ${user.avgSessionDuration}m</span>` : ''}
${user.totalSessionTime ? `<span class="user-detail-item" title="Total Time">⏳ ${user.totalSessionTime}m</span>` : ''}
${user.language && user.language !== 'Unknown' ? `<span class="user-detail-item" title="Language">🗣️ ${this.escapeHtml(user.language)}</span>` : ''}
${user.peakActivityHour !== null ? `<span class="user-detail-item" title="Peak Activity Hour">🕐 ${user.peakActivityHour}:00</span>` : ''}
${user.pageViews ? `<span class="user-detail-item" title="Page Views">📄 ${user.pageViews} views</span>` : ''}
${user.requestCount ? `<span class="user-detail-item" title="Total Requests">📊 ${user.requestCount} reqs</span>` : ''}
<span class="user-detail-item ${isOnline ? 'online-status' : 'offline-status'}">${isOnline ? '🟢 Online' : '⚫ Offline'}</span>
</div>
${user.referer && user.referer !== 'Direct' ? `
<div class="user-referer">
<span class="referer-label">Source:</span>
<span class="referer-value">${this.escapeHtml(user.referer)}</span>
</div>
` : ''}
${user.lastChatTitle ? `
<div class="user-referer">
<span class="referer-label">Last Chat:</span>
<span class="referer-value" title="${this.escapeHtml(user.lastChatTitle)}">${this.escapeHtml(user.lastChatTitle.length > 40 ? user.lastChatTitle.substring(0, 40) + '...' : user.lastChatTitle)}</span>
</div>
` : ''}
${user.platform ? `
<div class="user-referer">
<span class="referer-label">Platform:</span>
<span class="referer-value">${this.escapeHtml(user.platform)}</span>
</div>
` : ''}
${user.connectionType ? `
<div class="user-referer">
<span class="referer-label">Connection:</span>
<span class="referer-value">${this.escapeHtml(user.connectionType)}</span>
</div>
` : ''}
<div class="user-engagement">
<div class="engagement-bar">
<div class="engagement-fill ${engagementClass}" style="width: ${engagementScore}%"></div>
</div>
<span class="engagement-label">Engagement: ${engagementScore}%</span>
</div>
${user.activityByHour && user.activityByHour.some(v => v > 0) ? `
<div class="user-activity-mini">
<span class="activity-mini-label">Activity Pattern:</span>
<div class="activity-mini-chart">
${this._renderMiniActivityChart(user.activityByHour)}
</div>
</div>
` : ''}
</div>
`;
}
_renderMiniActivityChart(activityByHour) {
if (!activityByHour || !Array.isArray(activityByHour)) return '';
const max = Math.max(...activityByHour, 1);
return activityByHour.map((val, hour) => {
const height = Math.max((val / max) * 100, 5);
const isPeak = val === max && val > 0;
return `<div class="mini-bar ${isPeak ? 'peak' : ''}" style="height: ${height}%" title="${hour}:00 - ${val} actions"></div>`;
}).join('');
}
_renderUsersVirtual(users) {
// Simple virtual scrolling implementation
const ITEM_HEIGHT = 76; // Approximate height of user card
const VISIBLE_ITEMS = Math.ceil(this.userList.clientHeight / ITEM_HEIGHT) + 5; // Buffer
let startIndex = 0;
const endIndex = Math.min(startIndex + VISIBLE_ITEMS, users.length);
let html = '';
for (let i = startIndex; i < endIndex; i++) {
html += this._renderUserCard(users[i]);
}
// Add placeholder for remaining items
if (endIndex < users.length) {
const remainingHeight = (users.length - endIndex) * ITEM_HEIGHT;
html += `<div style="height: ${remainingHeight}px; display: flex; align-items: center; justify-content: center; color: var(--text-tertiary); font-size: 12px;">+${users.length - endIndex} more users</div>`;
}
this.userList.innerHTML = html;
this._attachUserEventListeners();
}
_attachUserEventListeners() {
// Add click and keyboard handlers
this.userList.querySelectorAll('.user-card').forEach(card => {
card.addEventListener('click', () => this._selectUser(card.dataset.ip));
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._selectUser(card.dataset.ip);
}
});
});
}
_renderChats() {
if (!this.chats.length) {
this.chatsList.innerHTML = `
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
</svg>
<p>No chats found for this user</p>
</div>
`;
return;
}
// Sort chats by last message time (most recent first)
const sortedChats = [...this.chats].sort((a, b) => (b.lastMessage || 0) - (a.lastMessage || 0));
let html = '';
for (const chat of sortedChats) {
const isActive = this.currentChat === chat.id;
const timeAgo = this._formatTimeAgo(chat.lastMessage);
const title = chat.title || 'Untitled Chat';
html += `
<div class="chat-card ${isActive ? 'active' : ''} fade-in" data-id="${this.escapeHtml(chat.id)}" tabindex="0" role="button" aria-label="Select chat ${title}">
<div class="chat-card-title">${this.escapeHtml(title)}</div>
<div class="chat-card-meta">
<span class="chat-card-status ${chat.isActive ? 'active status-indicator' : ''}"></span>
<span>${chat.messageCount || 0} messages</span>
<span>·</span>
<span>${timeAgo}</span>
</div>
</div>
`;
}
this.chatsList.innerHTML = html;
// Add click and keyboard handlers
this.chatsList.querySelectorAll('.chat-card').forEach(card => {
card.addEventListener('click', () => this._selectChat(card.dataset.id));
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._selectChat(card.dataset.id);
}
});
});
}
_renderMessages(messages, chatInfo) {
if (!messages.length) {
this.messagesArea.innerHTML = `
<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
</svg>
<p>No messages in this chat</p>
</div>
`;
return;
}
// Show chat header
this.chatHeader.style.display = 'flex';
this.chatTitle.textContent = chatInfo?.title || 'Chat';
this.chatMeta.textContent = `Started ${this._formatDate(chatInfo?.createdAt)} · ${messages.length} messages`;
let html = '';
for (const msg of messages) {
html += this._renderMessage(msg);
}
this.messagesArea.innerHTML = html;
this._initCodeCopyButtons();
this.messagesArea.scrollTop = this.messagesArea.scrollHeight;
}
_renderMessage(msg) {
const isUser = msg.role === 'user';
const avatar = isUser ? 'U' : 'R';
const roleName = isUser ? 'User' : 'Rox';
const time = this._formatTime(msg.timestamp);
// Attachments
let attachmentsHtml = '';
if (msg.attachments?.length) {
attachmentsHtml = '<div class="message-attachments">';
for (const att of msg.attachments) {
attachmentsHtml += `
<div class="attachment-chip">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
${this.escapeHtml(att.name)}
</div>
`;
}
attachmentsHtml += '</div>';
}
// Model badge for assistant
let modelBadge = '';
let internetBadge = '';
if (!isUser) {
if (msg.model) {
modelBadge = `<span class="model-badge">${this.escapeHtml(msg.model)}</span>`;
}
if (msg.usedInternet) {
internetBadge = `<span class="internet-badge">🌐 ${this.escapeHtml(msg.internetSource || 'Web')}</span>`;
}
}
// Duration for assistant
let durationHtml = '';
if (!isUser && msg.duration) {
durationHtml = `<span class="message-time">${(msg.duration / 1000).toFixed(1)}s</span>`;
}
return `
<div class="message ${isUser ? 'user' : 'assistant'} fade-in">
<div class="message-avatar">${avatar}</div>
<div class="message-wrapper">
<div class="message-header">
<span class="message-role">${roleName}</span>
${modelBadge}
${internetBadge}
<span class="message-time">${time}</span>
${durationHtml}
</div>
${attachmentsHtml}
<div class="message-content">${this._formatContent(msg.content)}</div>
</div>
</div>
`;
}
// ==================== CONTENT FORMATTING (EXACT MATCH WITH USER SIDE) ====================
_formatContent(content) {
if (!content || typeof content !== 'string') return '';
let formatted = content;
// Remove internet search indicator lines
formatted = formatted.replace(/^🌐\s*Searching for.*?\.\.\.?\s*$/gm, '');
formatted = formatted.replace(/^🌐\s*LIVE INTERNET SEARCH RESULTS:?\s*$/gm, '');
formatted = formatted.replace(/^\s*\n+/g, '').replace(/\n{3,}/g, '\n\n');
// ==================== COMPREHENSIVE NUMBER-TEXT SPACING FIX ====================
// This fixes ALL cases where numbers are attached to text without proper spacing
// STEP 1: Fix numbered list items at start of lines
// Handle "1AI" -> "1. AI" (number + uppercase letter)
formatted = formatted.replace(/^(\d+)([A-Z][a-zA-Z])/gm, '$1. $2');
// Handle "1**text**" -> "1. **text**" (number + bold markdown)
formatted = formatted.replace(/^(\d+)(\*\*[^*]+\*\*)/gm, '$1. $2');
// Handle "1text" -> "1. text" (number + any letter at line start)
formatted = formatted.replace(/^(\d+)([a-zA-Z])/gm, '$1. $2');
// Handle "1.text" -> "1. text" (dot but no space)
formatted = formatted.replace(/^(\d+)\.([^\s\d])/gm, '$1. $2');
// Handle "1)" -> "1. " (parenthesis style to dot style)
formatted = formatted.replace(/^(\d+)\)\s*/gm, '$1. ');
// Handle "1-" -> "1. " (dash style to dot style)
formatted = formatted.replace(/^(\d+)-\s*/gm, '$1. ');
// STEP 2: Fix numbers attached to words ANYWHERE in text (not just line start)
formatted = formatted.replace(/(\s)(\d+)([A-Z][a-zA-Z])/g, '$1$2. $3');
formatted = formatted.replace(/(\s)(\d+)([a-z])/g, '$1$2. $3');
// STEP 3: Fix specific patterns like "Word1Word" -> "Word 1. Word"
formatted = formatted.replace(/([a-zA-Z])(\d+)([A-Z][a-zA-Z])/g, '$1\n$2. $3');
formatted = formatted.replace(/([a-z])(\d+)([a-z])/g, '$1 $2. $3');
// STEP 4: Fix bullet points without space
formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1');
formatted = formatted.replace(/^\*([A-Za-z])/gm, '* $1');
formatted = formatted.replace(/^•([A-Za-z])/gm, '• $1');
// STEP 5: Fix "For Word" type headers followed by numbered lists
formatted = formatted.replace(/\n(\d+)([A-Z])/g, '\n$1. $2');
formatted = formatted.replace(/\n(\d+)([a-z])/g, '\n$1. $2');
// STEP 6: Fix numbers inside bold markdown (e.g., **1Star** -> **1. Star**)
formatted = formatted.replace(/\*\*(\d+)([A-Z][a-zA-Z])/g, '**$1. $2');
formatted = formatted.replace(/\*\*(\d+)([a-z])/g, '**$1. $2');
// Store math blocks temporarily
const mathBlocks = [];
const inlineMath = [];
// Protect display math blocks: $$ ... $$ or \[ ... \]
formatted = formatted.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
const placeholder = `__MATH_BLOCK_${mathBlocks.length}__`;
mathBlocks.push({ math: math.trim(), display: true });
return placeholder;
});
formatted = formatted.replace(/\\\[([\s\S]*?)\\\]/g, (_, 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, (_, math) => {
const placeholder = `__INLINE_MATH_${inlineMath.length}__`;
inlineMath.push({ math: math.trim(), display: false });
return placeholder;
});
formatted = formatted.replace(/\\\((.+?)\\\)/gs, (_, math) => {
const placeholder = `__INLINE_MATH_${inlineMath.length}__`;
inlineMath.push({ math: math.trim(), display: false });
return placeholder;
});
// Store code blocks - detect LaTeX inside code blocks and render as math
const codeBlocks = [];
formatted = formatted.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, lang, code) => {
const trimmedCode = code.trim();
const language = lang ? lang.toLowerCase() : '';
// Check if this is LaTeX content inside a code block
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);
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);
const isLaTeX = (language === 'latex' || language === 'tex' || language === 'math') ||
((language === '' || language === 'plaintext') && hasLaTeXCommands && !hasCodePatterns);
if (isLaTeX) {
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(`<div class="code-block"><div class="code-header"><span class="code-language">${displayLang}</span><button class="code-copy-btn" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg><span>Copy</span></button></div><div class="code-content"><code>${escapedCode}</code></div></div>`);
return placeholder;
});
// Inline code - extract BEFORE auto-formatting
const inlineCodes = [];
formatted = formatted.replace(/`([^`]+)`/g, (_, code) => {
const placeholder = `__INLINE_CODE_${inlineCodes.length}__`;
inlineCodes.push(`<code>${this.escapeHtml(code)}</code>`);
return placeholder;
});
// Apply auto-format math expressions AFTER code blocks are protected
formatted = this._autoFormatMathExpressions(formatted);
// Tables
const tablePlaceholders = [];
formatted = this._parseMarkdownTables(formatted, tablePlaceholders);
// Convert escaped HTML entities to markdown
formatted = formatted.replace(/&lt;h([1-6])[^&]*&gt;([\s\S]*?)&lt;\/h\1&gt;/gi, (_, level, text) => `${'#'.repeat(parseInt(level))} ${text.trim()}`);
formatted = formatted.replace(/&lt;strong[^&]*&gt;([\s\S]*?)&lt;\/strong&gt;/gi, '**$1**');
formatted = formatted.replace(/&lt;b[^&]*&gt;([\s\S]*?)&lt;\/b&gt;/gi, '**$1**');
formatted = formatted.replace(/&lt;em[^&]*&gt;([\s\S]*?)&lt;\/em&gt;/gi, '*$1*');
formatted = formatted.replace(/&lt;i[^&]*&gt;([\s\S]*?)&lt;\/i&gt;/gi, '*$1*');
formatted = formatted.replace(/&lt;u[^&]*&gt;([\s\S]*?)&lt;\/u&gt;/gi, '_$1_');
formatted = formatted.replace(/&lt;br[^&]*\/?&gt;/gi, '\n');
formatted = formatted.replace(/&lt;span[^&]*&gt;([\s\S]*?)&lt;\/span&gt;/gi, '$1');
// Handle raw HTML tags
let prev = '';
let maxIter = 10;
while (prev !== formatted && maxIter-- > 0) {
prev = formatted;
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(/<span[^>]*>([\s\S]*?)<\/span>/gi, '$1');
}
// Headings
formatted = formatted.replace(/^###### (.+)$/gm, (_, text) => `<h6>${this._formatInlineContent(text)}</h6>`);
formatted = formatted.replace(/^##### (.+)$/gm, (_, text) => `<h5>${this._formatInlineContent(text)}</h5>`);
formatted = formatted.replace(/^#### (.+)$/gm, (_, text) => `<h4>${this._formatInlineContent(text)}</h4>`);
formatted = formatted.replace(/^### (.+)$/gm, (_, text) => `<h3>${this._formatInlineContent(text)}</h3>`);
formatted = formatted.replace(/^## (.+)$/gm, (_, text) => `<h2>${this._formatInlineContent(text)}</h2>`);
formatted = formatted.replace(/^# (.+)$/gm, (_, text) => `<h1>${this._formatInlineContent(text)}</h1>`);
// Bold, Italic, Underline (use display-safe escaping)
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, text) => `<strong>${this.escapeHtmlDisplay(text)}</strong>`);
formatted = formatted.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, (_, text) => `<em>${this.escapeHtmlDisplay(text)}</em>`);
formatted = formatted.replace(/(?<![_\w])_([^_]+)_(?![_\w])/g, (match, text) => {
if (/__/.test(match)) return match;
return `<u>${this.escapeHtmlDisplay(text)}</u>`;
});
// Lists
formatted = formatted.replace(/^(\d+)\. (.+)$/gm, (_, num, text) => `<li data-num="${num}">${this._formatInlineContent(text)}</li>`);
formatted = formatted.replace(/((?:<li data-num="\d+">[\s\S]*?<\/li>\n?)+)/g, '<ol>$1</ol>');
formatted = formatted.replace(/<\/ol>\n<ol>/g, '');
formatted = formatted.replace(/ data-num="\d+"/g, '');
formatted = formatted.replace(/^[-*] (.+)$/gm, (_, text) => `<uli>${this._formatInlineContent(text)}</uli>`);
formatted = formatted.replace(/((?:<uli>[\s\S]*?<\/uli>\n?)+)/g, '<ul>$1</ul>');
formatted = formatted.replace(/<\/ul>\n<ul>/g, '');
formatted = formatted.replace(/<uli>/g, '<li>');
formatted = formatted.replace(/<\/uli>/g, '</li>');
// Blockquotes
formatted = formatted.replace(/^> (.+)$/gm, (_, text) => `<blockquote>${this._formatInlineContent(text)}</blockquote>`);
formatted = formatted.replace(/<\/blockquote>\n<blockquote>/g, '<br>');
// Horizontal rule
formatted = formatted.replace(/^---$/gm, '<hr>');
formatted = formatted.replace(/^\*\*\*$/gm, '<hr>');
formatted = formatted.replace(/^___$/gm, '<hr>');
// Links
formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safeUrl = this._sanitizeUrl(url);
if (!safeUrl) return this.escapeHtmlDisplay(text);
return `<a href="${this.escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${this.escapeHtmlDisplay(text)}</a>`;
});
// 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|__MATH)/.test(trimmed) ||
/<\/(h[1-6]|ul|ol|blockquote|div|pre)>$/.test(trimmed);
if (isBlockElement || trimmed === '') {
if (paragraphContent.length > 0) {
result.push('<p>' + paragraphContent.join('<br>') + '</p>');
paragraphContent = [];
}
if (trimmed !== '') result.push(line);
} else {
paragraphContent.push(trimmed);
}
}
if (paragraphContent.length > 0) {
result.push('<p>' + paragraphContent.join('<br>') + '</p>');
}
formatted = result.join('\n');
// Restore placeholders
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);
});
tablePlaceholders.forEach((table, i) => {
formatted = formatted.replace(new RegExp(`__TABLE_BLOCK_${i}__`, 'g'), table);
});
mathBlocks.forEach((item, i) => {
const rendered = this._renderMath(item.math, true);
formatted = formatted.replace(new RegExp(`__MATH_BLOCK_${i}__`, 'g'), rendered);
});
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><br><\/p>/g, '');
formatted = formatted.replace(/<p>\s*<\/p>/g, '');
// Final pass for remaining escaped HTML entities
formatted = formatted.replace(/&lt;\s*h([1-6])\s*&gt;([\s\S]*?)&lt;\s*\/\s*h\1\s*&gt;/gi, (_, level, text) => `<h${level}>${text.trim()}</h${level}>`);
formatted = formatted.replace(/&lt;\s*strong\s*&gt;([\s\S]*?)&lt;\s*\/\s*strong\s*&gt;/gi, '<strong>$1</strong>');
formatted = formatted.replace(/&lt;\s*em\s*&gt;([\s\S]*?)&lt;\s*\/\s*em\s*&gt;/gi, '<em>$1</em>');
formatted = formatted.replace(/&lt;\s*br\s*\/?&gt;/gi, '<br>');
formatted = formatted.replace(/&lt;\s*hr\s*\/?&gt;/gi, '<hr>');
return formatted;
}
_formatInlineContent(text) {
if (!text) return '';
let result = text;
// Convert escaped HTML entities to markdown
result = result.replace(/&lt;strong[^&]*&gt;([\s\S]*?)&lt;\/strong&gt;/gi, '**$1**');
result = result.replace(/&lt;b[^&]*&gt;([\s\S]*?)&lt;\/b&gt;/gi, '**$1**');
result = result.replace(/&lt;em[^&]*&gt;([\s\S]*?)&lt;\/em&gt;/gi, '*$1*');
result = result.replace(/&lt;i[^&]*&gt;([\s\S]*?)&lt;\/i&gt;/gi, '*$1*');
result = result.replace(/&lt;u[^&]*&gt;([\s\S]*?)&lt;\/u&gt;/gi, '_$1_');
result = result.replace(/&lt;span[^&]*&gt;([\s\S]*?)&lt;\/span&gt;/gi, '$1');
// Handle raw HTML tags
let prev = '';
let maxIter = 10;
while (prev !== result && maxIter-- > 0) {
prev = result;
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');
}
// Store bold/italic with unique markers
const bolds = [];
const italics = [];
const underlines = [];
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(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, content) => {
italics.push(content);
return `${italicMarker}${italics.length - 1}${italicMarker}`;
});
result = result.replace(/(?<![_\w])_([^_]+)_(?![_\w])/g, (match, content) => {
if (/__/.test(match)) return match;
underlines.push(content);
return `${underlineMarker}${underlines.length - 1}${underlineMarker}`;
});
// Escape for XSS protection
result = this.escapeHtmlDisplay(result);
// Restore bold with proper HTML
bolds.forEach((content, i) => {
result = result.replace(new RegExp(`${boldMarker}${i}${boldMarker}`, 'g'), `<strong>${this.escapeHtmlDisplay(content)}</strong>`);
});
// Restore italic with proper HTML
italics.forEach((content, i) => {
result = result.replace(new RegExp(`${italicMarker}${i}${italicMarker}`, 'g'), `<em>${this.escapeHtmlDisplay(content)}</em>`);
});
// Restore underline with proper HTML
underlines.forEach((content, i) => {
result = result.replace(new RegExp(`${underlineMarker}${i}${underlineMarker}`, 'g'), `<u>${this.escapeHtmlDisplay(content)}</u>`);
});
return result;
}
_renderMath(math, displayMode = false) {
if (!math) return '';
try {
// Check if KaTeX is available (global scope)
const katexLib = typeof katex !== 'undefined' ? katex : (typeof window !== 'undefined' ? window.katex : null);
if (katexLib && typeof katexLib.renderToString === 'function') {
const html = katexLib.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 `<div class="math-block">${html}</div>`;
}
return `<span class="math-inline">${html}</span>`;
}
} 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
for (let i = 0; i < 5; i++) {
fallback = fallback.replace(/\^(\{[^{}]+\})/g, (_, exp) => {
const content = exp.slice(1, -1);
return content.split('').map(c => SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || c).join('');
});
fallback = fallback.replace(/\^([0-9a-zA-Z+\-])/g, (_, c) => {
return SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || `^${c}`;
});
fallback = fallback.replace(/_(\{[^{}]+\})/g, (_, exp) => {
const content = exp.slice(1, -1);
return content.split('').map(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || c).join('');
});
fallback = fallback.replace(/_([0-9a-zA-Z])/g, (_, c) => {
return SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || `_${c}`;
});
}
// Final cleanup
fallback = fallback.replace(/[{}]/g, '');
if (displayMode) {
return `<div class="math-block math-fallback">${this.escapeHtmlDisplay(fallback)}</div>`;
}
return `<span class="math-inline math-fallback">${this.escapeHtmlDisplay(fallback)}</span>`;
}
_parseMarkdownTables(content, placeholders) {
if (!content) return content;
const lines = content.split('\n');
const result = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
const nextLine = lines[i + 1];
if (nextLine && /^\|[\s:|-]+\|$/.test(nextLine.trim())) {
const tableLines = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim().startsWith('|')) {
tableLines.push(lines[j]);
j++;
}
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');
}
_convertTableToHtml(lines) {
if (lines.length < 2) return null;
const headerLine = lines[0].trim();
const headerCells = headerLine.split('|').filter(c => c.trim()).map(c => c.trim());
const separatorLine = lines[1].trim();
const aligns = separatorLine.split('|').filter(c => c.trim()).map(c => {
const cell = c.trim();
if (cell.startsWith(':') && cell.endsWith(':')) return 'center';
if (cell.endsWith(':')) return 'right';
return 'left';
});
let html = '<div class="table-wrapper"><table><thead><tr>';
headerCells.forEach((h, i) => {
html += `<th style="text-align:${aligns[i] || 'left'}">${this._formatInlineContent(h)}</th>`;
});
html += '</tr></thead><tbody>';
for (let i = 2; i < lines.length; i++) {
const cells = lines[i].split('|').filter(c => c !== '').map(c => c.trim());
html += '<tr>';
cells.forEach((c, j) => {
if (c !== undefined) {
html += `<td style="text-align:${aligns[j] || 'left'}">${this._formatInlineContent(c)}</td>`;
}
});
html += '</tr>';
}
html += '</tbody></table></div>';
return html;
}
_initCodeCopyButtons() {
this.messagesArea?.querySelectorAll('.code-copy-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const codeBlock = btn.closest('.code-block');
const code = codeBlock?.querySelector('code')?.textContent;
if (code) {
try {
await navigator.clipboard.writeText(code);
btn.classList.add('copied');
btn.querySelector('span').textContent = 'Copied!';
setTimeout(() => {
btn.classList.remove('copied');
btn.querySelector('span').textContent = 'Copy';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
});
});
}
// ==================== USER INTERACTIONS ====================
_selectUser(ip) {
this.currentUser = ip;
this.currentChat = null;
this.selectedUserName.textContent = this._maskIp(ip);
// Update breadcrumb
this._updateBreadcrumb(['Dashboard', `User: ${this._maskIp(ip)}`]);
// Update chat count badge
const user = this.users.find(u => u.ip === ip);
if (this.chatCountBadge && this.chatCount && user) {
this.chatCount.textContent = user.chatCount || 0;
this.chatCountBadge.style.display = 'flex';
}
// Render user details panel
if (user) {
this._renderUserDetailsPanel(user);
}
// Update active state
this.userList.querySelectorAll('.user-card').forEach(card => {
card.classList.toggle('active', card.dataset.ip === ip);
});
// Load chats
this._loadUserChats(ip);
// Clear messages
this.chatHeader.style.display = 'none';
this.messagesArea.innerHTML = `
<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
</svg>
<p>Select a chat to view messages</p>
</div>
`;
// On mobile, close sidebar and show chats panel
if (window.innerWidth <= 768) {
this._closeSidebar();
this._openChatsPanel();
}
}
_selectChat(chatId) {
this.currentChat = chatId;
// Update breadcrumb
const chat = this.chats.find(c => c.id === chatId);
const chatTitle = chat?.title || 'Chat';
this._updateBreadcrumb(['Dashboard', `User: ${this._maskIp(this.currentUser)}`, chatTitle]);
// Update active state
this.chatsList.querySelectorAll('.chat-card').forEach(card => {
card.classList.toggle('active', card.dataset.id === chatId);
});
// Load messages
this._loadChatMessages(chatId);
// On mobile, hide chats panel
if (window.innerWidth <= 768) {
this._closeChatsPanel();
}
}
_updateBreadcrumb(items) {
if (!this.breadcrumb) return;
const html = items.map((item, index) => {
const isLast = index === items.length - 1;
const isClickable = index < items.length - 1;
let breadcrumbHtml = `<span class="breadcrumb-item ${isLast ? 'active' : ''}" ${isClickable ? 'data-level="' + index + '"' : ''}>${item}</span>`;
if (!isLast) {
breadcrumbHtml += '<span class="breadcrumb-separator"></span>';
}
return breadcrumbHtml;
}).join('');
this.breadcrumb.innerHTML = html;
// Add click handlers for navigation
this.breadcrumb.querySelectorAll('.breadcrumb-item[data-level]').forEach(item => {
item.addEventListener('click', () => {
const level = parseInt(item.dataset.level);
this._navigateToBreadcrumbLevel(level);
});
});
}
_navigateToBreadcrumbLevel(level) {
if (level === 0) {
// Navigate to dashboard
this.currentUser = null;
this.currentChat = null;
this.selectedUserName.textContent = '-';
this._updateBreadcrumb(['Dashboard']);
if (this.chatCountBadge) {
this.chatCountBadge.style.display = 'none';
}
// Clear active states
this.userList.querySelectorAll('.user-card').forEach(card => {
card.classList.remove('active');
});
// Clear chats and messages
this.chatsList.innerHTML = '<div class="empty-state"><p>Select a user to view chats</p></div>';
this.messagesArea.innerHTML = '<div class="empty-state"><p>Select a chat to view messages</p></div>';
this.chatHeader.style.display = 'none';
} else if (level === 1 && this.currentUser) {
// Navigate back to user level
this.currentChat = null;
this._updateBreadcrumb(['Dashboard', `User: ${this._maskIp(this.currentUser)}`]);
// Clear chat selection
this.chatsList.querySelectorAll('.chat-card').forEach(card => {
card.classList.remove('active');
});
// Clear messages
this.messagesArea.innerHTML = '<div class="empty-state"><p>Select a chat to view messages</p></div>';
this.chatHeader.style.display = 'none';
}
}
_filterUsers(query) {
const q = query.toLowerCase().trim();
let visibleCount = 0;
this.userList.querySelectorAll('.user-card').forEach(card => {
const ip = card.dataset.ip.toLowerCase();
const deviceText = Array.from(card.querySelectorAll('.user-badge'))
.map(badge => badge.textContent.toLowerCase()).join(' ');
const isVisible = ip.includes(q) || deviceText.includes(q);
card.style.display = isVisible ? '' : 'none';
if (isVisible) {
visibleCount++;
// Highlight matching text
this._highlightSearchText(card, q);
}
});
// Update search results counter
if (this.searchResultsCount) {
if (q && visibleCount > 0) {
this.searchResultsCount.textContent = visibleCount;
this.searchResultsCount.style.display = 'block';
} else {
this.searchResultsCount.style.display = 'none';
}
}
// Show search results count
if (q && visibleCount === 0) {
this.userList.innerHTML += '<div class="no-data">No users match your search</div>';
}
}
_highlightSearchText(element, query) {
if (!query) return;
const textNodes = this._getTextNodes(element);
textNodes.forEach(node => {
const text = node.textContent;
const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi');
if (regex.test(text)) {
const highlightedText = text.replace(regex, '<mark>$1</mark>');
const wrapper = document.createElement('span');
wrapper.innerHTML = highlightedText;
node.parentNode.replaceChild(wrapper, node);
}
});
}
_getTextNodes(element) {
const textNodes = [];
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
if (node.textContent.trim()) {
textNodes.push(node);
}
}
return textNodes;
}
escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
_switchTab(tab) {
// Add smooth transition feedback
const currentTab = document.querySelector('.tab-btn.active');
if (currentTab) {
currentTab.style.transform = 'scale(0.95)';
setTimeout(() => {
currentTab.style.transform = '';
}, 150);
}
this.tabBtns.forEach(btn => {
const isActive = btn.dataset.tab === tab;
btn.classList.toggle('active', isActive);
if (isActive) {
btn.style.transform = 'scale(1.05)';
setTimeout(() => {
btn.style.transform = '';
}, 150);
}
});
// Smooth content transition
const fadeOut = (element) => {
if (element) {
element.style.opacity = '0';
element.style.transform = 'translateY(10px)';
}
};
const fadeIn = (element) => {
if (element) {
setTimeout(() => {
element.style.display = '';
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
}, 150);
}
};
if (tab === 'users') {
fadeOut(this.statsTab);
setTimeout(() => {
this.statsTab.style.display = 'none';
fadeIn(this.usersTab);
}, 150);
} else {
fadeOut(this.usersTab);
setTimeout(() => {
this.usersTab.style.display = 'none';
fadeIn(this.statsTab);
}, 150);
}
}
_toggleSidebar() {
this.sidebar.classList.toggle('open');
this.sidebarOverlay.classList.toggle('active');
// Prevent body scroll when sidebar is open on mobile
document.body.style.overflow = this.sidebar.classList.contains('open') ? 'hidden' : '';
}
_closeSidebar() {
this.sidebar.classList.remove('open');
this.sidebarOverlay.classList.remove('active');
document.body.style.overflow = '';
}
_openChatsPanel() {
this.chatsPanel?.classList.add('open');
this.chatsPanelOverlay?.classList.add('active');
document.body.style.overflow = 'hidden';
}
_closeChatsPanel() {
this.chatsPanel?.classList.remove('open');
this.chatsPanelOverlay?.classList.remove('active');
document.body.style.overflow = '';
}
// ==================== ENHANCED ACTIONS ====================
async _refreshData() {
if (this.refreshBtn) {
this.refreshBtn.disabled = true;
this.refreshBtn.classList.add('spinning');
}
try {
// Show context-aware loading
this._setLoadingState(true, 'refresh');
await this._loadData();
// Refresh current user's chats if selected
if (this.currentUser) {
await this._loadUserChats(this.currentUser);
}
// Refresh current chat messages if selected
if (this.currentChat) {
await this._loadChatMessages(this.currentChat);
}
this._showToast('Data refreshed successfully', 'success', 3000);
} catch (err) {
console.error('Refresh failed:', err);
this._showToast('Failed to refresh data', 'error');
} finally {
this._setLoadingState(false);
if (this.refreshBtn) {
this.refreshBtn.disabled = false;
this.refreshBtn.classList.remove('spinning');
this.refreshBtn.querySelector('span').textContent = 'Refresh';
}
}
}
// Enhanced keyboard shortcuts
_initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Only handle shortcuts when not typing in inputs
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.key) {
case 'Escape':
this._hideDialog();
this._hideHelp();
this._closeSidebar();
this._closeChatsPanel();
break;
case 'r':
case 'R':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
this._refreshData();
}
break;
case 'f':
case 'F':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
this.userSearch?.focus();
}
break;
case '1':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
this._switchTab('users');
}
break;
case '2':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
this._switchTab('stats');
}
break;
}
});
}
// ==================== EXPORT FUNCTIONS ====================
_toggleExportDropdown(e) {
e.stopPropagation();
this.exportDropdown?.classList.toggle('open');
}
async _exportJson() {
this.exportDropdown?.classList.remove('open');
try {
const response = await fetch(`${API_BASE}/export?format=json`, {
headers: { 'Authorization': `Bearer ${this.token}` }
});
if (!response.ok) throw new Error('Export failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rox-admin-export-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
this._showToast('JSON export downloaded', 'success');
} catch (err) {
this._showToast('Export failed', 'error');
}
}
_exportStatsPdf() {
this.exportDropdown?.classList.remove('open');
const s = this.stats;
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' });
// Build model usage HTML
let modelUsageHtml = '';
if (s.modelUsage && Object.keys(s.modelUsage).length > 0) {
const total = Object.values(s.modelUsage).reduce((a, b) => a + b, 0) || 1;
modelUsageHtml = Object.entries(s.modelUsage).map(([model, count]) => {
const percent = Math.round((count / total) * 100);
return `<div class="usage-row"><span class="usage-label">${this.escapeHtml(model)}</span><div class="usage-bar"><div class="usage-fill" style="width:${percent}%"></div></div><span class="usage-value">${count} (${percent}%)</span></div>`;
}).join('');
}
const pdfHtml = this._getPdfTemplate('Rox AI Admin Report', dateStr, timeStr, `
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${s.totalUsers || 0}</div><div class="stat-label">Total Users</div></div>
<div class="stat-card"><div class="stat-value">${s.totalChats || 0}</div><div class="stat-label">Total Chats</div></div>
<div class="stat-card"><div class="stat-value">${s.todayQueries || 0}</div><div class="stat-label">Today's Queries</div></div>
<div class="stat-card"><div class="stat-value">${s.totalMessages || 0}</div><div class="stat-label">Total Messages</div></div>
<div class="stat-card"><div class="stat-value">${s.activeSessions || 0}</div><div class="stat-label">Active Sessions</div></div>
<div class="stat-card"><div class="stat-value">${s.avgResponseTime || 0}ms</div><div class="stat-label">Avg Response Time</div></div>
</div>
${modelUsageHtml ? `<h2>Model Usage</h2><div class="model-usage">${modelUsageHtml}</div>` : ''}
`);
this._printPdf(pdfHtml);
this._showToast('Stats PDF ready - uncheck "Headers and footers" in print options', 'success');
}
_exportUsersPdf() {
this.exportDropdown?.classList.remove('open');
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' });
const usersHtml = this.users.map(user => `
<tr>
<td>${this.escapeHtml(this._maskIp(user.ip))}</td>
<td>${this.escapeHtml(user.device?.browser || '-')}</td>
<td>${this.escapeHtml(user.device?.os || '-')}</td>
<td>${user.chatCount || 0}</td>
<td><span class="status-badge ${user.isOnline ? 'online' : ''}">${user.isOnline ? 'Online' : 'Offline'}</span></td>
<td>${this.escapeHtml(this._formatTimeAgo(user.lastActivity))}</td>
</tr>
`).join('');
const pdfHtml = this._getPdfTemplate('Rox AI Users Report', dateStr, timeStr, `
<p class="summary">Total Users: <strong>${this.users.length}</strong></p>
<table>
<thead>
<tr>
<th>IP Address</th>
<th>Browser</th>
<th>OS</th>
<th>Chats</th>
<th>Status</th>
<th>Last Active</th>
</tr>
</thead>
<tbody>${usersHtml}</tbody>
</table>
`);
this._printPdf(pdfHtml);
this._showToast('Users PDF ready - uncheck "Headers and footers" in print options', 'success');
}
_exportChatJson() {
if (!this.currentChat) {
this._showToast('No chat selected', 'error');
return;
}
const chat = this.chats.find(c => c.id === this.currentChat);
if (!chat) {
this._showToast('Chat not found', 'error');
return;
}
const data = {
chatId: this.currentChat,
title: chat.title,
exportedAt: new Date().toISOString(),
messageCount: chat.messageCount,
messages: this._currentMessages || []
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-${this.currentChat.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
this._showToast('Chat JSON downloaded', 'success');
}
_exportChatPdf() {
if (!this.currentChat || !this._currentMessages?.length) {
this._showToast('No chat selected or no messages', 'error');
return;
}
const chat = this.chats.find(c => c.id === this.currentChat);
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 messages with proper markdown rendering
const messagesHtml = this._currentMessages.map(msg => {
const isUser = msg.role === 'user';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleString() : '';
const model = msg.model || '';
const formattedContent = this._formatContentForPdf(msg.content || '');
return `
<div class="message ${isUser ? 'user' : 'assistant'}">
<div class="message-header">
<span class="role-badge ${isUser ? 'user-badge' : 'ai-badge'}">${isUser ? 'USER' : 'ROX AI'}</span>
<span class="message-meta">${time}${model ? ` • ${this.escapeHtml(model)}` : ''}</span>
</div>
<div class="message-content">${formattedContent}</div>
</div>
`;
}).join('');
const pdfHtml = this._getPdfTemplate(
chat?.title || 'Chat Export',
dateStr,
timeStr,
`<p class="summary">${this._currentMessages.length} messages</p><div class="messages">${messagesHtml}</div>`,
true // isChat flag for chat-specific styles
);
this._printPdf(pdfHtml);
this._showToast('Chat PDF ready - uncheck "Headers and footers" in print options', 'success');
}
// Format content for PDF with proper markdown rendering (same approach as main app.js)
_formatContentForPdf(content) {
if (!content || typeof content !== 'string') return '';
let formatted = content;
// Remove internet search indicator lines
formatted = formatted.replace(/^🌐\s*Searching for.*?\.\.\.?\s*$/gm, '');
formatted = formatted.replace(/^🌐\s*LIVE INTERNET SEARCH RESULTS:?\s*$/gm, '');
formatted = formatted.replace(/^\s*\n+/g, '').replace(/\n{3,}/g, '\n\n');
// ==================== COMPREHENSIVE NUMBER-TEXT SPACING FIX ====================
// This fixes ALL cases where numbers are attached to text without proper spacing
// STEP 1: Fix numbered list items at start of lines
// Handle "1AI" -> "1. AI" (number + uppercase letter)
formatted = formatted.replace(/^(\d+)([A-Z][a-zA-Z])/gm, '$1. $2');
// Handle "1**text**" -> "1. **text**" (number + bold markdown)
formatted = formatted.replace(/^(\d+)(\*\*[^*]+\*\*)/gm, '$1. $2');
// Handle "1text" -> "1. text" (number + any letter at line start)
formatted = formatted.replace(/^(\d+)([a-zA-Z])/gm, '$1. $2');
// Handle "1.text" -> "1. text" (dot but no space)
formatted = formatted.replace(/^(\d+)\.([^\s\d])/gm, '$1. $2');
// Handle "1)" -> "1. " (parenthesis style to dot style)
formatted = formatted.replace(/^(\d+)\)\s*/gm, '$1. ');
// Handle "1-" -> "1. " (dash style to dot style)
formatted = formatted.replace(/^(\d+)-\s*/gm, '$1. ');
// STEP 2: Fix numbers attached to words ANYWHERE in text (not just line start)
// This catches cases like "Cost1Use" -> "Cost 1. Use" or inline "see step1for" -> "see step 1 for"
// Pattern: word boundary + number + letter (not at line start)
formatted = formatted.replace(/(\s)(\d+)([A-Z][a-zA-Z])/g, '$1$2. $3');
formatted = formatted.replace(/(\s)(\d+)([a-z])/g, '$1$2. $3');
// STEP 3: Fix specific patterns seen in the screenshot
// "Word1Word" -> "Word 1. Word" (word + number + word)
formatted = formatted.replace(/([a-zA-Z])(\d+)([A-Z][a-zA-Z])/g, '$1\n$2. $3');
// "word1word" -> "word 1. word" (lowercase word + number + lowercase word)
formatted = formatted.replace(/([a-z])(\d+)([a-z])/g, '$1 $2. $3');
// STEP 4: Fix bullet points without space
formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1');
formatted = formatted.replace(/^\*([A-Za-z])/gm, '* $1');
formatted = formatted.replace(/^•([A-Za-z])/gm, '• $1');
// STEP 5: Ensure proper spacing after list markers
// "1.Word" -> "1. Word"
formatted = formatted.replace(/^(\d+)\.([A-Za-z])/gm, '$1. $2');
// "-Word" -> "- Word"
formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1');
// STEP 6: Fix "For Word" type headers followed by numbered lists
// Pattern: "For Something\n1Text" -> "For Something\n1. Text"
formatted = formatted.replace(/\n(\d+)([A-Z])/g, '\n$1. $2');
formatted = formatted.replace(/\n(\d+)([a-z])/g, '\n$1. $2');
// Strip ALL HTML tags (with ANY attributes including style) to plain text/markdown
let prevStrip = '';
let maxIterations = 15;
while (prevStrip !== formatted && maxIterations-- > 0) {
prevStrip = formatted;
formatted = formatted.replace(/<strong\b[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**');
formatted = formatted.replace(/<b\b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**');
formatted = formatted.replace(/<em\b[^>]*>([\s\S]*?)<\/em>/gi, '*$1*');
formatted = formatted.replace(/<i\b[^>]*>([\s\S]*?)<\/i>/gi, '*$1*');
formatted = formatted.replace(/<u\b[^>]*>([\s\S]*?)<\/u>/gi, '_$1_');
formatted = formatted.replace(/<span\b[^>]*>([\s\S]*?)<\/span>/gi, '$1');
formatted = formatted.replace(/<div\b[^>]*>([\s\S]*?)<\/div>/gi, '$1\n');
formatted = formatted.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, '$1\n\n');
formatted = formatted.replace(/<br\b[^>]*\/?>/gi, '\n');
formatted = formatted.replace(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, '# $1\n');
formatted = formatted.replace(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi, '## $1\n');
formatted = formatted.replace(/<h3\b[^>]*>([\s\S]*?)<\/h3>/gi, '### $1\n');
formatted = formatted.replace(/<h4\b[^>]*>([\s\S]*?)<\/h4>/gi, '#### $1\n');
formatted = formatted.replace(/<h5\b[^>]*>([\s\S]*?)<\/h5>/gi, '##### $1\n');
formatted = formatted.replace(/<h6\b[^>]*>([\s\S]*?)<\/h6>/gi, '###### $1\n');
formatted = formatted.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1');
formatted = formatted.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n');
formatted = formatted.replace(/<\/?(?:ul|ol)\b[^>]*>/gi, '');
formatted = formatted.replace(/<blockquote\b[^>]*>([\s\S]*?)<\/blockquote>/gi, '> $1\n');
formatted = formatted.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
formatted = formatted.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, '```\n$1\n```');
formatted = formatted.replace(/<hr\b[^>]*\/?>/gi, '\n---\n');
}
// Decode common HTML entities
formatted = formatted.replace(/&amp;/g, '&');
formatted = formatted.replace(/&lt;/g, '<');
formatted = formatted.replace(/&gt;/g, '>');
formatted = formatted.replace(/&quot;/g, '"');
formatted = formatted.replace(/&#39;/g, "'");
formatted = formatted.replace(/&#x27;/g, "'");
formatted = formatted.replace(/&#039;/g, "'");
formatted = formatted.replace(/&nbsp;/g, ' ');
// After decoding entities, run HTML stripping again
prevStrip = '';
maxIterations = 5;
while (prevStrip !== formatted && maxIterations-- > 0) {
prevStrip = formatted;
formatted = formatted.replace(/<strong\b[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**');
formatted = formatted.replace(/<b\b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**');
formatted = formatted.replace(/<em\b[^>]*>([\s\S]*?)<\/em>/gi, '*$1*');
formatted = formatted.replace(/<i\b[^>]*>([\s\S]*?)<\/i>/gi, '*$1*');
formatted = formatted.replace(/<span\b[^>]*>([\s\S]*?)<\/span>/gi, '$1');
}
// 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)
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)\b[^>]*>/gi, '');
// Additional pass: Fix any remaining number-text spacing issues after HTML stripping
// These patterns may have been hidden inside HTML tags - COMPREHENSIVE FIX
// STEP 1: Fix numbered list items at start of lines
formatted = formatted.replace(/^(\d+)([A-Z][a-zA-Z])/gm, '$1. $2');
formatted = formatted.replace(/^(\d+)(\*\*[^*]+\*\*)/gm, '$1. $2');
formatted = formatted.replace(/^(\d+)([a-zA-Z])/gm, '$1. $2');
formatted = formatted.replace(/^(\d+)\.([^\s\d])/gm, '$1. $2');
formatted = formatted.replace(/^(\d+)\)\s*/gm, '$1. ');
formatted = formatted.replace(/^(\d+)-\s*/gm, '$1. ');
// STEP 2: Fix numbers attached to words ANYWHERE in text
formatted = formatted.replace(/(\s)(\d+)([A-Z][a-zA-Z])/g, '$1$2. $3');
formatted = formatted.replace(/(\s)(\d+)([a-z])/g, '$1$2. $3');
// STEP 3: Fix specific patterns like "Word1Word"
formatted = formatted.replace(/([a-zA-Z])(\d+)([A-Z][a-zA-Z])/g, '$1\n$2. $3');
formatted = formatted.replace(/([a-z])(\d+)([a-z])/g, '$1 $2. $3');
// STEP 4: Fix bullet points without space
formatted = formatted.replace(/^-([A-Za-z])/gm, '- $1');
formatted = formatted.replace(/^\*([A-Za-z])/gm, '* $1');
formatted = formatted.replace(/^•([A-Za-z])/gm, '• $1');
// STEP 5: Fix after newlines
formatted = formatted.replace(/\n(\d+)([A-Z])/g, '\n$1. $2');
formatted = formatted.replace(/\n(\d+)([a-z])/g, '\n$1. $2');
// STEP 6: Fix numbers inside bold markdown (e.g., **1Star** -> **1. Star**)
formatted = formatted.replace(/\*\*(\d+)([A-Z][a-zA-Z])/g, '**$1. $2');
formatted = formatted.replace(/\*\*(\d+)([a-z])/g, '**$1. $2');
// Clean up multiple newlines and spaces
formatted = formatted.replace(/\n{3,}/g, '\n\n');
formatted = formatted.replace(/ +/g, ' ');
// Store math blocks temporarily for PDF handling
const mathBlocks = [];
const inlineMathPdf = [];
// Protect display math blocks: $$ ... $$ or \[ ... \]
formatted = formatted.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
const placeholder = `__PDF_MATH_BLOCK_${mathBlocks.length}__`;
mathBlocks.push(math.trim());
return placeholder;
});
formatted = formatted.replace(/\\\[([\s\S]*?)\\\]/g, (_, math) => {
const placeholder = `__PDF_MATH_BLOCK_${mathBlocks.length}__`;
mathBlocks.push(math.trim());
return placeholder;
});
// Protect inline math: $ ... $ or \( ... \)
formatted = formatted.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (_, math) => {
const placeholder = `__PDF_INLINE_MATH_${inlineMathPdf.length}__`;
inlineMathPdf.push(math.trim());
return placeholder;
});
formatted = formatted.replace(/\\\((.+?)\\\)/gs, (_, math) => {
const placeholder = `__PDF_INLINE_MATH_${inlineMathPdf.length}__`;
inlineMathPdf.push(math.trim());
return placeholder;
});
// Store code blocks temporarily
const codeBlocks = [];
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const placeholder = `__PDF_CODE_BLOCK_${codeBlocks.length}__`;
const language = lang ? lang.toUpperCase() : 'CODE';
codeBlocks.push(`<div class="code-block-wrapper"><div class="code-block-header">${this.escapeHtml(language)}</div><pre><code>${this.escapeHtml(code.trim())}</code></pre></div>`);
return placeholder;
});
// Store inline codes
const inlineCodes = [];
formatted = formatted.replace(/`([^`]+)`/g, (_, code) => {
const placeholder = `__PDF_INLINE_CODE_${inlineCodes.length}__`;
inlineCodes.push(`<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-family:'Courier New',monospace;font-size:13px;color:#7c3aed;">${this.escapeHtml(code)}</code>`);
return placeholder;
});
// Headings with PDF-optimized styling (use _formatInlineContentForPdf for inline formatting)
formatted = formatted.replace(/^###### (.+)$/gm, (_, text) => `<h6 style="font-size:14px;font-weight:600;color:#4a5568;margin:16px 0 8px 0;">${this._formatInlineContentForPdf(text)}</h6>`);
formatted = formatted.replace(/^##### (.+)$/gm, (_, text) => `<h5 style="font-size:15px;font-weight:600;color:#2d3748;margin:18px 0 10px 0;">${this._formatInlineContentForPdf(text)}</h5>`);
formatted = formatted.replace(/^#### (.+)$/gm, (_, text) => `<h4 style="font-size:16px;font-weight:600;color:#2d3748;margin:20px 0 10px 0;">${this._formatInlineContentForPdf(text)}</h4>`);
formatted = formatted.replace(/^### (.+)$/gm, (_, text) => `<h3 style="font-size:18px;font-weight:600;color:#667eea;margin:22px 0 12px 0;padding-left:12px;border-left:3px solid #667eea;">${this._formatInlineContentForPdf(text)}</h3>`);
formatted = formatted.replace(/^## (.+)$/gm, (_, text) => `<h2 style="font-size:20px;font-weight:600;color:#1a202c;margin:24px 0 14px 0;padding-bottom:8px;border-bottom:1px solid #e2e8f0;">${this._formatInlineContentForPdf(text)}</h2>`);
formatted = formatted.replace(/^# (.+)$/gm, (_, text) => `<h1 style="font-size:24px;font-weight:700;color:#667eea;margin:28px 0 16px 0;padding-bottom:10px;border-bottom:2px solid #667eea;">${this._formatInlineContentForPdf(text)}</h1>`);
// Ordered lists with styled numbers (use _formatInlineContentForPdf for inline formatting)
formatted = formatted.replace(/^(\d+)\.\s*(.+)$/gm, (_, num, text) => `<div style="display:flex;align-items:flex-start;margin:12px 0;gap:12px;"><span style="min-width:28px;height:28px;background:linear-gradient(135deg, #667eea 0%, #764ba2 100%);color:white;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex-shrink:0;">${num}</span><span style="flex:1;line-height:1.7;padding-top:4px;">${this._formatInlineContentForPdf(text.trim())}</span></div>`);
// Unordered lists with bullet points (use _formatInlineContentForPdf for inline formatting)
formatted = formatted.replace(/^[-*•]\s*(.+)$/gm, (_, text) => `<div style="display:flex;align-items:flex-start;margin:10px 0;padding-left:4px;gap:12px;"><span style="color:#667eea;font-size:20px;line-height:1;flex-shrink:0;">•</span><span style="flex:1;line-height:1.7;">${this._formatInlineContentForPdf(text.trim())}</span></div>`);
// Blockquotes (use _formatInlineContentForPdf for inline formatting)
formatted = formatted.replace(/^> (.+)$/gm, (_, text) => `<blockquote style="margin:16px 0;padding:16px 20px;border-left:4px solid #667eea;background:linear-gradient(90deg, rgba(102, 126, 234, 0.08) 0%, transparent 100%);border-radius:0 8px 8px 0;font-style:italic;color:#4a5568;">${this._formatInlineContentForPdf(text)}</blockquote>`);
// Tables for PDF
formatted = this._parseMarkdownTablesForPdf(formatted);
// Horizontal rules
formatted = formatted.replace(/^---$/gm, '<hr style="border:none;height:2px;background:linear-gradient(90deg, transparent, #667eea, transparent);margin:24px 0;opacity:0.5;">');
// Links (format link text for inline markdown)
formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safeUrl = this._sanitizeUrl(url);
if (!safeUrl) return this._formatInlineContentForPdf(text);
return `<a href="${this.escapeHtml(safeUrl)}" style="color:#667eea;text-decoration:none;border-bottom:1px solid #667eea;">${this._formatInlineContentForPdf(text)}</a>`;
});
// Paragraphs - format inline content for any remaining markdown
const lines = formatted.split('\n');
const result = [];
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) {
const formattedParagraph = paragraphContent.map(p => this._formatInlineContentForPdf(p)).join('<br>');
result.push('<p style="margin:0 0 12px 0;line-height:1.7;color:#2d3748;">' + formattedParagraph + '</p>');
paragraphContent = [];
}
if (trimmed !== '') result.push(line);
} else {
paragraphContent.push(trimmed);
}
}
if (paragraphContent.length > 0) {
const formattedParagraph = paragraphContent.map(p => this._formatInlineContentForPdf(p)).join('<br>');
result.push('<p style="margin:0 0 12px 0;line-height:1.7;color:#2d3748;">' + formattedParagraph + '</p>');
}
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[^>]*><br><\/p>/g, '');
formatted = formatted.replace(/<p[^>]*>\s*<\/p>/g, '');
return formatted;
}
// Render math for PDF with Unicode fallback (matches app.js _renderMathForPDF)
_renderMathForPdf(math, displayMode = false) {
if (!math) return '';
// Convert LaTeX to Unicode for PDF
let rendered = 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 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
.replace(/\\frac\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g, '($1)/($2)')
.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)')
.replace(/\\dfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)')
.replace(/\\tfrac\{([^}]+)\}\{([^}]+)\}/g, '($1/$2)')
// Square roots
.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
.replace(/\\int_\{([^}]+)\}\^\{([^}]+)\}/g, '∫[$1→$2]')
.replace(/\\int/g, '∫').replace(/\\oint/g, '∮')
.replace(/\\sum_\{([^}]+)\}\^\{([^}]+)\}/g, 'Σ[$1→$2]')
.replace(/\\sum/g, 'Σ').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(/\\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, 'ω')
// Greek letters (uppercase)
.replace(/\\Gamma/g, 'Γ').replace(/\\Delta/g, 'Δ').replace(/\\Theta/g, 'Θ')
.replace(/\\Lambda/g, 'Λ').replace(/\\Xi/g, 'Ξ').replace(/\\Pi/g, 'Π')
.replace(/\\Sigma/g, 'Σ').replace(/\\Phi/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, '∘')
// 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(/\\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(/\\to/g, '→').replace(/\\gets/g, '←').replace(/\\mapsto/g, '↦')
// Set theory
.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, '∅')
// Logic
.replace(/\\land/g, '∧').replace(/\\lor/g, '∨').replace(/\\lnot/g, '¬').replace(/\\neg/g, '¬')
// Misc
.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(/\\prime/g, '′').replace(/\\degree/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
for (let i = 0; i < 5; i++) {
rendered = rendered.replace(/\^(\{[^{}]+\})/g, (_, exp) => {
const content = exp.slice(1, -1);
return content.split('').map(c => SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || c).join('');
});
rendered = rendered.replace(/\^([0-9a-zA-Z+\-])/g, (_, c) => {
return SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()] || `^${c}`;
});
rendered = rendered.replace(/_(\{[^{}]+\})/g, (_, exp) => {
const content = exp.slice(1, -1);
return content.split('').map(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || c).join('');
});
rendered = rendered.replace(/_([0-9a-zA-Z])/g, (_, c) => {
return SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || `_${c}`;
});
}
// Final cleanup
rendered = rendered.replace(/[{}]/g, '');
if (displayMode) {
return `<div style="margin:20px 0;padding:16px 20px;background:linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);border-radius:8px;border-left:4px solid #667eea;text-align:center;font-family:'Times New Roman',Georgia,serif;font-size:16px;font-style:italic;color:#2d3748;">${this.escapeHtml(rendered)}</div>`;
}
return `<span style="padding:2px 6px;background:rgba(102, 126, 234, 0.1);border-radius:4px;font-family:'Times New Roman',Georgia,serif;font-style:italic;">${this.escapeHtml(rendered)}</span>`;
}
_parseMarkdownTablesForPdf(content) {
if (!content) return content;
const lines = content.split('\n');
const result = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
const nextLine = lines[i + 1];
if (nextLine && /^\|[\s:|-]+\|$/.test(nextLine.trim())) {
const tableLines = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim().startsWith('|')) {
tableLines.push(lines[j]);
j++;
}
result.push(this._convertTableToHtmlForPdf(tableLines));
i = j;
continue;
}
}
result.push(line);
i++;
}
return result.join('\n');
}
_convertTableToHtmlForPdf(lines) {
if (lines.length < 2) return lines.join('\n');
const sep = lines[1];
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 headerCells = lines[0].split('|').filter(c => c.trim()).map(c => c.trim());
let html = '<table style="width:100%;border-collapse:collapse;margin:20px 0;border-radius:12px;overflow:hidden;box-shadow:0 4px 12px rgba(0,0,0,0.1);"><thead><tr style="background:linear-gradient(135deg, #667eea 0%, #764ba2 100%);">';
headerCells.forEach((h, i) => {
html += `<th style="text-align:${aligns[i] || 'left'};padding:14px 16px;color:#ffffff;font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:0.5px;border:none;">${this._formatInlineContentForPdf(h)}</th>`;
});
html += '</tr></thead><tbody>';
for (let i = 2; i < lines.length; i++) {
const cells = lines[i].match(/\|([^|]*)/g)?.map(c => c.slice(1).trim()) || [];
const rowBg = (i - 2) % 2 === 0 ? '#ffffff' : '#f8fafc';
html += `<tr style="background:${rowBg};">`;
cells.forEach((c, j) => {
if (c !== undefined) {
html += `<td style="text-align:${aligns[j] || 'left'};padding:14px 16px;border-bottom:1px solid #e2e8f0;color:#2d3748;font-size:14px;">${this._formatInlineContentForPdf(c)}</td>`;
}
});
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
// Format inline content for PDF (bold, italic, code, underline)
_formatInlineContentForPdf(text) {
if (!text) return '';
let result = text;
// Convert any escaped HTML tags back to markdown first
result = result.replace(/&lt;strong[^&]*&gt;([\s\S]*?)&lt;\/strong&gt;/gi, '**$1**');
result = result.replace(/&lt;b[^&]*&gt;([\s\S]*?)&lt;\/b&gt;/gi, '**$1**');
result = result.replace(/&lt;em[^&]*&gt;([\s\S]*?)&lt;\/em&gt;/gi, '*$1*');
result = result.replace(/&lt;i[^&]*&gt;([\s\S]*?)&lt;\/i&gt;/gi, '*$1*');
result = result.replace(/&lt;u[^&]*&gt;([\s\S]*?)&lt;\/u&gt;/gi, '_$1_');
result = result.replace(/&lt;code[^&]*&gt;([\s\S]*?)&lt;\/code&gt;/gi, '`$1`');
// Convert raw HTML tags to markdown
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(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
result = result.replace(/<span[^>]*>([\s\S]*?)<\/span>/gi, '$1');
// Store formatted content with markers
const bolds = [];
const italics = [];
const underlines = [];
const codes = [];
const boldMarker = '\u0000BOLD\u0000';
const italicMarker = '\u0000ITALIC\u0000';
const underlineMarker = '\u0000UNDERLINE\u0000';
const codeMarker = '\u0000CODE\u0000';
// Extract bold **text**
result = result.replace(/\*\*([^*]+)\*\*/g, (_, content) => {
bolds.push(content);
return `${boldMarker}${bolds.length - 1}${boldMarker}`;
});
// Extract italic *text*
result = result.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, content) => {
italics.push(content);
return `${italicMarker}${italics.length - 1}${italicMarker}`;
});
// Extract underline _text_
result = result.replace(/(?<![_\w])_([^_]+)_(?![_\w])/g, (match, content) => {
if (/__/.test(match)) return match;
underlines.push(content);
return `${underlineMarker}${underlines.length - 1}${underlineMarker}`;
});
// Extract inline code `code`
result = result.replace(/`([^`]+)`/g, (_, content) => {
codes.push(content);
return `${codeMarker}${codes.length - 1}${codeMarker}`;
});
// Escape HTML for XSS protection
result = this.escapeHtmlDisplay(result);
// Restore bold with styled HTML for PDF
bolds.forEach((content, i) => {
result = result.replace(new RegExp(`${boldMarker}${i}${boldMarker}`, 'g'),
`<strong style="font-weight:600;color:#1a202c;">${this.escapeHtmlDisplay(content)}</strong>`);
});
// Restore italic with styled HTML for PDF
italics.forEach((content, i) => {
result = result.replace(new RegExp(`${italicMarker}${i}${italicMarker}`, 'g'),
`<em style="font-style:italic;color:#4a5568;">${this.escapeHtmlDisplay(content)}</em>`);
});
// Restore underline with styled HTML for PDF
underlines.forEach((content, i) => {
result = result.replace(new RegExp(`${underlineMarker}${i}${underlineMarker}`, 'g'),
`<u style="text-decoration:underline;">${this.escapeHtmlDisplay(content)}</u>`);
});
// Restore inline code with styled HTML for PDF
codes.forEach((content, i) => {
result = result.replace(new RegExp(`${codeMarker}${i}${codeMarker}`, 'g'),
`<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-family:'Fira Code',monospace;font-size:12px;color:#e53e3e;">${this.escapeHtmlDisplay(content)}</code>`);
});
return result;
}
// Generate PDF HTML template (same professional styling as main app.js)
_getPdfTemplate(title, dateStr, timeStr, content, isChat = false) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.escapeHtml(title)}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@page { margin: 0.5in; size: A4; }
@media print {
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { padding: 0.5in; }
.code-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; }
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px; line-height: 1.7; color: #1a1a2e; background: #fff;
padding: 40px; max-width: 800px; margin: 0 auto;
}
.pdf-header {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 24px; margin-bottom: 32px; border-bottom: 2px solid #e5e7eb;
}
.pdf-brand { display: flex; align-items: center; gap: 16px; }
.pdf-logo { width: 64px; height: 64px; shape-rendering: geometricPrecision; image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; }
.pdf-title {
font-size: 28px; font-weight: 700;
color: #667eea;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
letter-spacing: -0.5px;
}
.pdf-meta { text-align: right; color: #6b7280; font-size: 12px; }
.pdf-meta-date { font-weight: 600; color: #374151; margin-bottom: 4px; }
.pdf-meta-label {
display: inline-block; padding: 4px 10px;
background: #667eea;
color: white; border-radius: 12px; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px; margin-top: 8px;
}
.pdf-content { padding: 24px 0; }
.summary { font-size: 14px; color: #6b7280; margin-bottom: 20px; }
.summary strong { color: #667eea; }
/* Stats Grid */
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 32px; }
.stat-card {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; text-align: center;
}
.stat-value { font-size: 28px; font-weight: 700; color: #667eea; }
.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; }
/* Model Usage */
.model-usage { margin-top: 16px; }
.usage-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.usage-label { width: 120px; font-size: 13px; color: #374151; font-weight: 500; }
.usage-bar { flex: 1; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.usage-fill { height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; }
.usage-value { width: 80px; font-size: 12px; color: #6b7280; text-align: right; }
/* Tables */
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #e5e7eb; }
th { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600; }
tr:nth-child(even) { background: #f9fafb; }
tr:last-child td { border-bottom: none; }
.status-badge { padding: 4px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }
.status-badge.online { background: #d1fae5; color: #059669; }
/* Messages */
.messages { display: flex; flex-direction: column; gap: 24px; }
.message { border-radius: 12px; overflow: hidden; }
.message.user { background: #f8fafc; border: 1px solid #e5e7eb; }
.message.assistant { background: linear-gradient(135deg, rgba(102,126,234,0.05) 0%, rgba(118,75,162,0.05) 100%); border: 1px solid rgba(102,126,234,0.2); }
.message-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid rgba(0,0,0,0.05); }
.role-badge { padding: 4px 12px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.user-badge { background: #374151; color: white; }
.ai-badge { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.message-meta { font-size: 11px; color: #9ca3af; }
.message-content { padding: 16px; line-height: 1.7; color: #374151; }
.message-content p { margin: 0 0 12px; }
.message-content p:last-child { margin-bottom: 0; }
/* Headings */
h1 { font-size: 22px; font-weight: 700; color: #667eea; margin: 24px 0 12px; padding-bottom: 8px; border-bottom: 2px solid #667eea; }
h2 { font-size: 18px; font-weight: 600; color: #1f2937; margin: 20px 0 10px; padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; }
h3 { font-size: 16px; font-weight: 600; color: #667eea; margin: 16px 0 8px; padding-left: 10px; border-left: 3px solid #667eea; }
h4, h5, h6 { font-size: 14px; font-weight: 600; color: #374151; margin: 14px 0 6px; }
/* Lists */
ul, ol { margin: 12px 0; padding-left: 24px; }
li { margin: 6px 0; color: #374151; }
li::marker { color: #667eea; }
/* Code */
.code-block { margin: 16px 0; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.code-block-wrapper { margin: 16px 0; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.code-header, .code-block-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 8px 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
.code-block pre, .code-block-wrapper pre { margin: 0; background: #1e293b; }
.code-block code, .code-block-wrapper code { display: block; padding: 16px; font-family: 'Fira Code', monospace; font-size: 13px; line-height: 1.6; color: #e2e8f0; white-space: pre-wrap; word-wrap: break-word; }
.inline-code { background: #f3e8ff; color: #7c3aed; padding: 2px 6px; border-radius: 4px; font-family: 'Fira Code', monospace; font-size: 13px; }
/* Blockquotes */
blockquote { margin: 16px 0; padding: 12px 20px; border-left: 4px solid #667eea; background: #f8fafc; border-radius: 0 8px 8px 0; color: #4b5563; font-style: italic; }
/* Links */
a { color: #667eea; text-decoration: none; border-bottom: 1px solid #667eea; }
/* HR */
hr { border: none; height: 2px; background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #667eea 100%); margin: 24px 0; }
strong { font-weight: 600; color: #1f2937; }
em { font-style: italic; color: #4b5563; }
/* Footer */
.pdf-footer { margin-top: 40px; padding-top: 20px; border-top: 2px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px; text-rendering: optimizeLegibility; }
.pdf-footer-brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; color: #6b7280; -webkit-font-smoothing: antialiased; }
.pdf-footer-logo { width: 28px; height: 28px; shape-rendering: geometricPrecision; image-rendering: crisp-edges; }
@media print {
.message { page-break-inside: avoid; }
.code-block { page-break-inside: avoid; }
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; }
}
</style>
</head>
<body>
<header class="pdf-header">
<div class="pdf-brand">
<svg class="pdf-logo" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
<path d="M32 8 L56 20 L56 44 L32 56 L8 44 L8 20 Z" stroke="#667eea" stroke-width="4" fill="none" stroke-linejoin="round"/>
<circle cx="32" cy="32" r="12" fill="#764ba2"/>
</svg>
<span class="pdf-title">${this.escapeHtml(title)}</span>
</div>
<div class="pdf-meta">
<div class="pdf-meta-date">${dateStr} at ${timeStr}</div>
<span class="pdf-meta-label">Admin Report</span>
</div>
</header>
<main class="pdf-content">${content}</main>
<footer class="pdf-footer">
<div class="pdf-footer-brand">
<svg class="pdf-footer-logo" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><path d="M32 8 L56 20 L56 44 L32 56 L8 44 L8 20 Z" stroke="#667eea" stroke-width="4" fill="none" stroke-linejoin="round"/><circle cx="32" cy="32" r="10" fill="#764ba2"/></svg>
Generated by Rox AI Admin Panel
</div>
</footer>
</body>
</html>`;
}
// Print PDF using browser print dialog (same approach as main app.js)
_printPdf(pdfHtml) {
let existingFrame = document.getElementById('adminPdfFrame');
if (existingFrame) existingFrame.remove();
const printFrame = document.createElement('iframe');
printFrame.id = 'adminPdfFrame';
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();
setTimeout(() => {
try {
frameWindow.focus();
frameWindow.print();
} catch (e) {
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);
}
// Store current messages for export
_currentMessages = [];
// ==================== USER DETAILS PANEL ====================
_renderUserDetailsPanel(user) {
if (!this.userDetailsContent || !user) return;
const isOnline = user.isOnline;
const firstSeenDate = user.firstSeen ? new Date(user.firstSeen).toLocaleDateString() : 'Unknown';
const lastActiveDate = user.lastActivity ? new Date(user.lastActivity).toLocaleString() : 'Unknown';
const engagementScore = user.engagementScore || 0;
const userType = user.userType || 'New';
// Generate avatar initials from IP
const ipParts = user.ip.split('.');
const avatarText = ipParts.length >= 2 ? ipParts[0].slice(-1) + ipParts[1].slice(-1) : 'U';
// Build activity chart
let activityChartHtml = '';
if (user.activityByHour && user.activityByHour.some(v => v > 0)) {
const maxActivity = Math.max(...user.activityByHour, 1);
activityChartHtml = `
<div class="user-activity-timeline">
<div class="activity-hour-bar">
${user.activityByHour.map((val, hour) => {
const height = Math.max((val / maxActivity) * 100, 5);
const isPeak = val === maxActivity && val > 0;
return `<div class="activity-hour ${isPeak ? 'peak' : ''}" style="height: ${height}%" title="${hour}:00 - ${val} actions"></div>`;
}).join('')}
</div>
<div class="activity-hour-labels">
<span>12AM</span>
<span>6AM</span>
<span>12PM</span>
<span>6PM</span>
<span>11PM</span>
</div>
</div>
`;
}
// Build user tags
const tags = [];
if (user.sessionCount <= 1) tags.push({ label: 'New User', class: 'new' });
else if (user.sessionCount > 5) tags.push({ label: 'Power User', class: 'power' });
else tags.push({ label: 'Returning', class: 'returning' });
if (user.bounceStatus === 'Bounced') tags.push({ label: 'Bounced', class: 'dormant' });
if (user.totalTokensUsed > 10000) tags.push({ label: 'Heavy Usage', class: 'power' });
if (user.errorCount > 0) tags.push({ label: `${user.errorCount} Errors`, class: 'dormant' });
const tagsHtml = tags.map(t => `<span class="user-tag ${t.class}">${t.label}</span>`).join('');
const html = `
<!-- User Profile Card -->
<div class="user-profile-card">
<div class="user-profile-header">
<div class="user-profile-avatar">${avatarText.toUpperCase()}</div>
<div class="user-profile-info">
<div class="user-profile-ip">${this.escapeHtml(user.ip)}</div>
<div class="user-profile-status">
<span class="status-dot ${isOnline ? 'online' : ''}"></span>
<span>${isOnline ? 'Online now' : 'Offline'}</span>
</div>
</div>
</div>
<!-- User Stats Grid -->
<div class="user-stats-grid">
<div class="user-stat-item">
<div class="user-stat-value">${user.chatCount || 0}</div>
<div class="user-stat-label">Chats</div>
</div>
<div class="user-stat-item">
<div class="user-stat-value">${user.totalMessages || 0}</div>
<div class="user-stat-label">Messages</div>
</div>
<div class="user-stat-item">
<div class="user-stat-value">${user.sessionCount || 1}</div>
<div class="user-stat-label">Sessions</div>
</div>
<div class="user-stat-item">
<div class="user-stat-value">${engagementScore}%</div>
<div class="user-stat-label">Engagement</div>
</div>
</div>
<!-- User Tags -->
<div class="user-tags">${tagsHtml}</div>
</div>
<!-- Device Info Section -->
<div class="user-info-section">
<div class="user-info-section-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
Device Info
</div>
<div class="user-info-row">
<span class="user-info-label">Browser</span>
<span class="user-info-value">${this.escapeHtml(user.device?.fullBrowser || user.device?.browser || 'Unknown')}</span>
</div>
<div class="user-info-row">
<span class="user-info-label">OS</span>
<span class="user-info-value">${this.escapeHtml(user.device?.fullOs || user.device?.os || 'Unknown')}</span>
</div>
<div class="user-info-row">
<span class="user-info-label">Device Type</span>
<span class="user-info-value">${this.escapeHtml(user.device?.device || 'Unknown')}</span>
</div>
${user.screenResolution ? `
<div class="user-info-row">
<span class="user-info-label">Screen</span>
<span class="user-info-value">${this.escapeHtml(user.screenResolution)}</span>
</div>
` : ''}
${user.colorScheme ? `
<div class="user-info-row">
<span class="user-info-label">Theme</span>
<span class="user-info-value">${user.colorScheme === 'dark' ? '🌙 Dark' : '☀️ Light'}</span>
</div>
` : ''}
${user.connectionType ? `
<div class="user-info-row">
<span class="user-info-label">Connection</span>
<span class="user-info-value">${this.escapeHtml(user.connectionType)}</span>
</div>
` : ''}
</div>
<!-- Location Info Section -->
<div class="user-info-section">
<div class="user-info-section-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
Location & Source
</div>
${user.country ? `
<div class="user-info-row">
<span class="user-info-label">Country</span>
<span class="user-info-value">${this.escapeHtml(user.country)}</span>
</div>
` : ''}
${user.city ? `
<div class="user-info-row">
<span class="user-info-label">City</span>
<span class="user-info-value">${this.escapeHtml(user.city)}</span>
</div>
` : ''}
${user.language && user.language !== 'Unknown' ? `
<div class="user-info-row">
<span class="user-info-label">Language</span>
<span class="user-info-value">${this.escapeHtml(user.language)}</span>
</div>
` : ''}
${user.referer && user.referer !== 'Direct' ? `
<div class="user-info-row">
<span class="user-info-label">Source</span>
<span class="user-info-value">${this.escapeHtml(user.referer)}</span>
</div>
` : `
<div class="user-info-row">
<span class="user-info-label">Source</span>
<span class="user-info-value">Direct</span>
</div>
`}
</div>
<!-- Activity Info Section -->
<div class="user-info-section">
<div class="user-info-section-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Activity
</div>
<div class="user-info-row">
<span class="user-info-label">First Seen</span>
<span class="user-info-value">${firstSeenDate}</span>
</div>
<div class="user-info-row">
<span class="user-info-label">Last Active</span>
<span class="user-info-value">${lastActiveDate}</span>
</div>
${user.visits ? `
<div class="user-info-row">
<span class="user-info-label">Visits</span>
<span class="user-info-value">${user.visits}</span>
</div>
` : ''}
${user.pageViews ? `
<div class="user-info-row">
<span class="user-info-label">Page Views</span>
<span class="user-info-value">${user.pageViews}</span>
</div>
` : ''}
${user.totalSessionTime ? `
<div class="user-info-row">
<span class="user-info-label">Total Time</span>
<span class="user-info-value">${user.totalSessionTime}m</span>
</div>
` : ''}
${user.peakActivityHour !== null && user.peakActivityHour !== undefined ? `
<div class="user-info-row">
<span class="user-info-label">Peak Hour</span>
<span class="user-info-value highlight">${user.peakActivityHour}:00</span>
</div>
` : ''}
${activityChartHtml}
</div>
<!-- Model Usage Section -->
${user.favoriteModel || user.totalTokensUsed ? `
<div class="user-info-section">
<div class="user-info-section-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
AI Usage
</div>
${user.favoriteModel ? `
<div class="user-info-row">
<span class="user-info-label">Favorite Model</span>
<span class="user-info-value highlight">${this.escapeHtml(user.favoriteModel)}</span>
</div>
` : ''}
${user.totalTokensUsed ? `
<div class="user-info-row">
<span class="user-info-label">Tokens Used</span>
<span class="user-info-value">${this._formatNumber(user.totalTokensUsed)}</span>
</div>
` : ''}
${user.avgResponseTime ? `
<div class="user-info-row">
<span class="user-info-label">Avg Response</span>
<span class="user-info-value">${user.avgResponseTime}ms</span>
</div>
` : ''}
${user.errorCount > 0 ? `
<div class="user-info-row">
<span class="user-info-label">Errors</span>
<span class="user-info-value error">${user.errorCount}</span>
</div>
` : ''}
</div>
` : ''}
<!-- Privacy Section -->
${user.doNotTrack ? `
<div class="user-info-section">
<div class="user-info-section-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Privacy
</div>
<div class="user-info-row">
<span class="user-info-label">Do Not Track</span>
<span class="user-info-value warning">Enabled</span>
</div>
</div>
` : ''}
`;
this.userDetailsContent.innerHTML = html;
// Show the panel
if (this.userDetailsPanel) {
this.userDetailsPanel.classList.add('active');
}
}
_hideUserDetails() {
if (this.userDetailsPanel) {
this.userDetailsPanel.classList.remove('active');
}
if (this.userDetailsContent) {
this.userDetailsContent.innerHTML = `
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
<p>Select a user to view details</p>
</div>
`;
}
}
_confirmClearLogs() {
this._showDialog({
title: 'Clear All Logs',
message: 'This will permanently delete all chat logs and user data. This action cannot be undone.',
type: 'danger',
onConfirm: () => this._clearLogs()
});
}
async _clearLogs() {
try {
const res = await this._apiPost('/clear-logs', { confirm: true });
if (res.success) {
this._showToast(`Cleared ${res.cleared} logs`, 'success');
this._loadData();
this.currentUser = null;
this.currentChat = null;
this.selectedUserName.textContent = '-';
this.chatsList.innerHTML = '<div class="empty-state"><p>Select a user to view chats</p></div>';
this.messagesArea.innerHTML = '<div class="empty-state"><p>Select a chat to view messages</p></div>';
this.chatHeader.style.display = 'none';
} else {
this._showToast(res.error || 'Clear failed', 'error');
}
} catch (err) {
this._showToast('Clear failed', 'error');
}
}
// ==================== UI HELPERS ====================
_showDialog({ title, message, type = 'warning', onConfirm }) {
this.dialogTitle.textContent = title;
this.dialogMessage.textContent = message;
const icon = document.getElementById('dialogIcon');
icon.className = `dialog-icon ${type}`;
icon.innerHTML = type === 'danger'
? '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>'
: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
this.dialogConfirm.onclick = () => {
this._hideDialog();
if (onConfirm) onConfirm();
};
this.dialogOverlay.style.display = 'flex';
}
_hideDialog() {
this.dialogOverlay.style.display = 'none';
}
_showHelp() {
this.helpOverlay.style.display = 'flex';
}
_hideHelp() {
this.helpOverlay.style.display = 'none';
}
_showToast(message, type = 'info', duration = 5000) {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = {
success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>'
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || icons.info}</span>
<span class="toast-message">${this.escapeHtml(message)}</span>
<button class="toast-close" aria-label="Close notification">×</button>
`;
const closeBtn = toast.querySelector('.toast-close');
closeBtn.onclick = () => this._removeToast(toast);
this.toastContainer.appendChild(toast);
// Auto-remove with enhanced animation
const timeoutId = setTimeout(() => this._removeToast(toast), duration);
// Pause auto-remove on hover
toast.addEventListener('mouseenter', () => clearTimeout(timeoutId));
toast.addEventListener('mouseleave', () => {
setTimeout(() => this._removeToast(toast), 1000);
});
// Add progress bar for visual feedback
if (duration > 2000) {
const progressBar = document.createElement('div');
progressBar.className = 'toast-progress';
progressBar.style.cssText = `
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background: currentColor;
opacity: 0.3;
animation: toast-progress ${duration}ms linear;
`;
toast.appendChild(progressBar);
}
}
_removeToast(toast) {
if (toast && toast.parentNode) {
toast.classList.add('removing');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}
}
// ==================== UTILITY FUNCTIONS ====================
escapeHtml(str) {
if (!str || typeof str !== 'string') return '';
return str.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c] || c);
}
// Display-safe escape - only escapes < and > for XSS, preserves & and quotes for readability
escapeHtmlDisplay(text) {
if (typeof text !== 'string') return '';
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Auto-format math expressions (superscripts, subscripts, symbols)
_autoFormatMathExpressions(content) {
if (!content || typeof content !== 'string') return content;
let formatted = content;
// Convert superscript patterns: x^2, r^6, n^{10}, etc.
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('');
const allConverted = exp.split('').every(c => SUPERSCRIPTS[c] || SUPERSCRIPTS[c.toLowerCase()]);
if (allConverted) return base + converted;
return match;
});
// Convert subscript patterns: x_1, a_n, x_{10}, etc.
formatted = formatted.replace(/\b([a-zA-Z])_(\{([^}]+)\}|([0-9]))\b/g, (match, base, _, bracedSub, plainSub) => {
if (match.includes('__')) return match;
const sub = bracedSub || plainSub;
const converted = sub.split('').map(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()] || c).join('');
const allConverted = sub.split('').every(c => SUBSCRIPTS[c] || SUBSCRIPTS[c.toLowerCase()]);
if (allConverted) return base + converted;
return match;
});
// Convert scientific notation: 1e6 → 1×10⁶
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
const mathSymbols = [
'≈', '≠', '≤', '≥', '±', '∓', '×', '÷', '·',
'→', '←', '↔', '⇒', '⇐', '⇔', '↑', '↓',
'∞', '∝', '∂', '∇', '∫', '∑', '∏',
'∈', '∉', '⊂', '⊃', '⊆', '⊇', '∪', '∩', '∅',
'∀', '∃', '∧', '∨', '¬',
'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ',
'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ρ',
'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
'Γ', 'Δ', 'Θ', 'Λ', 'Ξ', 'Π', 'Σ', 'Φ', 'Ψ', 'Ω',
'√', '∛', '∜'
];
const symbolPattern = new RegExp(`([${mathSymbols.join('')}])`, 'g');
formatted = formatted.replace(symbolPattern, '<span class="math-symbol">$1</span>');
formatted = formatted.replace(/<span class="math-symbol"><span class="math-symbol">([^<]+)<\/span><\/span>/g, '<span class="math-symbol">$1</span>');
return formatted;
}
_sanitizeUrl(url) {
if (!url || typeof url !== 'string') return null;
const trimmed = url.trim();
if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) return null;
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) {
return trimmed;
}
return null;
}
_maskIp(ip) {
// Show full IP address for admin panel
if (!ip) return 'Unknown';
return ip;
}
_formatTimeAgo(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
}
_formatDate(timestamp) {
if (!timestamp) return 'Unknown';
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
});
}
_formatTime(timestamp) {
if (!timestamp) return '';
return new Date(timestamp).toLocaleTimeString('en-US', {
hour: 'numeric', minute: '2-digit', hour12: true
});
}
}
// ==================== INITIALIZE ====================
document.addEventListener('DOMContentLoaded', () => {
window.adminPanel = new AdminPanel();
});