Changeset 0.10.5 (#61)

This commit is contained in:
2026-02-25 00:18:03 +00:00
parent e5ee78c498
commit d2ec55b16d
16 changed files with 1643 additions and 801 deletions

729
src/js/ui-primitives.js Normal file
View File

@@ -0,0 +1,729 @@
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
// Single source of truth for constants + reusable rendering functions.
// Loaded after ui-format.js, before ui-core.js.
// Follows the renderPresetForm() 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';
// ══════════════════════════════════════════════════════════════════════════════
// §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 =>
m[pidField] === providerId &&
(m.model_type || 'chat') === typeFilter
);
}
function onProviderChange(roleId, slot) {
const provSel = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`);
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
if (!provSel || !modelSel) return;
modelSel.innerHTML = '<option value="">— select model —</option>';
const provId = provSel.value;
if (!provId) return;
filterModels(provId, roleId).forEach(m => {
const opt = document.createElement('option');
opt.value = m[midField];
opt.textContent = m[mnameField] || m[midField];
modelSel.appendChild(opt);
});
}
function getBinding(roleId, slot) {
const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value;
const model = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`)?.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(--error)'; }
}
}
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-muted)'; }
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(--error)'; }
}
}
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 modelOptions = selectedProv
? filterModels(selectedProv, roleId).map(m =>
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
).join('')
: '';
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">
<option value="">— select model —</option>
${modelOptions}
</select>
</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; }
});
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();
});
}