Changeset 0.8.5 (#48)

This commit is contained in:
2026-02-22 12:22:56 +00:00
parent f9f362c900
commit 633421708f
13 changed files with 343 additions and 26 deletions

View File

@@ -208,14 +208,15 @@ Required for enterprise and compliance. Cheap to build, expensive to retrofit.
(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
- [x] Users can create personal presets from any enabled base model
(removed user_providers_enabled gate; uses shared renderPresetForm)
- [x] Settings Models tab reworked as "My Models": toggle to hide/show
base models from selector, "My Presets" section with create/delete
- [x] `user_model_preferences` table: user_id, model_id, hidden boolean
— lightweight filter, not access control
- [ ] User personal provider models: simple enable/disable toggle
(no team dimension — personal keys are personal)
- [x] Hidden models filtered from main model selector dropdown
- [x] User personal provider models: simple enable/disable toggle
(same visibility toggle — hide from selector)
**Team Providers (0.8.6+)**
- [ ] `team_id` column on `api_configs` (nullable, symmetric with `user_id`)

View File

@@ -1 +1 @@
0.8.4
0.8.5

View File

@@ -141,6 +141,8 @@ check_column "api_configs" "is_global"
check_table "user_model_preferences"
check_column "user_model_preferences" "user_id"
check_column "user_model_preferences" "model_config_id"
check_column "user_model_preferences" "model_id"
check_column "user_model_preferences" "hidden"
# ── 8. Channel unification (006-008) ────────
echo ""

View File

@@ -0,0 +1,8 @@
-- ==========================================
-- Migration 019: (superseded by 020)
-- ==========================================
-- Original CREATE TABLE IF NOT EXISTS was a no-op because
-- user_model_preferences already existed from migration 005.
-- The actual rework is in 020_user_model_preferences_rework.sql.
-- ==========================================
SELECT 1;

View File

@@ -0,0 +1,24 @@
-- ==========================================
-- Migration 019: User Model Preferences (rework)
-- ==========================================
-- The user_model_preferences table was created in 005 with:
-- id UUID PK, user_id UUID, model_config_id UUID FK, is_enabled BOOL
-- That schema ties preferences to model_configs rows (global only).
-- We need string-based model_id to support personal provider models too,
-- plus a 'hidden' column with clearer semantics.
--
-- Strategy: add new columns, add unique constraint for UPSERT.
-- Old columns (model_config_id, is_enabled) remain for backward compat.
-- ==========================================
-- Add new columns if they don't exist
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS model_id VARCHAR(255);
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT false;
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
-- Make model_config_id nullable (new rows use model_id instead)
ALTER TABLE user_model_preferences ALTER COLUMN model_config_id DROP NOT NULL;
-- Unique constraint for UPSERT — NULLs are distinct in PG so old rows won't conflict
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_model_pref_user_model
ON user_model_preferences (user_id, model_id);

View File

@@ -0,0 +1,125 @@
package handlers
import (
"log"
"net/http"
"git.gobha.me/xcaliber/chat-switchboard/database"
"github.com/gin-gonic/gin"
)
// GetModelPreferences returns the user's hidden model list.
// GET /api/v1/models/preferences
func GetModelPreferences(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT model_id, hidden FROM user_model_preferences
WHERE user_id = $1
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query preferences"})
return
}
defer rows.Close()
type pref struct {
ModelID string `json:"model_id"`
Hidden bool `json:"hidden"`
}
prefs := make([]pref, 0)
for rows.Next() {
var p pref
if err := rows.Scan(&p.ModelID, &p.Hidden); err != nil {
continue
}
prefs = append(prefs, p)
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
// SetModelPreference sets hidden state for a single model.
// PUT /api/v1/models/preferences
func SetModelPreference(c *gin.Context) {
userID := getUserID(c)
var req struct {
ModelID string `json:"model_id" binding:"required"`
Hidden bool `json:"hidden"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := database.DB.Exec(`
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, model_id)
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
`, userID, req.ModelID, req.Hidden)
if err != nil {
log.Printf("[WARN] Failed to save model preference for user %s, model %s: %v", userID, req.ModelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"model_id": req.ModelID, "hidden": req.Hidden})
}
// BulkSetModelPreferences sets hidden state for multiple models at once.
// POST /api/v1/models/preferences/bulk
func BulkSetModelPreferences(c *gin.Context) {
userID := getUserID(c)
var req struct {
ModelIDs []string `json:"model_ids" binding:"required"`
Hidden bool `json:"hidden"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.ModelIDs) == 0 {
c.JSON(http.StatusOK, gin.H{"updated": 0})
return
}
tx, err := database.DB.Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin transaction"})
return
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, model_id)
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare statement"})
return
}
defer stmt.Close()
updated := 0
for _, modelID := range req.ModelIDs {
if _, err := stmt.Exec(userID, modelID, req.Hidden); err != nil {
log.Printf("[WARN] Failed to save preference for model %s: %v", modelID, err)
continue
}
updated++
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"})
return
}
c.JSON(http.StatusOK, gin.H{"updated": updated})
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"database/sql"
"log"
"net/http"
"strings"
@@ -110,12 +111,6 @@ func (h *PresetHandler) ListUserPresets(c *gin.Context) {
func (h *PresetHandler) CreateUserPreset(c *gin.Context) {
userID := getUserID(c)
// Gate: personal presets require user_providers_enabled
if !isUserProvidersEnabled() {
c.JSON(http.StatusForbidden, gin.H{"error": "personal presets are disabled by admin"})
return
}
var req createPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -159,7 +154,8 @@ func (h *PresetHandler) CreateUserPreset(c *gin.Context) {
).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset"})
log.Printf("[WARN] Failed to create user preset: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()})
return
}

View File

@@ -128,6 +128,9 @@ func main() {
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
protected.GET("/models", apiCfg.ListAllModels)
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
protected.GET("/models/preferences", handlers.GetModelPreferences)
protected.PUT("/models/preferences", handlers.SetModelPreference)
protected.POST("/models/preferences/bulk", handlers.BulkSetModelPreferences)
// User Settings & Profile
settings := handlers.NewSettingsHandler()

View File

@@ -1013,6 +1013,8 @@ button { font-family: var(--font); cursor: pointer; }
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); }
.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); }
.admin-model-toggle.team { border-color: #60a5fa; color: #60a5fa; }
.model-list-item.model-hidden { opacity: 0.5; }
.model-list-item.model-hidden .model-name { text-decoration: line-through; }
.admin-provider-row {
display: flex; align-items: center; gap: 12px; padding: 10px 0;

View File

@@ -305,8 +305,24 @@
</div>
<!-- Models Tab -->
<div class="settings-tab-content" id="settingsModelsTab" style="display:none">
<p class="section-hint">Models available from your providers and global configs.</p>
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
<section class="settings-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<h3 style="font-size:14px;margin:0">Available Models</h3>
<div>
<button class="btn-small" onclick="bulkSetUserModelVisibility(true)" title="Show all models in selector">Show All</button>
<button class="btn-small" onclick="bulkSetUserModelVisibility(false)" title="Hide all models from selector">Hide All</button>
</div>
</div>
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through presets.</p>
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
</section>
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:4px">My Presets</h3>
<p class="section-hint" style="margin-bottom:8px">Personal model presets with custom system prompts and settings.</p>
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Preset</button>
</section>
</div>
<!-- Teams Tab (visible to team admins) -->
<div class="settings-tab-content" id="settingsTeamsTab" style="display:none">

View File

@@ -214,6 +214,15 @@ const API = {
// ── Models ───────────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
// User presets
listUserPresets() { return this._get('/api/v1/presets'); },
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
listAllModels() { return this._get('/api/v1/models'); },
// ── API Configs (user providers) ─────────

View File

@@ -6,6 +6,7 @@ const App = {
chats: [],
currentChatId: null,
models: [],
hiddenModels: new Set(),
isGenerating: false,
abortController: null,
serverSettings: {},
@@ -200,10 +201,16 @@ function resolveCapabilities(backendCaps, modelId) {
async function fetchModels() {
try {
const data = await API.listEnabledModels();
// Load user model preferences
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
);
} catch (e) { App.hiddenModels = new Set(); }
App.models = (data.models || []).map(m => {
const isPreset = !!m.is_preset;
// Presets: use preset_id as selector value, base model for caps
// Regular: model_id is both
const id = isPreset ? (m.preset_id || m.id) : (m.model_id || m.id);
const baseModelId = m.model_id || m.id;
return {
@@ -218,6 +225,7 @@ async function fetchModels() {
presetScope: m.preset_scope || null,
presetAvatar: m.preset_avatar || null,
presetTeamName: m.preset_team_name || null,
hidden: !isPreset && App.hiddenModels.has(baseModelId),
};
});
@@ -233,7 +241,7 @@ async function fetchModels() {
}
return a.name.localeCompare(b.name);
});
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets)`);
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
UI.updateModelSelector();
UI.updateCapabilityBadges();
@@ -1092,6 +1100,49 @@ function initListeners() {
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
// User — personal presets
var _userPresetForm = null;
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
const container = document.getElementById('userAddPresetForm');
container.style.display = container.style.display === 'none' ? '' : 'none';
if (!_userPresetForm) {
_userPresetForm = renderPresetForm(container, {
prefix: 'userPreset',
showAvatar: true,
showProviderConfig: false,
onSubmit: async (vals) => {
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.createUserPreset(vals);
const presetId = result?.id;
if (presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
}
container.style.display = 'none';
_userPresetForm.clearForm();
UI.toast('Preset created');
await UI.loadUserPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _userPresetForm.clearForm(); }
});
}
// Populate model dropdown from user's available models
const sel = _userPresetForm.getModelSelect();
if (sel) {
sel.innerHTML = '<option value="">Select base model...</option>';
App.models.filter(m => !m.isPreset).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
});
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
@@ -1569,6 +1620,46 @@ async function bulkSetVisibility(visibility) {
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, currentlyHidden) {
try {
await API.setModelPreference(modelId, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(modelId);
else App.hiddenModels.add(modelId);
await UI.loadUserModels();
await fetchModels(); // refresh model selector
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkSetUserModelVisibility(show) {
const models = App.models.filter(m => !m.isPreset);
if (!models.length) return;
const shouldHide = !show;
// Only update models not already in the desired state
const toUpdate = models
.map(m => m.baseModelId || m.id)
.filter(mid => App.hiddenModels.has(mid) !== shouldHide);
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
try {
await API.bulkSetModelPreferences(toUpdate, shouldHide);
toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid));
await UI.loadUserModels();
await fetchModels();
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUserPreset(id, name) {
if (!confirm(`Delete preset "${name}"?`)) return;
try {
await API.deleteUserPreset(id);
UI.toast('Preset deleted');
await UI.loadUserPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Presets ────────────────────────────
var _adminPresetForm = null;

View File

@@ -533,7 +533,7 @@ const UI = {
const globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global');
const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team');
const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal');
const models = App.models.filter(m => !m.isPreset);
const models = App.models.filter(m => !m.isPreset && !m.hidden);
const addGroup = (label, items) => {
if (items.length === 0) return;
@@ -573,7 +573,7 @@ const UI = {
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = '';
App.models.forEach(m => {
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
@@ -781,7 +781,7 @@ const UI = {
UI.loadProviderList();
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); }
if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'teams') UI.loadTeamsTab();
},
@@ -1469,14 +1469,24 @@ const UI = {
if (!el) return;
el.innerHTML = '<div class="loading">Loading models...</div>';
try {
// Load preferences if not already loaded
if (!App.hiddenModels) {
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
);
} catch (e) { App.hiddenModels = new Set(); }
}
const data = await API.listEnabledModels();
// 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</div>';
return;
}
el.innerHTML = models.map(m => {
const mid = m.model_id || m.id;
const caps = m.capabilities || {};
const badges = [];
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
@@ -1484,11 +1494,41 @@ const UI = {
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>
const hidden = App.hiddenModels.has(mid);
const toggleCls = hidden ? '' : 'enabled';
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
<span class="model-name">${esc(mid)}</span>
<span class="model-caps-inline">${badges.join('')}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src}
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadUserPresets() {
const el = document.getElementById('userPresetList');
if (!el) return;
try {
const data = await API.listUserPresets();
// Only show personal presets owned by user
const presets = (data.presets || []).filter(p => p.scope === 'personal');
if (!presets.length) {
el.innerHTML = '<div class="empty-hint">No personal presets yet</div>';
return;
}
el.innerHTML = presets.map(p => {
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="preset-row-avatar" alt="">` : '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong>
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
</div>
<div class="preset-actions">
<button class="btn-delete" onclick="deleteUserPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }