Changeset 0.37.18 (#230)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-24 19:55:14 +00:00
committed by xcaliber
parent 96a4f16bc5
commit 3a4afea7f2
34 changed files with 1534 additions and 1290 deletions

View File

@@ -1,672 +1,42 @@
// ==========================================
// Debug Logger & Modal
// Debug Bootstrap (v0.37.18)
// ==========================================
// Console interceptor, fetch logger, state inspector.
// Adapted from ai-editor's error-logger.js + llm-debug-modal.js
// for no-devtools "hardmode" debugging.
// Thin entry point: initializes the debug engine (synchronous,
// captures early errors) then mounts the Preact debug modal
// after Preact globals are available.
//
// Activate: Ctrl+K → "debug" or tap the 🐛 badge (appears after init)
// ==========================================
//
// Exports: window.DebugLog,window.openDebugModal,window.switchDebugTab,window.clearDebugLog,window.copyDebugLog,window.exportDebugLog,window.runDebugDiagnostics,window.purgeCache
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,
};
// Preact SDK state (v0.37.14: replaces old API/App globals)
if (window.sw) {
snap.sdk = {
user: window.sw.auth?.user ? {
username: window.sw.auth.user.username,
role: window.sw.auth.user.role,
display_name: window.sw.auth.user.display_name
} : null,
isAuthed: !!window.sw.auth?.token,
wsConnected: window.sw.events?.connected ?? false,
theme: window.sw.theme?.resolved ?? 'unknown',
};
}
// 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)';
}
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 via Preact SDK
if (window.sw?.auth?.token) {
this.log('DIAG', 'Test 3: Token validation');
try {
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
headers: {
'Authorization': `Bearer ${window.sw.auth.token}`,
'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 via Preact SDK
this.log('DIAG', `Test 4: SDK WebSocket = ${window.sw?.events?.connected ? 'connected' : 'disconnected'}`);
// Test 5: 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 = '<div class="debug-empty">No log entries yet</div>';
return;
}
const typeColors = {
'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c',
'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35',
'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', 'NET:PROXY', '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) || req.proxyDetected;
const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71');
const statusText = req.proxyDetected
? `🛡️ PROXY (${req.status})`
: 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.contentType) {
html += `<div><strong>Content-Type:</strong> <code>${this._esc(req.contentType)}</code></div>`;
}
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 += `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;
// Replaces the 673-line imperative debug.js from v0.37.14.
// Engine: src/js/sw/components/debug/engine.js
// Modal: src/js/sw/components/debug/index.js
import { debugEngine } from './sw/components/debug/engine.js';
// Initialize ASAP — before SDK boot so we capture everything.
debugEngine.init();
// Backward-compat globals (used by badge click, command palette, etc.)
window.DebugLog = debugEngine;
window.openDebugModal = () => window.dispatchEvent(new CustomEvent('debug:toggle'));
window.copyDebugLog = () => debugEngine.copyLog();
window.exportDebugLog = () => debugEngine.downloadLog();
window.runDebugDiagnostics = () => debugEngine.runDiagnostics();
window.purgeCache = () => debugEngine.purgeCache();
// Mount Preact modal once the DOM + Preact globals are ready.
// Preact globals are set by the surface template's script block,
// which loads before this module (type=module is deferred).
function mountWhenReady() {
if (!window.preact || !window.html || !window.hooks) {
// Surface script hasn't loaded yet — retry on next tick.
requestAnimationFrame(mountWhenReady);
return;
}
};
// ── Inlined from ui-primitives.js (Scorched Earth III, v0.37.13) ──
function _escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function openModal(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add('active');
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
}
function showConfirm(message, opts = {}) {
return new Promise(resolve => {
const title = opts.title || 'Confirm';
const okText = opts.ok || 'Confirm';
const caText = opts.cancel || 'Cancel';
const danger = opts.danger !== false;
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-dialog">
<div class="confirm-header">${_escHtml(title)}</div>
<div class="confirm-body">${_escHtml(message)}</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${_escHtml(caText)}</button>
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${_escHtml(okText)}</button>
</div>
</div>`;
function close(result) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(result); }
function onKey(e) { if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
overlay.querySelector('[data-action="cancel"]').focus();
import('./sw/components/debug/index.js').then(({ mountDebugModal }) => {
mountDebugModal(document.getElementById('debugMount'));
});
}
// ── Modal Controls ──────────────────────────
function openDebugModal() {
DebugLog.render();
openModal('debugModal');
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountWhenReady);
} else {
mountWhenReady();
}
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(() => { DebugLog.log('DEBUG', '📋 Debug log copied to clipboard'); })
.catch(() => { DebugLog.log('ERROR', 'Copy to clipboard failed'); });
}
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();
// ── Exports (v0.37.14: window.* replaces sb.register) ──
window.DebugLog = DebugLog;
window.openDebugModal = openDebugModal;
window.closeModal = closeModal;
window.openModal = openModal;
window.showConfirm = showConfirm;
window.switchDebugTab = switchDebugTab;
window.clearDebugLog = clearDebugLog;
window.copyDebugLog = copyDebugLog;
window.exportDebugLog = exportDebugLog;
window.runDebugDiagnostics = runDebugDiagnostics;
window.purgeCache = purgeCache;

View File

@@ -1,527 +0,0 @@
// ==========================================
// Chat Switchboard REPL Console
// ==========================================
// Fourth tab in the debug modal (Ctrl+Shift+L).
// Evaluates JavaScript with top-level await support
// via AsyncFunction. Injects app globals for introspection.
//
// Features:
// - AsyncFunction wrapper (top-level await)
// - Injected globals: sw, DebugLog (v0.37.14: old globals removed)
// - Pretty-print results (collapsible JSON, red errors with stack)
// - Command history: ↑/↓ with sessionStorage
// - Tab-completion on live object graphs
// - Event label hints on Events.on(' / Events.emit('
// - Multi-line: Shift+Enter for newline, Enter to run
// - Admin-gated OR ?debug=1 URL param
// ==========================================
//
// Exports: window.REPL
const REPL = {
// ── State ────────────────────────────────
_history: [],
_historyIndex: -1,
_maxHistory: 100,
_storageKey: 'cs::repl::history',
_initialized: false,
// ── Initialization ───────────────────────
init() {
if (this._initialized) return;
this._initialized = true;
// Restore history from sessionStorage
try {
const saved = sessionStorage.getItem(this._storageKey);
if (saved) this._history = JSON.parse(saved);
} catch { /* ignore */ }
const input = document.getElementById('replInput');
if (!input) return;
input.addEventListener('keydown', (e) => this._onKeyDown(e));
input.addEventListener('input', () => this._autoResize(input));
// Gate: hide REPL tab if not admin and no ?debug=1
this._applyGate();
},
/**
* Hide the REPL tab unless user is admin or ?debug=1 is set.
*/
_applyGate() {
const tab = document.querySelector('.debug-tab[data-tab="repl"]');
if (!tab) return;
const isAdmin = window.sw?.auth?.user?.role === 'admin';
const debugParam = new URLSearchParams(window.location.search).has('debug');
if (!isAdmin && !debugParam) {
tab.style.display = 'none';
} else {
tab.style.display = '';
}
},
// ── Execution ────────────────────────────
/**
* Evaluate an expression with top-level await support.
* Injects app globals as local variables.
*/
async run(code) {
if (!code.trim()) return;
// Add to history
this._history.push(code);
if (this._history.length > this._maxHistory) this._history.shift();
this._historyIndex = -1;
this._saveHistory();
// Render input
this._appendInput(code);
const startTime = performance.now();
try {
// Build an AsyncFunction with injected globals.
// The last expression is auto-returned if no explicit return.
const wrappedCode = this._wrapCode(code);
// Injected globals accessible inside the REPL
const globals = this._getGlobals();
const argNames = Object.keys(globals);
const argValues = Object.values(globals);
// eslint-disable-next-line no-new-func
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const fn = new AsyncFunction(...argNames, wrappedCode);
const result = await fn(...argValues);
const elapsed = (performance.now() - startTime).toFixed(1);
if (result !== undefined) {
this._appendResult(result, elapsed);
} else {
this._appendInfo(`(undefined) [${elapsed}ms]`);
}
} catch (err) {
this._appendError(err);
}
this._scrollToBottom();
},
/**
* Wrap code for AsyncFunction evaluation.
* If the code is a single expression (no semicolons, no let/const/var/return),
* auto-return it. Otherwise, execute as-is.
*/
_wrapCode(code) {
const trimmed = code.trim();
// Multi-statement: check for declarations or multiple statements
if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) {
return trimmed;
}
// Single expression — auto-return
// Handle edge case: object literals starting with { need wrapping
if (trimmed.startsWith('{') && !trimmed.startsWith('{(')) {
return `return (${trimmed})`;
}
return `return (${trimmed})`;
},
/**
* Get the set of globals injected into REPL scope.
*/
_getGlobals() {
const globals = {};
// Preact SDK (v0.37.14: replaces old API/Events/Extensions globals)
if (window.sw) globals.sw = window.sw;
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
// Convenience aliases
globals.$ = (sel) => document.querySelector(sel);
globals.$$ = (sel) => [...document.querySelectorAll(sel)];
globals.sleep = (ms) => new Promise(r => setTimeout(r, ms));
return globals;
},
// ── Key Handling ─────────────────────────
_onKeyDown(e) {
const input = e.target;
// Enter (without Shift) → execute
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.run(input.value);
input.value = '';
this._autoResize(input);
return;
}
// ↑ / ↓ → history navigation (only when cursor is on first/last line)
if (e.key === 'ArrowUp' && this._isCursorOnFirstLine(input)) {
e.preventDefault();
this._navigateHistory(-1, input);
return;
}
if (e.key === 'ArrowDown' && this._isCursorOnLastLine(input)) {
e.preventDefault();
this._navigateHistory(1, input);
return;
}
// Tab → completion
if (e.key === 'Tab') {
e.preventDefault();
this._tabComplete(input);
return;
}
},
_isCursorOnFirstLine(input) {
return input.value.substring(0, input.selectionStart).indexOf('\n') === -1;
},
_isCursorOnLastLine(input) {
return input.value.substring(input.selectionStart).indexOf('\n') === -1;
},
_autoResize(input) {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
},
// ── History ──────────────────────────────
_navigateHistory(direction, input) {
if (this._history.length === 0) return;
if (this._historyIndex === -1) {
// Starting navigation — save current input
this._historyScratch = input.value;
this._historyIndex = this._history.length;
}
this._historyIndex += direction;
if (this._historyIndex < 0) this._historyIndex = 0;
if (this._historyIndex >= this._history.length) {
// Past end → restore scratch
this._historyIndex = -1;
input.value = this._historyScratch || '';
} else {
input.value = this._history[this._historyIndex];
}
this._autoResize(input);
// Move cursor to end
input.selectionStart = input.selectionEnd = input.value.length;
},
_saveHistory() {
try {
sessionStorage.setItem(this._storageKey, JSON.stringify(this._history));
} catch { /* quota exceeded — ignore */ }
},
// ── Tab Completion ──────────────────────
_tabComplete(input) {
const cursor = input.selectionStart;
const text = input.value.substring(0, cursor);
// Object property completion: obj.prop or obj.prop.sub
const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
if (propMatch) {
this._completeProperty(input, cursor, propMatch[1], propMatch[2]);
return;
}
// Top-level global completion
const wordMatch = text.match(/([\w$]+)$/);
if (wordMatch) {
this._completeGlobal(input, cursor, wordMatch[1]);
}
},
_completeProperty(input, cursor, objExpr, partial) {
try {
// Resolve the object in global scope
const globals = this._getGlobals();
// eslint-disable-next-line no-new-func
const obj = new Function(...Object.keys(globals), `return ${objExpr}`)(...Object.values(globals));
if (obj == null) return;
const keys = [];
// Own + prototype properties
for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
for (const k of Object.getOwnPropertyNames(o)) {
if (k.startsWith(partial) && !k.startsWith('_')) keys.push(k);
}
}
const unique = [...new Set(keys)].sort();
if (unique.length === 0) return;
if (unique.length === 1) {
this._insertCompletion(input, cursor, partial, unique[0]);
} else {
this._appendInfo('Completions: ' + unique.slice(0, 30).join(', ') + (unique.length > 30 ? ' …' : ''));
const common = this._commonPrefix(unique);
if (common.length > partial.length) {
this._insertCompletion(input, cursor, partial, common);
}
}
} catch { /* can't resolve — ignore */ }
},
_completeGlobal(input, cursor, partial) {
const globals = Object.keys(this._getGlobals());
// Add common window globals
const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math',
'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set'];
const matches = [...new Set(candidates)].filter(c => c.startsWith(partial)).sort();
if (matches.length === 0) return;
if (matches.length === 1) {
this._insertCompletion(input, cursor, partial, matches[0]);
} else {
this._appendInfo('Completions: ' + matches.join(', '));
const common = this._commonPrefix(matches);
if (common.length > partial.length) {
this._insertCompletion(input, cursor, partial, common);
}
}
},
_insertCompletion(input, cursor, partial, completion) {
const before = input.value.substring(0, cursor - partial.length);
const after = input.value.substring(cursor);
input.value = before + completion + after;
const newPos = before.length + completion.length;
input.selectionStart = input.selectionEnd = newPos;
},
_commonPrefix(strs) {
if (strs.length === 0) return '';
let prefix = strs[0];
for (let i = 1; i < strs.length; i++) {
while (!strs[i].startsWith(prefix)) {
prefix = prefix.slice(0, -1);
}
}
return prefix;
},
// ── Output Rendering ────────────────────
_appendInput(code) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-input';
const pre = document.createElement('pre');
pre.textContent = '> ' + code;
div.appendChild(pre);
output.appendChild(div);
},
_appendResult(value, elapsed) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-result';
const formatted = this._formatValue(value);
div.appendChild(formatted);
if (elapsed) {
const time = document.createElement('span');
time.className = 'repl-elapsed';
time.textContent = `[${elapsed}ms]`;
div.appendChild(time);
}
output.appendChild(div);
},
_appendError(err) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-error';
const msg = document.createElement('pre');
msg.className = 'repl-error-msg';
msg.textContent = err.message || String(err);
div.appendChild(msg);
if (err.stack) {
const stack = document.createElement('pre');
stack.className = 'repl-error-stack';
stack.textContent = err.stack.split('\n').slice(1, 5).join('\n');
stack.style.display = 'none';
const toggle = document.createElement('button');
toggle.className = 'repl-stack-toggle';
toggle.textContent = '▸ stack';
toggle.onclick = () => {
const shown = stack.style.display !== 'none';
stack.style.display = shown ? 'none' : '';
toggle.textContent = shown ? '▸ stack' : '▾ stack';
};
div.appendChild(toggle);
div.appendChild(stack);
}
output.appendChild(div);
},
_appendInfo(text) {
const output = document.getElementById('replOutput');
if (!output) return;
const div = document.createElement('div');
div.className = 'repl-entry repl-entry-info';
div.textContent = text;
output.appendChild(div);
},
/**
* Format a JS value for display.
* Objects/arrays get collapsible JSON; primitives get plain text.
*/
_formatValue(value) {
if (value === null) return this._textEl('null', 'repl-null');
if (value === undefined) return this._textEl('undefined', 'repl-undefined');
const type = typeof value;
if (type === 'string') return this._textEl(JSON.stringify(value), 'repl-string');
if (type === 'number' || type === 'bigint') return this._textEl(String(value), 'repl-number');
if (type === 'boolean') return this._textEl(String(value), 'repl-boolean');
if (type === 'function') return this._textEl(`[Function: ${value.name || 'anonymous'}]`, 'repl-function');
if (type === 'symbol') return this._textEl(String(value), 'repl-symbol');
if (value instanceof HTMLElement) {
return this._textEl(`<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`, 'repl-dom');
}
if (value instanceof Error) {
return this._textEl(`${value.constructor.name}: ${value.message}`, 'repl-error-msg');
}
// Objects and arrays → collapsible JSON
try {
const json = JSON.stringify(value, null, 2);
if (json.length < 200) {
return this._textEl(json, 'repl-json');
}
return this._collapsibleJson(json, value);
} catch {
return this._textEl(String(value), 'repl-object');
}
},
_textEl(text, className) {
const pre = document.createElement('pre');
pre.className = className || '';
pre.textContent = text;
return pre;
},
_collapsibleJson(json, value) {
const container = document.createElement('div');
container.className = 'repl-collapsible';
const summary = Array.isArray(value)
? `Array(${value.length})`
: `Object {${Object.keys(value).slice(0, 3).join(', ')}${Object.keys(value).length > 3 ? ', …' : ''}}`;
const toggle = document.createElement('button');
toggle.className = 'repl-json-toggle';
toggle.textContent = '▸ ' + summary;
const pre = document.createElement('pre');
pre.className = 'repl-json';
pre.textContent = json;
pre.style.display = 'none';
toggle.onclick = () => {
const shown = pre.style.display !== 'none';
pre.style.display = shown ? 'none' : '';
toggle.textContent = (shown ? '▸ ' : '▾ ') + summary;
};
container.appendChild(toggle);
container.appendChild(pre);
return container;
},
_scrollToBottom() {
const output = document.getElementById('replOutput');
if (output) output.scrollTop = output.scrollHeight;
},
// ── Toolbar Actions ──────────────────────
clear() {
const output = document.getElementById('replOutput');
if (output) output.innerHTML = '';
},
copyOutput() {
const output = document.getElementById('replOutput');
if (!output) return;
const text = output.innerText;
navigator.clipboard.writeText(text)
.then(() => { if (typeof showToast === 'function') showToast('📋 REPL output copied', 'success'); })
.catch(() => {});
},
showHelp() {
const globals = Object.keys(this._getGlobals());
const help = [
'╭─ REPL Help ─────────────────────────────╮',
'│ Enter Run expression │',
'│ Shift+Enter New line │',
'│ ↑ / ↓ Command history │',
'│ Tab Auto-complete │',
'╰──────────────────────────────────────────╯',
'',
'Injected globals: ' + globals.join(', '),
'',
'Top-level await is supported:',
' const r = await sw.api.get("/api/v1/profile")',
'',
'Shortcuts:',
' $(sel) → document.querySelector(sel)',
' $$(sel) → document.querySelectorAll(sel)',
' sleep(ms) → await sleep(1000)',
];
help.forEach(line => this._appendInfo(line));
this._scrollToBottom();
},
};
// Initialized from app.js startApp() after auth is confirmed,
// ensuring auth is available for admin gate check.
// ── Exports (v0.37.14: window.* replaces sb.ns) ──
window.REPL = REPL;

