Changeset 0.10.5 (#61)
This commit is contained in:
729
src/js/ui-primitives.js
Normal file
729
src/js/ui-primitives.js
Normal 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user