Changeset 0.8.3 (#46)
This commit is contained in:
36
server/database/migrations/018_model_visibility.sql
Normal file
36
server/database/migrations/018_model_visibility.sql
Normal 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';
|
||||
@@ -648,14 +648,14 @@ type modelConfigResponse struct {
|
||||
ProviderName string `json:"provider_name"`
|
||||
ModelID string `json:"model_id"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Visibility string `json:"visibility"`
|
||||
Capabilities map[string]interface{} `json:"capabilities"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type updateModelConfigRequest struct {
|
||||
IsEnabled *bool `json:"is_enabled"`
|
||||
Visibility *string `json:"visibility"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Capabilities map[string]interface{} `json:"capabilities"`
|
||||
}
|
||||
@@ -663,7 +663,7 @@ type updateModelConfigRequest struct {
|
||||
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
||||
rows, err := database.DB.Query(`
|
||||
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
|
||||
JOIN api_configs ac ON mc.api_config_id = ac.id
|
||||
WHERE ac.user_id IS NULL
|
||||
@@ -681,7 +681,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
||||
var capsJSON []byte
|
||||
if err := rows.Scan(
|
||||
&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 {
|
||||
continue
|
||||
}
|
||||
@@ -795,13 +795,18 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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(
|
||||
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW() WHERE id = $2`,
|
||||
*req.IsEnabled, modelID,
|
||||
`UPDATE model_configs SET visibility = $1, updated_at = NOW() WHERE id = $2`,
|
||||
v, modelID,
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -836,19 +841,24 @@ func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
||||
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) {
|
||||
var req struct {
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
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(
|
||||
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW()`,
|
||||
req.IsEnabled,
|
||||
`UPDATE model_configs SET visibility = $1, updated_at = NOW()
|
||||
WHERE api_config_id IN (SELECT id FROM api_configs WHERE user_id IS NULL)`,
|
||||
req.Visibility,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update models"})
|
||||
|
||||
@@ -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
|
||||
FROM model_configs mc
|
||||
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
|
||||
`)
|
||||
if err == nil {
|
||||
|
||||
@@ -455,6 +455,49 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||
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 ─────────────────────────────────
|
||||
|
||||
// getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes).
|
||||
|
||||
@@ -164,6 +164,7 @@ func main() {
|
||||
teamScoped.POST("/members", teams.AddMember)
|
||||
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||
teamScoped.GET("/models", teams.ListAvailableModels)
|
||||
|
||||
// Team presets
|
||||
teamPresets := handlers.NewPresetHandler()
|
||||
|
||||
@@ -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: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; }
|
||||
|
||||
.admin-provider-row {
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 0;
|
||||
|
||||
@@ -410,8 +410,9 @@
|
||||
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<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="bulkToggleModels(false)">Disable All</button>
|
||||
<button class="btn-small" onclick="bulkSetVisibility('enabled')">All Enabled</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>
|
||||
</div>
|
||||
<div id="adminModelList"></div>
|
||||
|
||||
@@ -279,7 +279,13 @@ const API = {
|
||||
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||||
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
||||
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}`); },
|
||||
|
||||
// ── Admin Presets ────────────────────────
|
||||
@@ -324,6 +330,7 @@ const API = {
|
||||
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
|
||||
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
||||
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||||
|
||||
// ── User Presets ─────────────────────────
|
||||
listPresets() { return this._get('/api/v1/presets'); },
|
||||
|
||||
@@ -1140,7 +1140,7 @@ function initListeners() {
|
||||
});
|
||||
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddPreset').style.display = '';
|
||||
UI.loadTeamPresetModelDropdown();
|
||||
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
|
||||
});
|
||||
document.getElementById('settingsTeamCancelPreset')?.addEventListener('click', () => {
|
||||
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(); }
|
||||
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 {
|
||||
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();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkToggleModels(enabled) {
|
||||
async function bulkSetVisibility(visibility) {
|
||||
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 {
|
||||
const resp = await API.adminBulkUpdateModels(enabled);
|
||||
hint.textContent = `${resp.count || 'All'} models ${enabled ? 'enabled' : 'disabled'}`;
|
||||
// Send both for backward compat with pre-0.8.3 backends
|
||||
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);
|
||||
await UI.loadAdminModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
|
||||
38
src/js/ui.js
38
src/js/ui.js
@@ -822,17 +822,31 @@ const UI = {
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamPresetModelDropdown() {
|
||||
async loadTeamPresetModelDropdown(teamId) {
|
||||
const sel = document.getElementById('settingsTeamPresetModel');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
const models = App.models.filter(m => !m.isPreset);
|
||||
models.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);
|
||||
});
|
||||
sel.innerHTML = '<option value="">Loading models...</option>';
|
||||
try {
|
||||
const resp = await API.teamListAvailableModels(teamId);
|
||||
const models = resp.models || [];
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
models.forEach(m => {
|
||||
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 ────────────────────────────
|
||||
@@ -1059,11 +1073,15 @@ 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>');
|
||||
// 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">
|
||||
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</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>`;
|
||||
}).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>`; }
|
||||
|
||||
Reference in New Issue
Block a user