View File

@@ -112,6 +112,26 @@ export function extractCodeBlocks(htmlStr) {
return segments;
}
/**
* Map language tag to file extension.
* @param {string} lang
* @returns {string}
*/
export function guessExtension(lang) {
const map = {
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
go: 'go', rust: 'rs', java: 'java', kotlin: 'kt', swift: 'swift',
c: 'c', cpp: 'cpp', csharp: 'cs', php: 'php', bash: 'sh',
shell: 'sh', zsh: 'sh', sql: 'sql', html: 'html', css: 'css',
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', toml: 'toml',
markdown: 'md', md: 'md', jsx: 'jsx', tsx: 'tsx', vue: 'vue',
svelte: 'svelte', scss: 'scss', less: 'less', lua: 'lua',
perl: 'pl', r: 'r', dart: 'dart', elixir: 'ex', erlang: 'erl',
haskell: 'hs', scala: 'scala', zig: 'zig', nim: 'nim',
};
return map[(lang || '').toLowerCase()] || 'txt';
}
function _unescapeHtml(text) {
return text
.replace(/&amp;/g, '&')

View File

@@ -38,15 +38,25 @@ const DELETE_SVG = html`
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>`;
const SAVE_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>`;
/**
* @param {{
* role: string,
* content: string,
* onRegenerate?: () => void,
* onDelete?: () => void,
* onSave?: () => void,
* hasCodeBlocks?: boolean,
* }} props
*/
export function MessageActions({ role, content, onRegenerate, onDelete }) {
export function MessageActions({ role, content, onRegenerate, onDelete, onSave, hasCodeBlocks }) {
const [copied, setCopied] = useState(false);
const _copy = useCallback(async () => {
@@ -80,6 +90,13 @@ export function MessageActions({ role, content, onRegenerate, onDelete }) {
aria-label="Copy message text">
${copied ? CHECK_SVG : COPY_SVG}
</button>
${role === 'assistant' && hasCodeBlocks && onSave && html`
<button class="sw-chat-msg__action-btn"
title="Save to workspace"
onClick=${onSave}
aria-label="Save code to workspace">
${SAVE_SVG}
</button>`}
${onDelete && html`
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
title="Delete"

View File

@@ -12,11 +12,11 @@
// onRegenerate=${fn} onDelete=${fn} />`
import { renderMarkdown } from './markdown.js';
import { CodeBlock, extractCodeBlocks } from './code-block.js';
import { CodeBlock, extractCodeBlocks, guessExtension } from './code-block.js';
import { MessageActions } from './message-actions.js';
const html = window.html;
const { useMemo } = window.hooks;
const { useMemo, useCallback } = window.hooks;
/**
* Format a relative time string from an ISO date.
@@ -65,6 +65,33 @@ export function MessageBubble({ role, content, thinking, id, created_at, streami
}, [thinking]);
const timeStr = _relTime(created_at);
const hasCodeBlocks = useMemo(() => segments.some(s => s.type === 'code'), [segments]);
const handleSave = useCallback(async () => {
if (!window.sw?.api?.workspaces?.getDefault) return;
const codeBlocks = segments.filter(s => s.type === 'code');
if (codeBlocks.length === 0) return;
try {
const ws = await window.sw.api.workspaces.getDefault();
const ts = Date.now();
for (let i = 0; i < codeBlocks.length; i++) {
const block = codeBlocks[i];
const ext = guessExtension(block.language);
const filename = codeBlocks.length === 1
? `snippet-${ts}.${ext}`
: `snippet-${ts}-${i + 1}.${ext}`;
await window.sw.api.workspaces.writeFile(ws.id, filename, block.code);
}
if (window.sw?.toast) {
window.sw.toast(`Saved ${codeBlocks.length} file${codeBlocks.length > 1 ? 's' : ''} to My Files`, 'success');
}
} catch (err) {
if (window.sw?.toast) {
window.sw.toast('Failed to save: ' + (err.message || err), 'error');
}
}
}, [segments]);
// While streaming, show thinking open if content hasn't started yet
const thinkingOpen = streaming && thinking && !content;
@@ -93,7 +120,9 @@ export function MessageBubble({ role, content, thinking, id, created_at, streami
role=${role}
content=${content}
onRegenerate=${onRegenerate}
onDelete=${onDelete} />
onDelete=${onDelete}
hasCodeBlocks=${hasCodeBlocks}
onSave=${handleSave} />
</div>`}
</div>`;
}

View File

@@ -10,11 +10,14 @@
const { useState, useEffect } = window.hooks;
const html = window.html;
const HEALTH_DOTS = { healthy: '\u{1F7E2}', degraded: '\u{1F7E1}', inactive: '\u{1F534}' };
/**
* @param {{ value: string, onChange: (id: string) => void }} props
*/
export function ModelSelector({ value, onChange }) {
const [models, setModels] = useState([]);
const [health, setHealth] = useState({});
useEffect(() => {
if (!window.sw?.api?.models?.enabled) return;
@@ -23,13 +26,25 @@ export function ModelSelector({ value, onChange }) {
if (cancelled) return;
const list = (resp.data || []).filter(m => !m.hidden);
setModels(list);
// Auto-select first model if no value set
if (!value && list.length && onChange) {
const first = list[0];
const id = first.isPersona ? (first.personaId || first.id) : first.id;
onChange(id);
}
}).catch(() => {});
// Fetch provider health for admin users (graceful degrade for non-admin)
if (window.sw?.api?.admin?.providers?.health) {
window.sw.api.admin.providers.health().then(resp => {
if (cancelled) return;
const map = {};
for (const p of (resp.data || resp || [])) {
if (p.provider_name) map[p.provider_name] = p.status;
}
setHealth(map);
}).catch(() => {});
}
return () => { cancelled = true; };
}, []);
@@ -47,7 +62,10 @@ export function ModelSelector({ value, onChange }) {
<option value="" disabled selected>No models</option>`}
${models.map(m => {
const id = m.isPersona ? (m.personaId || m.id) : m.id;
const label = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id);
const dot = !m.isPersona && m.provider_name && health[m.provider_name]
? (HEALTH_DOTS[health[m.provider_name]] || '') + ' '
: '';
const label = (m.isPersona ? '\ud83c\udfad ' : dot) + (m.name || m.id);
return html`<option key=${id} value=${id}>${label}</option>`;
})}
</select>`;

View File

@@ -0,0 +1,37 @@
// ==========================================
// Debug Modal — Badge Component
// ==========================================
// Bug badge that shows error count. Subscribes to engine
// for reactive updates.
//
// v0.37.18: Preact rebuild from debug.js _updateBadge().
const html = window.html;
const { useEffect } = window.hooks;
/**
* Updates the sidebar .avatar-bug element with current error count.
* Not a visible Preact component — just a side-effect hook.
*
* @param {{ engine: object }} props
*/
export function DebugBadge({ engine }) {
useEffect(() => {
function update() {
const bug = document.querySelector('.avatar-bug');
if (!bug) return;
if (engine.errorCount > 0) {
bug.title = `${engine.errorCount} error${engine.errorCount > 1 ? 's' : ''} \u2014 click for Debug Log`;
bug.classList.add('has-errors');
} else {
bug.title = 'Debug available';
bug.classList.remove('has-errors');
}
}
update(); // initial
return engine.subscribe(update);
}, [engine]);
return null; // invisible — side-effect only
}

View File

@@ -0,0 +1,73 @@
// ==========================================
// Debug Modal — Console Tab
// ==========================================
// Filterable console log display with type coloring,
// elapsed timestamps, and auto-scroll.
//
// v0.37.18: Preact rebuild from debug.js _renderConsoleTab().
const html = window.html;
const { useState, useMemo, useRef, useEffect } = window.hooks;
const TYPE_COLORS = {
'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c',
'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35',
'WARN': '#f39c12', 'WARNING': '#f39c12',
'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d',
'NET': '#2ecc71',
'DIAG': '#9b59b6'
};
const ERROR_TYPES = new Set(['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'NET:PROXY', 'WARN']);
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* @param {{ entries: Array }} props
*/
export function ConsoleTab({ entries }) {
const [errorsOnly, setErrorsOnly] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const contentRef = useRef(null);
const filtered = useMemo(() => {
if (!errorsOnly) return entries;
return entries.filter(e => ERROR_TYPES.has(e.type));
}, [entries, errorsOnly]);
useEffect(() => {
if (autoScroll && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [filtered, autoScroll]);
return html`
<div class="debug-console-tab">
<div class="debug-toolbar">
<label class="debug-check">
<input type="checkbox" checked=${errorsOnly}
onChange=${e => setErrorsOnly(e.target.checked)} />
Errors only
</label>
<label class="debug-check">
<input type="checkbox" checked=${autoScroll}
onChange=${e => setAutoScroll(e.target.checked)} />
Auto-scroll
</label>
<span class="debug-badge">${filtered.length}</span>
</div>
<div class="debug-content" ref=${contentRef}>
${filtered.length === 0
? html`<div class="debug-empty">No log entries yet</div>`
: filtered.map((entry, i) => html`
<div class="debug-entry" key=${i}>
<span class="debug-time">${entry.ts.slice(11, 23)} +${(entry.elapsed / 1000).toFixed(1)}s</span>${' '}
<span style="color:${TYPE_COLORS[entry.type] || '#95a5a6'};font-weight:bold;">[${_esc(entry.type)}]</span>${' '}
<span class="debug-msg">${_esc(entry.message)}</span>
</div>`)}
</div>
</div>`;
}

View File

@@ -0,0 +1,426 @@
// ==========================================
// Debug Engine — Preact-compatible data layer
// ==========================================
// Console interceptor, fetch logger, error tracker, diagnostics.
// Singleton — init() must run before SDK boot to capture early errors.
// UI-agnostic: Preact components subscribe via .subscribe().
//
// v0.37.18: Extracted from debug.js (v0.37.14).
//
// Exports: debugEngine (singleton)
export const debugEngine = {
entries: [],
maxEntries: 500,
networkLog: [],
maxNetwork: 200,
errorCount: 0,
_origConsole: {},
_origFetch: null,
_initTime: Date.now(),
_subscribers: new Set(),
// ── Bootstrap ────────────────────────────
init() {
this._interceptConsole();
this._interceptFetch();
this._interceptErrors();
this._registerShortcut();
this.log('DEBUG', 'Debug engine initialized');
this.log('DEBUG', `Page: ${location.href}`);
this.log('DEBUG', `UA: ${navigator.userAgent.slice(0, 120)}`);
},
// ── Subscription (reactive updates) ─────
subscribe(fn) {
this._subscribers.add(fn);
return () => this._subscribers.delete(fn);
},
_notify() {
for (const fn of this._subscribers) {
try { fn(); } catch (_) { /* subscriber error */ }
}
},
// ── 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) : {};
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 (_) {
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);
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)} \u2192 PROXY HTML (${entry.contentType}) (${entry.duration}ms)`);
try {
const clone = resp.clone();
const text = await clone.text();
entry.responsePreview = text.slice(0, 500);
} catch (_) { /* */ }
} else {
const logType = resp.ok ? 'NET' : 'NET:ERR';
self._addEntry(logType, `${method} ${url.slice(0, 80)} \u2192 ${resp.status} (${entry.duration}ms)`);
}
if (!resp.ok && !entry.proxyDetected) {
try {
const clone = resp.clone();
const text = await clone.text();
entry.responsePreview = text.slice(0, 500);
} catch (_) { /* 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)} \u2192 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}`);
});
window.addEventListener('unhandledrejection', (e) => {
const msg = e.reason?.message || e.reason || 'Unknown rejection';
this._addEntry('REJECTION', String(msg));
});
},
// ── Keyboard Shortcut ───────────────────
_registerShortcut() {
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
e.preventDefault();
// Dispatched as custom event; DebugModal listens for it.
window.dispatchEvent(new CustomEvent('debug:toggle'));
}
});
},
// ── 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._notify();
},
log(type, message) {
this._addEntry(type, message);
},
clear() {
this.entries = [];
this.networkLog = [];
this.errorCount = 0;
this._notify();
},
// ── 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 (_) { 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,
};
if (window.sw) {
snap.sdk = {
user: window.sw.auth?.user ? {
username: window.sw.auth.user.username,
role: window.sw.auth.user.role,
display_name: window.sw.auth.user.display_name
} : null,
isAuthed: !!window.sw.auth?.token,
wsConnected: window.sw.events?.connected ?? false,
theme: window.sw.theme?.resolved ?? 'unknown',
};
}
try {
snap.storageKeys = Object.keys(localStorage).filter(k =>
k.startsWith('chatSwitchboard') || k.startsWith('switchboard') || k.startsWith('sb_')
);
} catch (_) {
snap.storageKeys = '(error reading)';
}
return snap;
},
// ── Connection Diagnostics ──────────────
async runDiagnostics() {
this.log('DIAG', '\u2500\u2500 Starting connection diagnostics \u2500\u2500');
this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`);
const baseUrl = window.location.origin + (window.__BASE__ || '');
// Test 1: Health check
this.log('DIAG', `Test 1: Health check \u2192 ${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', ' \u26a0\ufe0f Got HTML instead of JSON \u2014 likely a PROXY interception page');
}
if (!resp.ok) {
this.log('DIAG', ` \u26a0\ufe0f Non-200 status: ${resp.status} ${resp.statusText}`);
}
try {
const json = JSON.parse(text);
this.log('DIAG', ` \u2705 Valid JSON: database=${json.database}, registration=${json.registration_enabled}`);
} catch (_) {
this.log('DIAG', ' \u274c NOT valid JSON \u2014 proxy or wrong endpoint');
}
} catch (err) {
this.log('DIAG', ` \u274c Fetch failed: ${err.message}`);
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
this.log('DIAG', ' \u26a0\ufe0f Request timed out \u2014 proxy may be blocking');
}
}
// Test 2: CORS preflight
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', ` \u274c OPTIONS failed: ${err.message}`);
}
// Test 3: Token validation
if (window.sw?.auth?.token) {
this.log('DIAG', 'Test 3: Token validation');
try {
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
headers: {
'Authorization': `Bearer ${window.sw.auth.token}`,
'Content-Type': 'application/json'
},
signal: AbortSignal.timeout(5000)
});
this.log('DIAG', ` Profile endpoint: ${resp.status}`);
if (resp.status === 401) {
this.log('DIAG', ' \u26a0\ufe0f Token expired or invalid');
}
} catch (err) {
this.log('DIAG', ` \u274c Token check failed: ${err.message}`);
}
} else {
this.log('DIAG', 'Test 3: Skipped (no token)');
}
// Test 4: WebSocket
this.log('DIAG', `Test 4: SDK WebSocket = ${window.sw?.events?.connected ? 'connected' : 'disconnected'}`);
// Test 5: Service Worker & Cache
this.log('DIAG', 'Test 5: 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 cacheEntries = await cache.keys();
this.log('DIAG', ` ${key}: ${cacheEntries.length} entries`);
}
} catch (e) {
this.log('DIAG', ` Cache check error: ${e.message}`);
}
this.log('DIAG', '\u2500\u2500 Diagnostics complete \u2500\u2500');
},
// ── 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} \u2192 ${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;
},
copyLog() {
const text = this.exportText();
navigator.clipboard.writeText(text)
.then(() => this.log('DEBUG', 'Debug log copied to clipboard'))
.catch(() => this.log('ERROR', 'Copy to clipboard failed'));
},
downloadLog() {
const text = this.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);
},
async purgeCache() {
this.log('DIAG', 'Purging Service Worker caches...');
try {
const keys = await caches.keys();
const deleted = await Promise.all(keys.map(k => caches.delete(k)));
this.log('DIAG', ` Deleted ${deleted.filter(Boolean).length} cache(s): ${keys.join(', ') || '(none)'}`);
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const reg of registrations) {
await reg.unregister();
this.log('DIAG', ` Unregistered SW: ${reg.scope}`);
}
}
this.log('DIAG', ' Cache purged \u2014 reloading...');
setTimeout(() => location.reload(), 500);
} catch (e) {
this.log('DIAG', ` Purge failed: ${e.message}`);
}
}
};

