// ========================================== // Chat Switchboard – UI Settings & Teams // ========================================== // Extends UI with settings modal tabs, team management, // providers, usage, model roles, and user preferences. Object.assign(UI, { // ── Appearance Settings ───────────────── loadAppearanceSettings() { const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); const scale = prefs.scale || 100; const msgFont = prefs.msgFont || 14; const theme = prefs.theme || 'dark'; 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 document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.theme === theme); }); // Highlight active keymap button document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.keymap === keymap); }); }, initAppearance() { // Load saved prefs on startup const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14); UI.applyTheme(prefs.theme || 'dark'); // Live preview on slider change const scaleEl = document.getElementById('settingsScale'); const msgFontEl = document.getElementById('settingsMsgFont'); if (scaleEl) scaleEl.addEventListener('input', () => { const v = parseInt(scaleEl.value); document.getElementById('scaleValue').textContent = v + '%'; UI.applyAppearance(v, parseInt(msgFontEl?.value || 14)); }); if (msgFontEl) msgFontEl.addEventListener('input', () => { const v = parseInt(msgFontEl.value); document.getElementById('msgFontValue').textContent = v + 'px'; UI.applyAppearance(parseInt(scaleEl?.value || 100), v); }); // Theme toggle buttons document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => { btn.addEventListener('click', () => { const theme = btn.dataset.theme; UI.applyTheme(theme); document.querySelectorAll('#themeToggle .theme-btn').forEach(b => b.classList.toggle('active', b === btn) ); const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); p.theme = theme; localStorage.setItem('cs-appearance', JSON.stringify(p)); }); }); // Editor keymap toggle buttons document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.keymap; document.querySelectorAll('#keymapToggle .theme-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) UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); UI._systemThemeQuery.addEventListener('change', () => { const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); if (p.theme === 'system') UI.applyTheme('system'); }); }, /** * Apply theme to the document. * @param {'light'|'dark'|'system'} mode */ applyTheme(mode) { let resolved = mode; if (mode === 'system') { resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } if (resolved === 'light') { document.documentElement.setAttribute('data-theme', 'light'); } else { document.documentElement.removeAttribute('data-theme'); } // Publish event for CM6 editors and extensions if (typeof Events !== 'undefined' && Events.emit) { 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(scale, msgFont) { const z = scale === 100 ? '' : scale / 100; // Zoom content areas + modals + panels (but NOT .app or banners) document.querySelectorAll('.sidebar, .chat-area, .modal-overlay, .side-panel, .admin-panel').forEach(el => el.style.zoom = z); const splash = document.getElementById('splashGate'); if (splash) splash.style.zoom = z; document.documentElement.style.setProperty('--msg-font', msgFont + 'px'); localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont })); // Recheck tab overflow after layout settles with new zoom requestAnimationFrame(() => { document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow); }); }, 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'; }, checkUserPresetsAllowed() { const addBtn = document.getElementById('userAddPresetBtn'); const addForm = document.getElementById('userAddPresetForm'); 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 => `
${esc(t.name)} ${t.my_role} ${t.my_role === 'admin' ? 'Manage →' : ''}
${t.description ? `
${esc(t.description)}
` : ''}
${t.member_count} member${t.member_count !== 1 ? 's' : ''}
`).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 = ` ${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', 'settingsTeamAddPreset', 'settingsTeamProviderForm'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); const af = document.getElementById('teamAuditFilterAction'); if (af) { af.innerHTML = ''; } // 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 = '
Loading...
'; try { const resp = await API.teamListMembers(teamId); const members = resp.data || []; el.innerHTML = members.map(m => `
${esc(m.display_name || m.email)} ${m.role}
`).join('') || '
No members
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, 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 loadTeamManagePresets(teamId) { const el = document.getElementById('settingsTeamPresets'); el.innerHTML = '
Loading...
'; try { const resp = await API.teamListPresets(teamId); const presets = resp.presets || []; el.innerHTML = presets.map(p => { const icon = p.icon || ''; return `
${icon ? icon + ' ' : ''}${esc(p.name)}
${esc(p.base_model_id)}
`; }).join('') || '
No team presets — create one to give your team preconfigured models
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadTeamManageGroups(teamId) { const el = document.getElementById('settingsTeamGroups'); if (!el) return; el.innerHTML = '
Loading...
'; try { const resp = await API.teamListGroups(teamId); const groups = resp.data || []; el.innerHTML = groups.map(g => `
${esc(g.name)}
${esc(g.description || 'No description')} · ${g.member_count} member${g.member_count !== 1 ? 's' : ''}
`).join('') || '
No groups assigned to this team yet
'; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, 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 = `
${esc(e.message)}
`; 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 = '
Loading...
'; // 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 = '
No activity recorded for this team
'; } 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 `
${esc(e.action)} ${esc(actor)} ${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}
${meta ? `${meta}` : ''} ${timeStr}
`; }).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 = `
${esc(e.message)}
`; } }, async loadTeamPresetModelDropdown(teamId) { const sel = document.getElementById('teamPreset_model'); if (!sel) return; sel.innerHTML = ''; try { const resp = await API.teamListAvailableModels(teamId); const models = resp.models || []; sel.innerHTML = ''; // 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 = ''; App.models.filter(m => !m.isPreset).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 = '
Loading...
'; }, 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 = '
Loading models...
'; 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.model_id) ); } catch (e) { if (!App.hiddenModels) App.hiddenModels = new Set(); } } const data = await API.listEnabledModels(); const models = (data.models || []).filter(m => !m.is_preset); if (!models.length) { el.innerHTML = '
No models available
'; return; } el.innerHTML = models.map(m => { const mid = m.model_id || m.id; const caps = m.capabilities || {}; const badges = renderCapBadges(caps, { compact: true }); const src = m.source === 'personal' ? 'personal' : m.source === 'team' ? `👥 ${esc(m.team_name || 'team')}` : ''; const hidden = App.hiddenModels.has(mid); const toggleCls = hidden ? '' : 'enabled'; const toggleLabel = hidden ? 'Hidden' : '✓ Visible'; return `
${esc(mid)} ${badges} ${esc(m.provider_name || m.provider || '')} ${src}
`; }).join(''); } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, async loadUserPresets() { const el = document.getElementById('userPresetList'); if (!el) return; try { const data = await API.listUserPresets(); // Only show personal presets owned by user const presets = (data.presets || []).filter(p => p.scope === 'personal'); if (!presets.length) { el.innerHTML = '
No personal presets yet
'; return; } el.innerHTML = presets.map(p => { const avatarEl = p.avatar ? `` : ''; return `
${avatarEl}${esc(p.name)}
${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}
`; }).join(''); } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, });