Changeset 0.8.4 (#47)
This commit is contained in:
49
ROADMAP.md
49
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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
286
src/js/app.js
286
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,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'); }
|
||||
|
||||
176
src/js/ui.js
176
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 = `
|
||||
<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>`; }
|
||||
|
||||
Reference in New Issue
Block a user