This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/ui-primitives.js
2026-03-09 01:54:06 +00:00

1111 lines
53 KiB
JavaScript

// ── 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 <option> tags for a <select>. */
optionsHTML(selected) {
return _types.map(t =>
`<option value="${t.id}"${t.id === selected ? ' selected' : ''}>${esc(t.label)}</option>`
).join('');
},
};
})();
// ══════════════════════════════════════════════════════════════════════════════
// §2 ROLE REGISTRY
// ══════════════════════════════════════════════════════════════════════════════
const Roles = (() => {
const _roles = [
{ id: 'utility', label: 'Utility', typeFilter: 'chat', hint: 'summarization, title gen' },
{ id: 'embedding', label: 'Embedding', typeFilter: 'embedding', hint: 'future: KB search, note search' },
];
return {
/** Register a new role (extension hook). */
add(entry) {
if (!entry?.id) throw new Error('Role entry requires id');
if (_roles.some(r => r.id === entry.id)) return;
_roles.push(entry);
},
all() { return _roles; },
get(id) { return _roles.find(r => r.id === id) || null; },
/** Model type filter string for a given role. */
typeFor(roleId) {
const r = _roles.find(r => r.id === roleId);
return r ? r.typeFilter : 'chat';
},
};
})();
// ══════════════════════════════════════════════════════════════════════════════
// §3 CAPABILITY BADGES
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render capability badge HTML from a capabilities object.
*
* @param {Object} caps - Capabilities object (max_output_tokens, vision, etc.)
* @param {Object} [opts]
* @param {boolean} [opts.compact=false] - Compact mode (icons only, no labels/titles)
* @param {Array} [opts.extra] - Additional badge entries [{cap, icon, label, title, cssClass}]
* @returns {string} HTML string of badge spans
*/
function renderCapBadges(caps, opts = {}) {
if (!caps) return '';
const compact = opts.compact === true;
const badges = [];
// ── Token counts ──
if (caps.max_output_tokens > 0) {
const k = caps.max_output_tokens >= 1000
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
: String(caps.max_output_tokens);
const label = compact ? k : k + ' out';
const title = compact ? '' : ` title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens"`;
badges.push(`<span class="cap-badge cap-context"${title}>${label}</span>`);
}
if (!compact && caps.max_context > 0) {
const k = (caps.max_context / 1000).toFixed(0) + 'K';
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
}
// ── Capability flags (registry-driven for extensibility) ──
const flags = [
{ cap: 'tool_calling', icon: '🔧', label: 'tools', title: 'Supports tool/function calling', css: 'cap-accent' },
{ cap: 'vision', icon: '👁', label: 'vision', title: 'Supports image/vision input', css: 'cap-accent' },
{ cap: 'thinking', icon: '💭', label: 'thinking', title: 'Supports extended thinking', css: 'cap-accent' },
{ cap: 'reasoning', icon: '🧠', label: 'reasoning', title: 'Reasoning model (chain-of-thought)', css: 'cap-accent' },
{ cap: 'code_optimized', icon: '⟨/⟩', label: 'code', title: 'Optimized for code generation', css: '' },
{ cap: 'web_search', icon: '🔍', label: 'search', title: 'Supports web search', css: '' },
];
for (const f of flags) {
if (!caps[f.cap]) continue;
const text = compact ? f.icon : `${f.icon} ${f.label}`;
const cls = f.css ? `cap-badge ${f.css}` : 'cap-badge';
const title = compact ? '' : ` title="${f.title}"`;
badges.push(`<span class="${cls}"${title}>${text}</span>`);
}
// Extension-provided badges
if (opts.extra) {
for (const e of opts.extra) {
if (e.cap && !caps[e.cap]) continue;
const cls = e.cssClass || 'cap-badge';
const title = e.title && !compact ? ` title="${esc(e.title)}"` : '';
const text = compact ? (e.icon || '') : `${e.icon || ''} ${e.label || ''}`.trim();
badges.push(`<span class="${cls}"${title}>${text}</span>`);
}
}
return badges.join('');
}
// ══════════════════════════════════════════════════════════════════════════════
// §4 PROVIDER FORM
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render a provider create/edit form into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} [opts.prefix='prov'] - ID prefix for form elements
* @param {boolean} [opts.showPrivate=false] - Show "private provider" checkbox
* @param {boolean} [opts.showDefaultModel=true] - Show default model field
* @param {Function} [opts.onSubmit] - Called with (values, isEdit) on save
* @param {Function} [opts.onCancel] - Called on cancel
* @returns {{ getValues, setValues, clear, setEditMode, container }}
*/
function renderProviderForm(containerEl, opts = {}) {
const pfx = opts.prefix || 'prov';
const showPrivate = opts.showPrivate === true;
const showModel = opts.showDefaultModel !== false;
containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="My Provider"></div>
<div class="form-group"><label>Provider</label><select id="${pfx}_type">${Providers.optionsHTML()}</select></div>
<div class="form-group"><label>Endpoint</label><input type="text" id="${pfx}_endpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input type="password" id="${pfx}_key" placeholder="sk-..."></div>
${showModel ? `<div class="form-group"><label>Default Model</label><input type="text" id="${pfx}_model" placeholder="gpt-4o"></div>` : ''}
${showPrivate ? `<div style="margin:6px 0"><label class="checkbox-label"><input type="checkbox" id="${pfx}_private"> Private provider (data stays on-prem)</label></div>` : ''}
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Save</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
</div>
`;
// ── Endpoint auto-fill on type change ──
const typeSelect = document.getElementById(`${pfx}_type`);
const endpointInput = document.getElementById(`${pfx}_endpoint`);
const endpoints = Providers.endpoints();
typeSelect?.addEventListener('change', function () {
const epVal = endpointInput?.value || '';
if (!epVal || Object.values(endpoints).includes(epVal)) {
endpointInput.value = endpoints[this.value] || '';
}
});
// ── Submit / Cancel wiring ──
const submitBtn = document.getElementById(`${pfx}_submitBtn`);
const cancelBtn = document.getElementById(`${pfx}_cancelBtn`);
submitBtn?.addEventListener('click', () => {
if (opts.onSubmit) opts.onSubmit(form.getValues(), !!form._editId);
});
cancelBtn?.addEventListener('click', () => {
if (opts.onCancel) opts.onCancel();
});
const form = {
_editId: null,
container: containerEl,
getValues() {
const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
provider: document.getElementById(`${pfx}_type`)?.value || 'openai',
endpoint: document.getElementById(`${pfx}_endpoint`)?.value.trim() || '',
api_key: document.getElementById(`${pfx}_key`)?.value || '',
};
if (showModel) {
v.model_default = document.getElementById(`${pfx}_model`)?.value.trim() || '';
}
if (showPrivate) {
v.is_private = document.getElementById(`${pfx}_private`)?.checked || false;
}
if (form._editId) v._editId = form._editId;
return v;
},
setValues(cfg) {
const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = cfg.name || '';
if (el('type')) el('type').value = cfg.provider || 'openai';
if (el('endpoint')) el('endpoint').value = cfg.endpoint || '';
if (el('key')) {
el('key').value = '';
el('key').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'sk-...';
}
if (showModel && el('model')) el('model').value = cfg.model_default || '';
if (showPrivate && el('private')) el('private').checked = !!cfg.is_private;
},
clear() {
['name', 'endpoint', 'key'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
if (showModel) {
const m = document.getElementById(`${pfx}_model`);
if (m) m.value = '';
}
if (showPrivate) {
const p = document.getElementById(`${pfx}_private`);
if (p) p.checked = false;
}
const typeSel = document.getElementById(`${pfx}_type`);
if (typeSel) typeSel.selectedIndex = 0;
const keyEl = document.getElementById(`${pfx}_key`);
if (keyEl) keyEl.placeholder = 'sk-...';
form._editId = null;
if (submitBtn) submitBtn.textContent = 'Save';
},
/** Switch form into edit mode for a given provider id. */
setEditMode(id, cfg) {
form._editId = id;
form.setValues(cfg);
if (submitBtn) submitBtn.textContent = 'Update';
},
/** Back to create mode. */
setCreateMode() {
form.clear();
},
/** Get the type select element (for extensions to hook into). */
getTypeSelect() { return typeSelect; },
};
return form;
}
// ══════════════════════════════════════════════════════════════════════════════
// §5 PROVIDER LIST
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render a provider list into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {Function} opts.apiFetch - async () → provider array
* @param {Function} [opts.onEdit] - (provider) → void
* @param {Function} [opts.onDelete] - (provider) → void
* @param {Function} [opts.onRefresh] - (provider) → void (refresh models btn)
* @param {Function} [opts.onToggleActive] - (provider) → void (active/inactive toggle)
* @param {boolean} [opts.showEndpoint=false]
* @param {boolean} [opts.showPrivate=false]
* @param {boolean} [opts.showActive=false]
* @param {boolean} [opts.showRefresh=false]
* @param {string} [opts.emptyMsg]
* @returns {{ refresh, getCache }}
*/
function renderProviderList(containerEl, opts = {}) {
const cache = {};
async function refresh(quiet) {
if (!quiet) containerEl.innerHTML = '<div class="loading">Loading...</div>';
try {
const raw = await opts.apiFetch();
// Normalize — APIs return different shapes
const list = Array.isArray(raw) ? raw : (raw.configs || raw.providers || raw.data || []);
// Clear and rebuild cache
Object.keys(cache).forEach(k => delete cache[k]);
list.forEach(c => { cache[c.id] = c; });
if (list.length === 0) {
containerEl.innerHTML = `<div class="empty-hint">${esc(opts.emptyMsg || 'No providers configured.')}</div>`;
return;
}
containerEl.innerHTML = list.map(c => {
const meta = [];
meta.push(esc(c.provider || ''));
if (c.has_key != null) meta.push(c.has_key ? '🔑' : '⚠️ no key');
if (opts.showEndpoint && c.endpoint) meta.push(esc(c.endpoint));
if (!opts.showEndpoint && c.model_default) meta.push(esc(c.model_default));
const badges = [];
if (opts.showPrivate && c.is_private)
badges.push('<span class="badge-private">private</span>');
if (opts.showActive) {
const active = c.is_active !== false;
badges.push(`<span class="${active ? 'badge-admin' : 'badge-user'}" style="font-size:10px">${active ? 'active' : 'inactive'}</span>`);
}
const actions = [];
if (opts.onRefresh)
actions.push(`<button class="btn-small" data-action="refresh" data-id="${c.id}">Refresh Models</button>`);
if (opts.onEdit)
actions.push(`<button class="btn-edit" data-action="edit" data-id="${c.id}" title="Edit">✎</button>`);
if (opts.onToggleActive) {
const active = c.is_active !== false;
actions.push(`<button class="admin-model-toggle ${active ? 'enabled' : ''}" data-action="toggle" data-id="${c.id}">${active ? '✓ Active' : 'Inactive'}</button>`);
}
if (opts.onDelete)
actions.push(`<button class="btn-delete" data-action="delete" data-id="${c.id}" title="Delete">✕</button>`);
return `<div class="provider-row" data-provider-id="${c.id}">
<div>
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${meta.join(' · ')}</span>
${badges.join(' ')}
</div>
<div class="provider-actions">${actions.join('')}</div>
</div>`;
}).join('');
} catch (e) {
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}
// ── Delegate clicks ──
containerEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const id = btn.dataset.id;
const provider = cache[id];
if (!provider) return;
switch (btn.dataset.action) {
case 'edit': opts.onEdit?.(provider); break;
case 'delete': opts.onDelete?.(provider); break;
case 'refresh': opts.onRefresh?.(provider); break;
case 'toggle': opts.onToggleActive?.(provider); break;
}
});
return { refresh, getCache: () => ({ ...cache }) };
}
// ══════════════════════════════════════════════════════════════════════════════
// §6 ROLE CONFIG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render role configuration (provider + model dropdowns per role).
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} opts.scope - 'admin' | 'personal'
* @param {Function} opts.fetchProviders - async () → provider array
* @param {Function} opts.fetchModels - async () → model array
* @param {Function} opts.fetchRoles - async () → { utility: {primary, fallback}, ... }
* @param {Function} opts.onSave - async (roleId, config) → void
* @param {Function} [opts.onClear] - async (roleId) → void (personal only)
* @param {Function} [opts.onTest] - async (roleId) → result (admin only)
* @param {boolean} [opts.showFallback=false]
* @param {string} [opts.modelIdField='model_id'] - field name for model ID on model objects
* @param {string} [opts.modelNameField='display_name'] - field for model display name
* @param {string} [opts.providerIdField='provider_config_id'] - field on models for their provider
* @param {string} [opts.noneLabel='— none —']
* @returns {{ refresh }}
*/
function renderRoleConfig(containerEl, opts = {}) {
const scope = opts.scope || 'admin';
const showFallback = opts.showFallback === true;
const midField = opts.modelIdField || 'model_id';
const mnameField = opts.modelNameField || 'display_name';
const pidField = opts.providerIdField || 'provider_config_id';
const noneLabel = opts.noneLabel || '— none —';
// Cached data for provider-change handler
let _providers = [];
let _models = [];
let _roles = {};
function filterModels(providerId, roleId) {
const typeFilter = Roles.typeFor(roleId);
return _models.filter(m => {
if (m[pidField] !== providerId) return false;
const mt = m.model_type || '';
// Embedding role: include models typed as 'embedding' OR untyped.
// Many providers don't report type for embedding models.
if (typeFilter === 'embedding') return !mt || mt === 'embedding';
// Other roles: default untyped to 'chat' (existing behavior).
return (mt || 'chat') === typeFilter;
});
}
function onProviderChange(roleId, slot) {
const provSel = containerEl.querySelector(`[data-role-provider="${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 (!provSel || !modelSel) return;
modelSel.innerHTML = '<option value="">— select model —</option>';
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 =>
`<option value="${p.id}"${p.id === selectedProv ? ' selected' : ''}>${esc(p.name)}</option>`
).join('');
const dropdownModels = selectedProv ? filterModels(selectedProv, roleId) : [];
const modelOptions = dropdownModels.map(m =>
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
).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 `
<div class="form-row" style="gap:8px;align-items:center${slotName === 'fallback' ? ';margin-top:6px' : ''}">
<label style="min-width:60px;font-size:12px">${slotLabel}</label>
<select data-role-provider="${roleId}-${slotName}" style="min-width:140px">
<option value="">${noneLabel}</option>
${provOptions}
</select>
<select data-role-model="${roleId}-${slotName}" style="min-width:200px${showManual ? ';display:none' : ''}">
<option value="">— select model —</option>
${modelOptions}
</select>
<input type="text" data-role-model-input="${roleId}-${slotName}"
placeholder="Model ID (e.g. text-embedding-3-small)"
value="${esc(showManual ? selectedModel : '')}"
style="min-width:200px;font-size:12px${showManual ? '' : ';display:none'}">
<button type="button" class="btn-small" data-role-model-toggle="${roleId}-${slotName}"
title="${showManual ? 'Switch to dropdown' : 'Type model ID manually'}"
style="padding:2px 6px;font-size:12px;line-height:1">${showManual ? '▾' : '✎'}</button>
</div>`;
}
async function refresh() {
containerEl.innerHTML = '<div class="loading">Loading...</div>';
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 `
<div class="settings-section" style="margin-bottom:${scope === 'admin' ? 16 : 12}px">
<${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 ? `<span class="form-hint">(${esc(role.hint)})</span>` : ''}
</${scope === 'admin' ? 'h3' : 'h4'}>
${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')}
${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''}
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-primary btn-small" data-role-save="${role.id}">Save</button>
${opts.onTest ? `<button class="btn-secondary btn-small" data-role-test="${role.id}">Test</button>` : ''}
${hasClear ? `<button class="btn-small btn-danger" data-role-clear="${role.id}" title="Remove override, use org default">Clear</button>` : ''}
<span data-role-status="${role.id}" style="font-size:12px"></span>
</div>
</div>`;
}).join('');
} catch (e) {
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}
// ── 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 <select>
* @param {string} [opts.groupByElId] - ID of groupBy <select>
* @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 = `
<div data-usage-totals></div>
<div data-usage-results></div>
`;
}
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
? '<div class="loading" style="font-size:12px">Loading...</div>'
: '<div class="loading">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await opts.apiFetch({ period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests && compact) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid"${gridStyle}>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.requests || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Requests</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Input${compact ? '' : ' Tokens'}</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Output${compact ? '' : ' Tokens'}</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label"${labelStyle}>Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length === 0) {
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
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 =>
`<th style="text-align:right">${esc(c.header)}</th>`
).join('');
resultsEl.innerHTML = `
<table class="admin-table"${tableStyle}>
<thead><tr>
<th>${firstCol}</th>
<th style="text-align:right">${compact ? 'Reqs' : 'Requests'}</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
${extraHeaders}
</tr></thead>
<tbody>${results.map(r => {
const extraCells = (opts.extraColumns || []).map(c =>
`<td style="text-align:right">${c.render(r)}</td>`
).join('');
return `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
${extraCells}
</tr>`;
}).join('')}</tbody>
</table>`;
} catch (e) {
totalsEl.innerHTML = `<div class="error-hint"${compact ? ' style="font-size:12px"' : ''}>${esc(e.message)}</div>`;
}
}
return { refresh };
}
// ══════════════════════════════════════════════════════════════════════════════
// §8 CONFIRM DIALOG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Custom confirm dialog replacing native confirm().
* Returns a Promise<boolean>.
*
* @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<boolean>}
*/
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 = `
<div class="confirm-dialog">
<div class="confirm-header">${esc(title)}</div>
<div class="confirm-body">${esc(message)}</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${esc(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();
});
}
// §8b PROMPT DIALOG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Custom prompt dialog replacing native prompt().
* Returns a Promise<string|null> — 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<string|null>}
*/
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 = `
<div class="confirm-dialog">
<div class="confirm-header">${esc(title)}</div>
${label ? `<div class="confirm-body">${esc(label)}</div>` : ''}
<div class="prompt-input-wrap">
<input class="prompt-input" type="${esc(iType)}"
value="${esc(defVal)}" placeholder="${esc(ph)}" autocomplete="off">
</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
<button class="btn-small btn-primary" data-action="ok">${esc(okText)}</button>
</div>
</div>`;
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 = '&#x2039;';
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
const right = document.createElement('button');
right.className = 'tab-arrow tab-arrow-right';
right.innerHTML = '&#x203A;';
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 = '<div class="toggle-group">' + options.map(o =>
`<button class="toggle-btn${current === o.id ? ' active' : ''}" data-theme="${o.id}" title="${o.label}">${o.icon}</button>`
).join('') + '</div>';
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 = `<img src="${esc(src)}" alt="Avatar">`;
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
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 };
}