From e78a27c06ee9800f6e371624fb312890507f5357 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 22 Feb 2026 10:52:47 +0000 Subject: [PATCH] Changeset 0.8.3 (#46) --- VERSION | 2 +- .../migrations/018_model_visibility.sql | 36 ++++++++++++++++ server/handlers/admin.go | 34 +++++++++------ server/handlers/apiconfigs.go | 2 +- server/handlers/teams.go | 43 +++++++++++++++++++ server/main.go | 1 + src/css/styles.css | 1 + src/index.html | 5 ++- src/js/api.js | 9 +++- src/js/app.js | 20 ++++++--- src/js/ui.js | 38 +++++++++++----- 11 files changed, 157 insertions(+), 34 deletions(-) create mode 100644 server/database/migrations/018_model_visibility.sql diff --git a/VERSION b/VERSION index 100435b..ee94dd8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.2 +0.8.3 diff --git a/server/database/migrations/018_model_visibility.sql b/server/database/migrations/018_model_visibility.sql new file mode 100644 index 0000000..b1f7a15 --- /dev/null +++ b/server/database/migrations/018_model_visibility.sql @@ -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'; diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 3ef9030..c06da35 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -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"}) diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 7ea5f64..0ff8d15 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -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 { diff --git a/server/handlers/teams.go b/server/handlers/teams.go index d73e58e..2168e19 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -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). diff --git a/server/main.go b/server/main.go index 52739c1..b4f7a5b 100644 --- a/server/main.go +++ b/server/main.go @@ -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() diff --git a/src/css/styles.css b/src/css/styles.css index f21b5fe..91c275f 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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; diff --git a/src/index.html b/src/index.html index 55a6fbc..71c7b65 100644 --- a/src/index.html +++ b/src/index.html @@ -410,8 +410,9 @@