// ========================================== // 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+K โ "debug" 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: '', contentType: null, duration: null, error: null, responsePreview: null, requestBodyPreview: null, proxyDetected: false }; // 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.contentType = resp.headers.get('content-type') || null; entry.duration = Math.round(performance.now() - start); // Detect proxy interception: API call returned HTML const isApiCall = url.includes('/api/') || url.includes('/health'); if (isApiCall && entry.contentType && entry.contentType.includes('text/html')) { entry.proxyDetected = true; self._addEntry('NET:PROXY', `${method} ${url.slice(0, 80)} โ ๐ก๏ธ PROXY HTML (${entry.contentType}) (${entry.duration}ms)`); try { const clone = resp.clone(); const text = await clone.text(); entry.responsePreview = text.slice(0, 500); } catch (e) { /* */ } } else { // 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 && !entry.proxyDetected) { 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' || type === 'NET:PROXY') { 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 = { env: window.__ENV__ || 'unknown', version: window.__VERSION__ || 'unknown', basePath: window.__BASE__ || '(root)', url: location.href, }; // 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 }; } // Extensions state if (typeof Extensions !== 'undefined') { snap.extensions = Extensions.debug(); } // CM6 editor state snap.cm6 = window.CM ? { version: window.CM.version, languages: Object.keys(window.CM.languages || {}) } : { available: false }; return snap; }, // โโ Connection Diagnostics โโโโโโโโโโโโโโ async runDiagnostics() { this.log('DIAG', 'โโ Starting connection diagnostics โโ'); this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`); const baseUrl = window.location.origin + (window.__BASE__ || ''); // 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}`); } // Test 5: Extensions this.log('DIAG', 'Test 5: Browser extensions'); if (typeof Extensions !== 'undefined') { const info = Extensions.debug(); const extNames = Object.keys(info.extensions); this.log('DIAG', ` Loaded: ${extNames.length} extension(s)`); for (const name of extNames) { const ext = info.extensions[name]; this.log('DIAG', ` ${name}: ${ext.active ? 'โ active' : 'โ inactive'}, renderers: [${ext.renderers.join(', ')}]`); } this.log('DIAG', ` Total renderers: ${info.rendererCount}, tool handlers: ${Extensions._toolHandlers?.size || 0}`); } else { this.log('DIAG', ' Extensions not loaded'); } // Test 6: Service Worker & Cache this.log('DIAG', 'Test 6: Service Worker cache'); try { if ('serviceWorker' in navigator) { const reg = await navigator.serviceWorker.getRegistration(); this.log('DIAG', ` SW registered: ${reg ? 'yes' : 'no'}${reg ? ` (scope: ${reg.scope})` : ''}`); if (reg?.active) { this.log('DIAG', ` SW state: ${reg.active.state}`); } } else { this.log('DIAG', ' SW not supported'); } const keys = await caches.keys(); this.log('DIAG', ` Caches (${keys.length}): ${keys.join(', ') || '(none)'}`); for (const key of keys) { const cache = await caches.open(key); const entries = await cache.keys(); this.log('DIAG', ` ${key}: ${entries.length} entries`); } } catch (e) { this.log('DIAG', ` Cache check error: ${e.message}`); } 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.contentType)}${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 += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\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) {
const proxy = r.proxyDetected ? ' [PROXY]' : '';
text += `[${r.ts}] ${r.method} ${r.url} โ ${r.status || 'FAILED'}${proxy} ${r.error || ''} (${r.duration || '?'}ms)`;
if (r.contentType) text += ` [${r.contentType}]`;
text += '\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 = 'flex';
DebugLog.render();
}
async function clearDebugLog() {
if (await showConfirm('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();
}
async function purgeCache() {
if (!await showConfirm(
'Purge all cached files and reload?\n\n' +
'This clears the Service Worker cache and forces a fresh download of all assets. ' +
'Useful when the UI feels stale after a deploy.',
{ danger: true }
)) return;
DebugLog.log('DIAG', '๐งน Purging Service Worker caches...');
try {
// Delete all SW caches
const keys = await caches.keys();
const deleted = await Promise.all(keys.map(k => caches.delete(k)));
DebugLog.log('DIAG', ` Deleted ${deleted.filter(Boolean).length} cache(s): ${keys.join(', ') || '(none)'}`);
// Unregister all service workers
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
DebugLog.log('DIAG', ` Unregistered SW: ${reg.scope}`);
}
}
DebugLog.log('DIAG', ' โ
Cache purged โ reloading...');
// Brief delay so the user sees the log, then hard reload
setTimeout(() => {
location.reload();
}, 500);
} catch (e) {
DebugLog.log('DIAG', ` โ Purge failed: ${e.message}`);
if (typeof showToast === 'function') showToast('Cache purge failed: ' + e.message, 'error');
}
}
// Initialize ASAP โ before app.js init() so we capture everything
DebugLog.init();