diff --git a/ROADMAP.md b/ROADMAP.md
index fa1726b..839de7e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
diff --git a/VERSION b/VERSION
index ee94dd8..b60d719 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.8.3
+0.8.4
diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go
index 0ff8d15..60d351a 100644
--- a/server/handlers/apiconfigs.go
+++ b/server/handlers/apiconfigs.go
@@ -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",
})
}
}
diff --git a/server/handlers/presets.go b/server/handlers/presets.go
index 432005b..8a4ef58 100644
--- a/server/handlers/presets.go
+++ b/server/handlers/presets.go
@@ -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
}
diff --git a/src/index.html b/src/index.html
index 71c7b65..79a1908 100644
--- a/src/index.html
+++ b/src/index.html
@@ -222,7 +222,7 @@
diff --git a/src/js/app.js b/src/js/app.js
index 51c03fa..0ee2349 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -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'); }
diff --git a/src/js/ui.js b/src/js/ui.js
index d454562..042cf01 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -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 = `
+
Name
+ ${showAvatar ? `
+
+
+ ๐ค
+
+
+
Upload Avatar
+
Remove
+
+
Optional ยท 128ร128 ยท shown in chat
+
+
` : ''}
+
Description
+
+
System Prompt
+
+
+ Create
+ Cancel
+
+ `;
+
+ // 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 = '
Loading models... ';
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 = '
Select base model... ';
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
? `
`
- : (p.icon ? `
${p.icon} ` : '');
- const scope = p.scope === 'global' ? '
global ' : '
personal ';
+ : '';
+ // Scope badge with attribution
+ let scopeBadge = '';
+ if (p.scope === 'global') {
+ scopeBadge = '
global ';
+ } else if (p.scope === 'team' && p.team_name) {
+ scopeBadge = `
๐ฅ ${esc(p.team_name)} `;
+ } else if (p.scope === 'personal') {
+ scopeBadge = '
personal ';
+ }
+ const creator = p.creator_name ? `
by ${esc(p.creator_name)} ` : '';
const status = p.is_active ? '' : '
inactive ';
return `
-
${avatarEl}${esc(p.name)} ${scope} ${status}
+
${avatarEl}${esc(p.name)} ${scopeBadge} ${creator} ${status}
${esc(p.base_model_id)} ยท ${esc(p.provider_name || 'auto')}
${p.description ? `
${esc(p.description)}
` : ''}
@@ -1313,9 +1470,10 @@ const UI = {
el.innerHTML = '
Loading models...
';
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 = '
No models available โ add a provider in the section above
';
+ el.innerHTML = '
No models available
';
return;
}
el.innerHTML = models.map(m => {
@@ -1325,10 +1483,12 @@ const UI = {
if (caps.tool_calling) badges.push('
๐ง ');
if (caps.vision) badges.push('
๐ ');
if (caps.thinking) badges.push('
๐ญ ');
+ const src = m.source === 'personal' ? '
personal ' : '';
return `
${esc(m.model_id || m.id)}
${badges.join('')}
${esc(m.provider_name || m.provider || '')}
+ ${src}
`;
}).join('');
} catch (e) { el.innerHTML = `
${esc(e.message)}
`; }