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

@@ -187,6 +187,55 @@ Required for enterprise and compliance. Cheap to build, expensive to retrofit.
- [x] Admin audit viewer (filter by action, resource type, paginated)
- [ ] Team admin sees audit entries scoped to their team
**Model Visibility (0.8.3)**
- [x] Three-state visibility: enabled (all users) / team (preset-building only) / disabled
- [x] Migration replaces `is_enabled` boolean with `visibility` varchar
- [x] Admin model list: 3-state cycle button, bulk set all to any state
- [x] `GET /teams/:teamId/models` returns enabled+team models for preset builders
- [x] User-facing `ListEnabledModels` strictly filters `visibility = 'enabled'`
**Preset Form Unification + UX (0.8.4)**
- [x] Single `renderPresetForm(options)` function shared across admin/team contexts
— parameterized: showAvatar, showProviderConfig, onSubmit, onCancel
- [x] Remove icon field from preset forms (not rendered anywhere meaningful)
- [x] Team admin preset form includes avatar upload (reuse admin component)
- [x] Hide Providers tab in Settings when `user_providers_enabled` is false
- [x] Settings Models tab: only show `visibility='enabled'` base models
(team-only models must not leak to user model list; presets filtered out)
- [x] Model provenance labels: badge indicating global vs personal source
— same model_id from both sources shown separately (different API keys)
- [x] Admin presets list: show created_by name + team name for attribution
(e.g. "Code Reviewer · 👥 Engineering · by sarah")
**User Presets + Model Filtering (0.8.5)**
- [ ] Users can create personal presets from any enabled base model
(backend `CreateUserPreset` exists, needs UI surface in Settings)
- [ ] Settings Models tab reworked as "My Models": toggle to hide/show
base models from selector, "+ Create Preset" action per model row
- [ ] `user_model_preferences` table: user_id, model_config_id, hidden boolean
— lightweight filter, not access control
- [ ] User personal provider models: simple enable/disable toggle
(no team dimension — personal keys are personal)
**Team Providers (0.8.6+)**
- [ ] `team_id` column on `api_configs` (nullable, symmetric with `user_id`)
- [ ] Team admins manage team-scoped provider configs
(add/remove API keys, toggle active, same UI as personal providers)
- [ ] `allow_team_providers` flag in `teams.settings` JSONB
— admin controls which teams can add their own API keys
- [ ] Team provider models use same 3-state visibility: enabled/disabled/team
— enabled: team members see in model selector (direct use)
— team: team admin can use for presets only (not direct member access)
— disabled: hidden from everyone
Same cycle button, same bulk actions, scoped to team admin panel
- [ ] `ListEnabledModels` adds section: team provider models where
`visibility = 'enabled'` AND user is member of owning team
- [ ] Team preset builder shows team provider models where
`visibility IN ('enabled', 'team')` AND user is admin of owning team
- [ ] Three-tier provider hierarchy with uniform visibility semantics:
global (sys-admin) → team (team-admin) → personal (user)
- [ ] Model provenance badges: global / team:TeamName / personal
**Usage / Cost Tracking**
- [ ] Capture from provider responses: prompt_tokens, completion_tokens,
cache_creation_tokens, cache_read_tokens

View File

@@ -1 +1 @@
0.8.3
0.8.4

View File

@@ -434,6 +434,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
ConfigID string `json:"config_id"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
Pricing *providers.ModelPricing `json:"pricing,omitempty"`
Source string `json:"source,omitempty"`
IsPreset bool `json:"is_preset,omitempty"`
PresetID string `json:"preset_id,omitempty"`
PresetScope string `json:"preset_scope,omitempty"`
@@ -477,6 +478,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
}
m.Capabilities.MaxOutputTokens = providers.ResolveMaxOutput(m.ModelID, m.Capabilities)
m.Source = "global"
models = append(models, m)
}
}
@@ -534,6 +536,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
ConfigID: cfgID,
Capabilities: caps,
Pricing: pm.Pricing,
Source: "personal",
})
}
}

View File

@@ -286,10 +286,12 @@ func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name,
COALESCE(u.username, '') as creator_name
COALESCE(u.username, '') as creator_name,
COALESCE(t.name, '') as team_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
LEFT JOIN users u ON mp.created_by = u.id
LEFT JOIN teams t ON mp.team_id = t.id
ORDER BY mp.scope ASC, mp.name ASC
`)
if err != nil {
@@ -301,6 +303,7 @@ func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
type adminPreset struct {
presetResponse
CreatorName string `json:"creator_name"`
TeamName string `json:"team_name"`
}
presets := make([]adminPreset, 0)
@@ -310,7 +313,8 @@ func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, &p.CreatorName,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
&p.CreatorName, &p.TeamName,
); err != nil {
continue
}

