Changeset 0.7.0 (#37)
This commit is contained in:
@@ -105,9 +105,9 @@ jobs:
|
|||||||
- name: Add /tools to PATH
|
- name: Add /tools to PATH
|
||||||
run: echo "/tools" >> $GITHUB_PATH
|
run: echo "/tools" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Install tools
|
# - name: Install tools
|
||||||
run: |
|
# run: |
|
||||||
apt-get update -qq && apt-get install -y -qq gettext-base postgresql-client > /dev/null
|
# apt-get update -qq && apt-get install -y -qq gettext-base postgresql-client > /dev/null
|
||||||
|
|
||||||
# ── Determine environment ──────────────────
|
# ── Determine environment ──────────────────
|
||||||
- name: Determine environment
|
- name: Determine environment
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# Stage 3: Production image (nginx + backend)
|
# Stage 3: Production image (nginx + backend)
|
||||||
#
|
#
|
||||||
# Vendor libs are baked in during build so the
|
# Vendor libs are baked in during build so the
|
||||||
# app works in disconnected (SCIF) environments
|
# app works in disconnected environments
|
||||||
# with no CDN access.
|
# with no CDN access.
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| `debug.js` | 550 lines | Console/network intercept, state inspector (Ctrl+Shift+L) |
|
||||||
| `vendor/` | 62KB | marked.js + DOMPurify (local, CDN fallback) |
|
| `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/)
|
### 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`
|
2. `node:20-alpine` — downloads vendor JS libs via `npm pack`
|
||||||
3. `nginx:1-alpine` — serves frontend, proxies `/api/` to Go backend
|
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
|
### Reverse Proxy
|
||||||
|
|
||||||
|
|||||||
40
ROADMAP.md
40
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
|
### ✅ 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] Provider capability system (known models, heuristic detection, resolution chain)
|
||||||
- [x] Dynamic max_tokens resolution
|
- [x] Dynamic max_tokens resolution
|
||||||
- [x] User + admin provider model listing with capabilities
|
- [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)**
|
**Frontend (Vanilla JS)**
|
||||||
- [x] Professional splash page (split-panel hero + tabbed auth)
|
- [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] Debug modal (console intercept, network log, state inspector)
|
||||||
- [x] EventBus client with exponential backoff + max retries
|
- [x] EventBus client with exponential backoff + max retries
|
||||||
- [x] Export (Markdown, JSON, Text)
|
- [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)**
|
**CI/CD (Gitea Actions)**
|
||||||
- [x] Three-env pipeline (dev/test/prod)
|
- [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
|
Named wrappers around base models with bundled configuration. Admins create
|
||||||
org-wide presets, users create personal ones (if user providers are enabled).
|
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),
|
max_tokens, tools_enabled (jsonb), created_by, scope (global/team/personal),
|
||||||
team_id (nullable, for future team scoping), is_shared
|
team_id (nullable, for future team scoping), is_shared
|
||||||
- [ ] Permission gating: personal presets require user_providers_enabled
|
- [x] Permission gating: personal presets require user_providers_enabled
|
||||||
- [ ] Admin preset management UI (create, edit, delete org-wide presets)
|
- [x] Admin preset management UI (create, edit, delete org-wide presets)
|
||||||
- [ ] User preset management UI in Settings (personal presets)
|
- [x] User preset management UI in Settings (personal presets)
|
||||||
- [ ] Presets appear as first-class entries in model selector
|
- [x] Presets appear as first-class entries in model selector
|
||||||
("Code Reviewer (GPT-4o)", "Research Assistant (Claude Opus)")
|
("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
|
## 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
|
- [ ] Splash page reads branding config on load, falls back to Switchboard defaults
|
||||||
- [ ] CSS accent color override from branding settings
|
- [ ] 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 `<img>` 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**
|
**Message Editing + Forking**
|
||||||
- [ ] Edit message → creates sibling (uses existing parent_id tree)
|
- [ ] Edit message → creates sibling (uses existing parent_id tree)
|
||||||
- [ ] Regenerate → creates sibling model response
|
- [ ] Regenerate → creates sibling model response
|
||||||
@@ -109,7 +129,9 @@ White-label support and UX improvements that exploit existing infrastructure.
|
|||||||
|
|
||||||
**UX Polish**
|
**UX Polish**
|
||||||
- [ ] Chat search / filter in sidebar
|
- [ ] 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)
|
- [ ] Keyboard shortcuts (Ctrl+K command palette)
|
||||||
- [ ] PWA manifest + offline shell + install prompt
|
- [ ] PWA manifest + offline shell + install prompt
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,18 @@ else
|
|||||||
ERRORS=$((ERRORS + 1))
|
ERRORS=$((ERRORS + 1))
|
||||||
fi
|
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
|
# ADD NEW MIGRATION CHECKS ABOVE THIS LINE
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════
|
||||||
|
|||||||
33
server/database/migrations/012_model_presets.sql
Normal file
33
server/database/migrations/012_model_presets.sql
Normal file
@@ -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)';
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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"})
|
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) {
|
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
|
||||||
configID := c.Param("id")
|
configID := c.Param("id")
|
||||||
|
|
||||||
|
|||||||
@@ -432,6 +432,9 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
ConfigID string `json:"config_id"`
|
ConfigID string `json:"config_id"`
|
||||||
Capabilities providers.ModelCapabilities `json:"capabilities"`
|
Capabilities providers.ModelCapabilities `json:"capabilities"`
|
||||||
Pricing *providers.ModelPricing `json:"pricing,omitempty"`
|
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)
|
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})
|
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type completionRequest struct {
|
|||||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content" binding:"required"`
|
||||||
Model string `json:"model,omitempty"`
|
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"`
|
APIConfigID string `json:"api_config_id,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
@@ -72,6 +73,32 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
return
|
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
|
// Resolve provider config
|
||||||
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req)
|
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +113,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load conversation history
|
// Load conversation history
|
||||||
messages, err := h.loadConversation(channelID)
|
messages, err := h.loadConversation(channelID, presetSystemPrompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||||
return
|
return
|
||||||
@@ -367,7 +394,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
|
|
||||||
// ── Conversation Loader ─────────────────────
|
// ── 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
|
// Load system prompt from channel
|
||||||
var systemPrompt *string
|
var systemPrompt *string
|
||||||
_ = database.DB.QueryRow(
|
_ = database.DB.QueryRow(
|
||||||
@@ -376,7 +403,13 @@ func (h *CompletionHandler) loadConversation(channelID string) ([]providers.Mess
|
|||||||
|
|
||||||
messages := make([]providers.Message, 0)
|
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{
|
messages = append(messages, providers.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: *systemPrompt,
|
Content: *systemPrompt,
|
||||||
|
|||||||
517
server/handlers/presets.go
Normal file
517
server/handlers/presets.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -130,6 +130,13 @@ func main() {
|
|||||||
protected.GET("/settings", settings.GetSettings)
|
protected.GET("/settings", settings.GetSettings)
|
||||||
protected.PUT("/settings", settings.UpdateSettings)
|
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)
|
// Public global settings (non-admin users can read safe subset)
|
||||||
adm := handlers.NewAdminHandler()
|
adm := handlers.NewAdminHandler()
|
||||||
protected.GET("/settings/public", adm.PublicSettings)
|
protected.GET("/settings/public", adm.PublicSettings)
|
||||||
@@ -161,6 +168,7 @@ func main() {
|
|||||||
// Global API Configs
|
// Global API Configs
|
||||||
admin.GET("/configs", adm.ListGlobalConfigs)
|
admin.GET("/configs", adm.ListGlobalConfigs)
|
||||||
admin.POST("/configs", adm.CreateGlobalConfig)
|
admin.POST("/configs", adm.CreateGlobalConfig)
|
||||||
|
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
|
||||||
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
||||||
|
|
||||||
// Model Configs
|
// Model Configs
|
||||||
@@ -169,6 +177,13 @@ func main() {
|
|||||||
admin.PUT("/models/bulk", adm.BulkUpdateModels)
|
admin.PUT("/models/bulk", adm.BulkUpdateModels)
|
||||||
admin.PUT("/models/:id", adm.UpdateModelConfig)
|
admin.PUT("/models/:id", adm.UpdateModelConfig)
|
||||||
admin.DELETE("/models/:id", adm.DeleteModelConfig)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,32 @@ type KnowledgeBase struct {
|
|||||||
Settings string `json:"settings,omitempty" db:"settings"`
|
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 ────────────────────────────────
|
// ── Settings ────────────────────────────────
|
||||||
|
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
/* ── App Shell ───────────────────────────── */
|
/* ── 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; }
|
.app-body { display: flex; flex: 1; min-height: 0; }
|
||||||
|
|
||||||
/* ── Environment Banners ──────────────────── */
|
/* ── Environment Banners ──────────────────── */
|
||||||
@@ -279,9 +279,11 @@ a:hover { text-decoration: underline; }
|
|||||||
.avatar-bug {
|
.avatar-bug {
|
||||||
position: absolute; bottom: -3px; right: -3px;
|
position: absolute; bottom: -3px; right: -3px;
|
||||||
font-size: 10px; line-height: 1;
|
font-size: 10px; line-height: 1;
|
||||||
|
display: none;
|
||||||
transition: filter var(--transition);
|
transition: filter var(--transition);
|
||||||
}
|
}
|
||||||
.avatar-bug.has-errors {
|
.avatar-bug.has-errors {
|
||||||
|
display: block;
|
||||||
filter: drop-shadow(0 0 4px var(--danger));
|
filter: drop-shadow(0 0 4px var(--danger));
|
||||||
animation: bug-pulse 1.5s ease infinite;
|
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:focus { outline: none; border-color: var(--accent); }
|
||||||
.model-bar select option { background: var(--bg-surface); color: var(--text); }
|
.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 {
|
.model-caps {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
margin-left: 4px; flex-wrap: wrap;
|
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-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 ────────────────────── */
|
/* ── Markdown Content ────────────────────── */
|
||||||
|
|
||||||
@@ -651,8 +692,8 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
|
|
||||||
/* Splash responsive */
|
/* Splash responsive */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.splash { flex-direction: column; }
|
.splash { flex-direction: column; min-height: 100vh; min-height: 100dvh; overflow-y: auto; }
|
||||||
.splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; }
|
.splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; flex: none; }
|
||||||
.hero-headline { font-size: 1.8rem; }
|
.hero-headline { font-size: 1.8rem; }
|
||||||
.hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; }
|
.hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; }
|
||||||
.hero-version { margin-top: 1.5rem; }
|
.hero-version { margin-top: 1.5rem; }
|
||||||
@@ -660,14 +701,19 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.auth-card { max-width: 400px; }
|
.auth-card { max-width: 400px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.splash-hero { padding: 2rem 1.25rem 1.5rem; }
|
.splash-hero { padding: 1.5rem 1.25rem 1rem; }
|
||||||
.hero-logo-row { gap: 10px; margin-bottom: 1rem; }
|
.hero-logo-row { gap: 10px; margin-bottom: 0.75rem; }
|
||||||
|
.hero-logo-mark { width: 40px; height: 40px; }
|
||||||
.hero-wordmark { font-size: 1.3rem; }
|
.hero-wordmark { font-size: 1.3rem; }
|
||||||
.hero-headline { font-size: 1.5rem; }
|
.hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; }
|
||||||
.hero-sub { font-size: 0.88rem; }
|
.hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; }
|
||||||
.hero-features { gap: 6px; }
|
.hero-features { gap: 6px; }
|
||||||
.hero-pill { padding: 5px 10px; font-size: 0.72rem; }
|
.hero-pill { padding: 5px 10px; font-size: 0.72rem; }
|
||||||
|
.hero-version { margin-top: 1rem; }
|
||||||
.splash-auth { padding: 1.5rem 1.25rem; }
|
.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 ────────────────────────────────── */
|
/* ── Forms ────────────────────────────────── */
|
||||||
@@ -686,6 +732,20 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.form-row { display: flex; gap: 0.75rem; }
|
.form-row { display: flex; gap: 0.75rem; }
|
||||||
.form-row .form-group { flex: 1; }
|
.form-row .form-group { flex: 1; }
|
||||||
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
|
.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); }
|
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
||||||
|
|
||||||
/* ── Modal ────────────────────────────────── */
|
/* ── 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-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; }
|
.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 { 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-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 cards */
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@@ -888,9 +959,24 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
|
|
||||||
/* ── Responsive ──────────────────────────── */
|
/* ── 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) {
|
@media (max-width: 768px) {
|
||||||
.sidebar { position: fixed; z-index: 100; height: 100%; }
|
.sidebar { position: fixed; z-index: 100; height: 100%; transition: width 0.2s ease; }
|
||||||
.sidebar.collapsed { width: 0; border: none; }
|
.sidebar.collapsed { width: 0; border: none; overflow: hidden; }
|
||||||
.msg-inner { padding: 0 1rem; }
|
.msg-inner { padding: 0 1rem; }
|
||||||
.model-caps { display: none; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
|
||||||
<base href="%%BASE_HREF%%">
|
<base href="%%BASE_HREF%%">
|
||||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||||
<title>Chat Switchboard</title>
|
<title>Chat Switchboard</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||||
<link rel="stylesheet" href="css/styles.css?v=0.6.2">
|
<link rel="stylesheet" href="css/styles.css?v=0.7.0.12">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -84,11 +84,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
<!-- Main Chat Area -->
|
<!-- Main Chat Area -->
|
||||||
<main class="chat-area">
|
<main class="chat-area">
|
||||||
<div class="model-bar">
|
<div class="model-bar">
|
||||||
<select id="modelSelect" title="Select model"><option value="">Select a model</option></select>
|
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="model-dropdown" id="modelDropdown">
|
||||||
|
<button class="model-dropdown-btn" id="modelDropdownBtn" title="Select model">
|
||||||
|
<span class="model-dropdown-label" id="modelDropdownLabel">Select a model</span>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3.5L5 6.5L8 3.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="model-dropdown-menu" id="modelDropdownMenu"></div>
|
||||||
|
</div>
|
||||||
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
|
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -192,6 +202,7 @@
|
|||||||
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||||
|
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
||||||
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,6 +234,22 @@
|
|||||||
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
|
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Appearance Tab -->
|
||||||
|
<div class="settings-tab-content" id="settingsAppearanceTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Display</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>UI Scale <span class="form-hint" id="scaleValue">100%</span></label>
|
||||||
|
<input type="range" id="settingsScale" min="80" max="130" step="5" value="100" class="range-input">
|
||||||
|
<div class="range-labels"><span>80%</span><span>100%</span><span>130%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Message Font Size <span class="form-hint" id="msgFontValue">14px</span></label>
|
||||||
|
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14" class="range-input">
|
||||||
|
<div class="range-labels"><span>12px</span><span>14px</span><span>20px</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<!-- Providers Tab -->
|
<!-- Providers Tab -->
|
||||||
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
|
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
@@ -259,6 +286,7 @@
|
|||||||
<button class="admin-tab active" data-tab="users">Users</button>
|
<button class="admin-tab active" data-tab="users">Users</button>
|
||||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||||
<button class="admin-tab" data-tab="models">Models</button>
|
<button class="admin-tab" data-tab="models">Models</button>
|
||||||
|
<button class="admin-tab" data-tab="presets">Presets</button>
|
||||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,6 +335,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="adminModelList"></div>
|
<div id="adminModelList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminPresetsTab" style="display:none">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Preset</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Name</label><input type="text" id="adminPresetName" placeholder="Code Reviewer"></div>
|
||||||
|
<div class="form-group"><label>Icon</label><input type="text" id="adminPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Description</label><input type="text" id="adminPresetDesc" placeholder="Reviews code for best practices"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Base Model</label><select id="adminPresetModel"></select></div>
|
||||||
|
<div class="form-group"><label>Provider Config</label><select id="adminPresetConfig"><option value="">Auto-resolve</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>System Prompt</label><textarea id="adminPresetPrompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Temperature</label><input type="number" id="adminPresetTemp" step="0.1" min="0" max="2" placeholder="default"></div>
|
||||||
|
<div class="form-group"><label>Max Tokens</label><input type="number" id="adminPresetMaxTokens" placeholder="default"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><button class="btn-small btn-primary" id="adminCreatePresetBtn">Create</button><button class="btn-small" id="adminCancelPresetBtn">Cancel</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="adminPresetList"></div>
|
||||||
|
</div>
|
||||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Registration</h3>
|
<h3>Registration</h3>
|
||||||
@@ -389,10 +440,10 @@
|
|||||||
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
|
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
|
||||||
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
|
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
|
||||||
|
|
||||||
<script src="js/debug.js?v=0.6.2"></script>
|
<script src="js/debug.js?v=0.7.0.12"></script>
|
||||||
<script src="js/events.js?v=0.6.2"></script>
|
<script src="js/events.js?v=0.7.0.12"></script>
|
||||||
<script src="js/api.js?v=0.6.2"></script>
|
<script src="js/api.js?v=0.7.0.12"></script>
|
||||||
<script src="js/ui.js?v=0.6.2"></script>
|
<script src="js/ui.js?v=0.7.0.12"></script>
|
||||||
<script src="js/app.js?v=0.6.2"></script>
|
<script src="js/app.js?v=0.7.0.12"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
|
// Backend-only mode. Handles auth tokens and
|
||||||
// all HTTP calls. No offline fallback.
|
// all HTTP calls. No offline fallback.
|
||||||
@@ -124,9 +124,15 @@ const API = {
|
|||||||
|
|
||||||
// ── Completions ──────────────────────────
|
// ── Completions ──────────────────────────
|
||||||
|
|
||||||
async streamCompletion(channelId, content, model, signal, apiConfigId) {
|
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
|
||||||
const body = { channel_id: channelId, content, stream: true };
|
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;
|
if (apiConfigId) body.api_config_id = apiConfigId;
|
||||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
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}`); },
|
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'); },
|
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||||||
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
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 }); },
|
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); },
|
||||||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||||
|
|
||||||
// ── User Models ──────────────────────────
|
// ── Admin Presets ────────────────────────
|
||||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
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 ───────────────────────
|
// ── HTTP Internals ───────────────────────
|
||||||
|
|
||||||
|
|||||||
251
src/js/app.js
251
src/js/app.js
@@ -173,32 +173,36 @@ async function fetchModels() {
|
|||||||
try {
|
try {
|
||||||
const data = await API.listEnabledModels();
|
const data = await API.listEnabledModels();
|
||||||
App.models = (data.models || []).map(m => {
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
name: m.display_name || id,
|
baseModelId,
|
||||||
|
name: m.display_name || baseModelId,
|
||||||
provider: m.provider_name || m.provider || '',
|
provider: m.provider_name || m.provider || '',
|
||||||
configId: m.config_id || null,
|
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) {
|
// Sort: presets first (global before personal), then regular models
|
||||||
const raw = await API.listAllModels();
|
App.models.sort((a, b) => {
|
||||||
App.models = (raw.models || []).map(m => {
|
if (a.isPreset && !b.isPreset) return -1;
|
||||||
const id = m.id || m.name;
|
if (!a.isPreset && b.isPreset) return 1;
|
||||||
return {
|
if (a.isPreset && b.isPreset) {
|
||||||
id,
|
if (a.presetScope !== b.presetScope) {
|
||||||
name: id,
|
return a.presetScope === 'global' ? -1 : 1;
|
||||||
provider: m.owned_by || m.provider || '',
|
}
|
||||||
configId: m.config_id || null,
|
}
|
||||||
capabilities: resolveCapabilities(m.capabilities, id),
|
return a.name.localeCompare(b.name);
|
||||||
};
|
});
|
||||||
});
|
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets)`);
|
||||||
}
|
|
||||||
|
|
||||||
App.models.sort((a, b) => a.id.localeCompare(b.id));
|
|
||||||
console.log(`📋 Loaded ${App.models.length} models`);
|
|
||||||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||||||
UI.updateModelSelector();
|
UI.updateModelSelector();
|
||||||
UI.updateCapabilityBadges();
|
UI.updateCapabilityBadges();
|
||||||
@@ -227,6 +231,11 @@ async function loadChats() {
|
|||||||
async function selectChat(chatId) {
|
async function selectChat(chatId) {
|
||||||
App.currentChatId = chatId;
|
App.currentChatId = chatId;
|
||||||
UI.renderChatList();
|
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);
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
@@ -253,6 +262,11 @@ async function newChat() {
|
|||||||
UI.showEmptyState();
|
UI.showEmptyState();
|
||||||
UI.showRegenerate(false);
|
UI.showRegenerate(false);
|
||||||
document.getElementById('messageInput').focus();
|
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) {
|
async function deleteChat(chatId) {
|
||||||
@@ -275,7 +289,11 @@ async function sendMessage() {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
input.style.height = 'auto';
|
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) {
|
if (!App.currentChatId) {
|
||||||
try {
|
try {
|
||||||
@@ -298,11 +316,10 @@ async function sendMessage() {
|
|||||||
App.abortController = new AbortController();
|
App.abortController = new AbortController();
|
||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
|
|
||||||
const modelInfo = App.models.find(m => m.id === model);
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId);
|
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
|
||||||
const assistantContent = await UI.streamResponse(resp, chat.messages);
|
const assistantContent = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
|
chat.messages.push({ role: 'assistant', content: assistantContent, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
|
||||||
chat.messageCount = chat.messages.length;
|
chat.messageCount = chat.messages.length;
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
UI.showRegenerate(true);
|
UI.showRegenerate(true);
|
||||||
@@ -343,16 +360,18 @@ async function regenerate() {
|
|||||||
const lastUser = chat.messages[chat.messages.length - 1];
|
const lastUser = chat.messages[chat.messages.length - 1];
|
||||||
if (lastUser.role !== 'user') return;
|
if (lastUser.role !== 'user') return;
|
||||||
|
|
||||||
const model = document.getElementById('modelSelect').value || App.settings.model;
|
const selectedId = UI.getModelValue();
|
||||||
const modelInfo = App.models.find(m => m.id === model);
|
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.isGenerating = true;
|
||||||
App.abortController = new AbortController();
|
App.abortController = new AbortController();
|
||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId);
|
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId, presetId);
|
||||||
const content = await UI.streamResponse(resp, chat.messages);
|
const content = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||||
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
|
chat.messages.push({ role: 'assistant', content, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
UI.showRegenerate(true);
|
UI.showRegenerate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -494,10 +513,16 @@ function initListeners() {
|
|||||||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||||
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
|
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
|
||||||
|
|
||||||
// Model selector
|
// Model selector (custom dropdown)
|
||||||
document.getElementById('modelSelect').addEventListener('change', function() {
|
UI.initModelDropdown();
|
||||||
if (this.value) { App.settings.model = this.value; saveSettings(); }
|
UI.initAppearance();
|
||||||
UI.updateCapabilityBadges();
|
|
||||||
|
// 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 () => {
|
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
@@ -557,15 +582,47 @@ function initListeners() {
|
|||||||
|
|
||||||
// Admin — providers
|
// Admin — providers
|
||||||
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
|
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');
|
const f = document.getElementById('adminAddProviderForm');
|
||||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
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);
|
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
|
||||||
|
|
||||||
// Admin — models
|
// Admin — models
|
||||||
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
|
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
|
// Admin — banner controls
|
||||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
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(); }
|
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
|
||||||
catch (e) { UI.toast(e.message, 'error'); }
|
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() {
|
async function createGlobalProvider() {
|
||||||
try {
|
try {
|
||||||
const name = document.getElementById('adminProvName').value.trim();
|
const name = document.getElementById('adminProvName').value.trim();
|
||||||
@@ -747,10 +821,26 @@ async function createGlobalProvider() {
|
|||||||
const ep = document.getElementById('adminProvEndpoint').value.trim();
|
const ep = document.getElementById('adminProvEndpoint').value.trim();
|
||||||
const key = document.getElementById('adminProvKey').value;
|
const key = document.getElementById('adminProvKey').value;
|
||||||
const model = document.getElementById('adminProvModel').value.trim();
|
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';
|
document.getElementById('adminAddProviderForm').style.display = 'none';
|
||||||
UI.toast('Provider added', 'success');
|
document.getElementById('adminCreateProvBtn').textContent = 'Save';
|
||||||
|
document.getElementById('adminProvKey').placeholder = 'sk-...';
|
||||||
await UI.loadAdminProviders();
|
await UI.loadAdminProviders();
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} 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'; }
|
} 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 ──────────────────────────────────
|
// ── Banners ──────────────────────────────────
|
||||||
|
|
||||||
async function initBanners() {
|
async function initBanners() {
|
||||||
|
|||||||
255
src/js/ui.js
255
src/js/ui.js
@@ -7,12 +7,16 @@ const UI = {
|
|||||||
// ── Sidebar ──────────────────────────────
|
// ── Sidebar ──────────────────────────────
|
||||||
|
|
||||||
toggleSidebar() {
|
toggleSidebar() {
|
||||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
const sb = document.getElementById('sidebar');
|
||||||
localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0');
|
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() {
|
restoreSidebar() {
|
||||||
if (localStorage.getItem('sb_sidebar') === '1') {
|
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
|
||||||
document.getElementById('sidebar').classList.add('collapsed');
|
document.getElementById('sidebar').classList.add('collapsed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -96,13 +100,23 @@ const UI = {
|
|||||||
_messageHTML(msg, index) {
|
_messageHTML(msg, index) {
|
||||||
const isUser = msg.role === 'user';
|
const isUser = msg.role === 'user';
|
||||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
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 `
|
return `
|
||||||
<div class="message ${msg.role}">
|
<div class="message ${msg.role}">
|
||||||
<div class="msg-inner">
|
<div class="msg-inner">
|
||||||
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="msg-head">
|
<div class="msg-head">
|
||||||
<span class="msg-role">${isUser ? 'You' : 'Assistant'}</span>
|
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||||||
<div class="msg-actions">
|
<div class="msg-actions">
|
||||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||||||
@@ -125,17 +139,18 @@ const UI = {
|
|||||||
|
|
||||||
// ── Streaming ────────────────────────────
|
// ── Streaming ────────────────────────────
|
||||||
|
|
||||||
async streamResponse(response, messages) {
|
async streamResponse(response, messages, modelName) {
|
||||||
const container = document.getElementById('chatMessages');
|
const container = document.getElementById('chatMessages');
|
||||||
document.getElementById('typingIndicator')?.remove();
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
|
||||||
|
const label = modelName || 'Assistant';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'message assistant';
|
div.className = 'message assistant';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="msg-inner">
|
<div class="msg-inner">
|
||||||
<div class="msg-avatar">🤖</div>
|
<div class="msg-avatar">🤖</div>
|
||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="msg-head"><span class="msg-role">Assistant</span></div>
|
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
||||||
<div class="msg-text" id="streamContent"></div>
|
<div class="msg-text" id="streamContent"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -174,50 +189,111 @@ const UI = {
|
|||||||
return content;
|
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() {
|
updateModelSelector() {
|
||||||
const sel = document.getElementById('modelSelect');
|
const menu = document.getElementById('modelDropdownMenu');
|
||||||
|
if (!menu) return;
|
||||||
const current = App.settings.model;
|
const current = App.settings.model;
|
||||||
sel.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
|
|
||||||
if (App.models.length === 0) {
|
if (App.models.length === 0) {
|
||||||
sel.innerHTML = '<option value="">No models loaded</option>';
|
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||||||
|
UI.setModelValue('', 'No models loaded');
|
||||||
} else {
|
} else {
|
||||||
App.models.forEach(m => {
|
const presets = App.models.filter(m => m.isPreset);
|
||||||
const opt = document.createElement('option');
|
const models = App.models.filter(m => !m.isPreset);
|
||||||
opt.value = m.id;
|
|
||||||
const label = m.name || m.id;
|
|
||||||
opt.textContent = label + (m.provider ? ` (${m.provider})` : '');
|
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current) {
|
if (presets.length > 0) {
|
||||||
const exists = [...sel.options].some(o => o.value === current);
|
const hdr = document.createElement('div');
|
||||||
if (exists) {
|
hdr.className = 'model-dropdown-group';
|
||||||
sel.value = current;
|
hdr.textContent = '⚡ Presets';
|
||||||
} else {
|
menu.appendChild(hdr);
|
||||||
const opt = document.createElement('option');
|
presets.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
|
||||||
opt.value = current;
|
}
|
||||||
opt.textContent = current + ' (custom)';
|
|
||||||
sel.insertBefore(opt, sel.firstChild);
|
if (models.length > 0) {
|
||||||
sel.value = current;
|
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');
|
const settingsSel = document.getElementById('settingsModel');
|
||||||
if (settingsSel) {
|
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;
|
if (current) settingsSel.value = current;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_createDropdownItem(m) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'model-dropdown-item';
|
||||||
|
div.dataset.value = m.id;
|
||||||
|
div.innerHTML = `<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||||
|
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 ────────────────────
|
// ── Capability Badges ────────────────────
|
||||||
|
|
||||||
getSelectedModelCaps() {
|
getSelectedModelCaps() {
|
||||||
const modelId = document.getElementById('modelSelect').value || App.settings.model;
|
const modelId = UI.getModelValue();
|
||||||
if (!modelId) return {};
|
if (!modelId) return {};
|
||||||
const model = App.models.find(m => m.id === modelId);
|
const model = App.models.find(m => m.id === modelId);
|
||||||
// Model in list has resolved caps; fallback to client-side lookup
|
// Model in list has resolved caps; fallback to client-side lookup
|
||||||
@@ -353,6 +429,46 @@ const UI = {
|
|||||||
UI.checkUserProvidersAllowed();
|
UI.checkUserProvidersAllowed();
|
||||||
}
|
}
|
||||||
if (tab === 'models') UI.loadUserModels();
|
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() {
|
async checkUserProvidersAllowed() {
|
||||||
@@ -420,6 +536,7 @@ const UI = {
|
|||||||
if (tab === 'stats') await this.loadAdminStats();
|
if (tab === 'stats') await this.loadAdminStats();
|
||||||
if (tab === 'providers') await this.loadAdminProviders();
|
if (tab === 'providers') await this.loadAdminProviders();
|
||||||
if (tab === 'models') await this.loadAdminModels();
|
if (tab === 'models') await this.loadAdminModels();
|
||||||
|
if (tab === 'presets') await this.loadAdminPresets();
|
||||||
if (tab === 'settings') await this.loadAdminSettings();
|
if (tab === 'settings') await this.loadAdminSettings();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -468,13 +585,17 @@ const UI = {
|
|||||||
const data = await API.adminListGlobalConfigs();
|
const data = await API.adminListGlobalConfigs();
|
||||||
const list = data.configs || data.data || data || [];
|
const list = data.configs || data.data || data || [];
|
||||||
const arr = Array.isArray(list) ? list : [];
|
const arr = Array.isArray(list) ? list : [];
|
||||||
el.innerHTML = arr.map(c => `
|
UI._providerCache = {};
|
||||||
<div class="admin-provider-row">
|
el.innerHTML = arr.map(c => {
|
||||||
|
UI._providerCache[c.id] = c;
|
||||||
|
return `<div class="admin-provider-row">
|
||||||
<span class="provider-name">${esc(c.name)}</span>
|
<span class="provider-name">${esc(c.name)}</span>
|
||||||
<span class="provider-meta">${esc(c.provider)}</span>
|
<span class="provider-meta">${esc(c.provider)}</span>
|
||||||
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
|
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
|
||||||
|
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
|
||||||
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
|
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
|
||||||
</div>`).join('') || '<div class="empty-hint">No global providers — add one above</div>';
|
</div>`;
|
||||||
|
}).join('') || '<div class="empty-hint">No global providers — add one above</div>';
|
||||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -502,6 +623,68 @@ const UI = {
|
|||||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadAdminPresets(quiet) {
|
||||||
|
const el = document.getElementById('adminPresetList');
|
||||||
|
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
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 = '<option value="">Select base model...</option>';
|
||||||
|
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' ? '<span class="badge-admin">global</span>' : '<span class="badge-user">personal</span>';
|
||||||
|
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||||
|
return `<div class="admin-preset-row">
|
||||||
|
<div class="preset-info">
|
||||||
|
<strong>${icon}${esc(p.name)}</strong> ${scope} ${status}
|
||||||
|
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||||
|
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
|
||||||
|
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||||
|
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
async loadAdminSettings() {
|
async loadAdminSettings() {
|
||||||
try {
|
try {
|
||||||
const data = await API.adminGetSettings();
|
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'; }
|
if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
|
||||||
else if (format === 'md') {
|
else if (format === 'md') {
|
||||||
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
|
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';
|
ext = 'md'; mime = 'text/markdown';
|
||||||
} else {
|
} 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';
|
ext = 'txt'; mime = 'text/plain';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user