Changeset 0.10.3 (#59)

This commit is contained in:
2026-02-24 21:32:07 +00:00
parent 4061e4a145
commit ba2cd42428
17 changed files with 5723 additions and 2458 deletions

640
src/js/ui-settings.js Normal file
View File

@@ -0,0 +1,640 @@
// ==========================================
// 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 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'; }
},
initAppearance() {
// Load saved prefs on startup
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
// 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);
});
},
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas + modals (but NOT .app or banners)
document.querySelectorAll('.sidebar, .chat-area, .modal-overlay').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');
if (!notice) return;
const allowed = App.policies?.allow_user_byok === 'true';
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';
},
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 => `
<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
document.getElementById('settingsTeamAddMember').style.display = 'none';
document.getElementById('settingsTeamAddPreset').style.display = 'none';
document.getElementById('settingsTeamAddProvider').style.display = 'none';
document.getElementById('settingsTeamEditProvider').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 || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row" style="padding:6px 0">
<div class="admin-user-info">
<strong style="font-size:13px">${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}" style="font-size:10px">${m.role}</span>
</div>
<div class="admin-user-actions">
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
<button class="btn-delete" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
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;
// Show/hide add button and entire section based on policy
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
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>`; }
},
async loadTeamManagePresets(teamId) {
const el = document.getElementById('settingsTeamPresets');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListPresets(teamId);
const presets = resp.presets || [];
el.innerHTML = presets.map(p => {
const icon = p.icon || '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
<div class="preset-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
</div>
<button class="btn-delete" onclick="settingsDeleteTeamPreset('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No team presets — create one to give your team preconfigured models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
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>`; }
},
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>`; }
},
_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;
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');
if (personalProviders.length === 0) {
el.style.display = 'none';
if (notice) notice.style.display = '';
return;
}
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 };
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).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>`;
}
},
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 loadTeamPresetModelDropdown(teamId) {
const sel = document.getElementById('teamPreset_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.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() {
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';
},
// ── 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.model_id)
);
} catch (e) { App.hiddenModels = new Set(); }
}
const data = await API.listEnabledModels();
const models = (data.models || []).filter(m => !m.is_preset);
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 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 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);
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.join('')}</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>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
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 = '<div class="empty-hint">No personal presets yet</div>';
return;
}
el.innerHTML = presets.map(p => {
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="preset-row-avatar" alt="">` : '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong>
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
</div>
<div class="preset-actions">
<button class="btn-delete" onclick="deleteUserPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
});