Changeset 0.10.5 (#61)

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

View File

@@ -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 = '<option value="">— select model —</option>';
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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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');

View File

@@ -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 = '<option value="">— select model —</option>';
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 += `<option value="${m.baseModelId}">${esc(m.name || m.baseModelId)}</option>`;
_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

View File

@@ -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 = '<div class="loading">Loading...</div>';
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 `
<div class="settings-section" style="margin-bottom:16px">
<h3 style="font-size:14px;margin:0 0 8px;text-transform:capitalize">${role}</h3>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Primary</label>
<select id="role-${role}-primary-provider" onchange="adminRoleProviderChanged('${role}','primary')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-primary-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.primary ? modelList.filter(m => m.provider_config_id === cfg.primary.provider_config_id && typeFilter(m)).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.primary.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;align-items:center;margin-top:6px">
<label style="min-width:60px;font-size:12px">Fallback</label>
<select id="role-${role}-fallback-provider" onchange="adminRoleProviderChanged('${role}','fallback')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.fallback?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-fallback-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.fallback ? modelList.filter(m => m.provider_config_id === cfg.fallback.provider_config_id && typeFilter(m)).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.fallback.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-primary btn-small" onclick="adminSaveRole('${role}')">Save</button>
<button class="btn-secondary btn-small" onclick="adminTestRole('${role}')">Test</button>
<span id="role-${role}-status" style="font-size:12px"></span>
</div>
</div>`;
}).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 = `<div class="error-hint">${esc(e.message)}</div>`; }
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 = '<div class="loading">Loading...</div>';
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 = `
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${(t.requests || 0).toLocaleString()}</div><div class="stat-label">Requests</div></div>
<div class="stat-card"><div class="stat-value">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label">Input Tokens</div></div>
<div class="stat-card"><div class="stat-value">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label">Output Tokens</div></div>
<div class="stat-card"><div class="stat-value">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label">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>';
} else {
resultsEl.innerHTML = `
<table class="admin-table" style="margin-top:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Requests</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<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>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// Load pricing table
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// 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 = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : ''}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}
},
_auditPage: 1,
@@ -342,24 +304,67 @@ Object.assign(UI, {
async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
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 `<div class="admin-provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
${c.is_private ? '<span class="badge-private">private</span>' : ''}
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No global providers — add one above</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
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(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
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 `<div class="admin-model-row">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-caps-inline">${badges}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
</div>`;

View File

@@ -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(`<span class="cap-badge cap-context" title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens">${k} out</span>`);
}
// Context window
if (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
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent" title="Supports tool/function calling">🔧 tools</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent" title="Supports image/vision input">👁 vision</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent" title="Supports extended thinking">💭 thinking</span>');
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent" title="Reasoning model (chain-of-thought)">🧠 reasoning</span>');
if (caps.code_optimized) badges.push('<span class="cap-badge" title="Optimized for code generation">⟨/⟩ code</span>');
if (caps.web_search) badges.push('<span class="cap-badge" title="Supports web search">🔍 search</span>');
el.innerHTML = badges.join('');
el.innerHTML = renderCapBadges(caps);
},
// ── User / Connection ────────────────────

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

@@ -0,0 +1,729 @@
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
// Single source of truth for constants + reusable rendering functions.
// Loaded after ui-format.js, before ui-core.js.
// Follows the renderPresetForm() pattern: (container, options) → control object.
// Designed for extension hooks: registries are open for .add(), primitives
// accept options for customization, and return handles for programmatic control.
// ─────────────────────────────────────────────────────────────────────────────
'use strict';
// ══════════════════════════════════════════════════════════════════════════════
// §1 PROVIDER REGISTRY
// ══════════════════════════════════════════════════════════════════════════════
const Providers = (() => {
const _types = [
{ id: 'openai', label: 'OpenAI-compatible', endpoint: 'https://api.openai.com/v1' },
{ id: 'anthropic', label: 'Anthropic', endpoint: 'https://api.anthropic.com' },
{ id: 'venice', label: 'Venice.ai', endpoint: 'https://api.venice.ai/api/v1' },
{ id: 'openrouter', label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1' },
];
return {
/** Register a new provider type (extension hook). */
add(entry) {
if (!entry?.id) throw new Error('Provider entry requires id');
if (_types.some(t => t.id === entry.id)) return; // no dupes
_types.push(entry);
},
/** All registered types. */
all() { return _types; },
/** Lookup by id. */
get(id) { return _types.find(t => t.id === id) || null; },
/** Map of id → label. */
labels() { return Object.fromEntries(_types.map(t => [t.id, t.label])); },
/** Map of id → default endpoint. */
endpoints() { return Object.fromEntries(_types.filter(t => t.endpoint).map(t => [t.id, t.endpoint])); },
/** Build <option> tags for a <select>. */
optionsHTML(selected) {
return _types.map(t =>
`<option value="${t.id}"${t.id === selected ? ' selected' : ''}>${esc(t.label)}</option>`
).join('');
},
};
})();
// ══════════════════════════════════════════════════════════════════════════════
// §2 ROLE REGISTRY
// ══════════════════════════════════════════════════════════════════════════════
const Roles = (() => {
const _roles = [
{ id: 'utility', label: 'Utility', typeFilter: 'chat', hint: 'summarization, title gen' },
{ id: 'embedding', label: 'Embedding', typeFilter: 'embedding', hint: 'future: KB search, note search' },
];
return {
/** Register a new role (extension hook). */
add(entry) {
if (!entry?.id) throw new Error('Role entry requires id');
if (_roles.some(r => r.id === entry.id)) return;
_roles.push(entry);
},
all() { return _roles; },
get(id) { return _roles.find(r => r.id === id) || null; },
/** Model type filter string for a given role. */
typeFor(roleId) {
const r = _roles.find(r => r.id === roleId);
return r ? r.typeFilter : 'chat';
},
};
})();
// ══════════════════════════════════════════════════════════════════════════════
// §3 CAPABILITY BADGES
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render capability badge HTML from a capabilities object.
*
* @param {Object} caps - Capabilities object (max_output_tokens, vision, etc.)
* @param {Object} [opts]
* @param {boolean} [opts.compact=false] - Compact mode (icons only, no labels/titles)
* @param {Array} [opts.extra] - Additional badge entries [{cap, icon, label, title, cssClass}]
* @returns {string} HTML string of badge spans
*/
function renderCapBadges(caps, opts = {}) {
if (!caps) return '';
const compact = opts.compact === true;
const badges = [];
// ── Token counts ──
if (caps.max_output_tokens > 0) {
const k = caps.max_output_tokens >= 1000
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
: String(caps.max_output_tokens);
const label = compact ? k : k + ' out';
const title = compact ? '' : ` title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens"`;
badges.push(`<span class="cap-badge cap-context"${title}>${label}</span>`);
}
if (!compact && caps.max_context > 0) {
const k = (caps.max_context / 1000).toFixed(0) + 'K';
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
}
// ── Capability flags (registry-driven for extensibility) ──
const flags = [
{ cap: 'tool_calling', icon: '🔧', label: 'tools', title: 'Supports tool/function calling', css: 'cap-accent' },
{ cap: 'vision', icon: '👁', label: 'vision', title: 'Supports image/vision input', css: 'cap-accent' },
{ cap: 'thinking', icon: '💭', label: 'thinking', title: 'Supports extended thinking', css: 'cap-accent' },
{ cap: 'reasoning', icon: '🧠', label: 'reasoning', title: 'Reasoning model (chain-of-thought)', css: 'cap-accent' },
{ cap: 'code_optimized', icon: '⟨/⟩', label: 'code', title: 'Optimized for code generation', css: '' },
{ cap: 'web_search', icon: '🔍', label: 'search', title: 'Supports web search', css: '' },
];
for (const f of flags) {
if (!caps[f.cap]) continue;
const text = compact ? f.icon : `${f.icon} ${f.label}`;
const cls = f.css ? `cap-badge ${f.css}` : 'cap-badge';
const title = compact ? '' : ` title="${f.title}"`;
badges.push(`<span class="${cls}"${title}>${text}</span>`);
}
// Extension-provided badges
if (opts.extra) {
for (const e of opts.extra) {
if (e.cap && !caps[e.cap]) continue;
const cls = e.cssClass || 'cap-badge';
const title = e.title && !compact ? ` title="${esc(e.title)}"` : '';
const text = compact ? (e.icon || '') : `${e.icon || ''} ${e.label || ''}`.trim();
badges.push(`<span class="${cls}"${title}>${text}</span>`);
}
}
return badges.join('');
}
// ══════════════════════════════════════════════════════════════════════════════
// §4 PROVIDER FORM
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render a provider create/edit form into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} [opts.prefix='prov'] - ID prefix for form elements
* @param {boolean} [opts.showPrivate=false] - Show "private provider" checkbox
* @param {boolean} [opts.showDefaultModel=true] - Show default model field
* @param {Function} [opts.onSubmit] - Called with (values, isEdit) on save
* @param {Function} [opts.onCancel] - Called on cancel
* @returns {{ getValues, setValues, clear, setEditMode, container }}
*/
function renderProviderForm(containerEl, opts = {}) {
const pfx = opts.prefix || 'prov';
const showPrivate = opts.showPrivate === true;
const showModel = opts.showDefaultModel !== false;
containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="My Provider"></div>
<div class="form-group"><label>Provider</label><select id="${pfx}_type">${Providers.optionsHTML()}</select></div>
<div class="form-group"><label>Endpoint</label><input type="text" id="${pfx}_endpoint" placeholder="https://api.openai.com/v1"></div>
<div class="form-group"><label>API Key</label><input type="password" id="${pfx}_key" placeholder="sk-..."></div>
${showModel ? `<div class="form-group"><label>Default Model</label><input type="text" id="${pfx}_model" placeholder="gpt-4o"></div>` : ''}
${showPrivate ? `<div style="margin:6px 0"><label class="checkbox-label"><input type="checkbox" id="${pfx}_private"> Private provider (data stays on-prem)</label></div>` : ''}
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Save</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
</div>
`;
// ── Endpoint auto-fill on type change ──
const typeSelect = document.getElementById(`${pfx}_type`);
const endpointInput = document.getElementById(`${pfx}_endpoint`);
const endpoints = Providers.endpoints();
typeSelect?.addEventListener('change', function () {
const epVal = endpointInput?.value || '';
if (!epVal || Object.values(endpoints).includes(epVal)) {
endpointInput.value = endpoints[this.value] || '';
}
});
// ── Submit / Cancel wiring ──
const submitBtn = document.getElementById(`${pfx}_submitBtn`);
const cancelBtn = document.getElementById(`${pfx}_cancelBtn`);
submitBtn?.addEventListener('click', () => {
if (opts.onSubmit) opts.onSubmit(form.getValues(), !!form._editId);
});
cancelBtn?.addEventListener('click', () => {
if (opts.onCancel) opts.onCancel();
});
const form = {
_editId: null,
container: containerEl,
getValues() {
const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
provider: document.getElementById(`${pfx}_type`)?.value || 'openai',
endpoint: document.getElementById(`${pfx}_endpoint`)?.value.trim() || '',
api_key: document.getElementById(`${pfx}_key`)?.value || '',
};
if (showModel) {
v.model_default = document.getElementById(`${pfx}_model`)?.value.trim() || '';
}
if (showPrivate) {
v.is_private = document.getElementById(`${pfx}_private`)?.checked || false;
}
if (form._editId) v._editId = form._editId;
return v;
},
setValues(cfg) {
const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = cfg.name || '';
if (el('type')) el('type').value = cfg.provider || 'openai';
if (el('endpoint')) el('endpoint').value = cfg.endpoint || '';
if (el('key')) {
el('key').value = '';
el('key').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'sk-...';
}
if (showModel && el('model')) el('model').value = cfg.model_default || '';
if (showPrivate && el('private')) el('private').checked = !!cfg.is_private;
},
clear() {
['name', 'endpoint', 'key'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
if (showModel) {
const m = document.getElementById(`${pfx}_model`);
if (m) m.value = '';
}
if (showPrivate) {
const p = document.getElementById(`${pfx}_private`);
if (p) p.checked = false;
}
const typeSel = document.getElementById(`${pfx}_type`);
if (typeSel) typeSel.selectedIndex = 0;
const keyEl = document.getElementById(`${pfx}_key`);
if (keyEl) keyEl.placeholder = 'sk-...';
form._editId = null;
if (submitBtn) submitBtn.textContent = 'Save';
},
/** Switch form into edit mode for a given provider id. */
setEditMode(id, cfg) {
form._editId = id;
form.setValues(cfg);
if (submitBtn) submitBtn.textContent = 'Update';
},
/** Back to create mode. */
setCreateMode() {
form.clear();
},
/** Get the type select element (for extensions to hook into). */
getTypeSelect() { return typeSelect; },
};
return form;
}
// ══════════════════════════════════════════════════════════════════════════════
// §5 PROVIDER LIST
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render a provider list into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {Function} opts.apiFetch - async () → provider array
* @param {Function} [opts.onEdit] - (provider) → void
* @param {Function} [opts.onDelete] - (provider) → void
* @param {Function} [opts.onRefresh] - (provider) → void (refresh models btn)
* @param {Function} [opts.onToggleActive] - (provider) → void (active/inactive toggle)
* @param {boolean} [opts.showEndpoint=false]
* @param {boolean} [opts.showPrivate=false]
* @param {boolean} [opts.showActive=false]
* @param {boolean} [opts.showRefresh=false]
* @param {string} [opts.emptyMsg]
* @returns {{ refresh, getCache }}
*/
function renderProviderList(containerEl, opts = {}) {
const cache = {};
async function refresh(quiet) {
if (!quiet) containerEl.innerHTML = '<div class="loading">Loading...</div>';
try {
const raw = await opts.apiFetch();
// Normalize — APIs return different shapes
const list = Array.isArray(raw) ? raw : (raw.configs || raw.providers || raw.data || []);
// Clear and rebuild cache
Object.keys(cache).forEach(k => delete cache[k]);
list.forEach(c => { cache[c.id] = c; });
if (list.length === 0) {
containerEl.innerHTML = `<div class="empty-hint">${esc(opts.emptyMsg || 'No providers configured.')}</div>`;
return;
}
containerEl.innerHTML = list.map(c => {
const meta = [];
meta.push(esc(c.provider || ''));
if (c.has_key != null) meta.push(c.has_key ? '🔑' : '⚠️ no key');
if (opts.showEndpoint && c.endpoint) meta.push(esc(c.endpoint));
if (!opts.showEndpoint && c.model_default) meta.push(esc(c.model_default));
const badges = [];
if (opts.showPrivate && c.is_private)
badges.push('<span class="badge-private">private</span>');
if (opts.showActive) {
const active = c.is_active !== false;
badges.push(`<span class="${active ? 'badge-admin' : 'badge-user'}" style="font-size:10px">${active ? 'active' : 'inactive'}</span>`);
}
const actions = [];
if (opts.onRefresh)
actions.push(`<button class="btn-small" data-action="refresh" data-id="${c.id}">Refresh Models</button>`);
if (opts.onEdit)
actions.push(`<button class="btn-edit" data-action="edit" data-id="${c.id}" title="Edit">✎</button>`);
if (opts.onToggleActive) {
const active = c.is_active !== false;
actions.push(`<button class="admin-model-toggle ${active ? 'enabled' : ''}" data-action="toggle" data-id="${c.id}">${active ? '✓ Active' : 'Inactive'}</button>`);
}
if (opts.onDelete)
actions.push(`<button class="btn-delete" data-action="delete" data-id="${c.id}" title="Delete">✕</button>`);
return `<div class="provider-row" data-provider-id="${c.id}">
<div>
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${meta.join(' · ')}</span>
${badges.join(' ')}
</div>
<div class="provider-actions">${actions.join('')}</div>
</div>`;
}).join('');
} catch (e) {
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}
// ── Delegate clicks ──
containerEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const id = btn.dataset.id;
const provider = cache[id];
if (!provider) return;
switch (btn.dataset.action) {
case 'edit': opts.onEdit?.(provider); break;
case 'delete': opts.onDelete?.(provider); break;
case 'refresh': opts.onRefresh?.(provider); break;
case 'toggle': opts.onToggleActive?.(provider); break;
}
});
return { refresh, getCache: () => ({ ...cache }) };
}
// ══════════════════════════════════════════════════════════════════════════════
// §6 ROLE CONFIG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render role configuration (provider + model dropdowns per role).
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {string} opts.scope - 'admin' | 'personal'
* @param {Function} opts.fetchProviders - async () → provider array
* @param {Function} opts.fetchModels - async () → model array
* @param {Function} opts.fetchRoles - async () → { utility: {primary, fallback}, ... }
* @param {Function} opts.onSave - async (roleId, config) → void
* @param {Function} [opts.onClear] - async (roleId) → void (personal only)
* @param {Function} [opts.onTest] - async (roleId) → result (admin only)
* @param {boolean} [opts.showFallback=false]
* @param {string} [opts.modelIdField='model_id'] - field name for model ID on model objects
* @param {string} [opts.modelNameField='display_name'] - field for model display name
* @param {string} [opts.providerIdField='provider_config_id'] - field on models for their provider
* @param {string} [opts.noneLabel='— none —']
* @returns {{ refresh }}
*/
function renderRoleConfig(containerEl, opts = {}) {
const scope = opts.scope || 'admin';
const showFallback = opts.showFallback === true;
const midField = opts.modelIdField || 'model_id';
const mnameField = opts.modelNameField || 'display_name';
const pidField = opts.providerIdField || 'provider_config_id';
const noneLabel = opts.noneLabel || '— none —';
// Cached data for provider-change handler
let _providers = [];
let _models = [];
let _roles = {};
function filterModels(providerId, roleId) {
const typeFilter = Roles.typeFor(roleId);
return _models.filter(m =>
m[pidField] === providerId &&
(m.model_type || 'chat') === typeFilter
);
}
function onProviderChange(roleId, slot) {
const provSel = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`);
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
if (!provSel || !modelSel) return;
modelSel.innerHTML = '<option value="">— select model —</option>';
const provId = provSel.value;
if (!provId) return;
filterModels(provId, roleId).forEach(m => {
const opt = document.createElement('option');
opt.value = m[midField];
opt.textContent = m[mnameField] || m[midField];
modelSel.appendChild(opt);
});
}
function getBinding(roleId, slot) {
const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value;
const model = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`)?.value;
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
}
async function handleSave(roleId) {
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
try {
const config = { primary: getBinding(roleId, 'primary') };
if (showFallback) config.fallback = getBinding(roleId, 'fallback');
await opts.onSave(roleId, config);
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--success)'; }
// Reload after short delay so dropdowns reflect saved state
setTimeout(() => refresh(), 500);
} catch (e) {
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
}
}
async function handleTest(roleId) {
if (!opts.onTest) return;
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-muted)'; }
try {
const result = await opts.onTest(roleId);
if (statusEl) {
const fb = result.used_fallback ? ' (fallback)' : '';
statusEl.textContent = `${result.model}${fb}`;
statusEl.style.color = 'var(--success)';
}
} catch (e) {
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
}
}
async function handleClear(roleId) {
if (!opts.onClear) return;
try {
await opts.onClear(roleId);
refresh();
} catch (e) { UI.toast(e.message, 'error'); }
}
function renderSlot(roleId, slotName, cfg, slotLabel) {
const binding = cfg?.[slotName];
const selectedProv = binding?.provider_config_id || '';
const selectedModel = binding?.model_id || '';
const provOptions = _providers.map(p =>
`<option value="${p.id}"${p.id === selectedProv ? ' selected' : ''}>${esc(p.name)}</option>`
).join('');
const modelOptions = selectedProv
? filterModels(selectedProv, roleId).map(m =>
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
).join('')
: '';
return `
<div class="form-row" style="gap:8px;align-items:center${slotName === 'fallback' ? ';margin-top:6px' : ''}">
<label style="min-width:60px;font-size:12px">${slotLabel}</label>
<select data-role-provider="${roleId}-${slotName}" style="min-width:140px">
<option value="">${noneLabel}</option>
${provOptions}
</select>
<select data-role-model="${roleId}-${slotName}" style="min-width:200px">
<option value="">— select model —</option>
${modelOptions}
</select>
</div>`;
}
async function refresh() {
containerEl.innerHTML = '<div class="loading">Loading...</div>';
try {
const [provRaw, modelRaw, roleData] = await Promise.all([
opts.fetchProviders(),
opts.fetchModels(),
opts.fetchRoles(),
]);
// Normalize
_providers = Array.isArray(provRaw) ? provRaw : (provRaw.configs || provRaw.providers || provRaw.data || []);
_models = Array.isArray(modelRaw) ? modelRaw : (modelRaw.models || modelRaw.data || []);
_roles = roleData || {};
const roles = Roles.all();
containerEl.innerHTML = roles.map(role => {
const cfg = _roles[role.id] || { primary: null, fallback: null };
const hasClear = scope === 'personal' && opts.onClear && cfg.primary;
return `
<div class="settings-section" style="margin-bottom:${scope === 'admin' ? 16 : 12}px">
<${scope === 'admin' ? 'h3' : 'h4'} style="font-size:${scope === 'admin' ? 14 : 13}px;margin:0 0 ${scope === 'admin' ? 8 : 6}px;text-transform:capitalize">
${esc(role.label || role.id)}
${scope === 'personal' && role.hint ? `<span class="form-hint">(${esc(role.hint)})</span>` : ''}
</${scope === 'admin' ? 'h3' : 'h4'}>
${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')}
${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''}
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-primary btn-small" data-role-save="${role.id}">Save</button>
${opts.onTest ? `<button class="btn-secondary btn-small" data-role-test="${role.id}">Test</button>` : ''}
${hasClear ? `<button class="btn-small btn-danger" data-role-clear="${role.id}" title="Remove override, use org default">Clear</button>` : ''}
<span data-role-status="${role.id}" style="font-size:12px"></span>
</div>
</div>`;
}).join('');
} catch (e) {
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
}
// ── Delegate events ──
containerEl.addEventListener('change', (e) => {
const provSel = e.target.closest('[data-role-provider]');
if (!provSel) return;
const [roleId, slot] = provSel.dataset.roleProvider.split('-');
onProviderChange(roleId, slot);
});
containerEl.addEventListener('click', (e) => {
const saveBtn = e.target.closest('[data-role-save]');
if (saveBtn) { handleSave(saveBtn.dataset.roleSave); return; }
const testBtn = e.target.closest('[data-role-test]');
if (testBtn) { handleTest(testBtn.dataset.roleTest); return; }
const clearBtn = e.target.closest('[data-role-clear]');
if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; }
});
return { refresh };
}
// ══════════════════════════════════════════════════════════════════════════════
// §7 USAGE DASHBOARD
// ══════════════════════════════════════════════════════════════════════════════
/**
* Render usage stats + table into a container.
*
* @param {HTMLElement} containerEl
* @param {Object} opts
* @param {Function} opts.apiFetch - async ({period, group_by}) → { totals, results }
* @param {string} [opts.periodElId] - ID of period <select>
* @param {string} [opts.groupByElId] - ID of groupBy <select>
* @param {boolean} [opts.compact=false] - Use compact sizing (user/team)
* @param {boolean} [opts.showUserColumn=false] - Include "User" in group_by options
* @param {Array} [opts.extraColumns] - [{header, render: (row) → html}]
* @returns {{ refresh }}
*/
function renderUsageDashboard(containerEl, opts = {}) {
const compact = opts.compact === true;
const cardStyle = compact ? ' style="padding:8px"' : '';
const valueStyle = compact ? ' style="font-size:16px"' : '';
const labelStyle = compact ? ' style="font-size:10px"' : '';
const gridStyle = compact ? ' style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px"' : '';
const tableStyle = compact ? ' style="font-size:12px"' : ' style="margin-top:12px"';
// We render into two sub-zones
let totalsEl, resultsEl;
function ensureStructure() {
if (!containerEl.querySelector('[data-usage-totals]')) {
containerEl.innerHTML = `
<div data-usage-totals></div>
<div data-usage-results></div>
`;
}
totalsEl = containerEl.querySelector('[data-usage-totals]');
resultsEl = containerEl.querySelector('[data-usage-results]');
}
async function refresh() {
ensureStructure();
const period = opts.periodElId ? (document.getElementById(opts.periodElId)?.value || '30d') : '30d';
const groupBy = opts.groupByElId ? (document.getElementById(opts.groupByElId)?.value || 'model') : 'model';
totalsEl.innerHTML = compact
? '<div class="loading" style="font-size:12px">Loading...</div>'
: '<div class="loading">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await opts.apiFetch({ period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests && compact) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid"${gridStyle}>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.requests || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Requests</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Input${compact ? '' : ' Tokens'}</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Output${compact ? '' : ' Tokens'}</div></div>
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label"${labelStyle}>Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length === 0) {
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
return;
}
// Column header depends on groupBy
let firstCol;
if (groupBy === 'day') firstCol = 'Date';
else if (groupBy === 'user') firstCol = 'User';
else firstCol = 'Model';
const extraHeaders = (opts.extraColumns || []).map(c =>
`<th style="text-align:right">${esc(c.header)}</th>`
).join('');
resultsEl.innerHTML = `
<table class="admin-table"${tableStyle}>
<thead><tr>
<th>${firstCol}</th>
<th style="text-align:right">${compact ? 'Reqs' : 'Requests'}</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
${extraHeaders}
</tr></thead>
<tbody>${results.map(r => {
const extraCells = (opts.extraColumns || []).map(c =>
`<td style="text-align:right">${c.render(r)}</td>`
).join('');
return `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
${extraCells}
</tr>`;
}).join('')}</tbody>
</table>`;
} catch (e) {
totalsEl.innerHTML = `<div class="error-hint"${compact ? ' style="font-size:12px"' : ''}>${esc(e.message)}</div>`;
}
}
return { refresh };
}
// ══════════════════════════════════════════════════════════════════════════════
// §8 CONFIRM DIALOG
// ══════════════════════════════════════════════════════════════════════════════
/**
* Custom confirm dialog replacing native confirm().
* Returns a Promise<boolean>.
*
* @param {string} message
* @param {Object} [opts]
* @param {string} [opts.title='Confirm']
* @param {string} [opts.ok='Confirm']
* @param {string} [opts.cancel='Cancel']
* @param {boolean} [opts.danger=false] - Styles the confirm button as destructive
* @returns {Promise<boolean>}
*/
function showConfirm(message, opts = {}) {
return new Promise(resolve => {
const title = opts.title || 'Confirm';
const okText = opts.ok || 'Confirm';
const caText = opts.cancel || 'Cancel';
const danger = opts.danger !== false; // default true for destructive actions
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-dialog">
<div class="confirm-header">${esc(title)}</div>
<div class="confirm-body">${esc(message)}</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${esc(okText)}</button>
</div>
</div>`;
function close(result) {
overlay.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') close(false);
if (e.key === 'Enter') close(true);
}
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
overlay.querySelector('[data-action="cancel"]').focus();
});
}

