Changeset 0.8.3 (#46)

This commit is contained in:
2026-02-22 10:52:47 +00:00
parent 5111d595f7
commit e78a27c06e
11 changed files with 157 additions and 34 deletions

View File

@@ -1 +1 @@
0.8.2 0.8.3

View File

@@ -0,0 +1,36 @@
-- ==========================================
-- Migration 018: Model Visibility
-- ==========================================
-- Replace binary is_enabled with three-state visibility:
-- 'enabled' — visible to all users in model selector
-- 'disabled' — hidden from everyone
-- 'team' — only available to team admins for building presets
-- ==========================================
-- Add new column (idempotent)
ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS visibility VARCHAR(10) DEFAULT 'disabled';
-- Backfill from is_enabled if it still exists
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'model_configs' AND column_name = 'is_enabled'
) THEN
UPDATE model_configs SET visibility = CASE
WHEN is_enabled = true THEN 'enabled'
ELSE 'disabled'
END;
ALTER TABLE model_configs DROP COLUMN is_enabled;
END IF;
END $$;
-- Ensure NOT NULL (safe: DEFAULT already covers new rows)
UPDATE model_configs SET visibility = 'disabled' WHERE visibility IS NULL;
ALTER TABLE model_configs ALTER COLUMN visibility SET NOT NULL;
-- Constraint (drop first for idempotency)
ALTER TABLE model_configs DROP CONSTRAINT IF EXISTS chk_model_visibility;
ALTER TABLE model_configs ADD CONSTRAINT chk_model_visibility
CHECK (visibility IN ('enabled', 'disabled', 'team'));
COMMENT ON COLUMN model_configs.visibility IS 'enabled=all users, team=team admin presets only, disabled=hidden';

View File

