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

@@ -1 +1 @@
0.4.0
0.4.1

View File

@@ -1412,3 +1412,157 @@ body {
border: 1px solid #f59e0b33;
color: #fbbf24;
}
/* ── Debug Modal ─────────────────────────── */
.debug-modal {
max-width: 900px;
height: 80vh;
display: flex;
flex-direction: column;
}
.debug-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
padding: 0 1rem;
background: var(--bg-secondary);
}
.debug-tab {
padding: 0.5rem 1rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.debug-tab:hover { color: var(--text-primary); }
.debug-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.debug-modal-body {
flex: 1;
overflow: hidden;
padding: 0 !important;
}
.debug-tab-content {
height: 100%;
display: flex;
flex-direction: column;
}
.debug-toolbar {
display: flex;
gap: 1rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
}
.debug-check {
display: flex;
align-items: center;
gap: 0.3rem;
color: var(--text-secondary);
cursor: pointer;
}
.debug-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.72rem;
line-height: 1.5;
}
.debug-entry {
padding: 1px 0.5rem;
border-bottom: 1px solid rgba(128, 128, 128, 0.08);
white-space: pre-wrap;
word-break: break-all;
}
.debug-entry:hover {
background: rgba(255, 255, 255, 0.02);
}
.debug-time {
color: var(--text-secondary);
opacity: 0.6;
font-size: 0.68rem;
}
.debug-msg {
color: var(--text-primary);
}
.debug-empty {
color: var(--text-secondary);
text-align: center;
padding: 2rem;
font-family: inherit;
}
.debug-net-entry {
border-bottom: 1px solid var(--border);
}
.debug-net-entry summary {
padding: 0.4rem 0.5rem;
cursor: pointer;
font-size: 0.75rem;
}
.debug-net-entry summary:hover {
background: rgba(255, 255, 255, 0.02);
}
.debug-net-method {
font-weight: bold;
color: var(--accent);
min-width: 3em;
display: inline-block;
}
.debug-net-url {
color: var(--text-primary);
word-break: break-all;
}
.debug-net-detail {
padding: 0.5rem 1rem;
font-size: 0.72rem;
}
.debug-pre {
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow: auto;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
margin: 0.3rem 0;
}
.debug-state-pre {
max-height: none;
font-size: 0.75rem;
}
.debug-footer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-top: 1px solid var(--border);
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Switchboard</title>
<link rel="stylesheet" href="css/styles.css?v=0.4.2">
<link rel="stylesheet" href="css/styles.css?v=0.4.1">
</head>
<body>
<div id="appContainer" class="app-hidden" style="display:none">
@@ -456,14 +456,66 @@
</div>
</div>
<!-- Debug Modal (Ctrl+Shift+D) -->
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">
<div class="modal-header">
<h2>🐛 Debug Log</h2>
<button class="modal-close" onclick="closeDebugModal()"></button>
</div>
<div class="debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">
Console (<span id="debugConsoleCount">0</span>)
</button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">
Network (<span id="debugNetworkCount">0</span>)
</button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">
State
</button>
</div>
<div class="modal-body debug-modal-body">
<!-- Console Tab -->
<div class="debug-tab-content" id="debugConsoleTab">
<div class="debug-toolbar">
<label class="debug-check">
<input type="checkbox" id="debugFilterErrors"> Errors only
</label>
<label class="debug-check">
<input type="checkbox" id="debugAutoScroll" checked> Auto-scroll
</label>
</div>
<div id="debugConsoleContent" class="debug-content"></div>
</div>
<!-- Network Tab -->
<div class="debug-tab-content" id="debugNetworkTab" style="display:none">
<div id="debugNetworkContent" class="debug-content"></div>
</div>
<!-- State Tab -->
<div class="debug-tab-content" id="debugStateTab" style="display:none">
<div id="debugStateContent" class="debug-content"></div>
</div>
</div>
<div class="modal-footer debug-footer">
<button class="btn btn-primary btn-small" onclick="runDebugDiagnostics()">🔍 Run Diagnostics</button>
<div style="margin-left: auto; display: flex; gap: 0.5rem;">
<button class="btn btn-secondary btn-small" onclick="clearDebugLog()">🗑️ Clear</button>
<button class="btn btn-secondary btn-small" onclick="copyDebugLog()">📋 Copy</button>
<button class="btn btn-secondary btn-small" onclick="exportDebugLog()">📥 Export</button>
</div>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
<script src="js/storage.js?v=0.4.2"></script>
<script src="js/backend.js?v=0.4.2"></script>
<script src="js/state.js?v=0.4.2"></script>
<script src="js/api.js?v=0.4.2"></script>
<script src="js/ui.js?v=0.4.2"></script>
<script src="js/admin.js?v=0.4.2"></script>
<script src="js/app.js?v=0.4.2"></script>
<script src="js/debug.js?v=0.4.1"></script>
<script src="js/storage.js?v=0.4.1"></script>
<script src="js/backend.js?v=0.4.1"></script>
<script src="js/state.js?v=0.4.1"></script>
<script src="js/api.js?v=0.4.1"></script>
<script src="js/ui.js?v=0.4.1"></script>
<script src="js/admin.js?v=0.4.1"></script>
<script src="js/app.js?v=0.4.1"></script>
</body>
</html>

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;
}