View File

@@ -191,37 +191,92 @@ Object.assign(UI, {
async loadTeamManageProviders(teamId) {
const el = document.getElementById('settingsTeamProviders');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
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 = '<div class="empty-hint">Team provider management is disabled by your administrator</div>';
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 ? ' <span class="badge-user" style="font-size:9px">🔒 private</span>' : '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
<strong>${esc(p.name)}</strong>
<div class="preset-meta" style="font-size:11px">${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} <span class="${statusCls}" style="font-size:10px">${statusLabel}</span>${privateBadge}</div>
</div>
<div class="preset-actions">
<button class="btn-small" onclick="settingsEditTeamProvider('${teamId}','${p.id}','${esc(p.name)}','${esc(p.endpoint || '')}','${esc(p.model_default || '')}',${!!p.is_private})" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="settingsToggleTeamProvider('${teamId}','${p.id}',${p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="settingsDeleteTeamProvider('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No team providers — add one to give your team access to additional models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// 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 = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.getMyUsage({ period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : 'Model'}</th>
<th style="text-align:right">Reqs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<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>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
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 = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.teamGetUsage(teamId, { period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded for team providers in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Reqs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<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>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
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 `
<div class="settings-section" style="margin-bottom:12px">
<h4 style="font-size:13px;margin:0 0 6px;text-transform:capitalize">${role}
<span class="form-hint">${role === 'utility' ? '(summarization, title gen)' : '(future: KB search, note search)'}</span>
</h4>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Provider</label>
<select id="userRole-${role}-provider" onchange="userRoleProviderChanged('${role}')" style="min-width:140px;font-size:12px">
<option value="">— use org default —</option>
${personalProviders.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="userRole-${role}-model" style="min-width:200px;font-size:12px">
<option value="">— select model —</option>
${cfg.primary ? personalModels.filter(m => m.configId === cfg.primary.provider_config_id && typeFilter(m)).map(m => `<option value="${m.baseModelId}" ${m.baseModelId === cfg.primary.model_id ? 'selected' : ''}>${esc(m.name || m.baseModelId)}</option>`).join('') : ''}
</select>
<button class="btn-small" onclick="saveUserRole('${role}')" style="font-size:11px">Save</button>
${cfg.primary ? `<button class="btn-small btn-danger" onclick="clearUserRole('${role}')" style="font-size:11px" title="Remove override, use org default">Clear</button>` : ''}
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
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 = '<div class="loading">Loading...</div>';
try {
const data = await API.listConfigs();
const list = Array.isArray(data) ? data : (data.configs || data.data || []);
if (list.length === 0) {
el.innerHTML = '<div class="empty-hint">No providers configured.</div>';
return;
}
el.innerHTML = list.map(c => `
<div class="provider-row">
<div>
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span>
</div>
<div class="provider-actions">
${c.has_key ? '🔑' : '⚠️'}
<button class="btn-small" onclick="refreshProviderModels('${c.id}','${esc(c.name)}')">Refresh Models</button>
<button class="btn-small" onclick="editProvider('${c.id}')">Edit</button>
<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>
</div>
</div>`).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
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(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
const badges = renderCapBadges(caps, { compact: true });
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
const hidden = App.hiddenModels.has(mid);
@@ -604,7 +526,7 @@ Object.assign(UI, {
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
<span class="model-name">${esc(mid)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-caps-inline">${badges}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src}
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>