// ========================================== // 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 = '
${this._esc(req.requestBodyPreview)}${this._esc(req.responsePreview)}${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();