diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 35643ec..d2dfef4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -105,9 +105,9 @@ jobs: - name: Add /tools to PATH run: echo "/tools" >> $GITHUB_PATH - - name: Install tools - run: | - apt-get update -qq && apt-get install -y -qq gettext-base postgresql-client > /dev/null +# - name: Install tools +# run: | +# apt-get update -qq && apt-get install -y -qq gettext-base postgresql-client > /dev/null # ── Determine environment ────────────────── - name: Determine environment diff --git a/Dockerfile b/Dockerfile index 5540135..915bdd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ # Stage 3: Production image (nginx + backend) # # Vendor libs are baked in during build so the -# app works in disconnected (SCIF) environments +# app works in disconnected environments # with no CDN access. # ============================================ diff --git a/README.md b/README.md index 7a41d49..09eaaed 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Point the frontend at the backend by setting the API base URL (defaults to same- | `debug.js` | 550 lines | Console/network intercept, state inspector (Ctrl+Shift+L) | | `vendor/` | 62KB | marked.js + DOMPurify (local, CDN fallback) | -No framework. No build step. No node_modules. Works in disconnected (SCIF) environments with vendor libs baked in. +No framework. No build step. No node_modules. Works in disconnected environments with vendor libs baked in. ### Backend (server/) @@ -142,7 +142,7 @@ The provided `Dockerfile` is a 3-stage build: 2. `node:20-alpine` — downloads vendor JS libs via `npm pack` 3. `nginx:1-alpine` — serves frontend, proxies `/api/` to Go backend -Vendor libs are baked into the image at build time — no CDN access needed at runtime (SCIF-safe). +Vendor libs are baked into the image at build time — no CDN access needed at runtime. ### Reverse Proxy diff --git a/ROADMAP.md b/ROADMAP.md index cfe03c3..2b4875e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ minor = add features (deprecate, don't remove), patch = fix something. --- -## Current State: v0.6.2 +## Current State: v0.7.0 ### ✅ Done @@ -40,6 +40,8 @@ minor = add features (deprecate, don't remove), patch = fix something. - [x] Provider capability system (known models, heuristic detection, resolution chain) - [x] Dynamic max_tokens resolution - [x] User + admin provider model listing with capabilities +- [x] Model presets (named wrappers: global/personal scope, system prompt, temp, max_tokens) +- [x] Preset unwrap in completion handler (transparent to provider) **Frontend (Vanilla JS)** - [x] Professional splash page (split-panel hero + tabbed auth) @@ -58,6 +60,14 @@ minor = add features (deprecate, don't remove), patch = fix something. - [x] Debug modal (console intercept, network log, state inspector) - [x] EventBus client with exponential backoff + max retries - [x] Export (Markdown, JSON, Text) +- [x] Model selector with preset grouping (⚡ Presets optgroup) +- [x] Admin Presets tab (create, toggle, delete global presets) +- [x] Preset-aware completion flow (preset_id sent to backend) +- [x] Custom dropdown for model selector (full CSS control, dark theme) +- [x] Admin edit buttons for providers and presets (inline form reuse) +- [x] Appearance settings tab (UI scale, message font size) +- [x] Mobile responsive layout (hamburger menu, sidebar overlay, dvh) +- [x] Model/preset name in message headers (replaces generic "Assistant") **CI/CD (Gitea Actions)** - [x] Three-env pipeline (dev/test/prod) @@ -76,20 +86,20 @@ minor = add features (deprecate, don't remove), patch = fix something. --- -## 0.7.0 — Custom Models / Presets +## ~~0.7.0 — Custom Models / Presets~~ ✅ Named wrappers around base models with bundled configuration. Admins create org-wide presets, users create personal ones (if user providers are enabled). -- [ ] `model_presets` table: name, base_model_id, system_prompt, temperature, +- [x] `model_presets` table: name, base_model_id, system_prompt, temperature, max_tokens, tools_enabled (jsonb), created_by, scope (global/team/personal), team_id (nullable, for future team scoping), is_shared -- [ ] Permission gating: personal presets require user_providers_enabled -- [ ] Admin preset management UI (create, edit, delete org-wide presets) -- [ ] User preset management UI in Settings (personal presets) -- [ ] Presets appear as first-class entries in model selector +- [x] Permission gating: personal presets require user_providers_enabled +- [x] Admin preset management UI (create, edit, delete org-wide presets) +- [x] User preset management UI in Settings (personal presets) +- [x] Presets appear as first-class entries in model selector ("Code Reviewer (GPT-4o)", "Research Assistant (Claude Opus)") -- [ ] Completion handler unwraps preset → base model + config overrides +- [x] Completion handler unwraps preset → base model + config overrides ## 0.7.x — Branding + Polish @@ -101,6 +111,16 @@ White-label support and UX improvements that exploit existing infrastructure. - [ ] Splash page reads branding config on load, falls back to Switchboard defaults - [ ] CSS accent color override from branding settings +**Profile Pictures / Avatars** +- [ ] `avatar` column on `users` table (base64 PNG, capped ~128x128, nullable) +- [ ] `preset_avatar` column on `model_presets` (alongside existing `icon` emoji field) +- [ ] Avatar upload in user Settings → Profile section (crop/resize client-side) +- [ ] Admin preset form: optional avatar upload (or keep emoji-only) +- [ ] `UI.avatar(msg)` helper: returns `` if avatar set, emoji fallback otherwise +- [ ] Replace hardcoded 👤/🤖 in `_messageHTML`, `streamResponse`, sidebar user area +- [ ] Avatar displayed in message headers, chat list, user flyout +- [ ] Storage: base64 in DB keeps backups self-contained (airgap-friendly) + **Message Editing + Forking** - [ ] Edit message → creates sibling (uses existing parent_id tree) - [ ] Regenerate → creates sibling model response @@ -109,7 +129,9 @@ White-label support and UX improvements that exploit existing infrastructure. **UX Polish** - [ ] Chat search / filter in sidebar -- [ ] UI preferences (theme toggle, font size — stored in user settings) +- [x] UI preferences: font size + UI scale (Appearance tab, localStorage) +- [x] Mobile responsive: hamburger menu, sidebar overlay, auto-collapse +- [x] Model name in message headers (preset name or model ID, not "Assistant") - [ ] Keyboard shortcuts (Ctrl+K command palette) - [ ] PWA manifest + offline shell + install prompt diff --git a/VERSION b/VERSION index a918a2a..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.7.0 diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh index f720e6e..728eb11 100644 --- a/scripts/db-validate.sh +++ b/scripts/db-validate.sh @@ -178,6 +178,18 @@ else ERRORS=$((ERRORS + 1)) fi +# ── 11. Model Presets (012) ───────────────── +echo "" +echo "Model Presets (012):" +check_table "model_presets" +check_column "model_presets" "name" +check_column "model_presets" "base_model_id" +check_column "model_presets" "api_config_id" +check_column "model_presets" "system_prompt" +check_column "model_presets" "scope" +check_column "model_presets" "created_by" +check_column "model_presets" "is_active" + # ═══════════════════════════════════════════ # ADD NEW MIGRATION CHECKS ABOVE THIS LINE # ═══════════════════════════════════════════ diff --git a/server/database/migrations/012_model_presets.sql b/server/database/migrations/012_model_presets.sql new file mode 100644 index 0000000..70d0ffa --- /dev/null +++ b/server/database/migrations/012_model_presets.sql @@ -0,0 +1,33 @@ +-- Model Presets: named wrappers around base models with bundled config. +-- Admins create org-wide presets, users create personal ones. + +CREATE TABLE IF NOT EXISTS model_presets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(200) NOT NULL, + description TEXT DEFAULT '', + base_model_id TEXT NOT NULL, -- e.g. "gpt-4o", "claude-sonnet-4-20250514" + api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE, + system_prompt TEXT DEFAULT '', + temperature REAL, -- NULL = use model default + max_tokens INTEGER, -- NULL = use model default + tools_enabled JSONB DEFAULT '[]'::jsonb, -- reserved for future tool framework + scope VARCHAR(20) NOT NULL DEFAULT 'personal' + CHECK (scope IN ('global', 'team', 'personal')), + team_id UUID, -- nullable; for future team scoping + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_shared BOOLEAN DEFAULT false, -- personal presets visible to others + is_active BOOLEAN DEFAULT true, + icon VARCHAR(10) DEFAULT '', -- emoji or short icon code + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_model_presets_scope ON model_presets(scope); +CREATE INDEX IF NOT EXISTS idx_model_presets_created_by ON model_presets(created_by); +CREATE INDEX IF NOT EXISTS idx_model_presets_team ON model_presets(team_id) WHERE team_id IS NOT NULL; + +COMMENT ON TABLE model_presets IS 'Named model configurations: org-wide or personal wrappers around base models'; +COMMENT ON COLUMN model_presets.base_model_id IS 'The underlying model_id (matches model_configs.model_id)'; +COMMENT ON COLUMN model_presets.api_config_id IS 'Which provider config to use (NULL = resolve at completion time)'; +COMMENT ON COLUMN model_presets.scope IS 'global = admin-created for all users; team = team-scoped; personal = user-created'; +COMMENT ON COLUMN model_presets.tools_enabled IS 'JSON array of tool names enabled for this preset (future use)'; diff --git a/server/handlers/admin.go b/server/handlers/admin.go index e30fade..92ef6e9 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "math" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -488,6 +489,56 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"id": id, "message": "global config created"}) } +func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { + configID := c.Param("id") + + var req struct { + Name *string `json:"name"` + Endpoint *string `json:"endpoint"` + APIKey *string `json:"api_key"` + ModelDefault *string `json:"model_default"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + sets := []string{} + args := []interface{}{} + argN := 1 + + add := func(col string, val interface{}) { + sets = append(sets, col+" = $"+strconv.Itoa(argN)) + args = append(args, val) + argN++ + } + if req.Name != nil { add("name", *req.Name) } + if req.Endpoint != nil { add("endpoint", *req.Endpoint) } + if req.APIKey != nil && *req.APIKey != "" { add("api_key_encrypted", *req.APIKey) } + if req.ModelDefault != nil { add("model_default", *req.ModelDefault) } + + if len(sets) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + sets = append(sets, "updated_at = NOW()") + args = append(args, configID) + query := "UPDATE api_configs SET " + strings.Join(sets, ", ") + " WHERE id = $" + strconv.Itoa(argN) + " AND user_id IS NULL" + result, err := database.DB.Exec(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update 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{"status": "updated"}) +} + func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { configID := c.Param("id") diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 2de2599..4e0bc24 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -432,6 +432,9 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { ConfigID string `json:"config_id"` Capabilities providers.ModelCapabilities `json:"capabilities"` Pricing *providers.ModelPricing `json:"pricing,omitempty"` + IsPreset bool `json:"is_preset,omitempty"` + PresetID string `json:"preset_id,omitempty"` + PresetScope string `json:"preset_scope,omitempty"` } models := make([]enabledModel, 0) @@ -532,6 +535,90 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { } } + // ── 3. Active presets (global + user's personal + shared) ── + presetRows, err := database.DB.Query(` + SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, + mp.icon, mp.scope, mp.temperature, mp.max_tokens, + COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name + FROM model_presets mp + LEFT JOIN api_configs ac ON mp.api_config_id = ac.id + WHERE mp.is_active = true + AND ( + mp.scope = 'global' + OR (mp.scope = 'personal' AND mp.created_by = $1) + OR (mp.scope = 'personal' AND mp.is_shared = true) + ) + ORDER BY mp.scope ASC, mp.name ASC + `, userID) + if err == nil { + defer presetRows.Close() + for presetRows.Next() { + var presetID, name, description, baseModelID, icon, scope, provID, provName string + var apiConfigID *string + var temp *float64 + var maxTok *int + if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID, + &icon, &scope, &temp, &maxTok, &provID, &provName); err != nil { + continue + } + + // Inherit capabilities from the base model already loaded in sections 1/2 + var caps providers.ModelCapabilities + capsFound := false + for _, existing := range models { + if existing.ModelID == baseModelID { + caps = existing.Capabilities + capsFound = true + break + } + } + if !capsFound { + // Base model not in enabled list — try synced model_configs + var capsJSON []byte + err := database.DB.QueryRow(` + SELECT capabilities FROM model_configs + WHERE model_id = $1 ORDER BY updated_at DESC LIMIT 1 + `, baseModelID).Scan(&capsJSON) + if err == nil && len(capsJSON) > 0 { + _ = json.Unmarshal(capsJSON, &caps) + capsFound = true + } + } + if !capsFound { + var found bool + caps, found = providers.LookupKnownModel(baseModelID) + if !found { + caps = providers.InferCapabilities(baseModelID) + } + } + caps.MaxOutputTokens = providers.ResolveMaxOutput(baseModelID, caps) + + cfgID := "" + if apiConfigID != nil { + cfgID = *apiConfigID + } + + // Build display name: "icon Name (base-model)" + displayName := name + if icon != "" { + displayName = icon + " " + name + } + + models = append(models, enabledModel{ + ID: presetID, + ModelID: baseModelID, + DisplayName: &displayName, + Provider: provID, + ProviderName: provName, + ConfigID: cfgID, + Capabilities: caps, + IsPreset: true, + PresetID: presetID, + PresetScope: scope, + }) + } + } + c.JSON(http.StatusOK, gin.H{"models": models}) } diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 5503710..9ffa617 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -21,6 +21,7 @@ type completionRequest struct { ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id Content string `json:"content" binding:"required"` Model string `json:"model,omitempty"` + PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config APIConfigID string `json:"api_config_id,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` @@ -72,6 +73,32 @@ func (h *CompletionHandler) Complete(c *gin.Context) { return } + // ── Preset unwrap: preset overrides defaults, explicit request fields win ── + var presetSystemPrompt string + if req.PresetID != "" { + preset := ResolvePreset(req.PresetID, userID) + if preset == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"}) + return + } + // Preset provides defaults; explicit request fields take priority + if req.Model == "" { + req.Model = preset.BaseModelID + } + if req.APIConfigID == "" && preset.APIConfigID != nil { + req.APIConfigID = *preset.APIConfigID + } + if req.Temperature == nil && preset.Temperature != nil { + req.Temperature = preset.Temperature + } + if req.MaxTokens == 0 && preset.MaxTokens != nil { + req.MaxTokens = *preset.MaxTokens + } + if preset.SystemPrompt != "" { + presetSystemPrompt = preset.SystemPrompt + } + } + // Resolve provider config providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req) if err != nil { @@ -86,7 +113,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } // Load conversation history - messages, err := h.loadConversation(channelID) + messages, err := h.loadConversation(channelID, presetSystemPrompt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) return @@ -367,7 +394,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c // ── Conversation Loader ───────────────────── -func (h *CompletionHandler) loadConversation(channelID string) ([]providers.Message, error) { +func (h *CompletionHandler) loadConversation(channelID string, presetSystemPrompt string) ([]providers.Message, error) { // Load system prompt from channel var systemPrompt *string _ = database.DB.QueryRow( @@ -376,7 +403,13 @@ func (h *CompletionHandler) loadConversation(channelID string) ([]providers.Mess messages := make([]providers.Message, 0) - if systemPrompt != nil && *systemPrompt != "" { + // Preset system prompt takes priority; channel system prompt is fallback + if presetSystemPrompt != "" { + messages = append(messages, providers.Message{ + Role: "system", + Content: presetSystemPrompt, + }) + } else if systemPrompt != nil && *systemPrompt != "" { messages = append(messages, providers.Message{ Role: "system", Content: *systemPrompt, diff --git a/server/handlers/presets.go b/server/handlers/presets.go new file mode 100644 index 0000000..ca713c1 --- /dev/null +++ b/server/handlers/presets.go @@ -0,0 +1,517 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +// PresetHandler handles model preset CRUD operations. +type PresetHandler struct{} + +// NewPresetHandler creates a new handler. +func NewPresetHandler() *PresetHandler { + return &PresetHandler{} +} + +// ── Request/Response Types ───────────────── + +type createPresetRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + BaseModelID string `json:"base_model_id" binding:"required"` + APIConfigID *string `json:"api_config_id,omitempty"` + SystemPrompt string `json:"system_prompt"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + ToolsEnabled string `json:"tools_enabled,omitempty"` + Icon string `json:"icon,omitempty"` + IsShared bool `json:"is_shared"` +} + +type updatePresetRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + BaseModelID *string `json:"base_model_id,omitempty"` + APIConfigID *string `json:"api_config_id,omitempty"` + SystemPrompt *string `json:"system_prompt,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + ToolsEnabled *string `json:"tools_enabled,omitempty"` + Icon *string `json:"icon,omitempty"` + IsShared *bool `json:"is_shared,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +type presetResponse struct { + models.ModelPreset + ProviderName string `json:"provider_name,omitempty"` + BaseModelName string `json:"base_model_name,omitempty"` +} + +// ── User Preset Endpoints ────────────────── +// These require user_providers_enabled for personal presets. + +// ListUserPresets returns all presets visible to the user: +// their own personal presets + all global presets + shared presets. +// GET /api/v1/presets +func (h *PresetHandler) ListUserPresets(c *gin.Context) { + userID := getUserID(c) + + rows, err := database.DB.Query(` + SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, + mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled, + mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active, + mp.icon, mp.created_at, mp.updated_at, + COALESCE(ac.name, '') as provider_name + FROM model_presets mp + LEFT JOIN api_configs ac ON mp.api_config_id = ac.id + WHERE mp.is_active = true + AND ( + mp.scope = 'global' + OR (mp.scope = 'personal' AND mp.created_by = $1) + OR (mp.scope = 'personal' AND mp.is_shared = true) + ) + ORDER BY mp.scope ASC, mp.name ASC + `, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"}) + return + } + defer rows.Close() + + presets := make([]presetResponse, 0) + for rows.Next() { + var p presetResponse + if err := rows.Scan( + &p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID, + &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled, + &p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive, + &p.Icon, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, + ); err != nil { + continue + } + presets = append(presets, p) + } + + c.JSON(http.StatusOK, gin.H{"presets": presets}) +} + +// CreateUserPreset creates a personal preset for the current user. +// POST /api/v1/presets +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()}) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + // Validate api_config_id belongs to user if provided + if req.APIConfigID != nil && *req.APIConfigID != "" { + var count int + err := database.DB.QueryRow(` + SELECT COUNT(*) FROM api_configs + WHERE id = $1 AND (user_id = $2 OR is_global = true OR user_id IS NULL) AND is_active = true + `, *req.APIConfigID, userID).Scan(&count) + if err != nil || count == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or inaccessible API config"}) + return + } + } + + toolsJSON := req.ToolsEnabled + if toolsJSON == "" { + toolsJSON = "[]" + } + + var id string + err := database.DB.QueryRow(` + INSERT INTO model_presets (name, description, base_model_id, api_config_id, + system_prompt, temperature, max_tokens, tools_enabled, + scope, created_by, is_shared, icon) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'personal', $9, $10, $11) + RETURNING id + `, req.Name, req.Description, req.BaseModelID, req.APIConfigID, + req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON, + userID, req.IsShared, req.Icon, + ).Scan(&id) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"id": id}) +} + +// UpdateUserPreset updates a personal preset owned by the current user. +// PUT /api/v1/presets/:id +func (h *PresetHandler) UpdateUserPreset(c *gin.Context) { + userID := getUserID(c) + presetID := c.Param("id") + + // Verify ownership + var createdBy, scope string + err := database.DB.QueryRow( + `SELECT created_by, scope FROM model_presets WHERE id = $1`, presetID, + ).Scan(&createdBy, &scope) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) + return + } + if scope != models.PresetScopePersonal || createdBy != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "can only edit your own personal presets"}) + return + } + + var req updatePresetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Build dynamic SET clause + sets := []string{} + args := []interface{}{} + argN := 1 + + addField := func(col string, val interface{}) { + sets = append(sets, col+" = $"+itoa(argN)) + args = append(args, val) + argN++ + } + + if req.Name != nil { + addField("name", strings.TrimSpace(*req.Name)) + } + if req.Description != nil { + addField("description", *req.Description) + } + if req.BaseModelID != nil { + addField("base_model_id", *req.BaseModelID) + } + if req.APIConfigID != nil { + addField("api_config_id", *req.APIConfigID) + } + if req.SystemPrompt != nil { + addField("system_prompt", *req.SystemPrompt) + } + if req.Temperature != nil { + addField("temperature", *req.Temperature) + } + if req.MaxTokens != nil { + addField("max_tokens", *req.MaxTokens) + } + if req.Icon != nil { + addField("icon", *req.Icon) + } + if req.IsShared != nil { + addField("is_shared", *req.IsShared) + } + if req.IsActive != nil { + addField("is_active", *req.IsActive) + } + + if len(sets) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + sets = append(sets, "updated_at = NOW()") + args = append(args, presetID) + + query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN) + _, err = database.DB.Exec(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "updated"}) +} + +// DeleteUserPreset deletes a personal preset owned by the current user. +// DELETE /api/v1/presets/:id +func (h *PresetHandler) DeleteUserPreset(c *gin.Context) { + userID := getUserID(c) + presetID := c.Param("id") + + result, err := database.DB.Exec(` + DELETE FROM model_presets WHERE id = $1 AND created_by = $2 AND scope = 'personal' + `, presetID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"}) + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "preset not found or not yours"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} + +// ── Admin Preset Endpoints ───────────────── + +// ListAdminPresets returns all presets (any scope) for admin management. +// GET /api/v1/admin/presets +func (h *PresetHandler) ListAdminPresets(c *gin.Context) { + rows, err := database.DB.Query(` + SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, + mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled, + mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active, + mp.icon, mp.created_at, mp.updated_at, + COALESCE(ac.name, '') as provider_name, + COALESCE(u.username, '') as creator_name + FROM model_presets mp + LEFT JOIN api_configs ac ON mp.api_config_id = ac.id + LEFT JOIN users u ON mp.created_by = u.id + ORDER BY mp.scope ASC, mp.name ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"}) + return + } + defer rows.Close() + + type adminPreset struct { + presetResponse + CreatorName string `json:"creator_name"` + } + + presets := make([]adminPreset, 0) + for rows.Next() { + var p adminPreset + if err := rows.Scan( + &p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID, + &p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled, + &p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive, + &p.Icon, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, &p.CreatorName, + ); err != nil { + continue + } + presets = append(presets, p) + } + + c.JSON(http.StatusOK, gin.H{"presets": presets}) +} + +// CreateAdminPreset creates a global preset (admin-managed, visible to all users). +// POST /api/v1/admin/presets +func (h *PresetHandler) CreateAdminPreset(c *gin.Context) { + userID := getUserID(c) + + var req createPresetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + // Validate api_config_id is a global config + if req.APIConfigID != nil && *req.APIConfigID != "" { + var count int + err := database.DB.QueryRow(` + SELECT COUNT(*) FROM api_configs + WHERE id = $1 AND (is_global = true OR user_id IS NULL) AND is_active = true + `, *req.APIConfigID).Scan(&count) + if err != nil || count == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "global presets must use a global API config"}) + return + } + } + + toolsJSON := req.ToolsEnabled + if toolsJSON == "" { + toolsJSON = "[]" + } + + var id string + err := database.DB.QueryRow(` + INSERT INTO model_presets (name, description, base_model_id, api_config_id, + system_prompt, temperature, max_tokens, tools_enabled, + scope, created_by, is_shared, icon) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'global', $9, true, $10) + RETURNING id + `, req.Name, req.Description, req.BaseModelID, req.APIConfigID, + req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON, + userID, req.Icon, + ).Scan(&id) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"id": id}) +} + +// UpdateAdminPreset updates any preset (admin can edit global and personal). +// PUT /api/v1/admin/presets/:id +func (h *PresetHandler) UpdateAdminPreset(c *gin.Context) { + presetID := c.Param("id") + + var req updatePresetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + sets := []string{} + args := []interface{}{} + argN := 1 + + addField := func(col string, val interface{}) { + sets = append(sets, col+" = $"+itoa(argN)) + args = append(args, val) + argN++ + } + + if req.Name != nil { + addField("name", strings.TrimSpace(*req.Name)) + } + if req.Description != nil { + addField("description", *req.Description) + } + if req.BaseModelID != nil { + addField("base_model_id", *req.BaseModelID) + } + if req.APIConfigID != nil { + addField("api_config_id", *req.APIConfigID) + } + if req.SystemPrompt != nil { + addField("system_prompt", *req.SystemPrompt) + } + if req.Temperature != nil { + addField("temperature", *req.Temperature) + } + if req.MaxTokens != nil { + addField("max_tokens", *req.MaxTokens) + } + if req.Icon != nil { + addField("icon", *req.Icon) + } + if req.IsShared != nil { + addField("is_shared", *req.IsShared) + } + if req.IsActive != nil { + addField("is_active", *req.IsActive) + } + + if len(sets) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + sets = append(sets, "updated_at = NOW()") + args = append(args, presetID) + + query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN) + result, err := database.DB.Exec(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"}) + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "updated"}) +} + +// DeleteAdminPreset deletes any preset. +// DELETE /api/v1/admin/presets/:id +func (h *PresetHandler) DeleteAdminPreset(c *gin.Context) { + presetID := c.Param("id") + + result, err := database.DB.Exec(`DELETE FROM model_presets WHERE id = $1`, presetID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"}) + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} + +// ── Helpers ──────────────────────────────── + +// isUserProvidersEnabled checks the global setting. +func isUserProvidersEnabled() bool { + var val string + err := database.DB.QueryRow( + `SELECT value FROM global_settings WHERE key = 'user_providers_enabled'`, + ).Scan(&val) + if err != nil { + return true // default: enabled + } + return val == "true" +} + +// itoa is a minimal int-to-string for building SQL arg placeholders. +func itoa(n int) string { + if n < 10 { + return string(rune('0' + n)) + } + return itoa(n/10) + string(rune('0'+n%10)) +} + +// ── Preset Resolution for Completion ─────── + +// ResolvePreset loads a preset by ID and returns its config overrides. +// Returns nil if preset not found or inactive. +func ResolvePreset(presetID, userID string) *models.ModelPreset { + var p models.ModelPreset + err := database.DB.QueryRow(` + SELECT id, name, base_model_id, api_config_id, system_prompt, + temperature, max_tokens, scope, created_by, is_active + FROM model_presets + WHERE id = $1 AND is_active = true + AND ( + scope = 'global' + OR (scope = 'personal' AND created_by = $2) + OR (scope = 'personal' AND is_shared = true) + ) + `, presetID, userID).Scan( + &p.ID, &p.Name, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt, + &p.Temperature, &p.MaxTokens, &p.Scope, &p.CreatedBy, &p.IsActive, + ) + if err != nil { + return nil + } + return &p +} diff --git a/server/main.go b/server/main.go index 32dec8f..b1ad371 100644 --- a/server/main.go +++ b/server/main.go @@ -130,6 +130,13 @@ func main() { protected.GET("/settings", settings.GetSettings) protected.PUT("/settings", settings.UpdateSettings) + // Model Presets (user) + presets := handlers.NewPresetHandler() + protected.GET("/presets", presets.ListUserPresets) + protected.POST("/presets", presets.CreateUserPreset) + protected.PUT("/presets/:id", presets.UpdateUserPreset) + protected.DELETE("/presets/:id", presets.DeleteUserPreset) + // Public global settings (non-admin users can read safe subset) adm := handlers.NewAdminHandler() protected.GET("/settings/public", adm.PublicSettings) @@ -161,6 +168,7 @@ func main() { // Global API Configs admin.GET("/configs", adm.ListGlobalConfigs) admin.POST("/configs", adm.CreateGlobalConfig) + admin.PUT("/configs/:id", adm.UpdateGlobalConfig) admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) // Model Configs @@ -169,6 +177,13 @@ func main() { admin.PUT("/models/bulk", adm.BulkUpdateModels) admin.PUT("/models/:id", adm.UpdateModelConfig) admin.DELETE("/models/:id", adm.DeleteModelConfig) + + // Model Presets (admin) + presetAdm := handlers.NewPresetHandler() + admin.GET("/presets", presetAdm.ListAdminPresets) + admin.POST("/presets", presetAdm.CreateAdminPreset) + admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset) + admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset) } } diff --git a/server/models/models.go b/server/models/models.go index 6590193..1dfea8e 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -150,6 +150,32 @@ type KnowledgeBase struct { Settings string `json:"settings,omitempty" db:"settings"` } +// ── Model Presets ────────────────────────── + +const ( + PresetScopeGlobal = "global" + PresetScopeTeam = "team" + PresetScopePersonal = "personal" +) + +type ModelPreset struct { + BaseModel + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + BaseModelID string `json:"base_model_id" db:"base_model_id"` + APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"` + SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` + Temperature *float64 `json:"temperature,omitempty" db:"temperature"` + MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"` + ToolsEnabled string `json:"tools_enabled,omitempty" db:"tools_enabled"` // JSON array + Scope string `json:"scope" db:"scope"` + TeamID *string `json:"team_id,omitempty" db:"team_id"` + CreatedBy string `json:"created_by" db:"created_by"` + IsShared bool `json:"is_shared" db:"is_shared"` + IsActive bool `json:"is_active" db:"is_active"` + Icon string `json:"icon,omitempty" db:"icon"` +} + // ── Settings ──────────────────────────────── type Settings struct { diff --git a/src/css/styles.css b/src/css/styles.css index 52921e1..3023cae 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -54,7 +54,7 @@ a:hover { text-decoration: underline; } /* ── App Shell ───────────────────────────── */ -.app { display: flex; height: 100vh; flex-direction: column; } +.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; } .app-body { display: flex; flex: 1; min-height: 0; } /* ── Environment Banners ──────────────────── */ @@ -279,9 +279,11 @@ a:hover { text-decoration: underline; } .avatar-bug { position: absolute; bottom: -3px; right: -3px; font-size: 10px; line-height: 1; + display: none; transition: filter var(--transition); } .avatar-bug.has-errors { + display: block; filter: drop-shadow(0 0 4px var(--danger)); animation: bug-pulse 1.5s ease infinite; } @@ -328,6 +330,45 @@ a:hover { text-decoration: underline; } .model-bar select:focus { outline: none; border-color: var(--accent); } .model-bar select option { background: var(--bg-surface); color: var(--text); } +/* ── Custom Model Dropdown ───────────────── */ + +.model-dropdown { position: relative; } +.model-dropdown-btn { + display: flex; align-items: center; gap: 6px; + background: none; border: 1px solid transparent; + color: var(--text); font-size: 14px; font-weight: 500; + padding: 4px 8px; border-radius: var(--radius); cursor: pointer; + font-family: var(--font); max-width: 340px; + transition: border-color var(--transition); +} +.model-dropdown-btn:hover { border-color: var(--border); } +.model-dropdown-btn svg { flex-shrink: 0; opacity: 0.5; } +.model-dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-dropdown-menu { + display: none; position: absolute; top: 100%; left: 0; z-index: 200; + min-width: 280px; max-width: 400px; max-height: 400px; overflow-y: auto; + background: var(--bg-raised); border: 1px solid var(--border-light); + border-radius: var(--radius); padding: 4px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + margin-top: 4px; +} +.model-dropdown-menu.open { display: block; } +.model-dropdown-group { + padding: 6px 10px 4px; font-size: 11px; font-weight: 600; + color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; +} +.model-dropdown-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border-radius: 4px; + cursor: pointer; font-size: 13px; + color: var(--text-2); white-space: nowrap; + transition: background var(--transition), color var(--transition); +} +.model-dropdown-item:hover { background: var(--bg-hover); color: var(--text); } +.model-dropdown-item.selected { background: var(--bg-hover); color: var(--accent); } +.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; } +.model-dropdown-item .item-provider { margin-left: auto; font-size: 10px; color: var(--text-3); } + .model-caps { display: flex; align-items: center; gap: 4px; margin-left: 4px; flex-wrap: wrap; @@ -386,7 +427,7 @@ a:hover { text-decoration: underline; } } .msg-action-btn:hover { background: var(--bg-hover); color: var(--text); } -.msg-text { font-size: 14px; line-height: 1.7; } +.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; } /* ── Markdown Content ────────────────────── */ @@ -651,8 +692,8 @@ button { font-family: var(--font); cursor: pointer; } /* Splash responsive */ @media (max-width: 860px) { - .splash { flex-direction: column; } - .splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; } + .splash { flex-direction: column; min-height: 100vh; min-height: 100dvh; overflow-y: auto; } + .splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; flex: none; } .hero-headline { font-size: 1.8rem; } .hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; } .hero-version { margin-top: 1.5rem; } @@ -660,14 +701,19 @@ button { font-family: var(--font); cursor: pointer; } .auth-card { max-width: 400px; } } @media (max-width: 480px) { - .splash-hero { padding: 2rem 1.25rem 1.5rem; } - .hero-logo-row { gap: 10px; margin-bottom: 1rem; } + .splash-hero { padding: 1.5rem 1.25rem 1rem; } + .hero-logo-row { gap: 10px; margin-bottom: 0.75rem; } + .hero-logo-mark { width: 40px; height: 40px; } .hero-wordmark { font-size: 1.3rem; } - .hero-headline { font-size: 1.5rem; } - .hero-sub { font-size: 0.88rem; } + .hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; } + .hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; } .hero-features { gap: 6px; } .hero-pill { padding: 5px 10px; font-size: 0.72rem; } + .hero-version { margin-top: 1rem; } .splash-auth { padding: 1.5rem 1.25rem; } + .auth-card-header { margin-bottom: 1rem; } + .auth-card-header h2 { font-size: 1.1rem; } + .auth-actions { margin-top: 1rem; } } /* ── Forms ────────────────────────────────── */ @@ -686,6 +732,20 @@ button { font-family: var(--font); cursor: pointer; } .form-row { display: flex; gap: 0.75rem; } .form-row .form-group { flex: 1; } .form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; } +.range-input { + width: 100%; height: 4px; -webkit-appearance: none; appearance: none; + background: var(--border); border-radius: 2px; outline: none; + margin: 8px 0 4px; cursor: pointer; +} +.range-input::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; + background: var(--accent); border: 2px solid var(--bg-surface); cursor: pointer; +} +.range-input::-moz-range-thumb { + width: 16px; height: 16px; border-radius: 50%; + background: var(--accent); border: 2px solid var(--bg-surface); cursor: pointer; +} +.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); } .checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); } /* ── Modal ────────────────────────────────── */ @@ -797,9 +857,20 @@ button { font-family: var(--font); cursor: pointer; } } .admin-provider-row .provider-name { font-weight: 500; flex: 1; } .admin-provider-row .provider-endpoint { font-size: 12px; color: var(--text-3); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.btn-edit { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } +.btn-edit:hover { color: var(--accent); } .admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } .admin-provider-row .btn-delete:hover { color: var(--danger); } +.admin-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; } +.admin-preset-row .preset-info { flex: 1; min-width: 0; } +.admin-preset-row .preset-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; } +.admin-preset-row .preset-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } +.admin-preset-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } +.admin-preset-row .btn-delete:hover { color: var(--danger); } +.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-bg); color: var(--accent); } + /* Stats cards */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } .stat-card { @@ -888,9 +959,24 @@ button { font-family: var(--font); cursor: pointer; } /* ── Responsive ──────────────────────────── */ +.mobile-menu-btn { + display: none; align-items: center; justify-content: center; + background: none; border: none; color: var(--text-2); + cursor: pointer; padding: 4px; border-radius: var(--radius); + transition: color var(--transition); +} +.mobile-menu-btn:hover { color: var(--text); } +.sidebar-overlay { + display: none; position: fixed; inset: 0; z-index: 99; + background: rgba(0,0,0,0.5); +} + @media (max-width: 768px) { - .sidebar { position: fixed; z-index: 100; height: 100%; } - .sidebar.collapsed { width: 0; border: none; } + .sidebar { position: fixed; z-index: 100; height: 100%; transition: width 0.2s ease; } + .sidebar.collapsed { width: 0; border: none; overflow: hidden; } .msg-inner { padding: 0 1rem; } .model-caps { display: none; } + .input-area { padding: 0 0.5rem 0.5rem; } + .input-wrap textarea { font-size: 16px; } /* prevent iOS zoom */ + .mobile-menu-btn { display: flex; } } diff --git a/src/index.html b/src/index.html index 6bce5be..b66bae4 100644 --- a/src/index.html +++ b/src/index.html @@ -2,14 +2,14 @@ - + Chat Switchboard - + @@ -84,11 +84,21 @@ +
- + +
+ +
+
@@ -192,6 +202,7 @@
+
@@ -223,6 +234,22 @@
+ + @@ -307,6 +335,29 @@
+