View File

@@ -222,7 +222,7 @@
<div class="modal-tabs settings-tabs">
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')" id="settingsProvidersTabBtn">Providers</button>
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
<button class="settings-tab" data-stab="teams" onclick="UI.switchSettingsTab('teams')" id="settingsTeamsTabBtn" style="display:none">Teams</button>
</div>
@@ -331,22 +331,7 @@
<section class="settings-section">
<h4 style="font-size:13px;margin-bottom:6px">Team Presets</h4>
<div id="settingsTeamPresets"></div>
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Name</label><input type="text" id="settingsTeamPresetName" placeholder="Code Reviewer"></div>
<div class="form-group"><label>Icon</label><input type="text" id="settingsTeamPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="settingsTeamPresetDesc" placeholder="Reviews code for best practices"></div>
<div class="form-row">
<div class="form-group"><label>Base Model</label><select id="settingsTeamPresetModel"></select></div>
</div>
<div class="form-group"><label>System Prompt</label><textarea id="settingsTeamPresetPrompt" 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="settingsTeamPresetTemp" step="0.1" min="0" max="2" placeholder="default"></div>
<div class="form-group"><label>Max Tokens</label><input type="number" id="settingsTeamPresetMaxTokens" placeholder="default"></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamCreatePresetBtn">Create</button><button class="btn-small" id="settingsTeamCancelPreset">Cancel</button></div>
</div>
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
</section>
</div>
@@ -421,34 +406,7 @@
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Preset</button>
</div>
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Name</label><input type="text" id="adminPresetName" placeholder="Code Reviewer"></div>
<div class="form-group"><label>Icon</label><input type="text" id="adminPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
</div>
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
<div class="avatar-preview avatar-preview-sm" id="presetAvatarPreview">
<span id="presetAvatarPlaceholder">🤖</span>
</div>
<div class="avatar-actions">
<button class="btn-small" type="button" id="presetAvatarUploadBtn">Upload Avatar</button>
<button class="btn-small btn-danger" type="button" id="presetAvatarRemoveBtn" style="display:none">Remove</button>
<input type="file" id="presetAvatarFileInput" 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="adminPresetDesc" placeholder="Reviews code for best practices"></div>
<div class="form-row">
<div class="form-group"><label>Base Model</label><select id="adminPresetModel"></select></div>
<div class="form-group"><label>Provider Config</label><select id="adminPresetConfig"><option value="">Auto-resolve</option></select></div>
</div>
<div class="form-group"><label>System Prompt</label><textarea id="adminPresetPrompt" 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="adminPresetTemp" step="0.1" min="0" max="2" placeholder="default"></div>
<div class="form-group"><label>Max Tokens</label><input type="number" id="adminPresetMaxTokens" placeholder="default"></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreatePresetBtn">Create</button><button class="btn-small" id="adminCancelPresetBtn">Cancel</button></div>
</div>
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
<div id="adminPresetList"></div>
</div>
<div class="admin-tab-content" id="adminTeamsTab" style="display:none">

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,37 +1056,40 @@ 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 = '';
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
document.getElementById('settingsTeamCancelPreset')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddPreset').style.display = 'none';
});
document.getElementById('settingsTeamCreatePresetBtn')?.addEventListener('click', async () => {
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;
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,
};
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
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 = ''; });
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(); // refresh model selector
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _teamPresetForm.clearForm(); }
});
} else {
_teamPresetForm.clearForm();
}
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
// Admin — banner controls
@@ -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>`; }