Changeset 0.4.1 (#33)

This commit is contained in:
2026-02-18 18:58:47 +00:00
parent c98eb80950
commit e2f538d787
6 changed files with 795 additions and 36 deletions

View File

@@ -174,13 +174,16 @@ async function sendApiRequest(messages) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = ''; // Buffer for incomplete SSE lines
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
sseBuffer += decoder.decode(value, { stream: true });
const lines = sseBuffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer
sseBuffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {

548
src/js/debug.js Normal file
View File

@@ -0,0 +1,548 @@
// ==========================================
// 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+D)';
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.5;
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.5'; badge.style.transform = ''; });
badge.addEventListener('click', () => openDebugModal());
document.body.appendChild(badge);
// Keyboard shortcut
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
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 = `🐛<span style="position:absolute;top:-4px;right:-4px;background:#e74c3c;color:#fff;font-size:10px;border-radius:50%;width:16px;height:16px;display:flex;align-items:center;justify-content:center;">${this._errorCount > 99 ? '99+' : this._errorCount}</span>`;
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 = '<div class="debug-empty">No log entries yet</div>';
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 += `<div class="debug-entry">`;
html += `<span class="debug-time">${time} +${elapsed}s</span> `;
html += `<span style="color:${color};font-weight:bold;">[${this._esc(entry.type)}]</span> `;
html += `<span class="debug-msg">${this._esc(entry.message)}</span>`;
html += `</div>`;
}
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 = '<div class="debug-empty">No network requests logged</div>';
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 += `<details class="debug-net-entry">`;
html += `<summary>`;
html += `<span class="debug-net-method">${req.method}</span> `;
html += `<span class="debug-net-url">${this._esc(req.url)}</span> `;
html += `<span style="color:${statusColor}">${statusText}</span>`;
if (req.duration != null) html += ` <span class="debug-time">${req.duration}ms</span>`;
html += `</summary>`;
html += `<div class="debug-net-detail">`;
if (req.requestBodyPreview) {
html += `<div><strong>Request:</strong> <code>${this._esc(req.requestBodyPreview)}</code></div>`;
}
if (req.responsePreview) {
html += `<div><strong>Response:</strong> <pre class="debug-pre">${this._esc(req.responsePreview)}</pre></div>`;
}
html += `<div class="debug-time">${req.ts}</div>`;
html += `</div></details>`;
}
container.innerHTML = html;
},
_renderStateTab() {
const container = document.getElementById('debugStateContent');
if (!container) return;
const snap = this.getStateSnapshot();
container.innerHTML = `<pre class="debug-pre debug-state-pre">${this._esc(JSON.stringify(snap, null, 2))}</pre>`;
},
_esc(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
// ── 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();

View File

@@ -205,7 +205,7 @@ async function loadProviderList() {
<div class="provider-row">
<div class="provider-info">
<span class="provider-name">${escapeHtmlUI(c.name)}</span>
<span class="provider-meta">${c.provider} · ${c.model_default || 'no default'}</span>
<span class="provider-meta">${escapeHtmlUI(c.provider)} · ${escapeHtmlUI(c.model_default || 'no default')}</span>
</div>
<div class="provider-actions">
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
@@ -214,7 +214,7 @@ async function loadProviderList() {
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + e.message + '</div>';
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + escapeHtmlUI(e.message) + '</div>';
}
}
@@ -361,53 +361,55 @@ function createMessageHTML(message, index) {
function formatMessage(content) {
let formatted = content;
// Extract and format thinking blocks FIRST (before escaping)
const thinkingBlocks = [];
// Extract thinking blocks FIRST (before escaping)
if (State.settings.showThinking) {
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => {
const escapedContent = escapeHtml(thinkingContent.trim());
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9);
return `__THINKING_BLOCK_${blockId}__${escapedContent}__END_THINKING_BLOCK__`;
// Store raw content; we'll escape it when reinserting
thinkingBlocks.push({ id: blockId, content: thinkingContent.trim() });
return `__THINKPLACEHOLDER_${blockId}__`;
});
} else {
// Remove thinking blocks if setting is off
formatted = formatted.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
}
// Now escape HTML for the rest
// Escape HTML for the entire string (placeholders are plain text, safe to escape)
formatted = escapeHtml(formatted);
// Restore thinking blocks with proper HTML
formatted = formatted.replace(/__THINKING_BLOCK_([\w-]+)__([\s\S]*?)__END_THINKING_BLOCK__/g, (match, blockId, content) => {
return `
<div class="thinking-block" id="${blockId}">
<div class="thinking-header" onclick="toggleThinkingBlock('${blockId}')">
// Restore thinking blocks with properly-escaped content (single escape only)
for (const block of thinkingBlocks) {
const escapedContent = escapeHtml(block.content).replace(/\n/g, '<br>');
formatted = formatted.replace(`__THINKPLACEHOLDER_${block.id}__`, `
<div class="thinking-block" id="${block.id}">
<div class="thinking-header" onclick="toggleThinkingBlock('${block.id}')">
<span class="thinking-toggle">▼</span>
<span class="thinking-label">💭 Thinking</span>
</div>
<div class="thinking-content">${content.replace(/\n/g, '<br>')}</div>
<div class="thinking-content">${escapedContent}</div>
</div>
`;
});
`);
}
// Code blocks with copy button
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
});
// Inline code
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
// Italic
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Line breaks (but not inside pre/code)
formatted = formatted.replace(/\n/g, '<br>');
return formatted;
}