// ========================================== // 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 = {}; // Backend state (redact tokens) if (typeof Backend !== 'undefined') { snap.backend = { baseUrl: Backend.baseUrl || '(empty)', hasAccessToken: !!Backend.accessToken, hasRefreshToken: !!Backend.refreshToken, user: Backend.user ? { username: Backend.user.username, role: Backend.user.role, display_name: Backend.user.display_name } : null, isConnected: Backend.isConnected, isManaged: Backend.isManaged }; } // App state if (typeof State !== 'undefined') { snap.state = { chatCount: State.chats?.length || 0, currentChatId: State.currentChatId, modelCount: State.models?.length || 0, isGenerating: State.isGenerating, settings: { model: State.settings?.model || '?', stream: State.settings?.stream, apiEndpoint: State.settings?.apiEndpoint ? '(set)' : '(empty)', apiKey: State.settings?.apiKey ? '(set)' : '(empty)', } }; } // localStorage keys try { snap.storageKeys = Object.keys(localStorage).filter(k => k.startsWith('chatSwitchboard') || k.startsWith('switchboard') ); } catch (e) { snap.storageKeys = '(error reading)'; } return snap; }, // ── Connection Diagnostics ────────────── async runDiagnostics() { this.log('DIAG', '── Starting connection diagnostics ──'); const baseUrl = (typeof Backend !== 'undefined' && Backend.baseUrl) ? Backend.baseUrl : 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 Backend !== 'undefined' && Backend.accessToken) { this.log('DIAG', 'Test 3: Token validation'); try { const resp = await this._origFetch(baseUrl + '/api/v1/profile', { headers: { 'Authorization': `Bearer ${Backend.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)'); } this.log('DIAG', '── Diagnostics complete ──'); this.render(); }, // ── Badge ─────────────────────────────── _injectBadge() { const badge = document.createElement('div'); badge.id = 'debugBadge'; badge.innerHTML = '🐛'; badge.title = 'Debug Log (Ctrl+Shift+L)'; badge.style.cssText = ` position: fixed; bottom: 12px; right: 12px; z-index: 100000; width: 36px; height: 36px; border-radius: 50%; background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444); display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 18px; opacity: 0.7; transition: opacity 0.2s, transform 0.2s; `; badge.addEventListener('mouseenter', () => { badge.style.opacity = '1'; badge.style.transform = 'scale(1.1)'; }); badge.addEventListener('mouseleave', () => { badge.style.opacity = this._errorCount > 0 ? '0.9' : '0.7'; badge.style.transform = ''; }); badge.addEventListener('click', () => openDebugModal()); document.body.appendChild(badge); // Keyboard shortcut document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && e.key === 'L') { e.preventDefault(); openDebugModal(); } }); }, _updateBadge() { const badge = document.getElementById('debugBadge'); if (!badge) return; if (this._errorCount > 0) { badge.style.opacity = '0.9'; badge.style.background = 'var(--danger, #c0392b)'; badge.innerHTML = `🐛${this._errorCount > 99 ? '99+' : this._errorCount}`; badge.style.position = 'fixed'; // keep relative positioning for the counter } }, // ── 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();
document.getElementById('debugModal').classList.add('active');
}
function closeDebugModal() {
document.getElementById('debugModal').classList.remove('active');
}
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');
document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`)?.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();