View File

@@ -0,0 +1,132 @@
// ==========================================
// Debug Modal — Root Component
// ==========================================
// Preact modal with tabs: Console, Network, State, REPL.
// Global overlay — not a routed surface.
// Mounts via mountDebugModal(el, engine).
//
// v0.37.18: Preact rebuild of debug modal (CR P2-5).
import { debugEngine } from './engine.js';
import { ConsoleTab } from './console-tab.js';
import { NetworkTab } from './network-tab.js';
import { StateTab } from './state-tab.js';
import { ReplTab } from './repl-tab.js';
import { DebugBadge } from './badge.js';
const { h, render } = window.preact;
const html = window.html;
const { useState, useEffect, useCallback, useRef } = window.hooks;
const TABS = ['console', 'network', 'state', 'repl'];
function DebugModal({ engine }) {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState('console');
const [tick, setTick] = useState(0);
const overlayRef = useRef(null);
// Subscribe to engine for reactive re-render
useEffect(() => {
return engine.subscribe(() => setTick(t => t + 1));
}, [engine]);
// Listen for toggle events (Ctrl+Shift+L, badge click)
useEffect(() => {
const toggle = () => setOpen(prev => !prev);
window.addEventListener('debug:toggle', toggle);
return () => window.removeEventListener('debug:toggle', toggle);
}, []);
// Expose global open/close for backward compat
useEffect(() => {
window.openDebugModal = () => setOpen(true);
window.closeDebugModal = () => setOpen(false);
}, []);
// Close on overlay click
const onOverlayClick = useCallback((e) => {
if (e.target === overlayRef.current) setOpen(false);
}, []);
// Close on Escape
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
const onClear = useCallback(async () => {
if (window.sw?.confirm) {
const ok = await window.sw.confirm('Clear all debug logs?');
if (!ok) return;
}
engine.clear();
}, [engine]);
const onPurge = useCallback(async () => {
if (window.sw?.confirm) {
const ok = await window.sw.confirm(
'Purge all cached files and reload?\n\n' +
'This clears the Service Worker cache and forces a fresh download of all assets.'
);
if (!ok) return;
}
engine.purgeCache();
}, [engine]);
return html`
<${DebugBadge} engine=${engine} />
${open && html`
<div class="modal-overlay active" ref=${overlayRef} onClick=${onOverlayClick}>
<div class="modal modal-wide">
<div class="modal-header">
<h2>Debug Log</h2>
<button class="modal-close" onClick=${() => setOpen(false)}>\u2715</button>
</div>
<div class="modal-tabs">
${TABS.map(tab => html`
<button key=${tab}
class="debug-tab${activeTab === tab ? ' active' : ''}${tab === 'repl' && !_replVisible() ? ' debug-tab--hidden' : ''}"
onClick=${() => setActiveTab(tab)}>
${tab.charAt(0).toUpperCase() + tab.slice(1)}
${tab === 'console' && html`${' '}<span class="badge">${engine.entries.length}</span>`}
${tab === 'network' && html`${' '}<span class="badge">${engine.networkLog.length}</span>`}
</button>`)}
</div>
<div class="modal-body debug-modal-body">
${activeTab === 'console' && html`<${ConsoleTab} entries=${engine.entries} />`}
${activeTab === 'network' && html`<${NetworkTab} networkLog=${engine.networkLog} />`}
${activeTab === 'state' && html`<${StateTab} engine=${engine} />`}
${activeTab === 'repl' && html`<${ReplTab} visible=${true} />`}
</div>
<div class="modal-footer debug-modal-footer">
<div class="debug-footer-left">
<button class="btn-danger btn-small" onClick=${() => { setActiveTab('console'); engine.runDiagnostics(); }}>Diagnostics</button>
<button class="btn-danger btn-small" onClick=${onPurge}>Purge Cache</button>
</div>
<div class="debug-footer-right">
<button class="btn-secondary btn-small" onClick=${onClear}>Clear</button>
<button class="btn-secondary btn-small" onClick=${() => engine.copyLog()}>Copy</button>
<button class="btn-secondary btn-small" onClick=${() => engine.downloadLog()}>Export</button>
</div>
</div>
</div>
</div>`}`;
}
function _replVisible() {
const isAdmin = window.sw?.auth?.user?.role === 'admin';
const debugParam = new URLSearchParams(window.location.search).has('debug');
return isAdmin || debugParam;
}
/**
* Mount the debug modal into a target element.
* Call after Preact globals are available.
*/
export function mountDebugModal(el) {
if (!el) return;
render(html`<${DebugModal} engine=${debugEngine} />`, el);
}

