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 @@
+
+
@@ -259,6 +286,7 @@
+
@@ -307,6 +335,29 @@
+
+
+
+
+
+
+
Registration
@@ -389,10 +440,10 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/src/js/api.js b/src/js/api.js
index 62b2a2b..1d0d550 100644
--- a/src/js/api.js
+++ b/src/js/api.js
@@ -1,5 +1,5 @@
// ==========================================
-// Chat Switchboard – API Client (v0.6.2)
+// Chat Switchboard – API Client (v0.7.0)
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
@@ -124,9 +124,15 @@ const API = {
// ── Completions ──────────────────────────
- async streamCompletion(channelId, content, model, signal, apiConfigId) {
+ async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
const body = { channel_id: channelId, content, stream: true };
- if (model) body.model = model;
+ if (presetId) {
+ body.preset_id = presetId;
+ // Preset resolves the model on backend; still pass model for channel metadata
+ if (model) body.model = model;
+ } else {
+ if (model) body.model = model;
+ }
if (apiConfigId) body.api_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
@@ -203,6 +209,7 @@ const API = {
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
+ adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
@@ -210,8 +217,17 @@ const API = {
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); },
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
- // ── User Models ──────────────────────────
- listEnabledModels() { return this._get('/api/v1/models/enabled'); },
+ // ── Admin Presets ────────────────────────
+ adminListPresets() { return this._get('/api/v1/admin/presets'); },
+ adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
+ adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
+ adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
+
+ // ── User Presets ─────────────────────────
+ listPresets() { return this._get('/api/v1/presets'); },
+ createPreset(preset) { return this._post('/api/v1/presets', preset); },
+ updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
+ deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
// ── HTTP Internals ───────────────────────
diff --git a/src/js/app.js b/src/js/app.js
index c5032d5..a6888b5 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -173,32 +173,36 @@ async function fetchModels() {
try {
const data = await API.listEnabledModels();
App.models = (data.models || []).map(m => {
- const id = m.model_id || m.id;
+ const isPreset = !!m.is_preset;
+ // Presets: use preset_id as selector value, base model for caps
+ // Regular: model_id is both
+ const id = isPreset ? (m.preset_id || m.id) : (m.model_id || m.id);
+ const baseModelId = m.model_id || m.id;
return {
id,
- name: m.display_name || id,
+ baseModelId,
+ name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: m.config_id || null,
- capabilities: resolveCapabilities(m.capabilities, id),
+ capabilities: resolveCapabilities(m.capabilities, baseModelId),
+ isPreset,
+ presetId: m.preset_id || null,
+ presetScope: m.preset_scope || null,
};
});
- if (App.models.length === 0) {
- const raw = await API.listAllModels();
- App.models = (raw.models || []).map(m => {
- const id = m.id || m.name;
- return {
- id,
- name: id,
- provider: m.owned_by || m.provider || '',
- configId: m.config_id || null,
- capabilities: resolveCapabilities(m.capabilities, id),
- };
- });
- }
-
- App.models.sort((a, b) => a.id.localeCompare(b.id));
- console.log(`📋 Loaded ${App.models.length} models`);
+ // Sort: presets first (global before personal), then regular models
+ App.models.sort((a, b) => {
+ if (a.isPreset && !b.isPreset) return -1;
+ if (!a.isPreset && b.isPreset) return 1;
+ if (a.isPreset && b.isPreset) {
+ if (a.presetScope !== b.presetScope) {
+ return a.presetScope === 'global' ? -1 : 1;
+ }
+ }
+ return a.name.localeCompare(b.name);
+ });
+ console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
UI.updateModelSelector();
UI.updateCapabilityBadges();
@@ -227,6 +231,11 @@ async function loadChats() {
async function selectChat(chatId) {
App.currentChatId = chatId;
UI.renderChatList();
+ if (window.innerWidth <= 768) {
+ document.getElementById('sidebar').classList.add('collapsed');
+ const ov = document.getElementById('sidebarOverlay');
+ if (ov) ov.style.display = 'none';
+ }
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
@@ -253,6 +262,11 @@ async function newChat() {
UI.showEmptyState();
UI.showRegenerate(false);
document.getElementById('messageInput').focus();
+ if (window.innerWidth <= 768) {
+ document.getElementById('sidebar').classList.add('collapsed');
+ const ov = document.getElementById('sidebarOverlay');
+ if (ov) ov.style.display = 'none';
+ }
}
async function deleteChat(chatId) {
@@ -275,7 +289,11 @@ async function sendMessage() {
input.value = '';
input.style.height = 'auto';
- const model = document.getElementById('modelSelect').value || App.settings.model;
+ const selectedId = UI.getModelValue();
+ const modelInfo = App.models.find(m => m.id === selectedId);
+ // For presets, send the base model ID; for regular models, send the model ID
+ const model = modelInfo?.baseModelId || selectedId;
+ const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
if (!App.currentChatId) {
try {
@@ -298,11 +316,10 @@ async function sendMessage() {
App.abortController = new AbortController();
UI.setGenerating(true);
- const modelInfo = App.models.find(m => m.id === model);
try {
- const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId);
- const assistantContent = await UI.streamResponse(resp, chat.messages);
- chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
+ const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
+ const assistantContent = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
+ chat.messages.push({ role: 'assistant', content: assistantContent, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(true);
@@ -343,16 +360,18 @@ async function regenerate() {
const lastUser = chat.messages[chat.messages.length - 1];
if (lastUser.role !== 'user') return;
- const model = document.getElementById('modelSelect').value || App.settings.model;
- const modelInfo = App.models.find(m => m.id === model);
+ const selectedId = UI.getModelValue();
+ const modelInfo = App.models.find(m => m.id === selectedId);
+ const model = modelInfo?.baseModelId || selectedId;
+ const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
- const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId);
- const content = await UI.streamResponse(resp, chat.messages);
- chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
+ const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId, presetId);
+ const content = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
+ chat.messages.push({ role: 'assistant', content, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
UI.renderMessages(chat.messages);
UI.showRegenerate(true);
} catch (e) {
@@ -494,10 +513,16 @@ function initListeners() {
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
- // Model selector
- document.getElementById('modelSelect').addEventListener('change', function() {
- if (this.value) { App.settings.model = this.value; saveSettings(); }
- UI.updateCapabilityBadges();
+ // Model selector (custom dropdown)
+ UI.initModelDropdown();
+ UI.initAppearance();
+
+ // Mobile hamburger
+ document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
+ document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
+ document.getElementById('sidebar').classList.add('collapsed');
+ document.getElementById('sidebarOverlay').style.display = 'none';
+ localStorage.setItem('sb_sidebar', '1');
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModels();
@@ -557,15 +582,47 @@ function initListeners() {
// Admin — providers
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
+ _editingProviderId = null;
+ document.getElementById('adminCreateProvBtn').textContent = 'Save';
+ document.getElementById('adminProvKey').placeholder = 'sk-...';
+ document.getElementById('adminProvName').value = '';
+ document.getElementById('adminProvEndpoint').value = '';
+ document.getElementById('adminProvKey').value = '';
+ document.getElementById('adminProvModel').value = '';
const f = document.getElementById('adminAddProviderForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
- document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderForm').style.display = 'none'; });
+ document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => {
+ document.getElementById('adminAddProviderForm').style.display = 'none';
+ _editingProviderId = null;
+ document.getElementById('adminCreateProvBtn').textContent = 'Save';
+ document.getElementById('adminProvKey').placeholder = 'sk-...';
+ });
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
+ // Admin — presets
+ document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => {
+ _editingPresetId = null;
+ document.getElementById('adminCreatePresetBtn').textContent = 'Create';
+ document.getElementById('adminPresetName').value = '';
+ document.getElementById('adminPresetDesc').value = '';
+ document.getElementById('adminPresetPrompt').value = '';
+ document.getElementById('adminPresetIcon').value = '';
+ document.getElementById('adminPresetTemp').value = '';
+ document.getElementById('adminPresetMaxTokens').value = '';
+ const f = document.getElementById('adminAddPresetForm');
+ f.style.display = f.style.display === 'none' ? '' : 'none';
+ });
+ document.getElementById('adminCancelPresetBtn')?.addEventListener('click', () => {
+ document.getElementById('adminAddPresetForm').style.display = 'none';
+ _editingPresetId = null;
+ document.getElementById('adminCreatePresetBtn').textContent = 'Create';
+ });
+ document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset);
+
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
@@ -740,6 +797,23 @@ async function deleteGlobalProvider(id) {
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
catch (e) { UI.toast(e.message, 'error'); }
}
+
+var _editingProviderId = null;
+function editGlobalProvider(id) {
+ const c = UI._providerCache?.[id];
+ if (!c) return;
+ _editingProviderId = id;
+ const form = document.getElementById('adminAddProviderForm');
+ form.style.display = '';
+ document.getElementById('adminProvName').value = c.name || '';
+ document.getElementById('adminProvType').value = c.provider || 'openai';
+ document.getElementById('adminProvEndpoint').value = c.endpoint || '';
+ document.getElementById('adminProvKey').value = '';
+ document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...';
+ document.getElementById('adminProvModel').value = c.model_default || '';
+ document.getElementById('adminCreateProvBtn').textContent = 'Update';
+}
+
async function createGlobalProvider() {
try {
const name = document.getElementById('adminProvName').value.trim();
@@ -747,10 +821,26 @@ async function createGlobalProvider() {
const ep = document.getElementById('adminProvEndpoint').value.trim();
const key = document.getElementById('adminProvKey').value;
const model = document.getElementById('adminProvModel').value.trim();
- if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
- await API.adminCreateGlobalConfig(name, prov, ep, key, model);
+
+ if (_editingProviderId) {
+ // Update mode
+ const updates = {};
+ if (name) updates.name = name;
+ if (ep) updates.endpoint = ep;
+ if (key) updates.api_key = key;
+ updates.model_default = model;
+ await API.adminUpdateGlobalConfig(_editingProviderId, updates);
+ UI.toast('Provider updated', 'success');
+ } else {
+ // Create mode
+ if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
+ await API.adminCreateGlobalConfig(name, prov, ep, key, model);
+ UI.toast('Provider added', 'success');
+ }
+ _editingProviderId = null;
document.getElementById('adminAddProviderForm').style.display = 'none';
- UI.toast('Provider added', 'success');
+ document.getElementById('adminCreateProvBtn').textContent = 'Save';
+ document.getElementById('adminProvKey').placeholder = 'sk-...';
await UI.loadAdminProviders();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -781,6 +871,93 @@ async function bulkToggleModels(enabled) {
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
+// ── Admin Presets ────────────────────────────
+
+var _editingPresetId = null;
+function editAdminPreset(id) {
+ const p = UI._presetCache?.[id];
+ if (!p) return;
+ _editingPresetId = id;
+ const form = document.getElementById('adminAddPresetForm');
+ form.style.display = '';
+ document.getElementById('adminPresetName').value = p.name || '';
+ document.getElementById('adminPresetIcon').value = p.icon || '';
+ document.getElementById('adminPresetDesc').value = p.description || '';
+ document.getElementById('adminPresetPrompt').value = p.system_prompt || '';
+ document.getElementById('adminPresetTemp').value = p.temperature != null ? p.temperature : '';
+ document.getElementById('adminPresetMaxTokens').value = p.max_tokens != null ? p.max_tokens : '';
+ // Set base model and config dropdowns if present
+ const modelSel = document.getElementById('adminPresetModel');
+ if (modelSel) modelSel.value = p.base_model_id || '';
+ const cfgSel = document.getElementById('adminPresetConfig');
+ if (cfgSel) cfgSel.value = p.api_config_id || '';
+ document.getElementById('adminCreatePresetBtn').textContent = 'Update';
+}
+
+async function createAdminPreset() {
+ const name = document.getElementById('adminPresetName').value.trim();
+ const baseModelId = document.getElementById('adminPresetModel').value;
+ if (!name || !baseModelId) { UI.toast('Name and base model are required', 'warning'); return; }
+
+ const preset = {
+ name,
+ base_model_id: baseModelId,
+ description: document.getElementById('adminPresetDesc').value.trim(),
+ icon: document.getElementById('adminPresetIcon').value.trim(),
+ system_prompt: document.getElementById('adminPresetPrompt').value,
+ };
+
+ const configId = document.getElementById('adminPresetConfig').value;
+ if (configId) preset.api_config_id = configId;
+
+ const temp = parseFloat(document.getElementById('adminPresetTemp').value);
+ if (!isNaN(temp)) preset.temperature = temp;
+
+ const maxTok = parseInt(document.getElementById('adminPresetMaxTokens').value);
+ if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
+
+ try {
+ if (_editingPresetId) {
+ await API.adminUpdatePreset(_editingPresetId, preset);
+ UI.toast('Preset updated', 'success');
+ } else {
+ await API.adminCreatePreset(preset);
+ UI.toast('Preset created', 'success');
+ }
+ _editingPresetId = null;
+ document.getElementById('adminAddPresetForm').style.display = 'none';
+ document.getElementById('adminCreatePresetBtn').textContent = 'Create';
+ document.getElementById('adminPresetName').value = '';
+ document.getElementById('adminPresetDesc').value = '';
+ document.getElementById('adminPresetPrompt').value = '';
+ document.getElementById('adminPresetIcon').value = '';
+ document.getElementById('adminPresetTemp').value = '';
+ document.getElementById('adminPresetMaxTokens').value = '';
+ await UI.loadAdminPresets();
+ await fetchModels();
+ } catch (e) { UI.toast(e.message, 'error'); }
+}
+
+async function toggleAdminPreset(id, active) {
+ try {
+ const el = _adminScroll(), pos = el?.scrollTop || 0;
+ await API.adminUpdatePreset(id, { is_active: active });
+ await UI.loadAdminPresets(true);
+ _restoreScroll(el, pos);
+ await fetchModels();
+ } catch (e) { UI.toast(e.message, 'error'); }
+}
+
+async function deleteAdminPreset(id, name) {
+ if (!confirm(`Delete preset "${name}"?`)) return;
+ try {
+ await API.adminDeletePreset(id);
+ UI.toast('Preset deleted', 'success');
+ await UI.loadAdminPresets();
+ await fetchModels();
+ } catch (e) { UI.toast(e.message, 'error'); }
+}
+
// ── Banners ──────────────────────────────────
async function initBanners() {
diff --git a/src/js/ui.js b/src/js/ui.js
index 9b71b69..49cebaa 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -7,12 +7,16 @@ const UI = {
// ── Sidebar ──────────────────────────────
toggleSidebar() {
- document.getElementById('sidebar').classList.toggle('collapsed');
- localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0');
+ const sb = document.getElementById('sidebar');
+ sb.classList.toggle('collapsed');
+ const collapsed = sb.classList.contains('collapsed');
+ localStorage.setItem('sb_sidebar', collapsed ? '1' : '0');
+ const overlay = document.getElementById('sidebarOverlay');
+ if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none');
},
restoreSidebar() {
- if (localStorage.getItem('sb_sidebar') === '1') {
+ if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
document.getElementById('sidebar').classList.add('collapsed');
}
},
@@ -96,13 +100,23 @@ const UI = {
_messageHTML(msg, index) {
const isUser = msg.role === 'user';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
+ let assistantLabel = 'Assistant';
+ if (!isUser) {
+ // Use stored modelName, or resolve from current model list, or fall back to model id
+ if (msg.modelName) {
+ assistantLabel = msg.modelName;
+ } else if (msg.model) {
+ const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
+ assistantLabel = m?.name || msg.model;
+ }
+ }
return `
${isUser ? '👤' : '🤖'}
-
${isUser ? 'You' : 'Assistant'}
+
${isUser ? 'You' : esc(assistantLabel)}
${time ? `
${time}` : ''}
@@ -125,17 +139,18 @@ const UI = {
// ── Streaming ────────────────────────────
- async streamResponse(response, messages) {
+ async streamResponse(response, messages, modelName) {
const container = document.getElementById('chatMessages');
document.getElementById('typingIndicator')?.remove();
+ const label = modelName || 'Assistant';
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
🤖
-
Assistant
+
${esc(label)}
`;
@@ -174,50 +189,111 @@ const UI = {
return content;
},
- // ── Model Selector ───────────────────────
+ // ── Model Selector (Custom Dropdown) ────
+
+ _modelValue: '',
+
+ getModelValue() { return UI._modelValue || App.settings.model || ''; },
+
+ setModelValue(val, label) {
+ UI._modelValue = val;
+ if (val) { App.settings.model = val; saveSettings(); }
+ const btn = document.getElementById('modelDropdownLabel');
+ if (btn) btn.textContent = label || val || 'Select a model';
+ // Highlight selected item
+ document.querySelectorAll('#modelDropdownMenu .model-dropdown-item').forEach(el => {
+ el.classList.toggle('selected', el.dataset.value === val);
+ });
+ UI.updateCapabilityBadges();
+ },
updateModelSelector() {
- const sel = document.getElementById('modelSelect');
+ const menu = document.getElementById('modelDropdownMenu');
+ if (!menu) return;
const current = App.settings.model;
- sel.innerHTML = '';
+ menu.innerHTML = '';
if (App.models.length === 0) {
- sel.innerHTML = '
';
+ menu.innerHTML = '
No models loaded
';
+ UI.setModelValue('', 'No models loaded');
} else {
- App.models.forEach(m => {
- const opt = document.createElement('option');
- opt.value = m.id;
- const label = m.name || m.id;
- opt.textContent = label + (m.provider ? ` (${m.provider})` : '');
- sel.appendChild(opt);
- });
- }
+ const presets = App.models.filter(m => m.isPreset);
+ const models = App.models.filter(m => !m.isPreset);
- if (current) {
- const exists = [...sel.options].some(o => o.value === current);
- if (exists) {
- sel.value = current;
- } else {
- const opt = document.createElement('option');
- opt.value = current;
- opt.textContent = current + ' (custom)';
- sel.insertBefore(opt, sel.firstChild);
- sel.value = current;
+ if (presets.length > 0) {
+ const hdr = document.createElement('div');
+ hdr.className = 'model-dropdown-group';
+ hdr.textContent = '⚡ Presets';
+ menu.appendChild(hdr);
+ presets.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
+ }
+
+ if (models.length > 0) {
+ const hdr = document.createElement('div');
+ hdr.className = 'model-dropdown-group';
+ hdr.textContent = 'Models';
+ menu.appendChild(hdr);
+ models.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
+ }
+
+ // Restore selection
+ const match = App.models.find(m => m.id === current);
+ if (match) {
+ UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
+ } else if (App.models.length > 0) {
+ const first = App.models[0];
+ UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
}
}
- // Sync settings modal selector
+ // Sync settings modal selector (flat select is fine there)
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
- settingsSel.innerHTML = sel.innerHTML;
+ settingsSel.innerHTML = '';
+ App.models.forEach(m => {
+ const opt = document.createElement('option');
+ opt.value = m.id;
+ opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
+ settingsSel.appendChild(opt);
+ });
if (current) settingsSel.value = current;
}
},
+ _createDropdownItem(m) {
+ const div = document.createElement('div');
+ div.className = 'model-dropdown-item';
+ div.dataset.value = m.id;
+ div.innerHTML = `
${esc(m.name || m.id)}${m.provider ? `
${esc(m.provider)}` : ''}`;
+ div.addEventListener('click', () => {
+ UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
+ UI._closeModelDropdown();
+ });
+ return div;
+ },
+
+ _closeModelDropdown() {
+ document.getElementById('modelDropdownMenu')?.classList.remove('open');
+ },
+
+ initModelDropdown() {
+ const btn = document.getElementById('modelDropdownBtn');
+ const menu = document.getElementById('modelDropdownMenu');
+ if (!btn || !menu) return;
+
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ menu.classList.toggle('open');
+ });
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('#modelDropdown')) menu.classList.remove('open');
+ });
+ },
+
// ── Capability Badges ────────────────────
getSelectedModelCaps() {
- const modelId = document.getElementById('modelSelect').value || App.settings.model;
+ const modelId = UI.getModelValue();
if (!modelId) return {};
const model = App.models.find(m => m.id === modelId);
// Model in list has resolved caps; fallback to client-side lookup
@@ -353,6 +429,46 @@ const UI = {
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
+ if (tab === 'appearance') UI.loadAppearanceSettings();
+ },
+
+ // ── Appearance Settings ─────────────────
+
+ loadAppearanceSettings() {
+ const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
+ const scale = prefs.scale || 100;
+ const msgFont = prefs.msgFont || 14;
+
+ const scaleEl = document.getElementById('settingsScale');
+ const msgFontEl = document.getElementById('settingsMsgFont');
+ if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
+ if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
+ },
+
+ initAppearance() {
+ // Load saved prefs on startup
+ const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
+ UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
+
+ // Live preview on slider change
+ const scaleEl = document.getElementById('settingsScale');
+ const msgFontEl = document.getElementById('settingsMsgFont');
+ if (scaleEl) scaleEl.addEventListener('input', () => {
+ const v = parseInt(scaleEl.value);
+ document.getElementById('scaleValue').textContent = v + '%';
+ UI.applyAppearance(v, parseInt(msgFontEl?.value || 14));
+ });
+ if (msgFontEl) msgFontEl.addEventListener('input', () => {
+ const v = parseInt(msgFontEl.value);
+ document.getElementById('msgFontValue').textContent = v + 'px';
+ UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
+ });
+ },
+
+ applyAppearance(scale, msgFont) {
+ document.documentElement.style.zoom = scale / 100;
+ document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
+ localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
},
async checkUserProvidersAllowed() {
@@ -420,6 +536,7 @@ const UI = {
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
+ if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'settings') await this.loadAdminSettings();
},
@@ -468,13 +585,17 @@ const UI = {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
- el.innerHTML = arr.map(c => `
-
+ UI._providerCache = {};
+ el.innerHTML = arr.map(c => {
+ UI._providerCache[c.id] = c;
+ return `
${esc(c.name)}
${esc(c.provider)}
${esc(c.endpoint || '')}
+
-
`).join('') || '
No global providers — add one above
';
+
`;
+ }).join('') || '
No global providers — add one above
';
} catch (e) { el.innerHTML = `
${esc(e.message)}
`; }
},
@@ -502,6 +623,68 @@ const UI = {
} catch (e) { el.innerHTML = `
${esc(e.message)}
`; }
},
+ async loadAdminPresets(quiet) {
+ const el = document.getElementById('adminPresetList');
+ if (!quiet) el.innerHTML = '
Loading...
';
+ try {
+ const data = await API.adminListPresets();
+ const list = data.presets || [];
+
+ // Populate model dropdown from admin model list (all synced models)
+ const modelSel = document.getElementById('adminPresetModel');
+ if (modelSel) {
+ modelSel.innerHTML = '
';
+ try {
+ const modelData = await API.adminListModels();
+ const allModels = modelData.models || modelData.data || [];
+ const arr = Array.isArray(allModels) ? allModels : [];
+ arr.forEach(m => {
+ const opt = document.createElement('option');
+ const mid = m.model_id || m.id;
+ opt.value = mid;
+ opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
+ modelSel.appendChild(opt);
+ });
+ } catch (e) { console.warn('Failed to load models for preset form:', e.message); }
+ }
+
+ // Populate config dropdown
+ const cfgSel = document.getElementById('adminPresetConfig');
+ if (cfgSel && cfgSel.options.length <= 1) {
+ try {
+ const cfgData = await API.adminListGlobalConfigs();
+ const cfgs = cfgData.configs || cfgData.data || [];
+ (Array.isArray(cfgs) ? cfgs : []).forEach(c => {
+ const opt = document.createElement('option');
+ opt.value = c.id;
+ opt.textContent = c.name + ' (' + c.provider + ')';
+ cfgSel.appendChild(opt);
+ });
+ } catch (e) { /* optional */ }
+ }
+
+ UI._presetCache = {};
+ el.innerHTML = list.map(p => {
+ UI._presetCache[p.id] = p;
+ const icon = p.icon ? p.icon + ' ' : '';
+ const scope = p.scope === 'global' ? '
global' : '
personal';
+ const status = p.is_active ? '' : '
inactive';
+ return `
+
+
${icon}${esc(p.name)} ${scope} ${status}
+
${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}
+ ${p.description ? `
${esc(p.description)}
` : ''}
+
+
+
+
+
+
+
`;
+ }).join('') || '
No presets — create one to give users preconfigured model experiences
';
+ } catch (e) { el.innerHTML = `
${esc(e.message)}
`; }
+ },
+
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
@@ -602,10 +785,10 @@ const UI = {
if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
else if (format === 'md') {
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
- .map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`).join('\n\n---\n\n');
+ .map(m => `## ${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}\n\n${m.content}`).join('\n\n---\n\n');
ext = 'md'; mime = 'text/markdown';
} else {
- content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`).join('\n\n');
+ content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}]\n${m.content}`).join('\n\n');
ext = 'txt'; mime = 'text/plain';
}