Changeset 0.8.4 (#47)

This commit is contained in:
2026-02-22 11:23:29 +00:00
parent e78a27c06e
commit f9f362c900
7 changed files with 361 additions and 219 deletions

View File

@@ -130,32 +130,6 @@ function updateAvatarPreview(dataURI) {
}
}
// Preset avatar preview (admin form)
function updatePresetAvatarPreview(dataURI) {
const preview = document.getElementById('presetAvatarPreview');
const placeholder = document.getElementById('presetAvatarPlaceholder');
const removeBtn = document.getElementById('presetAvatarRemoveBtn');
if (!preview) return;
const existingImg = preview.querySelector('img');
if (dataURI) {
placeholder.style.display = 'none';
if (existingImg) {
existingImg.src = dataURI;
} else {
const img = document.createElement('img');
img.src = dataURI;
img.alt = 'Preset avatar';
preview.insertBefore(img, placeholder);
}
removeBtn.style.display = '';
} else {
if (existingImg) existingImg.remove();
placeholder.style.display = '';
removeBtn.style.display = 'none';
}
}
// ── Models ───────────────────────────────────
// Client-side known model capabilities — used when backend hasn't synced yet.
@@ -997,72 +971,16 @@ function initListeners() {
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
// Admin — presets
// Admin — presets (shared form)
document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => {
const form = ensureAdminPresetForm();
if (!form) return;
_editingPresetId = null;
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
document.getElementById('adminPresetName').value = '';
document.getElementById('adminPresetDesc').value = '';
document.getElementById('adminPresetPrompt').value = '';
document.getElementById('adminPresetIcon').value = '';
document.getElementById('adminPresetTemp').value = '';
document.getElementById('adminPresetMaxTokens').value = '';
updatePresetAvatarPreview(null);
form.setSubmitLabel('Create');
form.clearForm();
const f = document.getElementById('adminAddPresetForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelPresetBtn')?.addEventListener('click', () => {
document.getElementById('adminAddPresetForm').style.display = 'none';
_editingPresetId = null;
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
updatePresetAvatarPreview(null);
});
document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset);
// Admin — preset avatar
document.getElementById('presetAvatarUploadBtn')?.addEventListener('click', () => {
document.getElementById('presetAvatarFileInput').click();
});
document.getElementById('presetAvatarFileInput')?.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
if (!_editingPresetId) {
// For new presets: show preview, upload after create
const reader = new FileReader();
reader.onload = () => updatePresetAvatarPreview(reader.result);
reader.readAsDataURL(file);
this.value = '';
return;
}
// For existing presets: upload immediately
const reader = new FileReader();
reader.onload = async () => {
try {
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
if (data.avatar) {
updatePresetAvatarPreview(data.avatar);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('presetAvatarRemoveBtn')?.addEventListener('click', async () => {
if (!_editingPresetId) {
updatePresetAvatarPreview(null);
return;
}
try {
await API.adminDeletePresetAvatar(_editingPresetId);
updatePresetAvatarPreview(null);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
});
// Admin — Teams
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
@@ -1138,38 +1056,41 @@ function initListeners() {
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddMember').style.display = 'none';
});
var _teamPresetForm = null;
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddPreset').style.display = '';
const container = document.getElementById('settingsTeamAddPreset');
container.style.display = '';
if (!_teamPresetForm) {
_teamPresetForm = renderPresetForm(container, {
prefix: 'teamPreset',
showAvatar: true,
showProviderConfig: false,
onSubmit: async (vals) => {
const teamId = UI._managingTeamId;
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
try {
const result = await API.teamCreatePreset(teamId, vals);
// Upload pending avatar
const presetId = result?.id;
if (presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
}
container.style.display = 'none';
_teamPresetForm.clearForm();
UI.toast('Team preset created');
await UI.loadTeamManagePresets(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _teamPresetForm.clearForm(); }
});
} else {
_teamPresetForm.clearForm();
}
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
document.getElementById('settingsTeamCancelPreset')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddPreset').style.display = 'none';
});
document.getElementById('settingsTeamCreatePresetBtn')?.addEventListener('click', async () => {
const teamId = UI._managingTeamId;
const name = document.getElementById('settingsTeamPresetName').value.trim();
const baseModelId = document.getElementById('settingsTeamPresetModel').value;
if (!name) return UI.toast('Name required', 'error');
if (!baseModelId) return UI.toast('Select a base model', 'error');
const preset = {
name,
base_model_id: baseModelId,
description: document.getElementById('settingsTeamPresetDesc').value.trim(),
icon: document.getElementById('settingsTeamPresetIcon').value.trim(),
system_prompt: document.getElementById('settingsTeamPresetPrompt').value,
temperature: parseFloat(document.getElementById('settingsTeamPresetTemp').value) || undefined,
max_tokens: parseInt(document.getElementById('settingsTeamPresetMaxTokens').value) || undefined,
};
try {
await API.teamCreatePreset(teamId, preset);
document.getElementById('settingsTeamAddPreset').style.display = 'none';
['settingsTeamPresetName','settingsTeamPresetDesc','settingsTeamPresetIcon','settingsTeamPresetPrompt','settingsTeamPresetTemp','settingsTeamPresetMaxTokens']
.forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
UI.toast('Team preset created');
await UI.loadTeamManagePresets(teamId);
await fetchModels(); // refresh model selector
} catch (e) { UI.toast(e.message, 'error'); }
});
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
@@ -1650,50 +1571,103 @@ async function bulkSetVisibility(visibility) {
// ── Admin Presets ────────────────────────────
var _adminPresetForm = null;
function ensureAdminPresetForm() {
if (_adminPresetForm) return _adminPresetForm;
const container = document.getElementById('adminAddPresetForm');
if (!container) return null;
_adminPresetForm = renderPresetForm(container, {
prefix: 'adminPreset',
showAvatar: true,
showProviderConfig: true,
onSubmit: (vals) => createAdminPreset(vals),
onCancel: () => {
container.style.display = 'none';
_editingPresetId = null;
_adminPresetForm.setSubmitLabel('Create');
_adminPresetForm.clearForm();
}
});
// Override avatar handlers for edit-mode (upload immediately for existing presets)
const fileInput = document.getElementById('adminPreset_avatarFileInput');
if (fileInput) {
// Remove default handler set by renderPresetForm, add edit-aware one
const newInput = fileInput.cloneNode(true);
fileInput.parentNode.replaceChild(newInput, fileInput);
newInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
if (_editingPresetId) {
try {
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
if (data.avatar) {
_adminPresetForm.updateAvatarPreview(data.avatar);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
} else {
_adminPresetForm.updateAvatarPreview(reader.result);
}
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('adminPreset_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
}
const removeBtn = document.getElementById('adminPreset_avatarRemoveBtn');
if (removeBtn) {
const newBtn = removeBtn.cloneNode(true);
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
newBtn.addEventListener('click', async () => {
if (_editingPresetId) {
try {
await API.adminDeletePresetAvatar(_editingPresetId);
_adminPresetForm.updateAvatarPreview(null);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
} else {
_adminPresetForm.updateAvatarPreview(null);
}
});
}
return _adminPresetForm;
}
var _editingPresetId = null;
function editAdminPreset(id) {
const p = UI._presetCache?.[id];
if (!p) return;
_editingPresetId = id;
const form = document.getElementById('adminAddPresetForm');
form.style.display = '';
document.getElementById('adminPresetName').value = p.name || '';
document.getElementById('adminPresetIcon').value = p.icon || '';
document.getElementById('adminPresetDesc').value = p.description || '';
document.getElementById('adminPresetPrompt').value = p.system_prompt || '';
document.getElementById('adminPresetTemp').value = p.temperature != null ? p.temperature : '';
document.getElementById('adminPresetMaxTokens').value = p.max_tokens != null ? p.max_tokens : '';
// Set base model and config dropdowns if present
const modelSel = document.getElementById('adminPresetModel');
if (modelSel) modelSel.value = p.base_model_id || '';
const cfgSel = document.getElementById('adminPresetConfig');
if (cfgSel) cfgSel.value = p.api_config_id || '';
// Avatar preview
updatePresetAvatarPreview(p.avatar || null);
document.getElementById('adminCreatePresetBtn').textContent = 'Update';
ensureAdminPresetForm();
const form = _adminPresetForm;
if (!form) return;
document.getElementById('adminAddPresetForm').style.display = '';
form.setValues(p);
form.setSubmitLabel('Update');
}
async function createAdminPreset() {
const name = document.getElementById('adminPresetName').value.trim();
const baseModelId = document.getElementById('adminPresetModel').value;
if (!name || !baseModelId) { UI.toast('Name and base model are required', 'warning'); return; }
async function createAdminPreset(vals) {
if (!vals) {
if (_adminPresetForm) vals = _adminPresetForm.getValues();
else return;
}
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
const preset = {
name,
base_model_id: baseModelId,
description: document.getElementById('adminPresetDesc').value.trim(),
icon: document.getElementById('adminPresetIcon').value.trim(),
system_prompt: document.getElementById('adminPresetPrompt').value,
name: vals.name,
base_model_id: vals.base_model_id,
description: vals.description || '',
system_prompt: vals.system_prompt || '',
};
const configId = document.getElementById('adminPresetConfig').value;
if (configId) preset.api_config_id = configId;
const temp = parseFloat(document.getElementById('adminPresetTemp').value);
if (!isNaN(temp)) preset.temperature = temp;
const maxTok = parseInt(document.getElementById('adminPresetMaxTokens').value);
if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
if (vals.api_config_id) preset.api_config_id = vals.api_config_id;
if (vals.temperature != null) preset.temperature = vals.temperature;
if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens;
try {
let presetId = _editingPresetId;
@@ -1707,23 +1681,17 @@ async function createAdminPreset() {
}
// Upload pending avatar for new presets
const pendingImg = document.querySelector('#presetAvatarPreview img');
if (!_editingPresetId && presetId && pendingImg?.src?.startsWith('data:')) {
try {
await API.adminUploadPresetAvatar(presetId, pendingImg.src);
} catch (e) { console.warn('Preset avatar upload failed:', e.message); }
if (!_editingPresetId && presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
}
_editingPresetId = null;
document.getElementById('adminAddPresetForm').style.display = 'none';
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
document.getElementById('adminPresetName').value = '';
document.getElementById('adminPresetDesc').value = '';
document.getElementById('adminPresetPrompt').value = '';
document.getElementById('adminPresetIcon').value = '';
document.getElementById('adminPresetTemp').value = '';
document.getElementById('adminPresetMaxTokens').value = '';
updatePresetAvatarPreview(null);
if (_adminPresetForm) {
_adminPresetForm.setSubmitLabel('Create');
_adminPresetForm.clearForm();
}
await UI.loadAdminPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }

View File

@@ -22,6 +22,148 @@ function assistantAvatarURI(msg) {
return null;
}
// ── Shared Preset Form Component ────────────
// Renders a preset creation/edit form into a container element.
// Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel }
// Returns: { getValues(), setValues(preset), clearForm(), container }
function renderPresetForm(containerEl, options = {}) {
const pfx = options.prefix || 'pf';
const showAvatar = options.showAvatar !== false;
const showConfig = !!options.showProviderConfig;
containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
${showAvatar ? `
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
<span id="${pfx}_avatarPlaceholder">🤖</span>
</div>
<div class="avatar-actions">
<button class="btn-small" type="button" id="${pfx}_avatarUploadBtn">Upload Avatar</button>
<button class="btn-small btn-danger" type="button" id="${pfx}_avatarRemoveBtn" style="display:none">Remove</button>
<input type="file" id="${pfx}_avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
<div class="form-hint">Optional · 128×128 · shown in chat</div>
</div>
</div>` : ''}
<div class="form-group"><label>Description</label><input type="text" id="${pfx}_desc" placeholder="Reviews code for best practices"></div>
<div class="form-row">
<div class="form-group"><label>Base Model</label><select id="${pfx}_model"></select></div>
${showConfig ? `<div class="form-group"><label>Provider Config</label><select id="${pfx}_config"><option value="">Auto-resolve</option></select></div>` : ''}
</div>
<div class="form-group"><label>System Prompt</label><textarea id="${pfx}_prompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
<div class="form-row">
<div class="form-group"><label>Temperature</label><input type="number" id="${pfx}_temp" step="0.1" min="0" max="2" placeholder="default"></div>
<div class="form-group"><label>Max Tokens</label><input type="number" id="${pfx}_maxTokens" placeholder="default"></div>
</div>
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
</div>
`;
// Avatar wiring
if (showAvatar) {
const uploadBtn = document.getElementById(`${pfx}_avatarUploadBtn`);
const fileInput = document.getElementById(`${pfx}_avatarFileInput`);
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
uploadBtn?.addEventListener('click', () => fileInput.click());
fileInput?.addEventListener('change', function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => form.updateAvatarPreview(reader.result);
reader.readAsDataURL(file);
this.value = '';
});
removeBtn?.addEventListener('click', () => form.updateAvatarPreview(null));
}
// Cancel wiring
document.getElementById(`${pfx}_cancelBtn`)?.addEventListener('click', () => {
if (options.onCancel) options.onCancel();
});
// Submit wiring
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
if (options.onSubmit) options.onSubmit(form.getValues());
});
const form = {
getValues() {
const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
};
if (showConfig) {
const cfgId = document.getElementById(`${pfx}_config`)?.value;
if (cfgId) v.api_config_id = cfgId;
}
const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value);
if (!isNaN(temp)) v.temperature = temp;
const maxTok = parseInt(document.getElementById(`${pfx}_maxTokens`)?.value);
if (!isNaN(maxTok) && maxTok > 0) v.max_tokens = maxTok;
// Pending avatar data URI
const img = document.querySelector(`#${pfx}_avatarPreview img`);
if (img?.src?.startsWith('data:')) v._pendingAvatar = img.src;
return v;
},
setValues(p) {
const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = p.name || '';
if (el('desc')) el('desc').value = p.description || '';
if (el('prompt')) el('prompt').value = p.system_prompt || '';
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : '';
if (el('model')) el('model').value = p.base_model_id || '';
if (showConfig && el('config')) el('config').value = p.api_config_id || '';
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
},
clearForm() {
['name','desc','prompt','temp','maxTokens'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
const modelSel = document.getElementById(`${pfx}_model`);
if (modelSel) modelSel.selectedIndex = 0;
if (showConfig) {
const cfgSel = document.getElementById(`${pfx}_config`);
if (cfgSel) cfgSel.selectedIndex = 0;
}
if (showAvatar) form.updateAvatarPreview(null);
},
updateAvatarPreview(dataURI) {
const preview = document.getElementById(`${pfx}_avatarPreview`);
const placeholder = document.getElementById(`${pfx}_avatarPlaceholder`);
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
if (!preview) return;
const existing = preview.querySelector('img');
if (dataURI) {
if (placeholder) placeholder.style.display = 'none';
if (existing) { existing.src = dataURI; }
else {
const img = document.createElement('img');
img.src = dataURI; img.alt = 'Preset avatar';
preview.insertBefore(img, placeholder);
}
if (removeBtn) removeBtn.style.display = '';
} else {
if (existing) existing.remove();
if (placeholder) placeholder.style.display = '';
if (removeBtn) removeBtn.style.display = 'none';
}
},
setSubmitLabel(label) {
const btn = document.getElementById(`${pfx}_submitBtn`);
if (btn) btn.textContent = label;
},
getModelSelect() { return document.getElementById(`${pfx}_model`); },
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
};
return form;
}
const UI = {
// ── Sidebar ──────────────────────────────
@@ -623,6 +765,7 @@ const UI = {
UI.loadProfileIntoSettings();
UI.loadMyTeams();
UI.checkUserProvidersAllowed();
UI.switchSettingsTab('general');
openModal('settingsModal');
},
@@ -693,10 +836,12 @@ const UI = {
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');
const addBtn = document.getElementById('providerShowAddBtn');
const tabBtn = document.getElementById('settingsProvidersTabBtn');
if (!notice) return;
const allowed = App.serverSettings?.user_providers_enabled !== false;
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
},
async loadProfileIntoSettings() {
@@ -823,7 +968,7 @@ const UI = {
},
async loadTeamPresetModelDropdown(teamId) {
const sel = document.getElementById('settingsTeamPresetModel');
const sel = document.getElementById('teamPreset_model');
if (!sel) return;
sel.innerHTML = '<option value="">Loading models...</option>';
try {
@@ -1094,8 +1239,11 @@ const UI = {
const data = await API.adminListPresets();
const list = data.presets || [];
// Ensure form is initialized for dropdown population
ensureAdminPresetForm();
// Populate model dropdown from admin model list (all synced models)
const modelSel = document.getElementById('adminPresetModel');
const modelSel = _adminPresetForm?.getModelSelect();
if (modelSel) {
modelSel.innerHTML = '<option value="">Select base model...</option>';
try {
@@ -1113,7 +1261,7 @@ const UI = {
}
// Populate config dropdown
const cfgSel = document.getElementById('adminPresetConfig');
const cfgSel = _adminPresetForm?.getConfigSelect();
if (cfgSel && cfgSel.options.length <= 1) {
try {
const cfgData = await API.adminListGlobalConfigs();
@@ -1132,12 +1280,21 @@ const UI = {
UI._presetCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
: (p.icon ? `<span class="preset-row-icon">${p.icon}</span>` : '');
const scope = p.scope === 'global' ? '<span class="badge-admin">global</span>' : '<span class="badge-user">personal</span>';
: '';
// Scope badge with attribution
let scopeBadge = '';
if (p.scope === 'global') {
scopeBadge = '<span class="badge-admin">global</span>';
} else if (p.scope === 'team' && p.team_name) {
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
} else if (p.scope === 'personal') {
scopeBadge = '<span class="badge-user">personal</span>';
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scope} ${status}
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>
@@ -1313,9 +1470,10 @@ const UI = {
el.innerHTML = '<div class="loading">Loading models...</div>';
try {
const data = await API.listEnabledModels();
const models = data.models || [];
// Only show base models, not presets
const models = (data.models || []).filter(m => !m.is_preset);
if (!models.length) {
el.innerHTML = '<div class="empty-hint">No models available — add a provider in the section above</div>';
el.innerHTML = '<div class="empty-hint">No models available</div>';
return;
}
el.innerHTML = models.map(m => {
@@ -1325,10 +1483,12 @@ const UI = {
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>' : '';
return `<div class="model-list-item">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src}
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }