// ========================================== // Debug Logger & Modal // ========================================== // Console interceptor, fetch logger, state inspector. // Adapted from ai-editor's error-logger.js + llm-debug-modal.js // for no-devtools "hardmode" debugging. // // Activate: Ctrl+Shift+D or tap the 🐛 badge (appears after init) // ========================================== const DebugLog = { entries: [], maxEntries: 500, networkLog: [], maxNetwork: 200, _origConsole: {}, _origFetch: null, _initTime: Date.now(), _errorCount: 0, // ── Bootstrap ──────────────────────────── init() { this._interceptConsole(); this._interceptFetch(); this._interceptErrors(); this._injectBadge(); this.log('DEBUG', '🐛 Debug logger initialized'); this.log('DEBUG', `Page: ${location.href}`); this.log('DEBUG', `UA: ${navigator.userAgent.slice(0, 120)}`); }, // ── Console Interception ──────────────── _interceptConsole() { ['log', 'warn', 'error', 'info', 'debug'].forEach(method => { this._origConsole[method] = console[method].bind(console); console[method] = (...args) => { const type = method.toUpperCase(); const message = args.map(a => this._serialize(a)).join(' '); this._addEntry(type, message); this._origConsole[method](...args); }; }); }, // ── Fetch Interception ────────────────── _interceptFetch() { this._origFetch = window.fetch.bind(window); const self = this; window.fetch = async function(input, init) { const url = typeof input === 'string' ? input : input?.url || String(input); const method = init?.method || 'GET'; const entry = { id: Date.now(), ts: new Date().toISOString(), method, url: url.slice(0, 300), status: null, statusText: '', duration: null, error: null, responsePreview: null, requestBodyPreview: null }; // Capture request body preview (redact keys) if (init?.body) { try { const bodyStr = typeof init.body === 'string' ? init.body : ''; const parsed = bodyStr ? JSON.parse(bodyStr) : {}; // Redact sensitive fields const safe = { ...parsed }; ['password', 'api_key', 'apiKey', 'refresh_token', 'access_token'].forEach(k => { if (safe[k]) safe[k] = '***'; }); entry.requestBodyPreview = JSON.stringify(safe).slice(0, 300); } catch (e) { entry.requestBodyPreview = '(non-JSON body)'; } } self.networkLog.push(entry); if (self.networkLog.length > self.maxNetwork) { self.networkLog.shift(); } const start = performance.now(); try { const resp = await self._origFetch(input, init); entry.status = resp.status; entry.statusText = resp.statusText; entry.duration = Math.round(performance.now() - start); // Log non-ok responses at higher severity const logType = resp.ok ? 'NET' : 'NET:ERR'; self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`); // Clone and peek at error responses if (!resp.ok) { try { const clone = resp.clone(); const text = await clone.text(); entry.responsePreview = text.slice(0, 500); } catch (e) { /* can't peek */ } } return resp; } catch (err) { entry.duration = Math.round(performance.now() - start); entry.error = err.message || String(err); self._addEntry('NET:ERR', `${method} ${url.slice(0, 80)} → FAILED: ${entry.error} (${entry.duration}ms)`); throw err; } }; }, // ── Global Error Handlers ─────────────── _interceptErrors() { window.addEventListener('error', (e) => { this._addEntry('EXCEPTION', `${e.message} at ${e.filename || '?'}:${e.lineno || 0}:${e.colno || 0}`); this._errorCount++; this._updateBadge(); }); window.addEventListener('unhandledrejection', (e) => { const msg = e.reason?.message || e.reason || 'Unknown rejection'; this._addEntry('REJECTION', String(msg)); this._errorCount++; this._updateBadge(); }); }, // ── Entry Management ──────────────────── _addEntry(type, message) { const entry = { ts: new Date().toISOString(), elapsed: Date.now() - this._initTime, type, message }; this.entries.push(entry); if (this.entries.length > this.maxEntries) { this.entries.shift(); } if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR') { this._errorCount++; this._updateBadge(); } // Auto-refresh if modal is open if (document.getElementById('debugModal')?.classList.contains('active')) { this._scheduleRender(); } }, _renderTimer: null, _scheduleRender() { if (this._renderTimer) return; this._renderTimer = setTimeout(() => { this._renderTimer = null; this.render(); }, 250); }, log(type, message) { this._addEntry(type, message); }, clear() { this.entries = []; this.networkLog = []; this._errorCount = 0; this._updateBadge(); }, // ── Serialization ─────────────────────── _serialize(arg) { if (arg === null) return 'null'; if (arg === undefined) return 'undefined'; if (arg instanceof Error) { return `${arg.name}: ${arg.message}`; } if (typeof arg === 'object') { try { return JSON.stringify(arg, null, 0); } catch (e) { return String(arg); } } return String(arg); }, // ── State Snapshot ────────────────────── getStateSnapshot() { const snap = {}; // API client state (redact tokens) if (typeof API !== 'undefined') { snap.api = { hasAccessToken: !!API.accessToken, hasRefreshToken: !!API.refreshToken, user: API.user ? { username: API.user.username, role: API.user.role, display_name: API.user.display_name } : null, isAuthed: API.isAuthed, isAdmin: API.isAdmin }; } // App state if (typeof App !== 'undefined') { snap.app = { chatCount: App.chats?.length || 0, currentChatId: App.currentChatId, modelCount: App.models?.length || 0, isGenerating: App.isGenerating, settings: { model: App.settings?.model || '?', stream: App.settings?.stream, showThinking: App.settings?.showThinking } }; } // localStorage keys try { snap.storageKeys = Object.keys(localStorage).filter(k => k.startsWith('chatSwitchboard') || k.startsWith('switchboard') || k.startsWith('sb_') ); } catch (e) { snap.storageKeys = '(error reading)'; } // EventBus state if (typeof Events !== 'undefined') { snap.eventBus = { wsConnected: Events.connected, subscriptions: Events.debug(), queueLength: Events._wsQueue?.length || 0 }; } return snap; }, // ── Connection Diagnostics ────────────── async runDiagnostics() { this.log('DIAG', '── Starting connection diagnostics ──'); const baseUrl = (typeof API !== 'undefined' && API._base) ? API._base : window.location.origin; // Test 1: Basic fetch to same origin this.log('DIAG', `Test 1: Health check → ${baseUrl}/api/v1/health`); try { const start = performance.now(); const resp = await this._origFetch(baseUrl + '/api/v1/health', { signal: AbortSignal.timeout(10000) }); const dur = Math.round(performance.now() - start); const contentType = resp.headers.get('content-type') || '(none)'; const text = await resp.text(); this.log('DIAG', ` Status: ${resp.status} (${dur}ms)`); this.log('DIAG', ` Content-Type: ${contentType}`); this.log('DIAG', ` Body: ${text.slice(0, 300)}`); if (contentType.includes('text/html')) { this.log('DIAG', ' ⚠️ Got HTML instead of JSON — likely a PROXY interception page'); } if (!resp.ok) { this.log('DIAG', ` ⚠️ Non-200 status: ${resp.status} ${resp.statusText}`); } try { const json = JSON.parse(text); this.log('DIAG', ` ✅ Valid JSON: database=${json.database}, registration=${json.registration_enabled}`); } catch (e) { this.log('DIAG', ` ❌ NOT valid JSON — proxy or wrong endpoint`); } } catch (err) { this.log('DIAG', ` ❌ Fetch failed: ${err.message}`); if (err.name === 'TimeoutError' || err.name === 'AbortError') { this.log('DIAG', ' ⚠️ Request timed out — proxy may be blocking'); } } // Test 2: Check if we're getting CORS issues this.log('DIAG', 'Test 2: OPTIONS preflight check'); try { const resp = await this._origFetch(baseUrl + '/api/v1/health', { method: 'OPTIONS', signal: AbortSignal.timeout(5000) }); this.log('DIAG', ` Status: ${resp.status}`); this.log('DIAG', ` CORS headers: ${resp.headers.get('access-control-allow-origin') || '(none)'}`); } catch (err) { this.log('DIAG', ` ❌ OPTIONS failed: ${err.message}`); } // Test 3: Token validity if (typeof API !== 'undefined' && API.accessToken) { this.log('DIAG', 'Test 3: Token validation'); try { const resp = await this._origFetch(baseUrl + '/api/v1/profile', { headers: { 'Authorization': `Bearer ${API.accessToken}`, 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(5000) }); this.log('DIAG', ` Profile endpoint: ${resp.status}`); if (resp.status === 401) { this.log('DIAG', ' ⚠️ Token expired or invalid'); } } catch (err) { this.log('DIAG', ` ❌ Token check failed: ${err.message}`); } } else { this.log('DIAG', 'Test 3: Skipped (no token)'); } // Test 4: WebSocket connectivity this.log('DIAG', `Test 4: EventBus WebSocket = ${typeof Events !== 'undefined' ? (Events.connected ? 'connected' : 'disconnected') : 'N/A'}`); if (typeof Events !== 'undefined') { this.log('DIAG', ` Subscriptions: ${JSON.stringify(Events.debug())}`); this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`); } this.log('DIAG', '── Diagnostics complete ──'); this.render(); }, // ── Badge ─────────────────────────────── _injectBadge() { // Keyboard shortcut only — visual indicator is the avatar 🐛 document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key === 'L') { e.preventDefault(); openDebugModal(); } }); }, _updateBadge() { // Show error count on avatar bug in sidebar const bug = document.querySelector('.avatar-bug'); if (!bug) return; if (this._errorCount > 0) { bug.textContent = '🐛'; bug.title = `${this._errorCount} error${this._errorCount > 1 ? 's' : ''} — click for Debug Log`; bug.classList.add('has-errors'); } else { bug.textContent = '🐛'; bug.title = 'Debug available'; bug.classList.remove('has-errors'); } }, // ── Modal Rendering ───────────────────── render() { this._renderConsoleTab(); this._renderNetworkTab(); this._renderStateTab(); }, _renderConsoleTab() { const container = document.getElementById('debugConsoleContent'); if (!container) return; const count = document.getElementById('debugConsoleCount'); if (count) count.textContent = this.entries.length; if (this.entries.length === 0) { container.innerHTML = '
No log entries yet
'; return; } const typeColors = { 'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c', 'NET:ERR': '#e74c3c', 'WARN': '#f39c12', 'WARNING': '#f39c12', 'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d', 'NET': '#2ecc71', 'DIAG': '#9b59b6' }; let html = ''; const entries = document.getElementById('debugFilterErrors')?.checked ? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'WARN'].includes(e.type)) : this.entries; for (const entry of entries) { const color = typeColors[entry.type] || '#95a5a6'; const time = entry.ts.slice(11, 23); const elapsed = (entry.elapsed / 1000).toFixed(1); html += `
`; html += `${time} +${elapsed}s `; html += `[${this._esc(entry.type)}] `; html += `${this._esc(entry.message)}`; html += `
`; } container.innerHTML = html; if (document.getElementById('debugAutoScroll')?.checked) { container.scrollTop = container.scrollHeight; } }, _renderNetworkTab() { const container = document.getElementById('debugNetworkContent'); if (!container) return; const count = document.getElementById('debugNetworkCount'); if (count) count.textContent = this.networkLog.length; if (this.networkLog.length === 0) { container.innerHTML = '
No network requests logged
'; return; } let html = ''; for (const req of [...this.networkLog].reverse()) { const isErr = req.error || (req.status && req.status >= 400); const statusColor = isErr ? '#e74c3c' : '#2ecc71'; const statusText = req.error ? `❌ ${req.error}` : (req.status ? `${req.status} ${req.statusText}` : '⏳ pending'); html += `
`; html += ``; html += `${req.method} `; html += `${this._esc(req.url)} `; html += `${statusText}`; if (req.duration != null) html += ` ${req.duration}ms`; html += ``; html += `
`; if (req.requestBodyPreview) { html += `
Request: ${this._esc(req.requestBodyPreview)}
`; } if (req.responsePreview) { html += `
Response:
${this._esc(req.responsePreview)}
`; } html += `
${req.ts}
`; html += `
`; } container.innerHTML = html; }, _renderStateTab() { const container = document.getElementById('debugStateContent'); if (!container) return; const snap = this.getStateSnapshot(); container.innerHTML = `
${this._esc(JSON.stringify(snap, null, 2))}
`; }, _esc(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }, // ── Export ─────────────────────────────── exportText() { let text = `=== Chat Switchboard Debug Export ===\n`; text += `Exported: ${new Date().toISOString()}\n`; text += `URL: ${location.href}\n`; text += `UA: ${navigator.userAgent}\n\n`; text += `--- STATE ---\n`; text += JSON.stringify(this.getStateSnapshot(), null, 2) + '\n\n'; text += `--- CONSOLE LOG (${this.entries.length}) ---\n`; for (const e of this.entries) { text += `[${e.ts}] [${e.type}] ${e.message}\n`; } text += '\n'; text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`; for (const r of this.networkLog) { text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'} ${r.error || ''} (${r.duration || '?'}ms)\n`; if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`; } return text; } }; // ── Modal Controls ────────────────────────── function openDebugModal() { DebugLog.render(); openModal('debugModal'); } function closeDebugModal() { closeModal('debugModal'); } function switchDebugTab(tab) { document.querySelectorAll('.debug-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.debug-tab-content').forEach(c => c.style.display = 'none'); document.querySelector(`.debug-tab[data-tab="${tab}"]`)?.classList.add('active'); const panel = document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`); if (panel) panel.style.display = ''; DebugLog.render(); } function clearDebugLog() { if (confirm('Clear all debug logs?')) { DebugLog.clear(); DebugLog.render(); } } function copyDebugLog() { const text = DebugLog.exportText(); navigator.clipboard.writeText(text) .then(() => { if (typeof showToast === 'function') showToast('📋 Debug log copied', 'success'); }) .catch(() => { alert('Failed to copy'); }); } function exportDebugLog() { const text = DebugLog.exportText(); const blob = new Blob([text], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `switchboard-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; a.click(); URL.revokeObjectURL(url); } function runDebugDiagnostics() { switchDebugTab('console'); DebugLog.runDiagnostics(); } // Initialize ASAP — before app.js init() so we capture everything DebugLog.init();