// ── Chat Switchboard — UI Primitives ──────────────────────────────────────── // Single source of truth for constants + reusable rendering functions. // Loaded in base.html before all components and surfaces. // Follows the renderPersonaForm() pattern: (container, options) → control object. // Designed for extension hooks: registries are open for .add(), primitives // accept options for customization, and return handles for programmatic control. // ───────────────────────────────────────────────────────────────────────────── 'use strict'; // ══════════════════════════════════════════════════════════════════════════════ // §0 CORE UTILITIES // ══════════════════════════════════════════════════════════════════════════════ /** HTML-escape a string for safe insertion into innerHTML and attributes. */ function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML.replace(/"/g, '"'); } // ══════════════════════════════════════════════════════════════════════════════ // §0b COMPONENT LIFECYCLE // ══════════════════════════════════════════════════════════════════════════════ /** * Create a component registry (factory namespace with instance tracking). * * @param {string} name - Component name (for debug) * @returns {{ primary, _instances, _register, _unregister, get }} */ function createComponentRegistry(name) { return { primary: null, _instances: new Map(), _register(id, instance) { this._instances.set(id, instance); }, _unregister(id) { this._instances.delete(id); if (this.primary && this.primary.id === id) this.primary = null; }, get(id) { return this._instances.get(id) || null; }, }; } /** * Mix lifecycle methods into a component instance. * Adds _listeners array, _on() for tracked event binding, and destroy() * for automatic cleanup + registry removal. * Components needing extra teardown define _cleanup() which is called first. * * @param {Object} instance - The instance object (must have .id) * @param {Object} registry - The component registry (from createComponentRegistry) * @returns {Object} The instance, mutated with lifecycle methods */ function componentMixin(instance, registry) { instance._listeners = []; instance._on = function(el, event, handler) { if (!el) return; el.addEventListener(event, handler); this._listeners.push({ el, event, handler }); }; instance.destroy = function() { if (typeof this._cleanup === 'function') this._cleanup(); this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler)); this._listeners = []; registry._unregister(this.id); }; return instance; } // ══════════════════════════════════════════════════════════════════════════════ // §1 PROVIDER REGISTRY // ══════════════════════════════════════════════════════════════════════════════ const Providers = (() => { const _types = [ { id: 'openai', label: 'OpenAI-compatible', endpoint: 'https://api.openai.com/v1' }, { id: 'anthropic', label: 'Anthropic', endpoint: 'https://api.anthropic.com' }, { id: 'venice', label: 'Venice.ai', endpoint: 'https://api.venice.ai/api/v1' }, { id: 'openrouter', label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1' }, ]; return { /** Register a new provider type (extension hook). */ add(entry) { if (!entry?.id) throw new Error('Provider entry requires id'); if (_types.some(t => t.id === entry.id)) return; // no dupes _types.push(entry); }, /** All registered types. */ all() { return _types; }, /** Lookup by id. */ get(id) { return _types.find(t => t.id === id) || null; }, /** Map of id → label. */ labels() { return Object.fromEntries(_types.map(t => [t.id, t.label])); }, /** Map of id → default endpoint. */ endpoints() { return Object.fromEntries(_types.filter(t => t.endpoint).map(t => [t.id, t.endpoint])); }, /** Build '; const provId = provSel.value; if (!provId) return; const models = filterModels(provId, roleId); models.forEach(m => { const opt = document.createElement('option'); opt.value = m[midField]; opt.textContent = m[mnameField] || m[midField]; modelSel.appendChild(opt); }); // Auto-switch to manual entry if dropdown has no models. if (models.length === 0 && modelInput && toggleBtn) { modelSel.style.display = 'none'; modelInput.style.display = ''; toggleBtn.title = 'Switch to dropdown'; toggleBtn.textContent = '▾'; } } function toggleManualEntry(roleId, slot) { const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`); const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`); const toggleBtn = containerEl.querySelector(`[data-role-model-toggle="${roleId}-${slot}"]`); if (!modelSel || !modelInput || !toggleBtn) return; const showingDropdown = modelSel.style.display !== 'none'; if (showingDropdown) { // Switch to manual: copy dropdown value into input. modelInput.value = modelSel.value || modelInput.value; modelSel.style.display = 'none'; modelInput.style.display = ''; toggleBtn.title = 'Switch to dropdown'; toggleBtn.textContent = '▾'; } else { // Switch to dropdown: try to select the typed value. modelSel.style.display = ''; modelInput.style.display = 'none'; toggleBtn.title = 'Type model ID manually'; toggleBtn.textContent = '✎'; if (modelInput.value) { modelSel.value = modelInput.value; } } } function getBinding(roleId, slot) { const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value; const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`); const modelInput = containerEl.querySelector(`[data-role-model-input="${roleId}-${slot}"]`); // Use manual input if it's visible, otherwise use dropdown. const model = (modelInput && modelInput.style.display !== 'none') ? modelInput.value.trim() : (modelSel?.value || ''); return (prov && model) ? { provider_config_id: prov, model_id: model } : null; } async function handleSave(roleId) { const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`); try { const config = { primary: getBinding(roleId, 'primary') }; if (showFallback) config.fallback = getBinding(roleId, 'fallback'); await opts.onSave(roleId, config); if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--success)'; } // Reload after short delay so dropdowns reflect saved state setTimeout(() => refresh(), 500); } catch (e) { if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; } } } async function handleTest(roleId) { if (!opts.onTest) return; const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`); if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-3)'; } try { const result = await opts.onTest(roleId); if (statusEl) { const fb = result.used_fallback ? ' (fallback)' : ''; statusEl.textContent = `✓ ${result.model}${fb}`; statusEl.style.color = 'var(--success)'; } } catch (e) { if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; } } } async function handleClear(roleId) { if (!opts.onClear) return; try { await opts.onClear(roleId); refresh(); } catch (e) { UI.toast(e.message, 'error'); } } function renderSlot(roleId, slotName, cfg, slotLabel) { const binding = cfg?.[slotName]; const selectedProv = binding?.provider_config_id || ''; const selectedModel = binding?.model_id || ''; const provOptions = _providers.map(p => `` ).join(''); const dropdownModels = selectedProv ? filterModels(selectedProv, roleId) : []; const modelOptions = dropdownModels.map(m => `` ).join(''); // Show manual entry if: saved model ID not in dropdown, or dropdown is empty with a provider selected. const savedNotInDropdown = selectedModel && selectedProv && !dropdownModels.some(m => m[midField] === selectedModel); const showManual = savedNotInDropdown || (selectedProv && dropdownModels.length === 0); return `
`; } async function refresh() { containerEl.innerHTML = '
Loading...
'; try { const [provRaw, modelRaw, roleData] = await Promise.all([ opts.fetchProviders(), opts.fetchModels(), opts.fetchRoles(), ]); // Normalize _providers = Array.isArray(provRaw) ? provRaw : (provRaw.configs || provRaw.providers || provRaw.data || []); _models = Array.isArray(modelRaw) ? modelRaw : (modelRaw.models || modelRaw.data || []); _roles = roleData || {}; const roles = Roles.all(); containerEl.innerHTML = roles.map(role => { const cfg = _roles[role.id] || { primary: null, fallback: null }; const hasClear = scope === 'personal' && opts.onClear && cfg.primary; return `
<${scope === 'admin' ? 'h3' : 'h4'} style="font-size:${scope === 'admin' ? 14 : 13}px;margin:0 0 ${scope === 'admin' ? 8 : 6}px;text-transform:capitalize"> ${esc(role.label || role.id)} ${scope === 'personal' && role.hint ? `(${esc(role.hint)})` : ''} ${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')} ${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''}
${opts.onTest ? `` : ''} ${hasClear ? `` : ''}
`; }).join(''); } catch (e) { containerEl.innerHTML = `
${esc(e.message)}
`; } } // ── Delegate events ── containerEl.addEventListener('change', (e) => { const provSel = e.target.closest('[data-role-provider]'); if (!provSel) return; const [roleId, slot] = provSel.dataset.roleProvider.split('-'); onProviderChange(roleId, slot); }); containerEl.addEventListener('click', (e) => { const saveBtn = e.target.closest('[data-role-save]'); if (saveBtn) { handleSave(saveBtn.dataset.roleSave); return; } const testBtn = e.target.closest('[data-role-test]'); if (testBtn) { handleTest(testBtn.dataset.roleTest); return; } const clearBtn = e.target.closest('[data-role-clear]'); if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; } const toggleBtn = e.target.closest('[data-role-model-toggle]'); if (toggleBtn) { const [roleId, slot] = toggleBtn.dataset.roleModelToggle.split('-'); toggleManualEntry(roleId, slot); return; } }); return { refresh }; } // ══════════════════════════════════════════════════════════════════════════════ // §7 USAGE DASHBOARD // ══════════════════════════════════════════════════════════════════════════════ /** * Render usage stats + table into a container. * * @param {HTMLElement} containerEl * @param {Object} opts * @param {Function} opts.apiFetch - async ({period, group_by}) → { totals, results } * @param {string} [opts.periodElId] - ID of period * @param {boolean} [opts.compact=false] - Use compact sizing (user/team) * @param {boolean} [opts.showUserColumn=false] - Include "User" in group_by options * @param {Array} [opts.extraColumns] - [{header, render: (row) → html}] * @returns {{ refresh }} */ function renderUsageDashboard(containerEl, opts = {}) { const compact = opts.compact === true; const cardStyle = compact ? ' style="padding:8px"' : ''; const valueStyle = compact ? ' style="font-size:16px"' : ''; const labelStyle = compact ? ' style="font-size:10px"' : ''; const gridStyle = compact ? ' style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px"' : ''; const tableStyle = compact ? ' style="font-size:12px"' : ' style="margin-top:12px"'; // We render into two sub-zones let totalsEl, resultsEl; function ensureStructure() { if (!containerEl.querySelector('[data-usage-totals]')) { containerEl.innerHTML = `
`; } totalsEl = containerEl.querySelector('[data-usage-totals]'); resultsEl = containerEl.querySelector('[data-usage-results]'); } async function refresh() { ensureStructure(); const period = opts.periodElId ? (document.getElementById(opts.periodElId)?.value || '30d') : '30d'; const groupBy = opts.groupByElId ? (document.getElementById(opts.groupByElId)?.value || 'model') : 'model'; totalsEl.innerHTML = compact ? '
Loading...
' : '
Loading...
'; resultsEl.innerHTML = ''; try { const data = await opts.apiFetch({ period, group_by: groupBy }); const t = data.totals || {}; if (!t.requests && compact) { totalsEl.innerHTML = '
No usage recorded in this period
'; return; } totalsEl.innerHTML = `
${(t.requests || 0).toLocaleString()}
Requests
${(t.input_tokens || 0).toLocaleString()}
Input${compact ? '' : ' Tokens'}
${(t.output_tokens || 0).toLocaleString()}
Output${compact ? '' : ' Tokens'}
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
`; const results = data.results || []; if (results.length === 0) { resultsEl.innerHTML = '
No usage data for this period
'; return; } // Column header depends on groupBy let firstCol; if (groupBy === 'day') firstCol = 'Date'; else if (groupBy === 'user') firstCol = 'User'; else firstCol = 'Model'; const extraHeaders = (opts.extraColumns || []).map(c => `${esc(c.header)}` ).join(''); resultsEl.innerHTML = ` ${extraHeaders} ${results.map(r => { const extraCells = (opts.extraColumns || []).map(c => `` ).join(''); return ` ${extraCells} `; }).join('')}
${firstCol} ${compact ? 'Reqs' : 'Requests'} Input Output Cost
${c.render(r)}
${esc(r.label || r.group_key)} ${(r.requests || 0).toLocaleString()} ${(r.input_tokens || 0).toLocaleString()} ${(r.output_tokens || 0).toLocaleString()} $${(r.total_cost || 0).toFixed(4)}
`; } catch (e) { totalsEl.innerHTML = `
${esc(e.message)}
`; } } return { refresh }; } // ══════════════════════════════════════════════════════════════════════════════ // §8 CONFIRM DIALOG // ══════════════════════════════════════════════════════════════════════════════ /** * Custom confirm dialog replacing native confirm(). * Returns a Promise. * * @param {string} message * @param {Object} [opts] * @param {string} [opts.title='Confirm'] * @param {string} [opts.ok='Confirm'] * @param {string} [opts.cancel='Cancel'] * @param {boolean} [opts.danger=false] - Styles the confirm button as destructive * @returns {Promise} */ 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; // default true for destructive actions const overlay = document.createElement('div'); overlay.className = 'confirm-overlay'; overlay.innerHTML = `
${esc(title)}
${esc(message)}
`; 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(); }); } // §8b PROMPT DIALOG // ══════════════════════════════════════════════════════════════════════════════ /** * Custom prompt dialog replacing native prompt(). * Returns a Promise — null means cancelled or empty. * * @param {Object} opts * @param {string} [opts.title='Enter value'] * @param {string} [opts.label] - Optional helper text below title * @param {string} [opts.value=''] - Pre-filled value * @param {string} [opts.placeholder=''] * @param {string} [opts.ok='OK'] * @param {string} [opts.cancel='Cancel'] * @param {string} [opts.inputType='text'] * @returns {Promise} */ function showPrompt(opts = {}) { return new Promise(resolve => { const title = opts.title || 'Enter value'; const label = opts.label || ''; const defVal = opts.value || ''; const ph = opts.placeholder || ''; const okText = opts.ok || 'OK'; const caText = opts.cancel || 'Cancel'; const iType = opts.inputType || 'text'; const overlay = document.createElement('div'); overlay.className = 'confirm-overlay'; overlay.innerHTML = `
${esc(title)}
${label ? `
${esc(label)}
` : ''}
`; const input = overlay.querySelector('.prompt-input'); function close(result) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(result); } function onKey(e) { if (e.key === 'Escape') { close(null); } if (e.key === 'Enter') { const v = input.value.trim(); close(v || null); } } overlay.querySelector('[data-action="ok"]').addEventListener('click', () => { close(input.value.trim() || null); }); overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(null)); overlay.addEventListener('click', e => { if (e.target === overlay) close(null); }); document.addEventListener('keydown', onKey); document.body.appendChild(overlay); requestAnimationFrame(() => { input.focus(); if (defVal) input.select(); }); }); } // ── Modal open/close ──────────────────────── // Generic modal show/hide used by settings, admin, debug, team-admin modals. // Moved here from app.js so all surfaces (not just chat) can use modals. function openModal(id) { const el = document.getElementById(id); if (!el) return; el.classList.add('active'); // Defer overflow check — browser needs a frame to lay out the modal requestAnimationFrame(() => { el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow); }); } function closeModal(id) { const el = document.getElementById(id); if (el) el.classList.remove('active'); } // Toggle .is-scrollable on tab bars that overflow horizontally // and inject arrow navigation buttons when needed. function checkTabsOverflow(tabs) { if (!tabs) return; const scrollable = tabs.scrollWidth > tabs.clientWidth + 1; if (!scrollable) { const wrap = tabs.parentElement; if (wrap && wrap.classList.contains('modal-tabs-wrap')) { wrap.parentElement.insertBefore(tabs, wrap); wrap.remove(); } return; } if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) { const wrap = document.createElement('div'); wrap.className = 'modal-tabs-wrap'; tabs.parentElement.insertBefore(wrap, tabs); wrap.appendChild(tabs); const left = document.createElement('button'); left.className = 'tab-arrow tab-arrow-left'; left.innerHTML = '‹'; left.addEventListener('click', () => { tabs.scrollLeft -= 120; }); const right = document.createElement('button'); right.className = 'tab-arrow tab-arrow-right'; right.innerHTML = '›'; right.addEventListener('click', () => { tabs.scrollLeft += 120; }); wrap.appendChild(left); wrap.appendChild(right); tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true }); } updateTabArrows(tabs); } function updateTabArrows(tabs) { const wrap = tabs.parentElement; if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return; const left = wrap.querySelector('.tab-arrow-left'); const right = wrap.querySelector('.tab-arrow-right'); if (!left || !right) return; const atStart = tabs.scrollLeft <= 2; const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2; left.classList.toggle('visible', !atStart); right.classList.toggle('visible', !atEnd); } // ══════════════════════════════════════════════════════════════════════════════ // §9 THEME // ══════════════════════════════════════════════════════════════════════════════ const Theme = { _key: 'switchboard_theme', _mqListener: null, init() { this.set(localStorage.getItem(this._key) || 'system'); }, set(mode) { localStorage.setItem(this._key, mode); // Remove any prior system-preference listener if (this._mqListener) { window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._mqListener); this._mqListener = null; } if (mode === 'system') { // Resolve current OS preference and apply it const resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', resolved); // Listen for OS preference changes while in system mode this._mqListener = (e) => { // Only react if still in system mode if (this.get() === 'system') { document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light'); } }; window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this._mqListener); } else { document.documentElement.setAttribute('data-theme', mode); } }, get() { return localStorage.getItem(this._key) || 'system'; }, resolved() { const mode = this.get(); if (mode !== 'system') return mode; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }, renderToggle(containerId) { const el = document.getElementById(containerId); if (!el) return; const current = this.get(); const options = [ { id: 'light', label: 'Light', icon: '☀' }, { id: 'dark', label: 'Dark', icon: '☾' }, { id: 'system',label: 'System',icon: '⊞' }, ]; el.innerHTML = '
' + options.map(o => `` ).join('') + '
'; el.querySelectorAll('.toggle-btn').forEach(btn => { btn.addEventListener('click', () => { this.set(btn.dataset.theme); this.renderToggle(containerId); }); }); }, }; // ══════════════════════════════════════════════════════════════════════════════ // §10 AVATAR UPLOAD // ══════════════════════════════════════════════════════════════════════════════ /** * Mount an avatar upload component into a container. * * @param {HTMLElement} containerEl * @param {Object} opts * @param {string} [opts.current] - Current avatar URL * @param {number} [opts.size=80] - Avatar size in px * @param {Function} [opts.onUpload] - Called with (file, dataURL) * @returns {{ setImage }} */ function mountAvatarUpload(containerEl, opts = {}) { const size = opts.size || 80; const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.style.display = 'none'; containerEl.className = 'avatar-upload'; containerEl.style.width = size + 'px'; containerEl.style.height = size + 'px'; function render(src) { if (src) containerEl.innerHTML = `Avatar`; else containerEl.innerHTML = '
Upload
Photo
'; containerEl.appendChild(input); } render(opts.current || null); containerEl.addEventListener('click', () => input.click()); input.addEventListener('change', () => { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { render(ev.target.result); if (opts.onUpload) opts.onUpload(file, ev.target.result); }; reader.readAsDataURL(file); }); return { setImage: render }; }