diff --git a/VERSION b/VERSION index 9325c3c..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 \ No newline at end of file +0.4.0 diff --git a/migrations/004_model_configs.sql b/migrations/004_model_configs.sql new file mode 100644 index 0000000..f1da96b --- /dev/null +++ b/migrations/004_model_configs.sql @@ -0,0 +1,12 @@ +-- Model configurations: admin-curated list of available models +CREATE TABLE IF NOT EXISTS model_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE, + model_id TEXT NOT NULL, + display_name TEXT, + is_enabled BOOLEAN DEFAULT true, + capabilities JSONB DEFAULT '{"tool": false, "thinking": false, "vision": false, "code": false}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(api_config_id, model_id) +); diff --git a/server/handlers/admin.go b/server/handlers/admin.go index e7e0821..7d41e62 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -10,6 +10,7 @@ import ( "golang.org/x/crypto/bcrypt" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/providers" ) // ── Types ─────────────────────────────────── @@ -384,3 +385,283 @@ func (h *AdminHandler) GetStats(c *gin.Context) { c.JSON(http.StatusOK, stats) } + +// ── Global API Configs ────────────────────── + +type adminGlobalConfigResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + ModelDefault *string `json:"model_default"` + IsActive bool `json:"is_active"` + HasKey bool `json:"has_key"` + CreatedAt string `json:"created_at"` +} + +func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { + rows, err := database.DB.Query(` + SELECT id, name, provider, endpoint, model_default, is_active, + (api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key, + created_at + FROM api_configs + WHERE user_id IS NULL + ORDER BY created_at ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) + return + } + defer rows.Close() + + configs := make([]adminGlobalConfigResponse, 0) + for rows.Next() { + var cfg adminGlobalConfigResponse + if err := rows.Scan( + &cfg.ID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, + &cfg.ModelDefault, &cfg.IsActive, &cfg.HasKey, &cfg.CreatedAt, + ); err != nil { + continue + } + configs = append(configs, cfg) + } + + c.JSON(http.StatusOK, gin.H{"configs": configs}) +} + +func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { + var req createAPIConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var id string + err := database.DB.QueryRow(` + INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id) + VALUES ($1, $2, $3, $4, $5, NULL) + RETURNING id + `, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"id": id, "message": "global config created"}) +} + +func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { + configID := c.Param("id") + + result, err := database.DB.Exec( + `DELETE FROM api_configs WHERE id = $1 AND user_id IS NULL`, + configID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"}) + return + } + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "global config not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "global config deleted"}) +} + +// ── Model Configs ─────────────────────────── + +type modelConfigResponse struct { + ID string `json:"id"` + APIConfigID string `json:"api_config_id"` + ProviderName string `json:"provider_name"` + ModelID string `json:"model_id"` + DisplayName *string `json:"display_name"` + IsEnabled bool `json:"is_enabled"` + Capabilities map[string]interface{} `json:"capabilities"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type updateModelConfigRequest struct { + IsEnabled *bool `json:"is_enabled"` + DisplayName *string `json:"display_name"` + Capabilities map[string]interface{} `json:"capabilities"` +} + +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 + FROM model_configs mc + JOIN api_configs ac ON mc.api_config_id = ac.id + WHERE ac.user_id IS NULL + ORDER BY ac.name, mc.model_id + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) + return + } + defer rows.Close() + + models := make([]modelConfigResponse, 0) + for rows.Next() { + var m modelConfigResponse + var capsJSON []byte + if err := rows.Scan( + &m.ID, &m.APIConfigID, &m.ProviderName, &m.ModelID, &m.DisplayName, + &m.IsEnabled, &capsJSON, &m.CreatedAt, &m.UpdatedAt, + ); err != nil { + continue + } + _ = json.Unmarshal(capsJSON, &m.Capabilities) + models = append(models, m) + } + + c.JSON(http.StatusOK, gin.H{"models": models}) +} + +func (h *AdminHandler) FetchModels(c *gin.Context) { + // Load all global api_configs + rows, err := database.DB.Query(` + SELECT id, provider, endpoint, api_key_encrypted + FROM api_configs + WHERE user_id IS NULL AND is_active = true + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) + return + } + defer rows.Close() + + type fetchResult struct { + ConfigID string `json:"config_id"` + Provider string `json:"provider"` + Added int `json:"added"` + Skipped int `json:"skipped"` + Error string `json:"error,omitempty"` + } + results := make([]fetchResult, 0) + totalAdded := 0 + + for rows.Next() { + var cfgID, providerID, endpoint string + var apiKey *string + if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey); err != nil { + continue + } + + prov, err := providers.Get(providerID) + if err != nil { + results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()}) + continue + } + + key := "" + if apiKey != nil { + key = *apiKey + } + + models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{ + Endpoint: endpoint, + APIKey: key, + }) + if err != nil { + results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()}) + continue + } + + fr := fetchResult{ConfigID: cfgID, Provider: providerID} + for _, m := range models { + result, err := database.DB.Exec(` + INSERT INTO model_configs (api_config_id, model_id, display_name) + VALUES ($1, $2, $3) + ON CONFLICT (api_config_id, model_id) DO NOTHING + `, cfgID, m.ID, m.Name) + if err != nil { + fr.Skipped++ + continue + } + affected, _ := result.RowsAffected() + if affected > 0 { + fr.Added++ + totalAdded++ + } else { + fr.Skipped++ + } + } + results = append(results, fr) + } + + c.JSON(http.StatusOK, gin.H{ + "total_added": totalAdded, + "results": results, + }) +} + +func (h *AdminHandler) UpdateModelConfig(c *gin.Context) { + modelID := c.Param("id") + + var req updateModelConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Build dynamic update + if req.IsEnabled != nil { + _, err := database.DB.Exec( + `UPDATE model_configs SET is_enabled = $1, updated_at = NOW() WHERE id = $2`, + *req.IsEnabled, modelID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled"}) + return + } + } + + if req.DisplayName != nil { + _, err := database.DB.Exec( + `UPDATE model_configs SET display_name = $1, updated_at = NOW() WHERE id = $2`, + *req.DisplayName, modelID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update name"}) + return + } + } + + if req.Capabilities != nil { + capsJSON, err := json.Marshal(req.Capabilities) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capabilities"}) + return + } + _, err = database.DB.Exec( + `UPDATE model_configs SET capabilities = $1, updated_at = NOW() WHERE id = $2`, + string(capsJSON), modelID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update capabilities"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"message": "model updated"}) +} + +func (h *AdminHandler) DeleteModelConfig(c *gin.Context) { + modelID := c.Param("id") + result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) + return + } + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "model deleted"}) +} diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index b6bc8bb..553f76c 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -411,6 +411,47 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"models": allModels}) } +// ── List Enabled Models (from model_configs) ─ + +func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { + type enabledModel struct { + ID string `json:"id"` + ModelID string `json:"model_id"` + DisplayName *string `json:"display_name"` + Provider string `json:"provider"` + ProviderName string `json:"provider_name"` + ConfigID string `json:"config_id"` + Capabilities map[string]interface{} `json:"capabilities"` + } + + rows, err := database.DB.Query(` + 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 + ORDER BY ac.name, mc.model_id + `) + if err != nil { + c.JSON(http.StatusOK, gin.H{"models": []interface{}{}}) + return + } + defer rows.Close() + + models := make([]enabledModel, 0) + for rows.Next() { + var m enabledModel + var capsJSON []byte + if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil { + continue + } + m.Capabilities = make(map[string]interface{}) + _ = json.Unmarshal(capsJSON, &m.Capabilities) + models = append(models, m) + } + + c.JSON(http.StatusOK, gin.H{"models": models}) +} + // ── Helpers ───────────────────────────────── type scannable interface { diff --git a/server/main.go b/server/main.go index cc64ea8..104b93c 100644 --- a/server/main.go +++ b/server/main.go @@ -99,6 +99,7 @@ func main() { // Models (per-config and aggregate) protected.GET("/api-configs/:id/models", apiCfg.ListModels) protected.GET("/models", apiCfg.ListAllModels) + protected.GET("/models/enabled", apiCfg.ListEnabledModels) // User Settings & Profile settings := handlers.NewSettingsHandler() @@ -131,6 +132,17 @@ func main() { // Stats admin.GET("/stats", adm.GetStats) + + // Global API Configs + admin.GET("/configs", adm.ListGlobalConfigs) + admin.POST("/configs", adm.CreateGlobalConfig) + admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) + + // Model Configs + admin.GET("/models", adm.ListModelConfigs) + admin.POST("/models/fetch", adm.FetchModels) + admin.PUT("/models/:id", adm.UpdateModelConfig) + admin.DELETE("/models/:id", adm.DeleteModelConfig) } } diff --git a/server/main_test.go b/server/main_test.go index 794e575..80ef7b1 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -96,6 +96,7 @@ func TestAllRoutesRegistered(t *testing.T) { protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig) protected.GET("/api-configs/:id/models", apiCfg.ListModels) protected.GET("/models", apiCfg.ListAllModels) + protected.GET("/models/enabled", apiCfg.ListEnabledModels) // User Settings & Profile protected.GET("/profile", settings.GetProfile) @@ -118,6 +119,13 @@ func TestAllRoutesRegistered(t *testing.T) { admin.GET("/settings/:key", adm.GetGlobalSetting) admin.PUT("/settings/:key", adm.UpdateGlobalSetting) admin.GET("/stats", adm.GetStats) + admin.GET("/configs", adm.ListGlobalConfigs) + admin.POST("/configs", adm.CreateGlobalConfig) + admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) + admin.GET("/models", adm.ListModelConfigs) + admin.POST("/models/fetch", adm.FetchModels) + admin.PUT("/models/:id", adm.UpdateModelConfig) + admin.DELETE("/models/:id", adm.DeleteModelConfig) } routes := r.Routes() @@ -153,6 +161,7 @@ func TestAllRoutesRegistered(t *testing.T) { "GET /api/v1/api-configs/:id/models", // Models "GET /api/v1/models", + "GET /api/v1/models/enabled", // Profile & Settings "GET /api/v1/profile", "PUT /api/v1/profile", @@ -170,6 +179,13 @@ func TestAllRoutesRegistered(t *testing.T) { "GET /api/v1/admin/settings/:key", "PUT /api/v1/admin/settings/:key", "GET /api/v1/admin/stats", + "GET /api/v1/admin/configs", + "POST /api/v1/admin/configs", + "DELETE /api/v1/admin/configs/:id", + "GET /api/v1/admin/models", + "POST /api/v1/admin/models/fetch", + "PUT /api/v1/admin/models/:id", + "DELETE /api/v1/admin/models/:id", } for _, e := range expected { diff --git a/src/css/styles.css b/src/css/styles.css index 55de521..8225689 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -42,6 +42,7 @@ body { border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; + flex-shrink: 0; align-items: center; flex-wrap: wrap; gap: 1rem; @@ -87,6 +88,7 @@ body { .main-container { display: flex; flex: 1; + min-height: 0; overflow: hidden; } @@ -170,12 +172,14 @@ body { display: flex; flex-direction: column; overflow: hidden; + min-height: 0; } .chat-messages { flex: 1; overflow-y: auto; padding: 1rem 2rem; + min-height: 0; } /* Messages */ @@ -386,6 +390,7 @@ body { background: var(--bg-secondary); border-top: 1px solid var(--border); padding: 1rem 2rem; + flex-shrink: 0; } .input-container { max-width: 800px; margin: 0 auto; } @@ -1199,3 +1204,211 @@ body { #profileChangePwForm .form-group { margin-bottom: 0.5rem; } + +/* ── API Providers List ──────────────────── */ + +.provider-list { + margin-bottom: 0.5rem; +} + +.provider-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.6rem; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 0.4rem; + background: var(--bg-primary); +} + +.provider-info { + flex: 1; + min-width: 0; +} + +.provider-name { + font-weight: 600; + font-size: 0.9rem; + display: block; +} + +.provider-meta { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.provider-actions { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.provider-empty { + padding: 0.75rem; + text-align: center; + color: var(--text-secondary); + font-size: 0.85rem; + border: 1px dashed var(--border); + border-radius: 6px; + margin-bottom: 0.5rem; +} + +.provider-add-toggle { + margin-bottom: 0.5rem; +} + +.provider-add-form { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 0.5rem; +} + +.provider-add-form .form-group { + margin-bottom: 0.5rem; +} + +.provider-add-form .form-group:last-of-type { + margin-bottom: 0.75rem; +} + +.admin-hint { + font-size: 0.8rem; + color: var(--text-secondary); + margin: 0 0 0.75rem 0; +} + +/* ── Admin Models Tab ────────────────────── */ + +.admin-models-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.admin-models-header .admin-hint { + flex: 1; + margin: 0; +} + +.admin-model-group { + margin-bottom: 1rem; +} + +.admin-model-group-header { + font-size: 0.8rem; + font-weight: 600; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.25rem 0; + border-bottom: 1px solid var(--border); + margin-bottom: 0.25rem; +} + +.admin-model-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); +} + +.admin-model-row:last-child { + border-bottom: none; +} + +.admin-model-toggle { + flex-shrink: 0; +} + +.toggle-label-compact input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.admin-model-info { + flex: 1; + min-width: 0; +} + +.admin-model-name { + font-weight: 500; + font-size: 0.85rem; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-model-id { + font-size: 0.7rem; + color: var(--text-secondary); + font-family: monospace; +} + +.admin-model-caps { + display: flex; + gap: 0.25rem; + flex-shrink: 0; +} + +.cap-badge { + font-size: 0.65rem; + padding: 0.1rem 0.35rem; + border-radius: 3px; + border: 1px solid var(--border); + cursor: pointer; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + background: none; + transition: all 0.15s; +} + +.cap-active { + background: var(--accent); + color: #000; + border-color: var(--accent); +} + +.cap-inactive { + color: var(--text-secondary); + opacity: 0.5; +} + +.cap-badge:hover { + opacity: 1; +} + +.admin-model-actions { + flex-shrink: 0; +} + +/* ── Settings Mode Badge ─────────────────── */ + +.settings-mode-badge { + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + margin-bottom: 1rem; + font-weight: 500; +} + +.mode-managed { + background: #10b98115; + border: 1px solid #10b98133; + color: #34d399; +} + +.mode-unmanaged { + background: #f59e0b15; + border: 1px solid #f59e0b33; + color: #fbbf24; +} diff --git a/src/index.html b/src/index.html index f4e039e..ad3efee 100644 --- a/src/index.html +++ b/src/index.html @@ -4,7 +4,7 @@