Changeset 0.10.5 (#61)
This commit is contained in:
@@ -101,10 +101,8 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.sb-brand:hover { background: var(--bg-hover); }
|
||||
.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; }
|
||||
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; transition: opacity var(--transition); }
|
||||
.brand-collapse { flex-shrink: 0; width: 0; overflow: hidden; color: var(--text-2); opacity: 0; transition: opacity var(--transition); position: absolute; left: 10px; }
|
||||
.sb-brand:hover .brand-logo-img { opacity: 0; }
|
||||
.sb-brand:hover .brand-collapse { opacity: 1; }
|
||||
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; }
|
||||
.brand-collapse { display: none; }
|
||||
.brand-text { font-weight: 600; font-size: 14px; }
|
||||
|
||||
.sb-btn {
|
||||
@@ -125,7 +123,6 @@ a:hover { text-decoration: underline; }
|
||||
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
|
||||
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
|
||||
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .brand-collapse { left: 50%; transform: translateX(-50%); }
|
||||
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
|
||||
@@ -1215,9 +1212,12 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.admin-tab {
|
||||
padding: 10px 14px; background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
white-space: nowrap; flex-shrink: 0; transition: color var(--transition);
|
||||
outline: none;
|
||||
}
|
||||
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.admin-tab:hover { color: var(--text-2); }
|
||||
.admin-tab:focus { color: var(--text-3); }
|
||||
.admin-tab.active, .admin-tab.active:focus { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; }
|
||||
.admin-hint { font-size: 12px; color: var(--text-3); }
|
||||
@@ -1669,6 +1669,59 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
}
|
||||
@keyframes pwa-banner-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } }
|
||||
|
||||
/* ── Confirm Dialog ──────────────────────── */
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.55);
|
||||
animation: fade-in 0.12s ease;
|
||||
}
|
||||
.confirm-dialog {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg); padding: 0;
|
||||
min-width: 320px; max-width: 440px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
animation: confirm-in 0.15s ease;
|
||||
}
|
||||
.confirm-header {
|
||||
padding: 16px 20px 0; font-weight: 600; font-size: 15px; color: var(--text);
|
||||
}
|
||||
.confirm-body {
|
||||
padding: 12px 20px 20px; font-size: 13px; color: var(--text-2);
|
||||
line-height: 1.5; white-space: pre-wrap;
|
||||
}
|
||||
.confirm-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
}
|
||||
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
|
||||
/* ── Popup Menu (shared base) ───────────── */
|
||||
|
||||
.popup-menu {
|
||||
display: none; position: absolute; z-index: 200;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius); padding: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
min-width: 160px;
|
||||
}
|
||||
.popup-menu.open { display: block; }
|
||||
.popup-menu-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; border-radius: var(--radius);
|
||||
background: none; border: none; color: var(--text-2);
|
||||
cursor: pointer; font-size: 13px; width: 100%;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.popup-menu-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.popup-menu-item.disabled { opacity: 0.4; pointer-events: none; }
|
||||
.popup-menu-danger:hover { color: var(--danger); }
|
||||
.popup-menu-icon { flex-shrink: 0; display: flex; align-items: center; }
|
||||
.popup-menu-hint { margin-left: auto; font-size: 11px; color: var(--text-3); }
|
||||
.popup-menu-divider { height: 1px; background: var(--border); margin: 4px 8px; }
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
.mobile-menu-btn {
|
||||
|
||||
@@ -404,12 +404,6 @@
|
||||
<div id="providerList"></div>
|
||||
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
|
||||
<div id="providerAddForm" style="display:none">
|
||||
<div class="form-group"><label>Name</label><input type="text" id="providerName" placeholder="My OpenAI Key"></div>
|
||||
<div class="form-group"><label>Provider</label><select id="providerType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
|
||||
<div class="form-group"><label>Endpoint</label><input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||
<div class="form-group"><label>API Key</label><input type="password" id="providerApiKey" placeholder="sk-..."></div>
|
||||
<div class="form-group"><label>Default Model</label><input type="text" id="providerDefaultModel" placeholder="gpt-4o"></div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="providerCreateBtn">Save</button><button class="btn-small" id="providerCancelBtn">Cancel</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="userProvidersDisabled" class="settings-notice" style="display:none">
|
||||
@@ -512,31 +506,7 @@
|
||||
<!-- Providers tab -->
|
||||
<div class="team-tab-content" id="teamTabProviders" style="display:none">
|
||||
<div id="settingsTeamProviders"></div>
|
||||
<div id="settingsTeamEditProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||
<div class="form-grid-2col">
|
||||
<div class="form-group"><label>Name</label><input id="teamProviderEditName"></div>
|
||||
<div class="form-group"><label>Endpoint</label><input id="teamProviderEditEndpoint"></div>
|
||||
<div class="form-group"><label>API Key</label><input id="teamProviderEditKey" type="password" placeholder="(unchanged)"></div>
|
||||
<div class="form-group"><label>Default Model</label><input id="teamProviderEditDefault" placeholder="optional"></div>
|
||||
</div>
|
||||
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderEditPrivate"> Private provider (data stays on-prem)</label></div>
|
||||
<div class="form-row">
|
||||
<button class="btn-small btn-primary" id="settingsTeamEditProviderSubmit">Update</button>
|
||||
<button class="btn-small" id="settingsTeamCancelEditProvider">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsTeamAddProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||
<div class="form-grid-2col">
|
||||
<div class="form-group"><label>Name</label><input id="teamProviderName" placeholder="e.g. Team OpenAI"></div>
|
||||
<div class="form-group"><label>Provider</label><select id="teamProviderType"></select></div>
|
||||
<div class="form-group"><label>Endpoint</label><input id="teamProviderEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||
<div class="form-group"><label>API Key</label><input id="teamProviderKey" type="password" placeholder="sk-..."></div>
|
||||
</div>
|
||||
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderPrivate"> Private provider (data stays on-prem)</label></div>
|
||||
<div class="form-row">
|
||||
<button class="btn-small btn-primary" id="settingsTeamAddProviderSubmit">Add Provider</button>
|
||||
<button class="btn-small" id="settingsTeamCancelProvider">Cancel</button>
|
||||
</div>
|
||||
<div id="settingsTeamProviderForm" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||
</div>
|
||||
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
|
||||
</div>
|
||||
@@ -621,17 +591,6 @@
|
||||
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
|
||||
</div>
|
||||
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input type="text" id="adminProvName" placeholder="OpenAI Production"></div>
|
||||
<div class="form-group"><label>Provider</label><select id="adminProvType"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="venice">Venice.ai</option><option value="openrouter">OpenRouter</option></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Endpoint</label><input type="text" id="adminProvEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>API Key</label><input type="password" id="adminProvKey" placeholder="sk-..."></div>
|
||||
<div class="form-group"><label>Default Model</label><input type="text" id="adminProvModel" placeholder="gpt-4o"></div>
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminProvPrivate"> Private provider (data stays on-prem)</label>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateProvBtn">Save</button><button class="btn-small" id="adminCancelProvBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminProviderList"></div>
|
||||
</div>
|
||||
@@ -863,6 +822,7 @@
|
||||
<script src="js/events.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/api.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-settings.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
729
src/js/ui-primitives.js
Normal file
@@ -0,0 +1,729 @@
|
||||
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
|
||||
// Single source of truth for constants + reusable rendering functions.
|
||||
// Loaded after ui-format.js, before ui-core.js.
|
||||
// Follows the renderPresetForm() pattern: (container, options) → control object.
|
||||
// Designed for extension hooks: registries are open for .add(), primitives
|
||||
// accept options for customization, and return handles for programmatic control.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
'use strict';
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §1 PROVIDER REGISTRY
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const Providers = (() => {
|
||||
const _types = [
|
||||
{ id: 'openai', label: 'OpenAI-compatible', endpoint: 'https://api.openai.com/v1' },
|
||||
{ id: 'anthropic', label: 'Anthropic', endpoint: 'https://api.anthropic.com' },
|
||||
{ id: 'venice', label: 'Venice.ai', endpoint: 'https://api.venice.ai/api/v1' },
|
||||
{ id: 'openrouter', label: 'OpenRouter', endpoint: 'https://openrouter.ai/api/v1' },
|
||||
];
|
||||
|
||||
return {
|
||||
/** Register a new provider type (extension hook). */
|
||||
add(entry) {
|
||||
if (!entry?.id) throw new Error('Provider entry requires id');
|
||||
if (_types.some(t => t.id === entry.id)) return; // no dupes
|
||||
_types.push(entry);
|
||||
},
|
||||
/** All registered types. */
|
||||
all() { return _types; },
|
||||
/** Lookup by id. */
|
||||
get(id) { return _types.find(t => t.id === id) || null; },
|
||||
/** Map of id → label. */
|
||||
labels() { return Object.fromEntries(_types.map(t => [t.id, t.label])); },
|
||||
/** Map of id → default endpoint. */
|
||||
endpoints() { return Object.fromEntries(_types.filter(t => t.endpoint).map(t => [t.id, t.endpoint])); },
|
||||
/** Build <option> tags for a <select>. */
|
||||
optionsHTML(selected) {
|
||||
return _types.map(t =>
|
||||
`<option value="${t.id}"${t.id === selected ? ' selected' : ''}>${esc(t.label)}</option>`
|
||||
).join('');
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §2 ROLE REGISTRY
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const Roles = (() => {
|
||||
const _roles = [
|
||||
{ id: 'utility', label: 'Utility', typeFilter: 'chat', hint: 'summarization, title gen' },
|
||||
{ id: 'embedding', label: 'Embedding', typeFilter: 'embedding', hint: 'future: KB search, note search' },
|
||||
];
|
||||
|
||||
return {
|
||||
/** Register a new role (extension hook). */
|
||||
add(entry) {
|
||||
if (!entry?.id) throw new Error('Role entry requires id');
|
||||
if (_roles.some(r => r.id === entry.id)) return;
|
||||
_roles.push(entry);
|
||||
},
|
||||
all() { return _roles; },
|
||||
get(id) { return _roles.find(r => r.id === id) || null; },
|
||||
/** Model type filter string for a given role. */
|
||||
typeFor(roleId) {
|
||||
const r = _roles.find(r => r.id === roleId);
|
||||
return r ? r.typeFilter : 'chat';
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §3 CAPABILITY BADGES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Render capability badge HTML from a capabilities object.
|
||||
*
|
||||
* @param {Object} caps - Capabilities object (max_output_tokens, vision, etc.)
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.compact=false] - Compact mode (icons only, no labels/titles)
|
||||
* @param {Array} [opts.extra] - Additional badge entries [{cap, icon, label, title, cssClass}]
|
||||
* @returns {string} HTML string of badge spans
|
||||
*/
|
||||
function renderCapBadges(caps, opts = {}) {
|
||||
if (!caps) return '';
|
||||
const compact = opts.compact === true;
|
||||
const badges = [];
|
||||
|
||||
// ── Token counts ──
|
||||
if (caps.max_output_tokens > 0) {
|
||||
const k = caps.max_output_tokens >= 1000
|
||||
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
|
||||
: String(caps.max_output_tokens);
|
||||
const label = compact ? k : k + ' out';
|
||||
const title = compact ? '' : ` title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens"`;
|
||||
badges.push(`<span class="cap-badge cap-context"${title}>${label}</span>`);
|
||||
}
|
||||
if (!compact && caps.max_context > 0) {
|
||||
const k = (caps.max_context / 1000).toFixed(0) + 'K';
|
||||
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
|
||||
}
|
||||
|
||||
// ── Capability flags (registry-driven for extensibility) ──
|
||||
const flags = [
|
||||
{ cap: 'tool_calling', icon: '🔧', label: 'tools', title: 'Supports tool/function calling', css: 'cap-accent' },
|
||||
{ cap: 'vision', icon: '👁', label: 'vision', title: 'Supports image/vision input', css: 'cap-accent' },
|
||||
{ cap: 'thinking', icon: '💭', label: 'thinking', title: 'Supports extended thinking', css: 'cap-accent' },
|
||||
{ cap: 'reasoning', icon: '🧠', label: 'reasoning', title: 'Reasoning model (chain-of-thought)', css: 'cap-accent' },
|
||||
{ cap: 'code_optimized', icon: '⟨/⟩', label: 'code', title: 'Optimized for code generation', css: '' },
|
||||
{ cap: 'web_search', icon: '🔍', label: 'search', title: 'Supports web search', css: '' },
|
||||
];
|
||||
|
||||
for (const f of flags) {
|
||||
if (!caps[f.cap]) continue;
|
||||
const text = compact ? f.icon : `${f.icon} ${f.label}`;
|
||||
const cls = f.css ? `cap-badge ${f.css}` : 'cap-badge';
|
||||
const title = compact ? '' : ` title="${f.title}"`;
|
||||
badges.push(`<span class="${cls}"${title}>${text}</span>`);
|
||||
}
|
||||
|
||||
// Extension-provided badges
|
||||
if (opts.extra) {
|
||||
for (const e of opts.extra) {
|
||||
if (e.cap && !caps[e.cap]) continue;
|
||||
const cls = e.cssClass || 'cap-badge';
|
||||
const title = e.title && !compact ? ` title="${esc(e.title)}"` : '';
|
||||
const text = compact ? (e.icon || '') : `${e.icon || ''} ${e.label || ''}`.trim();
|
||||
badges.push(`<span class="${cls}"${title}>${text}</span>`);
|
||||
}
|
||||
}
|
||||
|
||||
return badges.join('');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §4 PROVIDER FORM
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Render a provider create/edit form into a container.
|
||||
*
|
||||
* @param {HTMLElement} containerEl
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.prefix='prov'] - ID prefix for form elements
|
||||
* @param {boolean} [opts.showPrivate=false] - Show "private provider" checkbox
|
||||
* @param {boolean} [opts.showDefaultModel=true] - Show default model field
|
||||
* @param {Function} [opts.onSubmit] - Called with (values, isEdit) on save
|
||||
* @param {Function} [opts.onCancel] - Called on cancel
|
||||
* @returns {{ getValues, setValues, clear, setEditMode, container }}
|
||||
*/
|
||||
function renderProviderForm(containerEl, opts = {}) {
|
||||
const pfx = opts.prefix || 'prov';
|
||||
const showPrivate = opts.showPrivate === true;
|
||||
const showModel = opts.showDefaultModel !== false;
|
||||
|
||||
containerEl.innerHTML = `
|
||||
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="My Provider"></div>
|
||||
<div class="form-group"><label>Provider</label><select id="${pfx}_type">${Providers.optionsHTML()}</select></div>
|
||||
<div class="form-group"><label>Endpoint</label><input type="text" id="${pfx}_endpoint" placeholder="https://api.openai.com/v1"></div>
|
||||
<div class="form-group"><label>API Key</label><input type="password" id="${pfx}_key" placeholder="sk-..."></div>
|
||||
${showModel ? `<div class="form-group"><label>Default Model</label><input type="text" id="${pfx}_model" placeholder="gpt-4o"></div>` : ''}
|
||||
${showPrivate ? `<div style="margin:6px 0"><label class="checkbox-label"><input type="checkbox" id="${pfx}_private"> Private provider (data stays on-prem)</label></div>` : ''}
|
||||
<div class="form-row">
|
||||
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Save</button>
|
||||
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── Endpoint auto-fill on type change ──
|
||||
const typeSelect = document.getElementById(`${pfx}_type`);
|
||||
const endpointInput = document.getElementById(`${pfx}_endpoint`);
|
||||
const endpoints = Providers.endpoints();
|
||||
typeSelect?.addEventListener('change', function () {
|
||||
const epVal = endpointInput?.value || '';
|
||||
if (!epVal || Object.values(endpoints).includes(epVal)) {
|
||||
endpointInput.value = endpoints[this.value] || '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Submit / Cancel wiring ──
|
||||
const submitBtn = document.getElementById(`${pfx}_submitBtn`);
|
||||
const cancelBtn = document.getElementById(`${pfx}_cancelBtn`);
|
||||
submitBtn?.addEventListener('click', () => {
|
||||
if (opts.onSubmit) opts.onSubmit(form.getValues(), !!form._editId);
|
||||
});
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
if (opts.onCancel) opts.onCancel();
|
||||
});
|
||||
|
||||
const form = {
|
||||
_editId: null,
|
||||
container: containerEl,
|
||||
|
||||
getValues() {
|
||||
const v = {
|
||||
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
|
||||
provider: document.getElementById(`${pfx}_type`)?.value || 'openai',
|
||||
endpoint: document.getElementById(`${pfx}_endpoint`)?.value.trim() || '',
|
||||
api_key: document.getElementById(`${pfx}_key`)?.value || '',
|
||||
};
|
||||
if (showModel) {
|
||||
v.model_default = document.getElementById(`${pfx}_model`)?.value.trim() || '';
|
||||
}
|
||||
if (showPrivate) {
|
||||
v.is_private = document.getElementById(`${pfx}_private`)?.checked || false;
|
||||
}
|
||||
if (form._editId) v._editId = form._editId;
|
||||
return v;
|
||||
},
|
||||
|
||||
setValues(cfg) {
|
||||
const el = id => document.getElementById(`${pfx}_${id}`);
|
||||
if (el('name')) el('name').value = cfg.name || '';
|
||||
if (el('type')) el('type').value = cfg.provider || 'openai';
|
||||
if (el('endpoint')) el('endpoint').value = cfg.endpoint || '';
|
||||
if (el('key')) {
|
||||
el('key').value = '';
|
||||
el('key').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'sk-...';
|
||||
}
|
||||
if (showModel && el('model')) el('model').value = cfg.model_default || '';
|
||||
if (showPrivate && el('private')) el('private').checked = !!cfg.is_private;
|
||||
},
|
||||
|
||||
clear() {
|
||||
['name', 'endpoint', 'key'].forEach(f => {
|
||||
const el = document.getElementById(`${pfx}_${f}`);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
if (showModel) {
|
||||
const m = document.getElementById(`${pfx}_model`);
|
||||
if (m) m.value = '';
|
||||
}
|
||||
if (showPrivate) {
|
||||
const p = document.getElementById(`${pfx}_private`);
|
||||
if (p) p.checked = false;
|
||||
}
|
||||
const typeSel = document.getElementById(`${pfx}_type`);
|
||||
if (typeSel) typeSel.selectedIndex = 0;
|
||||
const keyEl = document.getElementById(`${pfx}_key`);
|
||||
if (keyEl) keyEl.placeholder = 'sk-...';
|
||||
form._editId = null;
|
||||
if (submitBtn) submitBtn.textContent = 'Save';
|
||||
},
|
||||
|
||||
/** Switch form into edit mode for a given provider id. */
|
||||
setEditMode(id, cfg) {
|
||||
form._editId = id;
|
||||
form.setValues(cfg);
|
||||
if (submitBtn) submitBtn.textContent = 'Update';
|
||||
},
|
||||
|
||||
/** Back to create mode. */
|
||||
setCreateMode() {
|
||||
form.clear();
|
||||
},
|
||||
|
||||
/** Get the type select element (for extensions to hook into). */
|
||||
getTypeSelect() { return typeSelect; },
|
||||
};
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §5 PROVIDER LIST
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Render a provider list into a container.
|
||||
*
|
||||
* @param {HTMLElement} containerEl
|
||||
* @param {Object} opts
|
||||
* @param {Function} opts.apiFetch - async () → provider array
|
||||
* @param {Function} [opts.onEdit] - (provider) → void
|
||||
* @param {Function} [opts.onDelete] - (provider) → void
|
||||
* @param {Function} [opts.onRefresh] - (provider) → void (refresh models btn)
|
||||
* @param {Function} [opts.onToggleActive] - (provider) → void (active/inactive toggle)
|
||||
* @param {boolean} [opts.showEndpoint=false]
|
||||
* @param {boolean} [opts.showPrivate=false]
|
||||
* @param {boolean} [opts.showActive=false]
|
||||
* @param {boolean} [opts.showRefresh=false]
|
||||
* @param {string} [opts.emptyMsg]
|
||||
* @returns {{ refresh, getCache }}
|
||||
*/
|
||||
function renderProviderList(containerEl, opts = {}) {
|
||||
const cache = {};
|
||||
|
||||
async function refresh(quiet) {
|
||||
if (!quiet) containerEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const raw = await opts.apiFetch();
|
||||
// Normalize — APIs return different shapes
|
||||
const list = Array.isArray(raw) ? raw : (raw.configs || raw.providers || raw.data || []);
|
||||
|
||||
// Clear and rebuild cache
|
||||
Object.keys(cache).forEach(k => delete cache[k]);
|
||||
list.forEach(c => { cache[c.id] = c; });
|
||||
|
||||
if (list.length === 0) {
|
||||
containerEl.innerHTML = `<div class="empty-hint">${esc(opts.emptyMsg || 'No providers configured.')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
containerEl.innerHTML = list.map(c => {
|
||||
const meta = [];
|
||||
meta.push(esc(c.provider || ''));
|
||||
if (c.has_key != null) meta.push(c.has_key ? '🔑' : '⚠️ no key');
|
||||
if (opts.showEndpoint && c.endpoint) meta.push(esc(c.endpoint));
|
||||
if (!opts.showEndpoint && c.model_default) meta.push(esc(c.model_default));
|
||||
|
||||
const badges = [];
|
||||
if (opts.showPrivate && c.is_private)
|
||||
badges.push('<span class="badge-private">private</span>');
|
||||
if (opts.showActive) {
|
||||
const active = c.is_active !== false;
|
||||
badges.push(`<span class="${active ? 'badge-admin' : 'badge-user'}" style="font-size:10px">${active ? 'active' : 'inactive'}</span>`);
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (opts.onRefresh)
|
||||
actions.push(`<button class="btn-small" data-action="refresh" data-id="${c.id}">Refresh Models</button>`);
|
||||
if (opts.onEdit)
|
||||
actions.push(`<button class="btn-edit" data-action="edit" data-id="${c.id}" title="Edit">✎</button>`);
|
||||
if (opts.onToggleActive) {
|
||||
const active = c.is_active !== false;
|
||||
actions.push(`<button class="admin-model-toggle ${active ? 'enabled' : ''}" data-action="toggle" data-id="${c.id}">${active ? '✓ Active' : 'Inactive'}</button>`);
|
||||
}
|
||||
if (opts.onDelete)
|
||||
actions.push(`<button class="btn-delete" data-action="delete" data-id="${c.id}" title="Delete">✕</button>`);
|
||||
|
||||
return `<div class="provider-row" data-provider-id="${c.id}">
|
||||
<div>
|
||||
<span class="provider-name">${esc(c.name)}</span>
|
||||
<span class="provider-meta">${meta.join(' · ')}</span>
|
||||
${badges.join(' ')}
|
||||
</div>
|
||||
<div class="provider-actions">${actions.join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
} catch (e) {
|
||||
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delegate clicks ──
|
||||
containerEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const id = btn.dataset.id;
|
||||
const provider = cache[id];
|
||||
if (!provider) return;
|
||||
switch (btn.dataset.action) {
|
||||
case 'edit': opts.onEdit?.(provider); break;
|
||||
case 'delete': opts.onDelete?.(provider); break;
|
||||
case 'refresh': opts.onRefresh?.(provider); break;
|
||||
case 'toggle': opts.onToggleActive?.(provider); break;
|
||||
}
|
||||
});
|
||||
|
||||
return { refresh, getCache: () => ({ ...cache }) };
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §6 ROLE CONFIG
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Render role configuration (provider + model dropdowns per role).
|
||||
*
|
||||
* @param {HTMLElement} containerEl
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.scope - 'admin' | 'personal'
|
||||
* @param {Function} opts.fetchProviders - async () → provider array
|
||||
* @param {Function} opts.fetchModels - async () → model array
|
||||
* @param {Function} opts.fetchRoles - async () → { utility: {primary, fallback}, ... }
|
||||
* @param {Function} opts.onSave - async (roleId, config) → void
|
||||
* @param {Function} [opts.onClear] - async (roleId) → void (personal only)
|
||||
* @param {Function} [opts.onTest] - async (roleId) → result (admin only)
|
||||
* @param {boolean} [opts.showFallback=false]
|
||||
* @param {string} [opts.modelIdField='model_id'] - field name for model ID on model objects
|
||||
* @param {string} [opts.modelNameField='display_name'] - field for model display name
|
||||
* @param {string} [opts.providerIdField='provider_config_id'] - field on models for their provider
|
||||
* @param {string} [opts.noneLabel='— none —']
|
||||
* @returns {{ refresh }}
|
||||
*/
|
||||
function renderRoleConfig(containerEl, opts = {}) {
|
||||
const scope = opts.scope || 'admin';
|
||||
const showFallback = opts.showFallback === true;
|
||||
const midField = opts.modelIdField || 'model_id';
|
||||
const mnameField = opts.modelNameField || 'display_name';
|
||||
const pidField = opts.providerIdField || 'provider_config_id';
|
||||
const noneLabel = opts.noneLabel || '— none —';
|
||||
|
||||
// Cached data for provider-change handler
|
||||
let _providers = [];
|
||||
let _models = [];
|
||||
let _roles = {};
|
||||
|
||||
function filterModels(providerId, roleId) {
|
||||
const typeFilter = Roles.typeFor(roleId);
|
||||
return _models.filter(m =>
|
||||
m[pidField] === providerId &&
|
||||
(m.model_type || 'chat') === typeFilter
|
||||
);
|
||||
}
|
||||
|
||||
function onProviderChange(roleId, slot) {
|
||||
const provSel = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`);
|
||||
const modelSel = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`);
|
||||
if (!provSel || !modelSel) return;
|
||||
|
||||
modelSel.innerHTML = '<option value="">— select model —</option>';
|
||||
const provId = provSel.value;
|
||||
if (!provId) return;
|
||||
|
||||
filterModels(provId, roleId).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m[midField];
|
||||
opt.textContent = m[mnameField] || m[midField];
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function getBinding(roleId, slot) {
|
||||
const prov = containerEl.querySelector(`[data-role-provider="${roleId}-${slot}"]`)?.value;
|
||||
const model = containerEl.querySelector(`[data-role-model="${roleId}-${slot}"]`)?.value;
|
||||
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
|
||||
}
|
||||
|
||||
async function handleSave(roleId) {
|
||||
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
|
||||
try {
|
||||
const config = { primary: getBinding(roleId, 'primary') };
|
||||
if (showFallback) config.fallback = getBinding(roleId, 'fallback');
|
||||
await opts.onSave(roleId, config);
|
||||
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--success)'; }
|
||||
// Reload after short delay so dropdowns reflect saved state
|
||||
setTimeout(() => refresh(), 500);
|
||||
} catch (e) {
|
||||
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(roleId) {
|
||||
if (!opts.onTest) return;
|
||||
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
|
||||
if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-muted)'; }
|
||||
try {
|
||||
const result = await opts.onTest(roleId);
|
||||
if (statusEl) {
|
||||
const fb = result.used_fallback ? ' (fallback)' : '';
|
||||
statusEl.textContent = `✓ ${result.model}${fb}`;
|
||||
statusEl.style.color = 'var(--success)';
|
||||
}
|
||||
} catch (e) {
|
||||
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(roleId) {
|
||||
if (!opts.onClear) return;
|
||||
try {
|
||||
await opts.onClear(roleId);
|
||||
refresh();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function renderSlot(roleId, slotName, cfg, slotLabel) {
|
||||
const binding = cfg?.[slotName];
|
||||
const selectedProv = binding?.provider_config_id || '';
|
||||
const selectedModel = binding?.model_id || '';
|
||||
|
||||
const provOptions = _providers.map(p =>
|
||||
`<option value="${p.id}"${p.id === selectedProv ? ' selected' : ''}>${esc(p.name)}</option>`
|
||||
).join('');
|
||||
|
||||
const modelOptions = selectedProv
|
||||
? filterModels(selectedProv, roleId).map(m =>
|
||||
`<option value="${m[midField]}"${m[midField] === selectedModel ? ' selected' : ''}>${esc(m[mnameField] || m[midField])}</option>`
|
||||
).join('')
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="form-row" style="gap:8px;align-items:center${slotName === 'fallback' ? ';margin-top:6px' : ''}">
|
||||
<label style="min-width:60px;font-size:12px">${slotLabel}</label>
|
||||
<select data-role-provider="${roleId}-${slotName}" style="min-width:140px">
|
||||
<option value="">${noneLabel}</option>
|
||||
${provOptions}
|
||||
</select>
|
||||
<select data-role-model="${roleId}-${slotName}" style="min-width:200px">
|
||||
<option value="">— select model —</option>
|
||||
${modelOptions}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
containerEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const [provRaw, modelRaw, roleData] = await Promise.all([
|
||||
opts.fetchProviders(),
|
||||
opts.fetchModels(),
|
||||
opts.fetchRoles(),
|
||||
]);
|
||||
|
||||
// Normalize
|
||||
_providers = Array.isArray(provRaw) ? provRaw : (provRaw.configs || provRaw.providers || provRaw.data || []);
|
||||
_models = Array.isArray(modelRaw) ? modelRaw : (modelRaw.models || modelRaw.data || []);
|
||||
_roles = roleData || {};
|
||||
|
||||
const roles = Roles.all();
|
||||
containerEl.innerHTML = roles.map(role => {
|
||||
const cfg = _roles[role.id] || { primary: null, fallback: null };
|
||||
const hasClear = scope === 'personal' && opts.onClear && cfg.primary;
|
||||
return `
|
||||
<div class="settings-section" style="margin-bottom:${scope === 'admin' ? 16 : 12}px">
|
||||
<${scope === 'admin' ? 'h3' : 'h4'} style="font-size:${scope === 'admin' ? 14 : 13}px;margin:0 0 ${scope === 'admin' ? 8 : 6}px;text-transform:capitalize">
|
||||
${esc(role.label || role.id)}
|
||||
${scope === 'personal' && role.hint ? `<span class="form-hint">(${esc(role.hint)})</span>` : ''}
|
||||
</${scope === 'admin' ? 'h3' : 'h4'}>
|
||||
${renderSlot(role.id, 'primary', cfg, scope === 'personal' ? 'Provider' : 'Primary')}
|
||||
${showFallback ? renderSlot(role.id, 'fallback', cfg, 'Fallback') : ''}
|
||||
<div class="form-row" style="gap:8px;margin-top:8px">
|
||||
<button class="btn-primary btn-small" data-role-save="${role.id}">Save</button>
|
||||
${opts.onTest ? `<button class="btn-secondary btn-small" data-role-test="${role.id}">Test</button>` : ''}
|
||||
${hasClear ? `<button class="btn-small btn-danger" data-role-clear="${role.id}" title="Remove override, use org default">Clear</button>` : ''}
|
||||
<span data-role-status="${role.id}" style="font-size:12px"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
} catch (e) {
|
||||
containerEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delegate events ──
|
||||
containerEl.addEventListener('change', (e) => {
|
||||
const provSel = e.target.closest('[data-role-provider]');
|
||||
if (!provSel) return;
|
||||
const [roleId, slot] = provSel.dataset.roleProvider.split('-');
|
||||
onProviderChange(roleId, slot);
|
||||
});
|
||||
|
||||
containerEl.addEventListener('click', (e) => {
|
||||
const saveBtn = e.target.closest('[data-role-save]');
|
||||
if (saveBtn) { handleSave(saveBtn.dataset.roleSave); return; }
|
||||
const testBtn = e.target.closest('[data-role-test]');
|
||||
if (testBtn) { handleTest(testBtn.dataset.roleTest); return; }
|
||||
const clearBtn = e.target.closest('[data-role-clear]');
|
||||
if (clearBtn) { handleClear(clearBtn.dataset.roleClear); return; }
|
||||
});
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §7 USAGE DASHBOARD
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Render usage stats + table into a container.
|
||||
*
|
||||
* @param {HTMLElement} containerEl
|
||||
* @param {Object} opts
|
||||
* @param {Function} opts.apiFetch - async ({period, group_by}) → { totals, results }
|
||||
* @param {string} [opts.periodElId] - ID of period <select>
|
||||
* @param {string} [opts.groupByElId] - ID of groupBy <select>
|
||||
* @param {boolean} [opts.compact=false] - Use compact sizing (user/team)
|
||||
* @param {boolean} [opts.showUserColumn=false] - Include "User" in group_by options
|
||||
* @param {Array} [opts.extraColumns] - [{header, render: (row) → html}]
|
||||
* @returns {{ refresh }}
|
||||
*/
|
||||
function renderUsageDashboard(containerEl, opts = {}) {
|
||||
const compact = opts.compact === true;
|
||||
const cardStyle = compact ? ' style="padding:8px"' : '';
|
||||
const valueStyle = compact ? ' style="font-size:16px"' : '';
|
||||
const labelStyle = compact ? ' style="font-size:10px"' : '';
|
||||
const gridStyle = compact ? ' style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px"' : '';
|
||||
const tableStyle = compact ? ' style="font-size:12px"' : ' style="margin-top:12px"';
|
||||
|
||||
// We render into two sub-zones
|
||||
let totalsEl, resultsEl;
|
||||
|
||||
function ensureStructure() {
|
||||
if (!containerEl.querySelector('[data-usage-totals]')) {
|
||||
containerEl.innerHTML = `
|
||||
<div data-usage-totals></div>
|
||||
<div data-usage-results></div>
|
||||
`;
|
||||
}
|
||||
totalsEl = containerEl.querySelector('[data-usage-totals]');
|
||||
resultsEl = containerEl.querySelector('[data-usage-results]');
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
ensureStructure();
|
||||
const period = opts.periodElId ? (document.getElementById(opts.periodElId)?.value || '30d') : '30d';
|
||||
const groupBy = opts.groupByElId ? (document.getElementById(opts.groupByElId)?.value || 'model') : 'model';
|
||||
|
||||
totalsEl.innerHTML = compact
|
||||
? '<div class="loading" style="font-size:12px">Loading...</div>'
|
||||
: '<div class="loading">Loading...</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const data = await opts.apiFetch({ period, group_by: groupBy });
|
||||
const t = data.totals || {};
|
||||
|
||||
if (!t.requests && compact) {
|
||||
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
totalsEl.innerHTML = `
|
||||
<div class="stats-grid"${gridStyle}>
|
||||
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.requests || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Requests</div></div>
|
||||
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Input${compact ? '' : ' Tokens'}</div></div>
|
||||
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label"${labelStyle}>Output${compact ? '' : ' Tokens'}</div></div>
|
||||
<div class="stat-card"${cardStyle}><div class="stat-value"${valueStyle}>$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label"${labelStyle}>Est. Cost</div></div>
|
||||
</div>`;
|
||||
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Column header depends on groupBy
|
||||
let firstCol;
|
||||
if (groupBy === 'day') firstCol = 'Date';
|
||||
else if (groupBy === 'user') firstCol = 'User';
|
||||
else firstCol = 'Model';
|
||||
|
||||
const extraHeaders = (opts.extraColumns || []).map(c =>
|
||||
`<th style="text-align:right">${esc(c.header)}</th>`
|
||||
).join('');
|
||||
|
||||
resultsEl.innerHTML = `
|
||||
<table class="admin-table"${tableStyle}>
|
||||
<thead><tr>
|
||||
<th>${firstCol}</th>
|
||||
<th style="text-align:right">${compact ? 'Reqs' : 'Requests'}</th>
|
||||
<th style="text-align:right">Input</th>
|
||||
<th style="text-align:right">Output</th>
|
||||
<th style="text-align:right">Cost</th>
|
||||
${extraHeaders}
|
||||
</tr></thead>
|
||||
<tbody>${results.map(r => {
|
||||
const extraCells = (opts.extraColumns || []).map(c =>
|
||||
`<td style="text-align:right">${c.render(r)}</td>`
|
||||
).join('');
|
||||
return `<tr>
|
||||
<td>${esc(r.label || r.group_key)}</td>
|
||||
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
|
||||
${extraCells}
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>`;
|
||||
} catch (e) {
|
||||
totalsEl.innerHTML = `<div class="error-hint"${compact ? ' style="font-size:12px"' : ''}>${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §8 CONFIRM DIALOG
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Custom confirm dialog replacing native confirm().
|
||||
* Returns a Promise<boolean>.
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.title='Confirm']
|
||||
* @param {string} [opts.ok='Confirm']
|
||||
* @param {string} [opts.cancel='Cancel']
|
||||
* @param {boolean} [opts.danger=false] - Styles the confirm button as destructive
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function showConfirm(message, opts = {}) {
|
||||
return new Promise(resolve => {
|
||||
const title = opts.title || 'Confirm';
|
||||
const okText = opts.ok || 'Confirm';
|
||||
const caText = opts.cancel || 'Cancel';
|
||||
const danger = opts.danger !== false; // default true for destructive actions
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-dialog">
|
||||
<div class="confirm-header">${esc(title)}</div>
|
||||
<div class="confirm-body">${esc(message)}</div>
|
||||
<div class="confirm-footer">
|
||||
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
|
||||
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${esc(okText)}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
function close(result) {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') close(false);
|
||||
if (e.key === 'Enter') close(true);
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
|
||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('[data-action="cancel"]').focus();
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user