@@ -648,14 +648,14 @@ type modelConfigResponse struct {
ProviderName string `json:"provider_name"` ProviderName string `json:"provider_name"`
ModelID string `json:"model_id"` ModelID string `json:"model_id"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
IsEnabled bool `json:"is_enabled"` Visibility string `json:"visibility"`
Capabilities map[string]interface{} `json:"capabilities"` Capabilities map[string]interface{} `json:"capabilities"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
type updateModelConfigRequest struct { type updateModelConfigRequest struct {
IsEnabled *bool `json:"is_enabled"` Visibility *string `json:"visibility"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Capabilities map[string]interface{} `json:"capabilities"` Capabilities map[string]interface{} `json:"capabilities"`
} }
@@ -663,7 +663,7 @@ type updateModelConfigRequest struct {
func (h *AdminHandler) ListModelConfigs(c *gin.Context) { func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
rows, err := database.DB.Query(` rows, err := database.DB.Query(`
SELECT mc.id, mc.api_config_id, ac.name, mc.model_id, mc.display_name, SELECT mc.id, mc.api_config_id, ac.name, mc.model_id, mc.display_name,
mc.is_enabled, mc.capabilities, mc.created_at, mc.updated_at mc.visibility, mc.capabilities, mc.created_at, mc.updated_at
FROM model_configs mc FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id JOIN api_configs ac ON mc.api_config_id = ac.id
WHERE ac.user_id IS NULL WHERE ac.user_id IS NULL
@@ -681,7 +681,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
var capsJSON []byte var capsJSON []byte
if err := rows.Scan( if err := rows.Scan(
&m.ID, &m.APIConfigID, &m.ProviderName, &m.ModelID, &m.DisplayName, &m.ID, &m.APIConfigID, &m.ProviderName, &m.ModelID, &m.DisplayName,
&m.IsEnabled, &capsJSON, &m.CreatedAt, &m.UpdatedAt, &m.Visibility, &capsJSON, &m.CreatedAt, &m.UpdatedAt,
); err != nil { ); err != nil {
continue continue
} }
@@ -795,13 +795,18 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
} }
// Build dynamic update // Build dynamic update
if req.IsEnabled != nil { if req.Visibility != nil {
v := *req.Visibility
if v != "enabled" && v != "disabled" && v != "team" {
c.JSON(http.StatusBadRequest, gin.H{"error": "visibility must be enabled, disabled, or team"})
return
}
_, err := database.DB.Exec( _, err := database.DB.Exec(
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW() WHERE id = $2`, `UPDATE model_configs SET visibility = $1, updated_at = NOW() WHERE id = $2`,
*req.IsEnabled, modelID, v, modelID,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"})
return return
} }
} }
@@ -836,19 +841,24 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "model updated"}) c.JSON(http.StatusOK, gin.H{"message": "model updated"})
} }
// BulkUpdateModels enables or disables all models at once // BulkUpdateModels sets visibility for all models at once
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) { func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
var req struct { var req struct {
IsEnabled bool `json:"is_enabled"` Visibility string `json:"visibility"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if req.Visibility != "enabled" && req.Visibility != "disabled" && req.Visibility != "team" {
c.JSON(http.StatusBadRequest, gin.H{"error": "visibility must be enabled, disabled, or team"})
return
}
result, err := database.DB.Exec( result, err := database.DB.Exec(
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW()`, `UPDATE model_configs SET visibility = $1, updated_at = NOW()
req.IsEnabled, WHERE api_config_id IN (SELECT id FROM api_configs WHERE user_id IS NULL)`,
req.Visibility,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update models"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update models"})

View File

@@ -448,7 +448,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
FROM model_configs mc FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id JOIN api_configs ac ON mc.api_config_id = ac.id
WHERE mc.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL WHERE mc.visibility = 'enabled' AND ac.is_active = true AND ac.user_id IS NULL
ORDER BY ac.name, mc.model_id ORDER BY ac.name, mc.model_id
`) `)
if err == nil { if err == nil {

View File

@@ -455,6 +455,49 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": teams}) c.JSON(http.StatusOK, gin.H{"data": teams})
} }
// ── Team Models: Available for Presets ──────
// ListAvailableModels returns models with visibility 'enabled' or 'team'
// for team admins building presets. Requires RequireTeamAdmin middleware.
// GET /api/v1/teams/:teamId/models
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
ac.provider, ac.name as provider_name
FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id
WHERE mc.visibility IN ('enabled', 'team')
AND ac.is_active = true AND ac.user_id IS NULL
ORDER BY ac.name, mc.model_id
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
defer rows.Close()
type availableModel struct {
ID string `json:"id"`
ModelID string `json:"model_id"`
DisplayName *string `json:"display_name"`
Visibility string `json:"visibility"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
}
models := make([]availableModel, 0)
for rows.Next() {
var m availableModel
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
&m.Provider, &m.ProviderName); err != nil {
continue
}
models = append(models, m)
}
c.JSON(http.StatusOK, gin.H{"models": models})
}
// ── Helpers ───────────────────────────────── // ── Helpers ─────────────────────────────────
// getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes). // getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes).

View File

@@ -164,6 +164,7 @@ func main() {
teamScoped.POST("/members", teams.AddMember) teamScoped.POST("/members", teams.AddMember)
teamScoped.PUT("/members/:memberId", teams.UpdateMember) teamScoped.PUT("/members/:memberId", teams.UpdateMember)
teamScoped.DELETE("/members/:memberId", teams.RemoveMember) teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
teamScoped.GET("/models", teams.ListAvailableModels)
// Team presets // Team presets
teamPresets := handlers.NewPresetHandler() teamPresets := handlers.NewPresetHandler()

View File

@@ -1012,6 +1012,7 @@ button { font-family: var(--font); cursor: pointer; }
.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; } .admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; }
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); } .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.enabled { border-color: var(--success); color: var(--success); }
.admin-model-toggle.team { border-color: #60a5fa; color: #60a5fa; }
.admin-provider-row { .admin-provider-row {
display: flex; align-items: center; gap: 12px; padding: 10px 0; display: flex; align-items: center; gap: 12px; padding: 10px 0;

View File

@@ -410,8 +410,9 @@
<div class="admin-tab-content" id="adminModelsTab" style="display:none"> <div class="admin-tab-content" id="adminModelsTab" style="display:none">
<div class="admin-toolbar"> <div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button> <button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
<button class="btn-small" onclick="bulkToggleModels(true)">Enable All</button> <button class="btn-small" onclick="bulkSetVisibility('enabled')">All Enabled</button>
<button class="btn-small" onclick="bulkToggleModels(false)">Disable All</button> <button class="btn-small" onclick="bulkSetVisibility('team')">All Team</button>
<button class="btn-small" onclick="bulkSetVisibility('disabled')">All Disabled</button>
<span class="admin-hint" id="adminModelsHint"></span> <span class="admin-hint" id="adminModelsHint"></span>
</div> </div>
<div id="adminModelList"></div> <div id="adminModelList"></div>

View File

@@ -279,7 +279,13 @@ const API = {
adminListModels() { return this._get('/api/v1/admin/models'); }, adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); }, adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); }, adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); }, adminBulkUpdateModels(visibility) {
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
return this._put('/api/v1/admin/models/bulk', {
visibility,
is_enabled: visibility === 'enabled'
});
},
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); }, adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── Admin Presets ──────────────────────── // ── Admin Presets ────────────────────────
@@ -324,6 +330,7 @@ const API = {
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); }, teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); }, teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); }, teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
// ── User Presets ───────────────────────── // ── User Presets ─────────────────────────
listPresets() { return this._get('/api/v1/presets'); }, listPresets() { return this._get('/api/v1/presets'); },

View File

@@ -1140,7 +1140,7 @@ function initListeners() {
}); });
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => { document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddPreset').style.display = ''; document.getElementById('settingsTeamAddPreset').style.display = '';
UI.loadTeamPresetModelDropdown(); UI.loadTeamPresetModelDropdown(UI._managingTeamId);
}); });
document.getElementById('settingsTeamCancelPreset')?.addEventListener('click', () => { document.getElementById('settingsTeamCancelPreset')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddPreset').style.display = 'none'; document.getElementById('settingsTeamAddPreset').style.display = 'none';
@@ -1622,21 +1622,27 @@ async function fetchAdminModels() {
try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); } try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); }
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; } catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
} }
async function toggleModel(id, enabled) { async function cycleModelVisibility(id, current) {
// Cycle: disabled → enabled → team → disabled
const next = current === 'disabled' ? 'enabled' : current === 'enabled' ? 'team' : 'disabled';
try { try {
const el = _adminScroll(), pos = el?.scrollTop || 0; const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdateModel(id, { is_enabled: enabled }); // Send both for backward compat with pre-0.8.3 backends
await API.adminUpdateModel(id, { visibility: next, is_enabled: next === 'enabled' });
await UI.loadAdminModels(); await UI.loadAdminModels();
_restoreScroll(el, pos); _restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
} }
async function bulkToggleModels(enabled) { async function bulkSetVisibility(visibility) {
const hint = document.getElementById('adminModelsHint'); const hint = document.getElementById('adminModelsHint');
hint.textContent = enabled ? 'Enabling all...' : 'Disabling all...'; const labels = { enabled: 'Enabling', disabled: 'Disabling', team: 'Setting team-only for' };
hint.textContent = `${labels[visibility] || 'Updating'} all...`;
try { try {
const resp = await API.adminBulkUpdateModels(enabled); // Send both for backward compat with pre-0.8.3 backends
hint.textContent = `${resp.count || 'All'} models ${enabled ? 'enabled' : 'disabled'}`; const resp = await API.adminBulkUpdateModels(visibility);
const doneLabels = { enabled: 'enabled', disabled: 'disabled', team: 'set to team-only' };
hint.textContent = `${resp.count || 'All'} models ${doneLabels[visibility]}`;
setTimeout(() => { hint.textContent = ''; }, 3000); setTimeout(() => { hint.textContent = ''; }, 3000);
await UI.loadAdminModels(); await UI.loadAdminModels();
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; } } catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }

View File

@@ -822,17 +822,31 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}, },
async loadTeamPresetModelDropdown() { async loadTeamPresetModelDropdown(teamId) {
const sel = document.getElementById('settingsTeamPresetModel'); const sel = document.getElementById('settingsTeamPresetModel');
if (!sel) return; if (!sel) return;
sel.innerHTML = '<option value="">Select base model...</option>'; sel.innerHTML = '<option value="">Loading models...</option>';
const models = App.models.filter(m => !m.isPreset); try {
models.forEach(m => { const resp = await API.teamListAvailableModels(teamId);
const opt = document.createElement('option'); const models = resp.models || [];
opt.value = m.baseModelId || m.id; sel.innerHTML = '<option value="">Select base model...</option>';
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''); models.forEach(m => {
sel.appendChild(opt); const opt = document.createElement('option');
}); opt.value = m.model_id || m.id;
const vis = m.visibility === 'team' ? ' [team-only]' : '';
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
sel.appendChild(opt);
});
} catch (e) {
// Fallback to App.models if team endpoint fails
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);
});
}
}, },
// ── Providers ──────────────────────────── // ── Providers ────────────────────────────
@@ -1059,11 +1073,15 @@ const UI = {
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>'); 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.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>'); if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
// Support both old is_enabled (bool) and new visibility (string)
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
return `<div class="admin-model-row"> return `<div class="admin-model-row">
<span class="model-name">${esc(m.model_id || m.id)}</span> <span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges.join('')}</span> <span class="model-caps-inline">${badges.join('')}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span> <span class="provider-meta">${esc(m.provider_name || '')}</span>
<button class="admin-model-toggle ${m.is_enabled ? 'enabled' : ''}" onclick="toggleModel('${m.id}', ${!m.is_enabled})">${m.is_enabled ? '✓ Enabled' : 'Disabled'}</button> <button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
</div>`; </div>`;
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>'; }).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; } } catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }