841 lines
40 KiB
JavaScript
841 lines
40 KiB
JavaScript
// ==========================================
|
||
// 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>`; }
|
||
},
|
||
|
||
});
|