diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e280d..fac392d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ All notable changes to Chat Switchboard. +## [0.10.5] — 2026-02-24 + +### Added +- **`ui-primitives.js` — shared rendering primitives and registries.** + Single source of truth for provider types, role definitions, and reusable + UI components. Extension-ready via registry pattern (`Providers.add()`, + `Roles.add()`). Primitives follow the `renderPresetForm()` pattern: + `(container, options) → control object` with `getValues/setValues/clear`. + - `Providers` registry — types, labels, default endpoints (was 5 places → 1) + - `Roles` registry — names, type filters, hints (was hardcoded in 2 places → 1) + - `renderCapBadges(caps, opts)` — consolidates 3 badge builders (compact + detailed) + - `renderProviderForm(container, opts)` — replaces 3 form implementations + - `renderProviderList(container, opts)` — replaces 3 list renderers + - `renderRoleConfig(container, opts)` — replaces 2 role UIs + 4 handlers + - `renderUsageDashboard(container, opts)` — replaces 3 usage renderers + +### Fixed +- **Admin provider form now auto-fills endpoint on type change.** Was missing + from admin (worked in user BYOK and team forms). Now all three scopes use + the same primitive with identical behavior. +- **Team provider form consolidated.** Was two separate HTML forms (create + + edit) with separate listeners. Now a single dual-mode form matching the + pattern used by admin and user BYOK scopes. + +### Changed +- Provider type definitions removed from `index.html` (2 static ` -
-
-
-
-
@@ -863,6 +822,7 @@ + diff --git a/src/js/admin-handlers.js b/src/js/admin-handlers.js index 818542f..fc8edb8 100644 --- a/src/js/admin-handlers.js +++ b/src/js/admin-handlers.js @@ -61,7 +61,7 @@ async function toggleUserRole(id, role) { } catch (e) { UI.toast(e.message, 'error'); } } async function deleteUser(id, username) { - if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; + if (!await showConfirm(`Delete user "${username}"? This cannot be undone.`)) return; try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); } catch (e) { UI.toast(e.message, 'error'); } } @@ -89,68 +89,14 @@ async function adminResetUserPassword(id, username) { ); if (!pw) return; if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; } - if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return; + if (!await showConfirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return; try { await API.adminResetPassword(id, pw); UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success'); } catch (e) { UI.toast(e.message, 'error'); } } -async function deleteGlobalProvider(id) { - if (!confirm('Delete this global provider?')) return; - try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); } - catch (e) { UI.toast(e.message, 'error'); } -} - -var _editingProviderId = null; -function editGlobalProvider(id) { - const c = UI._providerCache?.[id]; - if (!c) return; - _editingProviderId = id; - const form = document.getElementById('adminAddProviderForm'); - form.style.display = ''; - document.getElementById('adminProvName').value = c.name || ''; - document.getElementById('adminProvType').value = c.provider || 'openai'; - document.getElementById('adminProvEndpoint').value = c.endpoint || ''; - document.getElementById('adminProvKey').value = ''; - document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...'; - document.getElementById('adminProvModel').value = c.model_default || ''; - document.getElementById('adminProvPrivate').checked = !!c.is_private; - document.getElementById('adminCreateProvBtn').textContent = 'Update'; -} - -async function createGlobalProvider() { - try { - const name = document.getElementById('adminProvName').value.trim(); - const prov = document.getElementById('adminProvType').value; - const ep = document.getElementById('adminProvEndpoint').value.trim(); - const key = document.getElementById('adminProvKey').value; - const model = document.getElementById('adminProvModel').value.trim(); - const isPrivate = document.getElementById('adminProvPrivate').checked; - - if (_editingProviderId) { - // Update mode - const updates = {}; - if (name) updates.name = name; - if (ep) updates.endpoint = ep; - if (key) updates.api_key = key; - updates.model_default = model; - updates.is_private = isPrivate; - await API.adminUpdateGlobalConfig(_editingProviderId, updates); - UI.toast('Provider updated', 'success'); - } else { - // Create mode - if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; } - await API.adminCreateGlobalConfig(name, prov, ep, key, model, isPrivate); - UI.toast('Provider added', 'success'); - } - _editingProviderId = null; - document.getElementById('adminAddProviderForm').style.display = 'none'; - document.getElementById('adminCreateProvBtn').textContent = 'Save'; - document.getElementById('adminProvKey').placeholder = 'sk-...'; - await UI.loadAdminProviders(); - } catch (e) { UI.toast(e.message, 'error'); } -} +// ── Admin Provider / Role handlers — now managed by UI primitives in ui-admin.js ── async function fetchAdminModels() { const hint = document.getElementById('adminModelsHint'); @@ -194,64 +140,7 @@ async function bulkSetVisibility(visibility) { } catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; } } -// ── Admin Roles ──────────────────────────── - -async function adminRoleProviderChanged(role, slot) { - const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value; - const modelSelect = document.getElementById(`role-${role}-${slot}-model`); - if (!modelSelect) return; - - modelSelect.innerHTML = ''; - if (!provId || !window._adminModelList) return; - - // Filter by provider AND model type matching the role - const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; - const models = window._adminModelList.filter(m => - m.provider_config_id === provId && - (m.model_type || 'chat') === typeForRole - ); - models.forEach(m => { - const opt = document.createElement('option'); - opt.value = m.model_id; - opt.textContent = m.display_name || m.model_id; - modelSelect.appendChild(opt); - }); -} - -async function adminSaveRole(role) { - const status = document.getElementById(`role-${role}-status`); - try { - const getBinding = (slot) => { - const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value; - const model = document.getElementById(`role-${role}-${slot}-model`)?.value; - return (prov && model) ? { provider_config_id: prov, model_id: model } : null; - }; - await API.adminUpdateRole(role, { - primary: getBinding('primary'), - fallback: getBinding('fallback') - }); - if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; } - // Reload to reflect saved state in dropdowns - setTimeout(() => UI.loadAdminRoles(), 500); - } catch (e) { - if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; } - } -} - -async function adminTestRole(role) { - const status = document.getElementById(`role-${role}-status`); - if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; } - try { - const result = await API.adminTestRole(role); - if (status) { - const fb = result.used_fallback ? ' (fallback)' : ''; - status.textContent = `✓ ${result.model}${fb}`; - status.style.color = 'var(--success)'; - } - } catch (e) { - if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; } - } -} +// ── Admin Roles — now managed by renderRoleConfig primitive in ui-admin.js ── // ── User Model Preferences ────────────────── @@ -284,7 +173,7 @@ async function bulkSetUserModelVisibility(show) { } async function deleteUserPreset(id, name) { - if (!confirm(`Delete preset "${name}"?`)) return; + if (!await showConfirm(`Delete preset "${name}"?`)) return; try { await API.deleteUserPreset(id); UI.toast('Preset deleted'); @@ -432,7 +321,7 @@ async function toggleAdminPreset(id, active) { } async function deleteAdminPreset(id, name) { - if (!confirm(`Delete preset "${name}"?`)) return; + if (!await showConfirm(`Delete preset "${name}"?`)) return; try { await API.adminDeletePreset(id); UI.toast('Preset deleted', 'success'); @@ -452,7 +341,7 @@ async function toggleTeamActive(id, active) { } async function deleteTeam(id, name) { - if (!confirm(`Delete team "${name}"? All members will be removed.`)) return; + if (!await showConfirm(`Delete team "${name}"? All members will be removed.`)) return; try { await API.adminDeleteTeam(id); UI.toast('Team deleted'); @@ -471,7 +360,7 @@ async function updateTeamMember(teamId, memberId, role) { } async function removeTeamMember(teamId, memberId, email) { - if (!confirm(`Remove ${email} from team?`)) return; + if (!await showConfirm(`Remove ${email} from team?`)) return; try { await API.adminRemoveMember(teamId, memberId); UI.toast('Member removed'); @@ -491,7 +380,7 @@ async function settingsUpdateTeamMember(teamId, memberId, role) { } async function settingsRemoveTeamMember(teamId, memberId, email) { - if (!confirm(`Remove ${email} from team?`)) return; + if (!await showConfirm(`Remove ${email} from team?`)) return; try { await API.teamRemoveMember(teamId, memberId); UI.toast('Member removed'); @@ -500,7 +389,7 @@ async function settingsRemoveTeamMember(teamId, memberId, email) { } async function settingsDeleteTeamPreset(teamId, presetId, name) { - if (!confirm(`Delete team preset "${name}"?`)) return; + if (!await showConfirm(`Delete team preset "${name}"?`)) return; try { await API.teamDeletePreset(teamId, presetId); UI.toast('Preset deleted'); @@ -509,36 +398,7 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) { } catch (e) { UI.toast(e.message, 'error'); } } -async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) { - try { - await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive }); - UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated'); - await UI.loadTeamManageProviders(teamId); - await fetchModels(); - } catch (e) { UI.toast(e.message, 'error'); } -} - -async function settingsDeleteTeamProvider(teamId, providerId, name) { - if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return; - try { - await API.teamDeleteProvider(teamId, providerId); - UI.toast('Provider deleted'); - await UI.loadTeamManageProviders(teamId); - await fetchModels(); - } catch (e) { UI.toast(e.message, 'error'); } -} - -function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) { - UI._editingTeamProviderId = provId; - document.getElementById('settingsTeamAddProvider').style.display = 'none'; - const form = document.getElementById('settingsTeamEditProvider'); - form.style.display = ''; - document.getElementById('teamProviderEditName').value = name; - document.getElementById('teamProviderEditEndpoint').value = endpoint; - document.getElementById('teamProviderEditKey').value = ''; - document.getElementById('teamProviderEditDefault').value = defaultModel; - document.getElementById('teamProviderEditPrivate').checked = isPrivate; -} +// ── Team Provider handlers — now managed by UI primitives in ui-settings.js ── // ── Admin Listeners (extracted from initListeners) ── @@ -558,26 +418,12 @@ function _initAdminListeners() { document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; }); document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser); - // Admin — providers + // Admin — providers (form managed by primitives in loadAdminProviders) document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => { - _editingProviderId = null; - document.getElementById('adminCreateProvBtn').textContent = 'Save'; - document.getElementById('adminProvKey').placeholder = 'sk-...'; - document.getElementById('adminProvName').value = ''; - document.getElementById('adminProvEndpoint').value = ''; - document.getElementById('adminProvKey').value = ''; - document.getElementById('adminProvModel').value = ''; - document.getElementById('adminProvPrivate').checked = false; const f = document.getElementById('adminAddProviderForm'); + if (UI._adminProvForm) UI._adminProvForm.setCreateMode(); f.style.display = f.style.display === 'none' ? '' : 'none'; }); - document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { - document.getElementById('adminAddProviderForm').style.display = 'none'; - _editingProviderId = null; - document.getElementById('adminCreateProvBtn').textContent = 'Save'; - document.getElementById('adminProvKey').placeholder = 'sk-...'; - }); - document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider); // Admin — models document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels); diff --git a/src/js/app.js b/src/js/app.js index 3ef930a..fdc973e 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -314,7 +314,7 @@ async function handleRegister() { } async function handleLogout() { - if (!confirm('Sign out?')) return; + if (!await showConfirm('Sign out?')) return; Events.disconnect(); Events.clear(); await API.logout(); diff --git a/src/js/chat.js b/src/js/chat.js index 02c4f19..4dd6756 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -193,7 +193,7 @@ async function newChat() { } async function deleteChat(chatId) { - if (!confirm('Delete this chat?')) return; + if (!await showConfirm('Delete this chat?')) return; try { await API.deleteChannel(chatId); App.chats = App.chats.filter(c => c.id !== chatId); diff --git a/src/js/debug.js b/src/js/debug.js index 7121c19..ede3f79 100644 --- a/src/js/debug.js +++ b/src/js/debug.js @@ -546,8 +546,8 @@ function switchDebugTab(tab) { DebugLog.render(); } -function clearDebugLog() { - if (confirm('Clear all debug logs?')) { +async function clearDebugLog() { + if (await showConfirm('Clear all debug logs?')) { DebugLog.clear(); DebugLog.render(); } diff --git a/src/js/notes.js b/src/js/notes.js index 5d759cc..597400b 100644 --- a/src/js/notes.js +++ b/src/js/notes.js @@ -11,6 +11,13 @@ var _notesSort = 'updated_desc'; // ── Notes ───────────────────────────────────── async function openNotes() { + const panel = document.getElementById('sidePanel'); + const notesTab = document.getElementById('sidePanelNotes'); + // If notes panel is already open and showing, close it + if (panel?.classList.contains('open') && notesTab?.style.display !== 'none') { + closeSidePanel(); + return; + } openSidePanel('notes'); _exitSelectMode(); await loadNotesList(); @@ -136,7 +143,7 @@ function _updateSelectedCount() { async function _bulkDeleteSelected() { if (_selectedNoteIds.size === 0) return; const count = _selectedNoteIds.size; - if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return; + if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return; try { const resp = await API.bulkDeleteNotes([..._selectedNoteIds]); @@ -300,7 +307,7 @@ async function saveNote() { async function deleteNote() { if (!_editingNoteId) return; - if (!confirm('Delete this note?')) return; + if (!await showConfirm('Delete this note?')) return; try { await API.deleteNote(_editingNoteId); UI.toast('Note deleted', 'success'); diff --git a/src/js/settings-handlers.js b/src/js/settings-handlers.js index 1e78ebe..f0a50d8 100644 --- a/src/js/settings-handlers.js +++ b/src/js/settings-handlers.js @@ -85,83 +85,78 @@ async function handleChangePassword() { } catch (e) { UI.toast(e.message, 'error'); } } -async function handleCreateProvider() { - const name = document.getElementById('providerName').value.trim(); - const provider = document.getElementById('providerType').value; - const endpoint = document.getElementById('providerEndpoint').value.trim(); - const apiKey = document.getElementById('providerApiKey').value.trim(); - const model = document.getElementById('providerDefaultModel').value.trim(); +// ── User BYOK Provider — primitive instances (initialized in _initSettingsListeners) ── +var _userProvForm = null; +var _userProvList = null; - const editId = UI._editingProviderId; +function _initUserProviderPrimitives() { + const formEl = document.getElementById('providerAddForm'); + const listEl = document.getElementById('providerList'); + if (!formEl || !listEl) return; - if (editId) { - // Update mode — api_key optional (blank = keep existing) - if (!name || !endpoint) return UI.toast('Fill in required fields', 'warning'); - const patch = { name, provider, endpoint, model_default: model }; - if (apiKey) patch.api_key = apiKey; - try { - await API.updateConfig(editId, patch); - UI.toast('Provider updated', 'success'); - UI._editingProviderId = null; - document.getElementById('providerApiKey').placeholder = 'API key'; - UI.hideProviderForm(); - UI.loadProviderList(); - fetchModels(); - } catch (e) { UI.toast(e.message, 'error'); } - } else { - // Create mode - if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning'); - try { - const result = await API.createConfig(name, provider, endpoint, apiKey, model); - if (result.warning) { - UI.toast(`Provider added — ${result.warning}`, 'warning'); - } else { - const count = result.models_fetched || 0; - UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success'); - } - UI.hideProviderForm(); - UI.loadProviderList(); - fetchModels(); - } catch (e) { UI.toast(e.message, 'error'); } - } -} + _userProvForm = renderProviderForm(formEl, { + prefix: 'userProv', + showPrivate: false, + showDefaultModel: true, + onSubmit: async (vals, isEdit) => { + try { + if (isEdit) { + if (!vals.name || !vals.endpoint) return UI.toast('Fill in required fields', 'warning'); + const patch = { name: vals.name, provider: vals.provider, endpoint: vals.endpoint, model_default: vals.model_default }; + if (vals.api_key) patch.api_key = vals.api_key; + await API.updateConfig(vals._editId, patch); + UI.toast('Provider updated', 'success'); + } else { + if (!vals.name || !vals.endpoint || !vals.api_key) return UI.toast('Fill in required fields', 'warning'); + const result = await API.createConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default); + if (result.warning) UI.toast(`Provider added — ${result.warning}`, 'warning'); + else { + const count = result.models_fetched || 0; + UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success'); + } + } + formEl.style.display = 'none'; + _userProvForm.setCreateMode(); + _userProvList.refresh(); + fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + onCancel: () => { + formEl.style.display = 'none'; + _userProvForm.setCreateMode(); + }, + }); -async function deleteProvider(id, name) { - if (!confirm(`Remove provider "${name}"?`)) return; - try { - await API.deleteConfig(id); - UI.toast('Provider removed', 'success'); - UI.loadProviderList(); - fetchModels(); - } catch (e) { UI.toast(e.message, 'error'); } -} - -async function refreshProviderModels(id, name) { - try { - UI.toast(`Fetching models for ${name}...`, 'info'); - const result = await API.fetchProviderModels(id); - const total = result.total || 0; - UI.toast(`${name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success'); - fetchModels(); // refresh the model selector - } catch (e) { - UI.toast(`Failed to fetch models: ${e.message}`, 'error'); - } -} - -async function editProvider(id) { - try { - const cfg = await API.getConfig(id); - // Populate the add form with existing values - document.getElementById('providerName').value = cfg.name || ''; - document.getElementById('providerType').value = cfg.provider || 'openai'; - document.getElementById('providerEndpoint').value = cfg.endpoint || ''; - document.getElementById('providerApiKey').value = ''; // Don't expose key - document.getElementById('providerApiKey').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'API key'; - document.getElementById('providerDefaultModel').value = cfg.model_default || ''; - // Show form and switch to edit mode - UI.showProviderForm(); - UI._editingProviderId = id; - } catch (e) { UI.toast(e.message, 'error'); } + _userProvList = renderProviderList(listEl, { + apiFetch: () => API.listConfigs(), + showRefresh: true, + emptyMsg: 'No providers configured.', + onEdit: async (prov) => { + try { + const cfg = await API.getConfig(prov.id); + _userProvForm.setEditMode(cfg.id, cfg); + formEl.style.display = ''; + } catch (e) { UI.toast(e.message, 'error'); } + }, + onDelete: async (prov) => { + if (!await showConfirm(`Remove provider "${prov.name}"?`)) return; + try { + await API.deleteConfig(prov.id); + UI.toast('Provider removed', 'success'); + _userProvList.refresh(); + fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + onRefresh: async (prov) => { + try { + UI.toast(`Fetching models for ${prov.name}...`, 'info'); + const result = await API.fetchProviderModels(prov.id); + const total = result.total || 0; + UI.toast(`${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success'); + fetchModels(); + } catch (e) { UI.toast(`Failed to fetch models: ${e.message}`, 'error'); } + }, + }); } async function handleSaveAdminSettings() { @@ -209,67 +204,50 @@ async function handleSaveAdminSettings() { } catch (e) { UI.toast(e.message, 'error'); } } -// ── User Model Role Functions ───────────────── +// ── User Model Role — primitive instance (initialized in _initSettingsListeners) ── +var _userRoleConfig = null; -async function userRoleProviderChanged(role) { - const provSel = document.getElementById(`userRole-${role}-provider`); - const modelSel = document.getElementById(`userRole-${role}-model`); - if (!provSel || !modelSel) return; +function _initUserRolePrimitive() { + const el = document.getElementById('userRolesConfig'); + if (!el) return; - const providerId = provSel.value; - modelSel.innerHTML = ''; - if (!providerId) return; - - // Filter by provider AND model type matching the role - const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; - const allModels = App.models || []; - const provModels = allModels.filter(m => - m.configId === providerId && - (m.model_type || 'chat') === typeForRole - ); - provModels.forEach(m => { - modelSel.innerHTML += ``; + _userRoleConfig = renderRoleConfig(el, { + scope: 'personal', + showFallback: false, + modelIdField: 'baseModelId', + modelNameField: 'name', + providerIdField: 'configId', + noneLabel: '— use org default —', + fetchProviders: async () => { + const configs = await API.listConfigs(); + const list = configs.configs || configs.data || []; + return list.filter(c => c.scope === 'personal'); + }, + fetchModels: async () => { + return App.models || []; + }, + fetchRoles: async () => { + const settings = await API.getSettings(); + return settings.model_roles || {}; + }, + onSave: async (roleId, config) => { + if (!config.primary) throw new Error('Select both a provider and model'); + const settings = await API.getSettings(); + const roles = settings.model_roles || {}; + roles[roleId] = config; + await API.updateSettings({ model_roles: roles }); + UI.toast(`${roleId} role override saved`, 'success'); + }, + onClear: async (roleId) => { + const settings = await API.getSettings(); + const roles = settings.model_roles || {}; + delete roles[roleId]; + await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null }); + UI.toast(`${roleId} role override removed — using org default`, 'success'); + }, }); } -async function saveUserRole(role) { - const provSel = document.getElementById(`userRole-${role}-provider`); - const modelSel = document.getElementById(`userRole-${role}-model`); - if (!provSel || !modelSel) return; - - const providerId = provSel.value; - const modelId = modelSel.value; - - if (!providerId || !modelId) { - UI.toast('Select both a provider and model', 'warning'); - return; - } - - try { - const settings = await API.getSettings(); - const roles = settings.model_roles || {}; - roles[role] = { primary: { provider_config_id: providerId, model_id: modelId } }; - await API.updateSettings({ model_roles: roles }); - UI.toast(`${role} role override saved`, 'success'); - UI.loadUserRoles(); - } catch (e) { - UI.toast(e.message || 'Failed to save role', 'error'); - } -} - -async function clearUserRole(role) { - try { - const settings = await API.getSettings(); - const roles = settings.model_roles || {}; - delete roles[role]; - await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null }); - UI.toast(`${role} role override removed — using org default`, 'success'); - UI.loadUserRoles(); - } catch (e) { - UI.toast(e.message || 'Failed to clear role', 'error'); - } -} - // ── Command Palette ────────────────────────── const _cmdCommands = [ @@ -473,20 +451,13 @@ function _initSettingsListeners() { } catch (e) { UI.toast(e.message || 'Remove failed', 'error'); } }); - document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm); - document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm); - document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider); - document.getElementById('providerType').addEventListener('change', function() { - const endpoints = { - openai: 'https://api.openai.com/v1', - anthropic: 'https://api.anthropic.com', - venice: 'https://api.venice.ai/api/v1', - openrouter: 'https://openrouter.ai/api/v1', - }; - const ep = document.getElementById('providerEndpoint'); - if (!ep.value || Object.values(endpoints).includes(ep.value)) { - ep.value = endpoints[this.value] || ''; - } + // User BYOK provider primitives + _initUserProviderPrimitives(); + _initUserRolePrimitive(); + document.getElementById('providerShowAddBtn').addEventListener('click', () => { + const formEl = document.getElementById('providerAddForm'); + if (_userProvForm) _userProvForm.setCreateMode(); + formEl.style.display = ''; }); // Settings — Team management (team admin self-service) @@ -538,83 +509,12 @@ function _initSettingsListeners() { UI.loadTeamPresetModelDropdown(UI._managingTeamId); }); - // Team — providers - const defaultEndpoints = { - openai: 'https://api.openai.com/v1', - anthropic: 'https://api.anthropic.com', - venice: 'https://api.venice.ai/api/v1', - openrouter: 'https://openrouter.ai/api/v1', - }; + // Team — providers (primitive-based, initialized lazily via UI.loadTeamManageProviders) document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => { - const form = document.getElementById('settingsTeamAddProvider'); - form.style.display = form.style.display === 'none' ? '' : 'none'; - const sel = document.getElementById('teamProviderType'); - if (sel && sel.options.length === 0) { - ['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => { - const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' }; - const opt = document.createElement('option'); - opt.value = p; - opt.textContent = labels[p] || p; - sel.appendChild(opt); - }); - } - }); - document.getElementById('teamProviderType')?.addEventListener('change', function() { - const ep = document.getElementById('teamProviderEndpoint'); - if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) { - ep.value = defaultEndpoints[this.value] || ''; - } - }); - document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => { - document.getElementById('settingsTeamAddProvider').style.display = 'none'; - }); - document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => { - const teamId = UI._managingTeamId; - const name = document.getElementById('teamProviderName').value.trim(); - const provider = document.getElementById('teamProviderType').value; - const endpoint = document.getElementById('teamProviderEndpoint').value.trim(); - const apiKey = document.getElementById('teamProviderKey').value; - const isPrivate = document.getElementById('teamProviderPrivate').checked; - if (!name) return UI.toast('Name required', 'error'); - if (!endpoint) return UI.toast('Endpoint required', 'error'); - try { - await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate }); - document.getElementById('settingsTeamAddProvider').style.display = 'none'; - document.getElementById('teamProviderName').value = ''; - document.getElementById('teamProviderEndpoint').value = ''; - document.getElementById('teamProviderKey').value = ''; - document.getElementById('teamProviderPrivate').checked = false; - UI.toast('Provider added'); - await UI.loadTeamManageProviders(teamId); - await fetchModels(); - } catch (e) { UI.toast(e.message, 'error'); } - }); - - // Team — edit provider - document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => { - document.getElementById('settingsTeamEditProvider').style.display = 'none'; - }); - document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => { - const teamId = UI._managingTeamId; - const provId = UI._editingTeamProviderId; - if (!provId) return; - const updates = {}; - const name = document.getElementById('teamProviderEditName').value.trim(); - const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim(); - const apiKey = document.getElementById('teamProviderEditKey').value; - const defaultModel = document.getElementById('teamProviderEditDefault').value.trim(); - const isPrivate = document.getElementById('teamProviderEditPrivate').checked; - if (name) updates.name = name; - if (endpoint) updates.endpoint = endpoint; - if (apiKey) updates.api_key = apiKey; - if (defaultModel) updates.model_default = defaultModel; - updates.is_private = isPrivate; - try { - await API.teamUpdateProvider(teamId, provId, updates); - document.getElementById('settingsTeamEditProvider').style.display = 'none'; - UI.toast('Provider updated'); - await UI.loadTeamManageProviders(teamId); - } catch (e) { UI.toast(e.message, 'error'); } + const formEl = document.getElementById('settingsTeamProviderForm'); + if (!formEl) return; + if (UI._teamProvForm) UI._teamProvForm.setCreateMode(); + formEl.style.display = formEl.style.display === 'none' ? '' : 'none'; }); // User — personal presets diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js index 83cd142..34aa499 100644 --- a/src/js/ui-admin.js +++ b/src/js/ui-admin.js @@ -41,7 +41,12 @@ Object.assign(UI, { closeTeamAdmin() { closeModal('teamAdminModal'); }, switchTeamTab(tab) { - document.querySelectorAll('#teamAdminTabs .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.ttab === tab)); + const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab'); + console.log('switchTeamTab:', tab, 'found tabs:', tabs.length); + tabs.forEach(t => { + t.classList.remove('active'); + if (t.dataset.ttab === tab) t.classList.add('active'); + }); document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none'); const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`); if (panel) panel.style.display = ''; @@ -56,7 +61,7 @@ Object.assign(UI, { }, async switchAdminTab(tab) { - document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none'); const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`); if (panel) panel.style.display = ''; @@ -134,131 +139,88 @@ Object.assign(UI, { async loadAdminRoles() { const el = document.getElementById('adminRolesContent'); if (!el) return; - el.innerHTML = '
Loading...
'; - try { - const roles = await API.adminListRoles(); - const configs = await API.adminListGlobalConfigs(); - const providers = configs.configs || configs || []; - const models = await API.adminListModels(); - const modelList = models.models || models.data || models || []; - const roleNames = ['utility', 'embedding']; - el.innerHTML = roleNames.map(role => { - const cfg = roles[role] || { primary: null, fallback: null }; - const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; - const typeFilter = m => (m.model_type || 'chat') === typeForRole; - return ` -
-

${role}

-
- - - -
-
- - - -
-
- - - -
-
`; - }).join(''); + if (!UI._adminRoleConfig) { + UI._adminRoleConfig = renderRoleConfig(el, { + scope: 'admin', + showFallback: true, + modelIdField: 'model_id', + modelNameField: 'display_name', + providerIdField: 'provider_config_id', + fetchProviders: async () => { + const configs = await API.adminListGlobalConfigs(); + return configs.configs || configs || []; + }, + fetchModels: async () => { + const models = await API.adminListModels(); + return models.models || models.data || models || []; + }, + fetchRoles: () => API.adminListRoles(), + onSave: async (roleId, config) => { + await API.adminUpdateRole(roleId, config); + }, + onTest: (roleId) => API.adminTestRole(roleId), + }); + } - // Store models list for provider change handler - window._adminModelList = modelList; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + await UI._adminRoleConfig.refresh(); }, // ── Admin: Usage ──────────────────────── async loadAdminUsage() { - const period = document.getElementById('usagePeriod')?.value || '30d'; - const groupBy = document.getElementById('usageGroupBy')?.value || 'model'; - + // Usage stats via primitive const totalsEl = document.getElementById('adminUsageTotals'); - const resultsEl = document.getElementById('adminUsageResults'); - const pricingEl = document.getElementById('adminPricingTable'); if (!totalsEl) return; + // Wrap totals+results in a container for the primitive (first time only) + let usageContainer = document.getElementById('_adminUsageContainer'); + if (!usageContainer) { + usageContainer = document.createElement('div'); + usageContainer.id = '_adminUsageContainer'; + const resultsEl = document.getElementById('adminUsageResults'); + totalsEl.parentElement.insertBefore(usageContainer, totalsEl); + usageContainer.appendChild(totalsEl); + if (resultsEl) usageContainer.appendChild(resultsEl); + } - totalsEl.innerHTML = '
Loading...
'; - resultsEl.innerHTML = ''; + if (!UI._adminUsageDash) { + UI._adminUsageDash = renderUsageDashboard(usageContainer, { + apiFetch: (p) => API.adminGetUsage(p), + periodElId: 'usagePeriod', + groupByElId: 'usageGroupBy', + compact: false, + showUserColumn: true, + }); + } + await UI._adminUsageDash.refresh(); - try { - const data = await API.adminGetUsage({ period, group_by: groupBy }); - const t = data.totals || {}; - - totalsEl.innerHTML = ` -
-
${(t.requests || 0).toLocaleString()}
Requests
-
${(t.input_tokens || 0).toLocaleString()}
Input Tokens
-
${(t.output_tokens || 0).toLocaleString()}
Output Tokens
-
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
-
`; - - const results = data.results || []; - if (results.length === 0) { - resultsEl.innerHTML = '
No usage data for this period
'; - } else { - resultsEl.innerHTML = ` - - - - - - - - - ${results.map(r => ` - - - - - - `).join('')} -
${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}RequestsInputOutputCost
${esc(r.label || r.group_key)}${(r.requests || 0).toLocaleString()}${(r.input_tokens || 0).toLocaleString()}${(r.output_tokens || 0).toLocaleString()}$${(r.total_cost || 0).toFixed(4)}
`; - } - } catch (e) { totalsEl.innerHTML = `
${esc(e.message)}
`; } - - // Load pricing table - try { - const pricing = await API.adminListPricing(); - const entries = pricing || []; - if (entries.length === 0) { - pricingEl.innerHTML = '
No pricing configured. Sync providers to populate from catalog.
'; - } else { - pricingEl.innerHTML = ` - - - - - - - - ${entries.map(p => ` - - - - - `).join('')} -
ModelInput $/MOutput $/MSource
${esc(p.model_id)}${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}${p.source}
`; - } - } catch (e) { pricingEl.innerHTML = `
${esc(e.message)}
`; } + // Pricing table (admin-only, separate from usage primitive) + const pricingEl = document.getElementById('adminPricingTable'); + if (pricingEl) { + try { + const pricing = await API.adminListPricing(); + const entries = pricing || []; + if (entries.length === 0) { + pricingEl.innerHTML = '
No pricing configured. Sync providers to populate from catalog.
'; + } else { + pricingEl.innerHTML = ` + + + + + + + + ${entries.map(p => ` + + + + + `).join('')} +
ModelInput $/MOutput $/MSource
${esc(p.model_id)}${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}${p.source}
`; + } + } catch (e) { pricingEl.innerHTML = `
${esc(e.message)}
`; } + } }, _auditPage: 1, @@ -342,24 +304,67 @@ Object.assign(UI, { async loadAdminProviders(quiet) { const el = document.getElementById('adminProviderList'); - if (!quiet) el.innerHTML = '
Loading...
'; - try { - const data = await API.adminListGlobalConfigs(); - const list = data.configs || data.data || data || []; - const arr = Array.isArray(list) ? list : []; - UI._providerCache = {}; - el.innerHTML = arr.map(c => { - UI._providerCache[c.id] = c; - return `
- ${esc(c.name)} - ${esc(c.provider)} - ${c.is_private ? 'private' : ''} - ${esc(c.endpoint || '')} - - -
`; - }).join('') || '
No global providers — add one above
'; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + const formEl = document.getElementById('adminAddProviderForm'); + + // Initialize admin provider form primitive (once) + if (!UI._adminProvForm && formEl) { + UI._adminProvForm = renderProviderForm(formEl, { + prefix: 'adminProv', + showPrivate: true, + showDefaultModel: true, + onSubmit: async (vals, isEdit) => { + try { + if (isEdit) { + const updates = {}; + if (vals.name) updates.name = vals.name; + if (vals.endpoint) updates.endpoint = vals.endpoint; + if (vals.api_key) updates.api_key = vals.api_key; + updates.model_default = vals.model_default; + updates.is_private = vals.is_private || false; + await API.adminUpdateGlobalConfig(vals._editId, updates); + UI.toast('Provider updated', 'success'); + } else { + if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; } + await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false); + UI.toast('Provider added', 'success'); + } + formEl.style.display = 'none'; + UI._adminProvForm.setCreateMode(); + UI._adminProvList.refresh(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + onCancel: () => { + formEl.style.display = 'none'; + UI._adminProvForm.setCreateMode(); + }, + }); + } + + // Initialize admin provider list primitive (once) + if (!UI._adminProvList) { + UI._adminProvList = renderProviderList(el, { + apiFetch: () => API.adminListGlobalConfigs(), + showEndpoint: true, + showPrivate: true, + emptyMsg: 'No global providers — add one above', + onEdit: (prov) => { + if (UI._adminProvForm) { + UI._adminProvForm.setEditMode(prov.id, prov); + formEl.style.display = ''; + } + }, + onDelete: async (prov) => { + if (!await showConfirm('Delete this global provider?')) return; + try { + await API.adminDeleteGlobalConfig(prov.id); + UI.toast('Provider deleted', 'success'); + UI._adminProvList.refresh(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + }); + } + + await UI._adminProvList.refresh(quiet); }, async loadAdminModels(quiet) { @@ -371,18 +376,14 @@ Object.assign(UI, { const arr = Array.isArray(list) ? list : []; el.innerHTML = arr.map(m => { const caps = m.capabilities || {}; - const badges = []; - if (caps.max_output_tokens > 0) badges.push(`${(caps.max_output_tokens/1000).toFixed(0)}K`); - if (caps.tool_calling) badges.push('🔧'); - if (caps.vision) badges.push('👁'); - if (caps.thinking) badges.push('💭'); + const badges = renderCapBadges(caps, { compact: true }); // Support both old is_enabled (bool) and new visibility (string) const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled'); const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled'; const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : ''; return `
${esc(m.model_id || m.id)} - ${badges.join('')} + ${badges} ${esc(m.provider_name || '')}
`; diff --git a/src/js/ui-core.js b/src/js/ui-core.js index a3c37de..9dedeeb 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -211,7 +211,26 @@ const UI = { toggleUserMenu() { const flyout = document.getElementById('userFlyout'); + const opening = !flyout.classList.contains('open'); flyout.classList.toggle('open'); + + // On open: refresh team admin button visibility (fire-and-forget) + if (opening) { + const menuBtn = document.getElementById('menuTeamAdmin'); + if (menuBtn) { + // Show from cache immediately if available + if (UI._myTeams && UI._myTeams.length > 0) { + const cached = UI._myTeams.some(t => t.my_role === 'admin'); + menuBtn.style.display = cached ? '' : 'none'; + } + // Refresh in background + API.listMyTeams().then(resp => { + const teams = resp.data || []; + UI._myTeams = teams; + menuBtn.style.display = teams.some(t => t.my_role === 'admin') ? '' : 'none'; + }).catch(() => {}); + } + } }, closeUserMenu() { @@ -749,31 +768,7 @@ const UI = { return; } - const badges = []; - - // Output tokens - if (caps.max_output_tokens > 0) { - const k = caps.max_output_tokens >= 1000 - ? (caps.max_output_tokens / 1000).toFixed(0) + 'K' - : caps.max_output_tokens; - badges.push(`${k} out`); - } - - // Context window - if (caps.max_context > 0) { - const k = (caps.max_context / 1000).toFixed(0) + 'K'; - badges.push(`${k} ctx`); - } - - // Capability flags - if (caps.tool_calling) badges.push('🔧 tools'); - if (caps.vision) badges.push('👁 vision'); - if (caps.thinking) badges.push('💭 thinking'); - if (caps.reasoning) badges.push('🧠 reasoning'); - if (caps.code_optimized) badges.push('⟨/⟩ code'); - if (caps.web_search) badges.push('🔍 search'); - - el.innerHTML = badges.join(''); + el.innerHTML = renderCapBadges(caps); }, // ── User / Connection ──────────────────── diff --git a/src/js/ui-primitives.js b/src/js/ui-primitives.js new file mode 100644 index 0000000..1e72a2d --- /dev/null +++ b/src/js/ui-primitives.js @@ -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 '; + 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 => + `` + ).join(''); + + const modelOptions = selectedProv + ? filterModels(selectedProv, roleId).map(m => + `` + ).join('') + : ''; + + return ` +
+ + + +
`; + } + + async function refresh() { + containerEl.innerHTML = '
Loading...
'; + try { + const [provRaw, modelRaw, roleData] = await Promise.all([ + opts.fetchProviders(), + opts.fetchModels(), + opts.fetchRoles(), + ]); + + // Normalize + _providers = Array.isArray(provRaw) ? provRaw : (provRaw.configs || provRaw.providers || provRaw.data || []); + _models = Array.isArray(modelRaw) ? modelRaw : (modelRaw.models || modelRaw.data || []); + _roles = roleData || {}; + + const roles = Roles.all(); + containerEl.innerHTML = roles.map(role => { + const cfg = _roles[role.id] || { primary: null, fallback: null }; + const hasClear = scope === 'personal' && opts.onClear && cfg.primary; + return ` +
+ <${scope === 'admin' ? 'h3' : 'h4'} style="font-size:${scope === 'admin' ? 14 : 13}px;margin:0 0 ${scope === 'admin' ? 8 : 6}px;text-transform:capitalize"> + ${esc(role.label || role.id)} + ${scope === 'personal' && role.hint ? `(${esc(role.hint)})` : ''} + + ${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')} + ${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''} +
+ + ${opts.onTest ? `` : ''} + ${hasClear ? `` : ''} + +
+
`; + }).join(''); + + } catch (e) { + containerEl.innerHTML = `
${esc(e.message)}
`; + } + } + + // ── Delegate events ── + containerEl.addEventListener('change', (e) => { + const provSel = e.target.closest('[data-role-provider]'); + if (!provSel) return; + const [roleId, slot] = provSel.dataset.roleProvider.split('-'); + onProviderChange(roleId, slot); + }); + + containerEl.addEventListener('click', (e) => { + const saveBtn = e.target.closest('[data-role-save]'); + if (saveBtn) { handleSave(saveBtn.dataset.roleSave); return; } + const testBtn = e.target.closest('[data-role-test]'); + if (testBtn) { handleTest(testBtn.dataset.roleTest); return; } + const clearBtn = e.target.closest('[data-role-clear]'); + if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; } + }); + + return { refresh }; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// §7 USAGE DASHBOARD +// ══════════════════════════════════════════════════════════════════════════════ + +/** + * Render usage stats + table into a container. + * + * @param {HTMLElement} containerEl + * @param {Object} opts + * @param {Function} opts.apiFetch - async ({period, group_by}) → { totals, results } + * @param {string} [opts.periodElId] - ID of period + * @param {boolean} [opts.compact=false] - Use compact sizing (user/team) + * @param {boolean} [opts.showUserColumn=false] - Include "User" in group_by options + * @param {Array} [opts.extraColumns] - [{header, render: (row) → html}] + * @returns {{ refresh }} + */ +function renderUsageDashboard(containerEl, opts = {}) { + const compact = opts.compact === true; + const cardStyle = compact ? ' style="padding:8px"' : ''; + const valueStyle = compact ? ' style="font-size:16px"' : ''; + const labelStyle = compact ? ' style="font-size:10px"' : ''; + const gridStyle = compact ? ' style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px"' : ''; + const tableStyle = compact ? ' style="font-size:12px"' : ' style="margin-top:12px"'; + + // We render into two sub-zones + let totalsEl, resultsEl; + + function ensureStructure() { + if (!containerEl.querySelector('[data-usage-totals]')) { + containerEl.innerHTML = ` +
+
+ `; + } + totalsEl = containerEl.querySelector('[data-usage-totals]'); + resultsEl = containerEl.querySelector('[data-usage-results]'); + } + + async function refresh() { + ensureStructure(); + const period = opts.periodElId ? (document.getElementById(opts.periodElId)?.value || '30d') : '30d'; + const groupBy = opts.groupByElId ? (document.getElementById(opts.groupByElId)?.value || 'model') : 'model'; + + totalsEl.innerHTML = compact + ? '
Loading...
' + : '
Loading...
'; + resultsEl.innerHTML = ''; + + try { + const data = await opts.apiFetch({ period, group_by: groupBy }); + const t = data.totals || {}; + + if (!t.requests && compact) { + totalsEl.innerHTML = '
No usage recorded in this period
'; + return; + } + + totalsEl.innerHTML = ` +
+
${(t.requests || 0).toLocaleString()}
Requests
+
${(t.input_tokens || 0).toLocaleString()}
Input${compact ? '' : ' Tokens'}
+
${(t.output_tokens || 0).toLocaleString()}
Output${compact ? '' : ' Tokens'}
+
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
+
`; + + const results = data.results || []; + if (results.length === 0) { + resultsEl.innerHTML = '
No usage data for this period
'; + return; + } + + // Column header depends on groupBy + let firstCol; + if (groupBy === 'day') firstCol = 'Date'; + else if (groupBy === 'user') firstCol = 'User'; + else firstCol = 'Model'; + + const extraHeaders = (opts.extraColumns || []).map(c => + `${esc(c.header)}` + ).join(''); + + resultsEl.innerHTML = ` + + + + + + + + ${extraHeaders} + + ${results.map(r => { + const extraCells = (opts.extraColumns || []).map(c => + `` + ).join(''); + return ` + + + + + + ${extraCells} + `; + }).join('')} +
${firstCol}${compact ? 'Reqs' : 'Requests'}InputOutputCost
${c.render(r)}
${esc(r.label || r.group_key)}${(r.requests || 0).toLocaleString()}${(r.input_tokens || 0).toLocaleString()}${(r.output_tokens || 0).toLocaleString()}$${(r.total_cost || 0).toFixed(4)}
`; + } catch (e) { + totalsEl.innerHTML = `
${esc(e.message)}
`; + } + } + + return { refresh }; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// §8 CONFIRM DIALOG +// ══════════════════════════════════════════════════════════════════════════════ + +/** + * Custom confirm dialog replacing native confirm(). + * Returns a Promise. + * + * @param {string} message + * @param {Object} [opts] + * @param {string} [opts.title='Confirm'] + * @param {string} [opts.ok='Confirm'] + * @param {string} [opts.cancel='Cancel'] + * @param {boolean} [opts.danger=false] - Styles the confirm button as destructive + * @returns {Promise} + */ +function showConfirm(message, opts = {}) { + return new Promise(resolve => { + const title = opts.title || 'Confirm'; + const okText = opts.ok || 'Confirm'; + const caText = opts.cancel || 'Cancel'; + const danger = opts.danger !== false; // default true for destructive actions + + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + overlay.innerHTML = ` +
+
${esc(title)}
+
${esc(message)}
+ +
`; + + function close(result) { + overlay.remove(); + document.removeEventListener('keydown', onKey); + resolve(result); + } + + function onKey(e) { + if (e.key === 'Escape') close(false); + if (e.key === 'Enter') close(true); + } + + overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true)); + overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false)); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); }); + document.addEventListener('keydown', onKey); + + document.body.appendChild(overlay); + overlay.querySelector('[data-action="cancel"]').focus(); + }); +} diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js index db36558..5d14c4c 100644 --- a/src/js/ui-settings.js +++ b/src/js/ui-settings.js @@ -191,37 +191,92 @@ Object.assign(UI, { async loadTeamManageProviders(teamId) { const el = document.getElementById('settingsTeamProviders'); if (!el) return; - el.innerHTML = '
Loading...
'; const addBtn = document.getElementById('settingsTeamAddProviderBtn'); - try { - const resp = await API.teamListProviders(teamId); - const provs = resp.providers || []; - const allowed = resp.allow_team_providers !== false; + const formEl = document.getElementById('settingsTeamProviderForm'); - // Show/hide add button and entire section based on policy - if (addBtn) addBtn.style.display = allowed ? '' : 'none'; + // Initialize team provider form primitive (once) + if (!UI._teamProvForm && formEl) { + UI._teamProvForm = renderProviderForm(formEl, { + prefix: 'teamProv', + showPrivate: true, + showDefaultModel: true, + onSubmit: async (vals, isEdit) => { + const tid = UI._managingTeamId; + try { + if (isEdit) { + const updates = {}; + if (vals.name) updates.name = vals.name; + if (vals.endpoint) updates.endpoint = vals.endpoint; + if (vals.api_key) updates.api_key = vals.api_key; + if (vals.model_default) updates.model_default = vals.model_default; + updates.is_private = vals.is_private || false; + await API.teamUpdateProvider(tid, vals._editId, updates); + UI.toast('Provider updated'); + } else { + if (!vals.name) return UI.toast('Name required', 'error'); + if (!vals.endpoint) return UI.toast('Endpoint required', 'error'); + await API.teamCreateProvider(tid, { + name: vals.name, provider: vals.provider, endpoint: vals.endpoint, + api_key: vals.api_key, is_private: vals.is_private || false, + }); + UI.toast('Provider added'); + } + formEl.style.display = 'none'; + UI._teamProvForm.setCreateMode(); + UI._teamProvList.refresh(); + await fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + onCancel: () => { + formEl.style.display = 'none'; + UI._teamProvForm.setCreateMode(); + }, + }); + } - if (!allowed && provs.length === 0) { - el.innerHTML = '
Team provider management is disabled by your administrator
'; - return; - } - el.innerHTML = provs.map(p => { - const statusCls = p.is_active ? 'badge-admin' : 'badge-user'; - const statusLabel = p.is_active ? 'active' : 'inactive'; - const privateBadge = p.is_private ? ' 🔒 private' : ''; - return `
-
- ${esc(p.name)} -
${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} ${statusLabel}${privateBadge}
-
-
- - - -
-
`; - }).join('') || '
No team providers — add one to give your team access to additional models
'; - } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + // Initialize team provider list primitive (once) + if (!UI._teamProvList) { + UI._teamProvList = renderProviderList(el, { + apiFetch: async () => { + const resp = await API.teamListProviders(UI._managingTeamId); + // Handle policy check + const allowed = resp.allow_team_providers !== false; + if (addBtn) addBtn.style.display = allowed ? '' : 'none'; + if (!allowed && !(resp.providers || []).length) { + throw { message: 'Team provider management is disabled by your administrator', _empty: true }; + } + return resp; + }, + showPrivate: true, + showActive: true, + emptyMsg: 'No team providers — add one to give your team access to additional models', + onEdit: (prov) => { + if (UI._teamProvForm) { + UI._teamProvForm.setEditMode(prov.id, prov); + formEl.style.display = ''; + } + }, + onDelete: async (prov) => { + if (!await showConfirm(`Delete team provider "${prov.name}"? This will remove all models from this provider for your team.`)) return; + try { + await API.teamDeleteProvider(UI._managingTeamId, prov.id); + UI.toast('Provider deleted'); + UI._teamProvList.refresh(); + await fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + onToggleActive: async (prov) => { + try { + await API.teamUpdateProvider(UI._managingTeamId, prov.id, { is_active: !prov.is_active }); + UI.toast(prov.is_active ? 'Provider deactivated' : 'Provider activated'); + UI._teamProvList.refresh(); + await fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } + }, + }); + } + + await UI._teamProvList.refresh(); }, async loadTeamManagePresets(teamId) { @@ -244,108 +299,34 @@ Object.assign(UI, { }, async loadMyUsage() { - const totalsEl = document.getElementById('myUsageTotals'); - const resultsEl = document.getElementById('myUsageResults'); - if (!totalsEl) return; - - const period = document.getElementById('myUsagePeriod')?.value || '30d'; - const groupBy = document.getElementById('myUsageGroupBy')?.value || 'model'; - - totalsEl.innerHTML = '
Loading...
'; - resultsEl.innerHTML = ''; - - try { - const data = await API.getMyUsage({ period, group_by: groupBy }); - const t = data.totals || {}; - - if (!t.requests) { - totalsEl.innerHTML = '
No usage recorded in this period
'; - return; - } - - totalsEl.innerHTML = ` -
-
${(t.requests || 0).toLocaleString()}
Requests
-
${(t.input_tokens || 0).toLocaleString()}
Input
-
${(t.output_tokens || 0).toLocaleString()}
Output
-
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
-
`; - - const results = data.results || []; - if (results.length > 0) { - resultsEl.innerHTML = ` - - - - - - - - - ${results.map(r => ` - - - - - - `).join('')} -
${groupBy === 'day' ? 'Date' : 'Model'}ReqsInputOutputCost
${esc(r.label || r.group_key)}${(r.requests || 0).toLocaleString()}${(r.input_tokens || 0).toLocaleString()}${(r.output_tokens || 0).toLocaleString()}$${(r.total_cost || 0).toFixed(4)}
`; - } - } catch (e) { totalsEl.innerHTML = `
${esc(e.message)}
`; } + const el = document.getElementById('myUsageTotals')?.parentElement; + if (!el) return; + if (!UI._myUsageDash) { + UI._myUsageDash = renderUsageDashboard(el, { + apiFetch: (p) => API.getMyUsage(p), + periodElId: 'myUsagePeriod', + groupByElId: 'myUsageGroupBy', + compact: true, + }); + } + await UI._myUsageDash.refresh(); }, async loadTeamUsage() { const teamId = UI._managingTeamId; if (!teamId) return; - - const totalsEl = document.getElementById('settingsTeamUsageTotals'); - const resultsEl = document.getElementById('settingsTeamUsageResults'); - if (!totalsEl) return; - - const period = document.getElementById('teamUsagePeriod')?.value || '30d'; - const groupBy = document.getElementById('teamUsageGroupBy')?.value || 'model'; - - totalsEl.innerHTML = '
Loading...
'; - resultsEl.innerHTML = ''; - - try { - const data = await API.teamGetUsage(teamId, { period, group_by: groupBy }); - const t = data.totals || {}; - - if (!t.requests) { - totalsEl.innerHTML = '
No usage recorded for team providers in this period
'; - return; - } - - totalsEl.innerHTML = ` -
-
${(t.requests || 0).toLocaleString()}
Requests
-
${(t.input_tokens || 0).toLocaleString()}
Input
-
${(t.output_tokens || 0).toLocaleString()}
Output
-
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
-
`; - - const results = data.results || []; - if (results.length > 0) { - resultsEl.innerHTML = ` - - - - - - - - - ${results.map(r => ` - - - - - - `).join('')} -
${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}ReqsInputOutputCost
${esc(r.label || r.group_key)}${(r.requests || 0).toLocaleString()}${(r.input_tokens || 0).toLocaleString()}${(r.output_tokens || 0).toLocaleString()}$${(r.total_cost || 0).toFixed(4)}
`; - } - } catch (e) { totalsEl.innerHTML = `
${esc(e.message)}
`; } + const el = document.getElementById('settingsTeamUsageTotals')?.parentElement; + if (!el) return; + if (!UI._teamUsageDash) { + UI._teamUsageDash = renderUsageDashboard(el, { + apiFetch: (p) => API.teamGetUsage(teamId, p), + periodElId: 'teamUsagePeriod', + groupByElId: 'teamUsageGroupBy', + compact: true, + showUserColumn: true, + }); + } + await UI._teamUsageDash.refresh(); }, _teamAuditPage: 1, @@ -364,8 +345,8 @@ Object.assign(UI, { if (tabBtn) tabBtn.style.display = byokAllowed ? '' : 'none'; if (!byokAllowed) return; + // Check if user has personal providers try { - // Get user's personal providers and their models const configs = await API.listConfigs(); const configList = configs.configs || configs.data || []; const personalProviders = configList.filter(c => c.scope === 'personal'); @@ -377,45 +358,12 @@ Object.assign(UI, { } el.style.display = ''; if (notice) notice.style.display = 'none'; - - // Load all models for personal providers - const allModels = App.models || []; - const personalModels = allModels.filter(m => - personalProviders.some(p => p.id === m.configId) - ); - - // Load current user settings to get existing role overrides - const settings = await API.getSettings(); - const userRoles = settings.model_roles || {}; - - const roleNames = ['utility', 'embedding']; - el.innerHTML = roleNames.map(role => { - const cfg = userRoles[role] || { primary: null }; - const typeForRole = role === 'embedding' ? 'embedding' : 'chat'; - const typeFilter = m => (m.model_type || 'chat') === typeForRole; - return ` -
-

${role} - ${role === 'utility' ? '(summarization, title gen)' : '(future: KB search, note search)'} -

-
- - - - - ${cfg.primary ? `` : ''} -
-
`; - }).join(''); } catch (e) { el.innerHTML = `
${esc(e.message)}
`; + return; } + + if (_userRoleConfig) await _userRoleConfig.refresh(); }, async loadTeamAuditLog(page) { @@ -532,38 +480,16 @@ Object.assign(UI, { // ── Providers ──────────────────────────── async loadProviderList() { + if (_userProvList) { await _userProvList.refresh(); return; } + // Fallback if primitives not yet initialized const el = document.getElementById('providerList'); el.innerHTML = '
Loading...
'; - try { - const data = await API.listConfigs(); - const list = Array.isArray(data) ? data : (data.configs || data.data || []); - if (list.length === 0) { - el.innerHTML = '
No providers configured.
'; - return; - } - el.innerHTML = list.map(c => ` -
-
- ${esc(c.name)} - ${esc(c.provider)} · ${esc(c.model_default || 'no default')} -
-
- ${c.has_key ? '🔑' : '⚠️'} - - - -
-
`).join(''); - } catch (e) { - el.innerHTML = `
${esc(e.message)}
`; - } }, showProviderForm() { document.getElementById('providerAddForm').style.display = ''; }, hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; - UI._editingProviderId = null; - document.getElementById('providerApiKey').placeholder = 'API key'; + if (_userProvForm) _userProvForm.setCreateMode(); }, // ── User Model List ───────────────────── @@ -592,11 +518,7 @@ Object.assign(UI, { el.innerHTML = models.map(m => { const mid = m.model_id || m.id; const caps = m.capabilities || {}; - const badges = []; - if (caps.max_output_tokens > 0) badges.push(`${(caps.max_output_tokens/1000).toFixed(0)}K`); - if (caps.tool_calling) badges.push('🔧'); - if (caps.vision) badges.push('👁'); - if (caps.thinking) badges.push('💭'); + const badges = renderCapBadges(caps, { compact: true }); const src = m.source === 'personal' ? 'personal' : m.source === 'team' ? `👥 ${esc(m.team_name || 'team')}` : ''; const hidden = App.hiddenModels.has(mid); @@ -604,7 +526,7 @@ Object.assign(UI, { const toggleLabel = hidden ? 'Hidden' : '✓ Visible'; return `
${esc(mid)} - ${badges.join('')} + ${badges} ${esc(m.provider_name || m.provider || '')} ${src}