View File

@@ -0,0 +1,64 @@
// ==========================================
// Debug Modal — Network Tab
// ==========================================
// Fetch log with expandable request/response details.
// Newest entries first.
//
// v0.37.18: Preact rebuild from debug.js _renderNetworkTab().
const html = window.html;
const { useState } = window.hooks;
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function NetEntry({ req }) {
const [open, setOpen] = useState(false);
const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected;
const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71');
const statusText = req.proxyDetected
? `PROXY (${req.status})`
: req.error
? `ERR ${req.error}`
: (req.status ? `${req.status} ${req.statusText}` : 'pending');
return html`
<div class="debug-net-entry">
<div class="debug-net-summary" onClick=${() => setOpen(!open)}>
<span class="debug-net-arrow">${open ? '\u25be' : '\u25b8'}</span>
<span class="debug-net-method">${req.method}</span>${' '}
<span class="debug-net-url">${_esc(req.url)}</span>${' '}
<span style="color:${statusColor}">${statusText}</span>
${req.duration != null && html`${' '}<span class="debug-time">${req.duration}ms</span>`}
</div>
${open && html`
<div class="debug-net-detail">
${req.contentType && html`<div><strong>Content-Type:</strong> <code>${_esc(req.contentType)}</code></div>`}
${req.requestBodyPreview && html`<div><strong>Request:</strong> <code>${_esc(req.requestBodyPreview)}</code></div>`}
${req.responsePreview && html`<div><strong>Response:</strong> <pre class="debug-pre">${_esc(req.responsePreview)}</pre></div>`}
<div class="debug-time">${req.ts}</div>
</div>`}
</div>`;
}
/**
* @param {{ networkLog: Array }} props
*/
export function NetworkTab({ networkLog }) {
const reversed = [...networkLog].reverse();
return html`
<div class="debug-network-tab">
<div class="debug-toolbar">
<span class="debug-badge">${networkLog.length}</span>
</div>
<div class="debug-content">
${reversed.length === 0
? html`<div class="debug-empty">No network requests logged</div>`
: reversed.map((req, i) => html`<${NetEntry} key=${req.id || i} req=${req} />`)}
</div>
</div>`;
}

