This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/ui-settings.js
2026-03-09 01:54:06 +00:00

841 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// Chat Switchboard UI Settings & Teams
// ==========================================
// Extends UI with settings modal tabs, team management,
// providers, usage, model roles, and user preferences.
Object.assign(UI, {
// ── General Settings (Settings Surface) ──
async loadGeneralSettings() {
// Fetch settings from API into App.settings
try {
const remote = await API.getSettings();
if (remote && typeof remote === 'object') {
if (remote.model) App.settings.model = remote.model;
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
if (remote.show_thinking !== undefined) App.settings.showThinking = remote.show_thinking;
}
} catch (e) { console.warn('Settings load failed:', e.message); }
// Fetch models for the model dropdown
await fetchModels();
// Populate form elements
const modelSel = document.getElementById('settingsModel');
if (modelSel) {
modelSel.innerHTML = '';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
modelSel.appendChild(opt);
});
if (App.settings.model) modelSel.value = App.settings.model;
}
const sysEl = document.getElementById('settingsSystemPrompt');
if (sysEl) sysEl.value = App.settings.systemPrompt || '';
const maxEl = document.getElementById('settingsMaxTokens');
if (maxEl) maxEl.value = App.settings.maxTokens || '';
const tempEl = document.getElementById('settingsTemp');
const tempVal = document.getElementById('settingsTempValue');
if (tempEl) {
tempEl.value = App.settings.temperature;
if (tempVal) tempVal.textContent = App.settings.temperature;
tempEl.addEventListener('input', () => {
if (tempVal) tempVal.textContent = tempEl.value;
});
}
const thinkEl = document.getElementById('settingsThinking');
if (thinkEl) thinkEl.checked = App.settings.showThinking;
// Model change → update max tokens hint
if (modelSel) {
modelSel.addEventListener('change', () => {
const model = App.findModel(modelSel.value);
const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
hint.textContent = caps.max_output_tokens > 0
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)` : '';
}
});
// Trigger once to show current model's hint
modelSel.dispatchEvent(new Event('change'));
}
// Save button (reuse existing pattern — form auto-saves are not a thing yet)
const section = document.querySelector('.settings-section');
if (section && !section.querySelector('.settings-save-btn')) {
const btn = document.createElement('button');
btn.className = 'btn-md btn-primary settings-save-btn';
btn.textContent = 'Save';
btn.style.marginTop = '12px';
btn.addEventListener('click', async () => {
App.settings.model = modelSel?.value || App.settings.model;
App.settings.systemPrompt = sysEl?.value?.trim() || '';
App.settings.maxTokens = parseInt(maxEl?.value) || 0;
App.settings.temperature = parseFloat(tempEl?.value) || 0.7;
App.settings.showThinking = thinkEl?.checked || false;
try {
await API.updateSettings({
model: App.settings.model,
system_prompt: App.settings.systemPrompt,
max_tokens: App.settings.maxTokens,
temperature: App.settings.temperature,
show_thinking: App.settings.showThinking,
});
UI.toast('Settings saved', 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
section.appendChild(btn);
}
},
// ── Appearance Settings ─────────────────
saveAppearance() {
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
// v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
const activeThemeBtn = document.querySelector('#themeToggle .toggle-btn.active');
if (activeThemeBtn && typeof Theme !== 'undefined') {
Theme.set(activeThemeBtn.dataset.theme);
}
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
UI.toast('Appearance saved', 'success');
},
// ── Teams Settings (Settings Surface) ────
async loadTeamsSettings() {
const el = document.getElementById('settingsDynamic');
if (!el) return;
try {
const resp = await API.listMyTeams();
const teams = resp.data || [];
if (!teams.length) {
el.innerHTML = '<div class="empty-hint">You are not a member of any teams.</div>';
return;
}
el.innerHTML = teams.map(t => `
<div class="settings-section" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h3 style="margin:0">${esc(t.name)}</h3>
<span class="badge-${t.my_role === 'admin' ? 'success' : 'muted'}" style="font-size:11px;">${t.my_role}</span>
</div>
${t.description ? `<p style="color:var(--text-2);font-size:13px;margin:0 0 8px;">${esc(t.description)}</p>` : ''}
<div style="font-size:12px;color:var(--text-3);">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed to load teams.</div>';
}
},
loadAppearanceSettings() {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
// v0.23.2: Read theme from Theme API
const theme = typeof Theme !== 'undefined' ? Theme.get() : 'system';
const keymap = prefs.editorKeymap || 'standard';
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
// Highlight active theme button and wire click
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
btn.onclick = () => {
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
};
});
// Highlight active keymap button
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.keymap === keymap);
});
},
initAppearance() {
// Theme and appearance are already applied by the universal init
// in base.html (Theme.init() + UI.restoreAppearance()). This method
// only wires up the settings UI controls.
// Scale slider: show value live, but apply zoom only on release to avoid
// jarring layout jumps mid-drag (zoom changes the layout viewport).
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) {
scaleEl.addEventListener('input', () => {
document.getElementById('scaleValue').textContent = parseInt(scaleEl.value) + '%';
});
scaleEl.addEventListener('change', () => {
UI.applyAppearance(parseInt(scaleEl.value), parseInt(msgFontEl?.value || 14));
});
}
if (msgFontEl) {
msgFontEl.addEventListener('input', () => {
document.getElementById('msgFontValue').textContent = parseInt(msgFontEl.value) + 'px';
});
msgFontEl.addEventListener('change', () => {
UI.applyAppearance(parseInt(scaleEl?.value || 100), parseInt(msgFontEl.value));
});
}
// Theme toggle buttons
document.querySelectorAll('#themeToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const theme = btn.dataset.theme;
UI.applyTheme(theme); // delegates to Theme.set() — single source of truth
document.querySelectorAll('#themeToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
});
});
// Editor keymap toggle buttons
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.keymap;
document.querySelectorAll('#keymapToggle .toggle-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
// Persist
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
p.editorKeymap = mode;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Notify open editors
if (typeof Events !== 'undefined' && Events.emit) {
Events.emit('keymap.changed', { mode });
}
});
});
// System theme change listener (for "system" mode)
// Note: Theme.set('system') registers its own listener, but this
// additionally fires the theme.changed event for CM6 editors.
UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
UI._systemThemeQuery.addEventListener('change', () => {
const mode = typeof Theme !== 'undefined' ? Theme.get() : 'system';
if (mode === 'system') UI.applyTheme('system');
});
},
/**
* Apply theme to the document.
* @param {'light'|'dark'|'system'} mode
*/
applyTheme(mode) {
// Delegate to Theme API which handles system-mode resolution and
// listens for OS preference changes.
if (typeof Theme !== 'undefined') {
Theme.set(mode);
} else {
// Fallback if Theme not loaded — resolve manually
let resolved = mode;
if (mode === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', resolved);
}
// Publish event for CM6 editors and extensions
if (typeof Events !== 'undefined' && Events.emit) {
const resolved = typeof Theme !== 'undefined' ? Theme.resolved() : mode;
Events.emit('theme.changed', { mode, resolved });
}
},
/** Get the currently resolved theme ('dark' or 'light') */
getResolvedTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
},
// applyAppearance — moved to ui-core.js (v0.25.0-cs11.1) so all surfaces can use it.
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');
const addBtn = document.getElementById('providerShowAddBtn');
const tabBtn = document.getElementById('settingsProvidersTabBtn');
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
const rolesTabBtn = document.getElementById('settingsRolesTabBtn');
const kbTabBtn = document.getElementById('settingsKBTabBtn');
if (!notice) return;
const allowed = App.policies?.allow_user_byok === 'true';
if (!kbTabBtn) console.warn('[Settings] settingsKBTabBtn not found in DOM');
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none';
if (kbTabBtn) kbTabBtn.style.display = allowed ? '' : 'none';
},
checkUserPersonasAllowed() {
const addBtn = document.getElementById('userAddPersonaBtn');
const addForm = document.getElementById('userAddPersonaForm');
const tabBtn = document.getElementById('settingsPersonasTabBtn');
const allowed = App.policies?.allow_user_personas === 'true';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (addForm && !allowed) addForm.style.display = 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
},
async loadProfileIntoSettings() {
try {
const p = await API.getProfile();
document.getElementById('profileDisplayName').value = p.display_name || '';
document.getElementById('profileEmail').value = p.email || '';
updateAvatarPreview(p.avatar || null);
} catch (e) { /* optional */ }
},
async loadMyTeams() {
const section = document.getElementById('settingsTeamsSection');
const el = document.getElementById('settingsTeamsList');
const menuBtn = document.getElementById('menuTeamAdmin');
if (!section || !el) return;
try {
const resp = await API.listMyTeams();
const teams = resp.data || [];
UI._myTeams = teams;
// Show/hide the Team Management flyout item for team admins
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
if (menuBtn) menuBtn.style.display = isTeamAdmin ? '' : 'none';
if (teams.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
el.innerHTML = teams.map(t => `
<div class="team-card"${t.my_role === 'admin' ? ` style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')"` : ''}>
<div class="team-card-info">
<strong>${esc(t.name)}</strong>
<span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span>
${t.my_role === 'admin' ? '<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>' : ''}
</div>
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
} catch (e) { section.style.display = 'none'; if (menuBtn) menuBtn.style.display = 'none'; }
},
_myTeams: [],
_managingTeamId: null,
loadTeamsTab() {
// No longer used — picker is in team admin modal
},
async openTeamManage(teamId, teamName) {
UI._managingTeamId = teamId;
UI._teamAuditPage = 1;
// If the modal isn't open yet (called from settings "Manage →"), open it
if (!document.getElementById('teamAdminModal').classList.contains('active')) {
openModal('teamAdminModal');
}
// Show tabbed content, hide picker
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
const titleEl = document.getElementById('teamAdminTitle');
if (adminTeams.length > 1) {
titleEl.innerHTML = `<span class="team-admin-back" onclick="UI.showTeamPicker()" title="Back to teams">←</span> ${esc(teamName)}`;
} else {
titleEl.textContent = teamName;
}
document.getElementById('teamAdminPicker').style.display = 'none';
document.getElementById('teamAdminContent').style.display = '';
// Reset forms (null-safe — not all form containers may exist)
['settingsTeamAddMember', 'settingsTeamAddPersona', 'settingsTeamProviderForm'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const af = document.getElementById('teamAuditFilterAction');
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
// Show Members tab first, load its data
UI.switchTeamTab('members');
},
showTeamPicker() {
document.getElementById('teamAdminTitle').textContent = 'Team Management';
document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPicker').style.display = '';
},
async loadTeamManageMembers(teamId) {
const el = document.getElementById('settingsTeamMembers');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListMembers(teamId);
const members = resp.data || [];
if (!members.length) { el.innerHTML = '<div class="empty-hint">No members</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Member</th><th>Role</th><th></th></tr></thead>
<tbody>${members.map(m => `<tr>
<td>
<div style="font-weight:500;font-size:13px">${esc(m.display_name || m.email)}</div>
${m.user_role === 'admin' ? '<span class="badge-admin" style="font-size:9px">sys-admin</span>' : ''}
</td>
<td>
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select" style="font-size:12px;padding:3px 6px;">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
</td>
<td class="admin-actions-cell">
<button class="icon-btn icon-btn-danger" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table>
<div class="admin-table-footer"><span>${members.length} member${members.length !== 1 ? 's' : ''}</span></div>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageProviders(teamId) {
const el = document.getElementById('settingsTeamProviders');
if (!el) return;
const addBtn = document.getElementById('settingsTeamAddProviderBtn');
const formEl = document.getElementById('settingsTeamProviderForm');
// 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();
},
});
}
// 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 loadTeamManagePersonas(teamId) {
const el = document.getElementById('settingsTeamPersonas');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListPersonas(teamId);
const personas = resp.personas || [];
el.innerHTML = personas.map(p => {
const icon = p.icon || '';
return `<div class="admin-persona-row" style="padding:6px 0">
<div class="persona-info">
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
<div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
</div>
<button class="btn-delete" onclick="settingsDeleteTeamPersona('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No team personas — create one to give your team preconfigured models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageGroups(teamId) {
const el = document.getElementById('settingsTeamGroups');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListGroups(teamId);
const groups = resp.data || [];
if (!groups.length) { el.innerHTML = '<div class="empty-hint">No groups assigned to this team yet</div>'; return; }
el.innerHTML = `
<table class="admin-table">
<thead><tr><th>Group</th><th>Members</th></tr></thead>
<tbody>${groups.map(g => `<tr>
<td><div style="font-weight:500">${esc(g.name)}</div><div class="admin-user-handle">${esc(g.description || 'No description')}</div></td>
<td style="font-size:12px;color:var(--text-2)">${g.member_count} member${g.member_count !== 1 ? 's' : ''}</td>
</tr>`).join('')}</tbody>
</table>`;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageSettings(teamId) {
if (!teamId) return;
try {
const team = await API.teamGetTeam ? API.teamGetTeam(teamId) : await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
const privEl = document.getElementById('teamSettingPrivatePolicy');
const allowEl = document.getElementById('teamSettingAllowProviders');
if (privEl) privEl.checked = !!settings.require_private_providers;
if (allowEl) allowEl.checked = settings.allow_team_providers !== false;
// Wire save button (once)
const saveBtn = document.getElementById('teamSettingSaveBtn');
if (saveBtn && !saveBtn._wired) {
saveBtn._wired = true;
saveBtn.addEventListener('click', async () => {
const tid = UI._managingTeamId;
const priv = document.getElementById('teamSettingPrivatePolicy')?.checked || false;
const allow = document.getElementById('teamSettingAllowProviders')?.checked !== false;
try {
await API.adminUpdateTeam(tid, { settings: JSON.stringify({ require_private_providers: priv, allow_team_providers: allow }) });
UI.toast('Team settings saved', 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
}
} catch (e) { /* proceed with defaults */ }
},
async loadMyUsage() {
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 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,
_teamAuditPerPage: 20,
// ── User Model Roles (BYOK overrides) ──
async loadUserRoles() {
const el = document.getElementById('userRolesConfig');
const notice = document.getElementById('userRolesDisabled');
const tabBtn = document.getElementById('settingsRolesTabBtn');
if (!el) return;
// Only show if BYOK is enabled
const byokAllowed = App.policies?.allow_user_byok === 'true';
if (tabBtn) tabBtn.style.display = byokAllowed ? '' : 'none';
if (!byokAllowed) return;
// Check if user has personal providers
try {
const configs = await API.listConfigs();
const configList = configs.configs || configs.data || [];
const personalProviders = configList.filter(c => c.scope === 'personal');
if (personalProviders.length === 0) {
el.style.display = 'none';
if (notice) notice.style.display = '';
return;
}
el.style.display = '';
if (notice) notice.style.display = 'none';
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
return;
}
if (_userRoleConfig) await _userRoleConfig.refresh();
},
async loadTeamAuditLog(page) {
const teamId = UI._managingTeamId;
if (!teamId) return;
if (page) UI._teamAuditPage = page;
const el = document.getElementById('settingsTeamAudit');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
// Populate action filter on first load
const actionSel = document.getElementById('teamAuditFilterAction');
if (actionSel && actionSel.options.length <= 1) {
try {
const resp = await API.teamListAuditActions(teamId);
(resp.actions || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
actionSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
const params = {
page: UI._teamAuditPage,
per_page: UI._teamAuditPerPage,
action: document.getElementById('teamAuditFilterAction')?.value || '',
};
try {
const resp = await API.teamListAudit(teamId, params);
const entries = resp.data || [];
const total = resp.total || 0;
const totalPages = Math.ceil(total / UI._teamAuditPerPage);
if (entries.length === 0) {
el.innerHTML = '<div class="empty-hint">No activity recorded for this team</div>';
} else {
el.innerHTML = entries.map(e => {
const ts = new Date(e.created_at);
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const actor = e.actor_name || 'system';
const meta = UI._formatAuditMeta(e.metadata);
return `<div class="audit-row">
<div class="audit-main">
<span class="audit-action">${esc(e.action)}</span>
<span class="audit-actor">${esc(actor)}</span>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
</div>
<div class="audit-meta">
${meta ? `<span class="audit-details">${meta}</span>` : ''}
<span class="audit-time">${timeStr}</span>
</div>
</div>`;
}).join('');
}
// Pagination
const pgEl = document.getElementById('teamAuditPagination');
if (totalPages > 1) {
pgEl.style.display = 'flex';
document.getElementById('teamAuditPageInfo').textContent = `Page ${UI._teamAuditPage} of ${totalPages} (${total} entries)`;
document.getElementById('teamAuditPrevBtn').disabled = UI._teamAuditPage <= 1;
document.getElementById('teamAuditNextBtn').disabled = UI._teamAuditPage >= totalPages;
} else {
pgEl.style.display = 'none';
}
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamPersonaModelDropdown(teamId) {
const sel = document.getElementById('teamPersona_model');
if (!sel) return;
sel.innerHTML = '<option value="">Loading models...</option>';
try {
const resp = await API.teamListAvailableModels(teamId);
const models = resp.models || [];
sel.innerHTML = '<option value="">Select base model...</option>';
// Group by source for clarity
const globalModels = models.filter(m => m.source !== 'team');
const teamModels = models.filter(m => m.source === 'team');
const addGroup = (label, items) => {
if (!items.length) return;
const group = document.createElement('optgroup');
group.label = label;
items.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id || m.id;
const vis = m.visibility === 'team' ? ' [team-only]' : '';
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
group.appendChild(opt);
});
sel.appendChild(group);
};
addGroup('Global Models', globalModels);
addGroup('Team Provider Models', teamModels);
} catch (e) {
// Fallback to App.models if team endpoint fails
sel.innerHTML = '<option value="">Select base model...</option>';
App.models.filter(m => !m.isPersona).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
},
// ── 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>';
},
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
if (_userProvForm) _userProvForm.setCreateMode();
},
// ── User Model List ─────────────────────
async loadUserModels() {
const el = document.getElementById('userModelList');
if (!el) return;
el.innerHTML = '<div class="loading">Loading models...</div>';
try {
// Load preferences if not already loaded
if (!App.hiddenModels) {
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
}
}
const data = await API.listEnabledModels();
const models = (data.models || []).filter(m => !m.is_persona);
if (!models.length) {
el.innerHTML = '<div class="empty-hint">No models available</div>';
return;
}
el.innerHTML = models.map(m => {
const mid = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || '';
const compositeKey = (cfgId || '') + ':' + mid;
const caps = m.capabilities || {};
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(compositeKey);
const toggleCls = hidden ? '' : 'enabled';
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}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src}
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', '${esc(cfgId)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadUserPersonas() {
const el = document.getElementById('userPersonaList');
if (!el) return;
try {
const data = await API.listUserPersonas();
// Only show personal personas owned by user
const personas = (data.personas || []).filter(p => p.scope === 'personal');
if (!personas.length) {
el.innerHTML = '<div class="empty-hint">No personal personas yet</div>';
return;
}
el.innerHTML = personas.map(p => {
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="persona-row-avatar" alt="">` : '';
return `<div class="admin-persona-row" style="padding:6px 0">
<div class="persona-info">
<strong>${avatarEl}${esc(p.name)}</strong>
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
</div>
<div class="persona-actions">
<button class="btn-delete" onclick="deleteUserPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
});