diff --git a/ROADMAP.md b/ROADMAP.md index 839de7e..1161714 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`) diff --git a/VERSION b/VERSION index b60d719..7ada0d3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.4 +0.8.5 diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh index 99a649d..a522ac2 100644 --- a/scripts/db-validate.sh +++ b/scripts/db-validate.sh @@ -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 "" diff --git a/server/database/migrations/019_user_model_preferences.sql b/server/database/migrations/019_user_model_preferences.sql new file mode 100644 index 0000000..c027db8 --- /dev/null +++ b/server/database/migrations/019_user_model_preferences.sql @@ -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; diff --git a/server/database/migrations/020_user_model_preferences_rework.sql b/server/database/migrations/020_user_model_preferences_rework.sql new file mode 100644 index 0000000..9b8a759 --- /dev/null +++ b/server/database/migrations/020_user_model_preferences_rework.sql @@ -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); diff --git a/server/handlers/model_prefs.go b/server/handlers/model_prefs.go new file mode 100644 index 0000000..d7d8351 --- /dev/null +++ b/server/handlers/model_prefs.go @@ -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}) +} diff --git a/server/handlers/presets.go b/server/handlers/presets.go index 8a4ef58..76c5610 100644 --- a/server/handlers/presets.go +++ b/server/handlers/presets.go @@ -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 } diff --git a/server/main.go b/server/main.go index b4f7a5b..88b1008 100644 --- a/server/main.go +++ b/server/main.go @@ -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() diff --git a/src/css/styles.css b/src/css/styles.css index 91c275f..a871919 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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; diff --git a/src/index.html b/src/index.html index 79a1908..6ac0745 100644 --- a/src/index.html +++ b/src/index.html @@ -305,8 +305,24 @@