View File

@@ -0,0 +1,237 @@
// ==========================================
// Debug Modal — REPL Tab
// ==========================================
// JavaScript REPL with top-level await, tab-completion,
// command history, and collapsible JSON output.
// Admin-gated OR ?debug=1 URL param.
//
// v0.37.18: Preact rebuild from repl.js (v0.37.14).
const html = window.html;
const { useState, useRef, useEffect, useCallback } = window.hooks;
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _getGlobals() {
const globals = {};
if (window.sw) globals.sw = window.sw;
if (window.DebugLog) globals.DebugLog = window.DebugLog;
globals.$ = (sel) => document.querySelector(sel);
globals.$$ = (sel) => [...document.querySelectorAll(sel)];
globals.sleep = (ms) => new Promise(r => setTimeout(r, ms));
return globals;
}
function _wrapCode(code) {
const trimmed = code.trim();
if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) {
return trimmed;
}
return `return (${trimmed})`;
}
function _formatValue(value) {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
const type = typeof value;
if (type === 'string') return JSON.stringify(value);
if (type === 'number' || type === 'bigint' || type === 'boolean') return String(value);
if (type === 'function') return `[Function: ${value.name || 'anonymous'}]`;
if (type === 'symbol') return String(value);
if (value instanceof HTMLElement) {
return `<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`;
}
if (value instanceof Error) return `${value.constructor.name}: ${value.message}`;
try { return JSON.stringify(value, null, 2); }
catch (_) { return String(value); }
}
/**
* @param {{ visible: boolean }} props
*/
export function ReplTab({ visible }) {
const [output, setOutput] = useState([]);
const [historyList] = useState(() => {
try {
const saved = sessionStorage.getItem('cs::repl::history');
return saved ? JSON.parse(saved) : [];
} catch (_) { return []; }
});
const historyIdx = useRef(-1);
const scratchRef = useRef('');
const inputRef = useRef(null);
const outputRef = useRef(null);
// Admin gate
const isAdmin = window.sw?.auth?.user?.role === 'admin';
const debugParam = new URLSearchParams(window.location.search).has('debug');
if (!isAdmin && !debugParam) return null;
const appendEntry = useCallback((type, text) => {
setOutput(prev => [...prev, { type, text }]);
}, []);
const runCode = useCallback(async (code) => {
if (!code.trim()) return;
historyList.push(code);
if (historyList.length > 100) historyList.shift();
historyIdx.current = -1;
try { sessionStorage.setItem('cs::repl::history', JSON.stringify(historyList)); } catch (_) {}
appendEntry('input', '> ' + code);
const startTime = performance.now();
try {
const globals = _getGlobals();
const argNames = Object.keys(globals);
const argValues = Object.values(globals);
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const fn = new AsyncFunction(...argNames, _wrapCode(code));
const result = await fn(...argValues);
const elapsed = (performance.now() - startTime).toFixed(1);
if (result !== undefined) {
appendEntry('result', `${_formatValue(result)} [${elapsed}ms]`);
} else {
appendEntry('info', `(undefined) [${elapsed}ms]`);
}
} catch (err) {
appendEntry('error', err.message || String(err));
if (err.stack) {
appendEntry('stack', err.stack.split('\n').slice(1, 5).join('\n'));
}
}
}, [historyList, appendEntry]);
const onKeyDown = useCallback((e) => {
const input = e.target;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
runCode(input.value);
input.value = '';
return;
}
if (e.key === 'ArrowUp' && input.value.substring(0, input.selectionStart).indexOf('\n') === -1) {
e.preventDefault();
if (historyIdx.current === -1) {
scratchRef.current = input.value;
historyIdx.current = historyList.length;
}
historyIdx.current = Math.max(0, historyIdx.current - 1);
input.value = historyList[historyIdx.current] || '';
input.selectionStart = input.selectionEnd = input.value.length;
return;
}
if (e.key === 'ArrowDown' && input.value.substring(input.selectionStart).indexOf('\n') === -1) {
e.preventDefault();
if (historyIdx.current === -1) return;
historyIdx.current++;
if (historyIdx.current >= historyList.length) {
historyIdx.current = -1;
input.value = scratchRef.current || '';
} else {
input.value = historyList[historyIdx.current] || '';
}
input.selectionStart = input.selectionEnd = input.value.length;
return;
}
if (e.key === 'Tab') {
e.preventDefault();
_tabComplete(input);
}
}, [runCode, historyList]);
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [output]);
if (!visible) return null;
return html`
<div class="debug-repl-tab">
<div class="debug-repl-output" ref=${outputRef}>
${output.map((entry, i) => html`
<div key=${i} class="repl-entry repl-entry-${entry.type}">
<pre>${_esc(entry.text)}</pre>
</div>`)}
</div>
<div class="debug-repl-input">
<input type="text" ref=${inputRef}
placeholder="Type expression\u2026"
onKeyDown=${onKeyDown} />
</div>
</div>`;
}
function _tabComplete(input) {
const cursor = input.selectionStart;
const text = input.value.substring(0, cursor);
const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
if (propMatch) {
try {
const globals = _getGlobals();
const obj = new Function(...Object.keys(globals), `return ${propMatch[1]}`)(...Object.values(globals));
if (obj == null) return;
const keys = [];
for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
for (const k of Object.getOwnPropertyNames(o)) {
if (k.startsWith(propMatch[2]) && !k.startsWith('_')) keys.push(k);
}
}
const unique = [...new Set(keys)].sort();
if (unique.length === 1) {
_insertCompletion(input, cursor, propMatch[2], unique[0]);
} else if (unique.length > 1) {
const common = _commonPrefix(unique);
if (common.length > propMatch[2].length) {
_insertCompletion(input, cursor, propMatch[2], common);
}
}
} catch (_) {}
return;
}
const wordMatch = text.match(/([\w$]+)$/);
if (wordMatch) {
const globals = Object.keys(_getGlobals());
const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math',
'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set'];
const matches = [...new Set(candidates)].filter(c => c.startsWith(wordMatch[1])).sort();
if (matches.length === 1) {
_insertCompletion(input, cursor, wordMatch[1], matches[0]);
} else if (matches.length > 1) {
const common = _commonPrefix(matches);
if (common.length > wordMatch[1].length) {
_insertCompletion(input, cursor, wordMatch[1], common);
}
}
}
}
function _insertCompletion(input, cursor, partial, completion) {
const before = input.value.substring(0, cursor - partial.length);
const after = input.value.substring(cursor);
input.value = before + completion + after;
const newPos = before.length + completion.length;
input.selectionStart = input.selectionEnd = newPos;
}
function _commonPrefix(strs) {
if (strs.length === 0) return '';
let prefix = strs[0];
for (let i = 1; i < strs.length; i++) {
while (!strs[i].startsWith(prefix)) prefix = prefix.slice(0, -1);
}
return prefix;
}

View File

@@ -0,0 +1,28 @@
// ==========================================
// Debug Modal — State Tab
// ==========================================
// Displays current application state snapshot as formatted JSON.
//
// v0.37.18: Preact rebuild from debug.js _renderStateTab().
const html = window.html;
const { useMemo } = window.hooks;
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* @param {{ engine: { getStateSnapshot: () => object } }} props
*/
export function StateTab({ engine }) {
const snap = useMemo(() => engine.getStateSnapshot(), [engine.entries.length]);
return html`
<div class="debug-state-tab">
<div class="debug-content">
<pre class="debug-pre debug-state-pre">${_esc(JSON.stringify(snap, null, 2))}</pre>
</div>
</div>`;
}

View File

@@ -94,7 +94,7 @@ export function createDomains(restClient) {
kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`),
setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }),
// Files
files: (id) => rc.get(`/api/v1/channels/${id}/files`),
files: (id, opts) => rc.get(`/api/v1/channels/${id}/files` + _qs(opts)),
uploadFile: (id, file) => rc.upload(`/api/v1/channels/${id}/files`, file),
},
@@ -150,7 +150,8 @@ export function createDomains(restClient) {
// ── 7. Workspaces ──────────────────────
workspaces: {
...crud(rc, '/api/v1/workspaces'),
update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data),
update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data),
getDefault: () => rc.get('/api/v1/workspaces/default'),
files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)),
readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })),
writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),

View File

@@ -117,8 +117,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
});
break;
case 'debug':
const dbg = document.getElementById('debugModal');
if (dbg) dbg.classList.toggle('active');
window.dispatchEvent(new CustomEvent('debug:toggle'));
break;
case 'chat':
location.href = BASE + '/';

View File

@@ -54,6 +54,16 @@ export default function ProvidersSection() {
} catch (e) { sw.toast(e.message, 'error'); }
}
async function testProvider(cfg) {
sw.toast(`Testing ${cfg.name}\u2026`, 'info');
try {
const result = await sw.api.admin.models.fetch();
sw.toast(`${cfg.name}: connection OK`, 'success');
} catch (e) {
sw.toast(`${cfg.name}: connection failed \u2014 ${e.message}`, 'error');
}
}
async function deleteProvider(id) {
const ok = await sw.confirm('Delete this provider config?', true);
if (!ok) return;
@@ -118,6 +128,7 @@ export default function ProvidersSection() {
</div>
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
<div style="display:flex;gap:6px;">
<button class="btn-small" onClick=${() => testProvider(c)}>Test</button>
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>

View File

@@ -110,6 +110,17 @@ export function ProvidersSection() {
}
}, []);
const testConnection = useCallback(async (prov) => {
sw.emit('toast', { message: `Testing ${prov.name}\u2026`, variant: 'info' });
try {
const result = await sw.api.providers.fetchModels(prov.id);
const total = result?.total || 0;
sw.emit('toast', { message: `${prov.name}: connection OK (${total} model${total !== 1 ? 's' : ''})`, variant: 'success' });
} catch (e) {
sw.emit('toast', { message: `${prov.name}: connection failed \u2014 ${e.message}`, variant: 'error' });
}
}, []);
const setField = useCallback((key, val) => {
setForm(f => ({ ...f, [key]: val }));
}, []);
@@ -179,6 +190,9 @@ export function ProvidersSection() {
${esc(p.endpoint)}
</td>
<td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Test connection" onClick=${() => testConnection(p)}>
\u{26A1}
</button>
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
\u{1F504}
</button>