Changeset 0.13.0 (#64)

This commit is contained in:
2026-02-25 22:52:19 +00:00
parent 88216ec4cb
commit 8292a6efa8
18 changed files with 360 additions and 9363 deletions

668
admin.go
View File

@@ -1,668 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type AdminHandler struct {
stores store.Stores
vault *crypto.KeyResolver
uekCache *crypto.UEKCache
}
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler {
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache}
}
// ── User Management ─────────────────────────
func (h *AdminHandler) ListUsers(c *gin.Context) {
opts := store.DefaultListOptions()
// Accept both limit/offset and page/per_page conventions
if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 {
opts.Limit = limit
}
if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 {
opts.Offset = offset
}
if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 {
opts.Limit = perPage
}
if page, _ := strconv.Atoi(c.Query("page")); page > 1 {
opts.Offset = (page - 1) * opts.Limit
}
users, total, err := h.stores.Users.List(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
return
}
c.JSON(http.StatusOK, gin.H{"users": users, "total": total})
}
func (h *AdminHandler) CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
role := models.UserRoleUser
if req.Role == models.UserRoleAdmin {
role = models.UserRoleAdmin
}
user := &models.User{
Username: strings.ToLower(req.Username),
Email: strings.ToLower(req.Email),
PasswordHash: string(hash),
Role: role,
IsActive: true,
}
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
h.auditLog(c, "user.create", "user", user.ID, nil)
c.JSON(http.StatusCreated, user)
}
func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
id := c.Param("id")
var req struct {
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
return
}
h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role})
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
}
func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
id := c.Param("id")
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"})
return
}
h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive})
c.JSON(http.StatusOK, gin.H{"message": "user status updated"})
}
func (h *AdminHandler) ResetPassword(c *gin.Context) {
id := c.Param("id")
var req struct {
Password string `json:"password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
return
}
// Destroy vault — old UEK is unrecoverable with new password.
// A fresh vault will be initialized on the user's next login.
h.destroyVault(c, id)
h.auditLog(c, "user.password_reset", "user", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
}
// destroyVault nullifies a user's encrypted UEK and deletes their personal
// provider keys. Called when an admin resets a user's password — the old
// UEK cannot be recovered without the old password.
func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
if h.uekCache == nil {
return
}
// Null out vault columns — user gets a fresh vault on next login
_, err := database.DB.Exec(`
UPDATE users
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
WHERE id = $1
`, userID)
if err != nil {
log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err)
}
// Evict from session cache (if user is currently logged in)
h.uekCache.Evict(userID)
// Delete personal provider configs — their keys are undecryptable now
result, err := database.DB.Exec(`
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
`, userID)
if err != nil {
log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err)
} else if rows, _ := result.RowsAffected(); rows > 0 {
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID)
}
h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
"reason": "admin_password_reset",
})
}
func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
return
}
h.auditLog(c, "user.delete", "user", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
// ── Global Settings ─────────────────────────
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
// Also include policies
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies})
}
func (h *AdminHandler) GetGlobalSetting(c *gin.Context) {
key := c.Param("key")
val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
return
}
c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
}
func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) {
key := c.Param("key")
userID, _ := c.Get("user_id")
uid := userID.(string)
var req struct {
Value json.RawMessage `json:"value"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Determine if this is a policy (string value) or a global config (JSON value)
var strVal string
if err := json.Unmarshal(req.Value, &strVal); err == nil {
// String value → try as policy first
if _, ok := models.PolicyDefaults[key]; ok {
if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"})
return
}
h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal})
c.JSON(http.StatusOK, gin.H{"message": "policy updated"})
return
}
}
// JSON value → global config
var jsonVal models.JSONMap
json.Unmarshal(req.Value, &jsonVal)
if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"})
return
}
h.auditLog(c, "settings.update", "global_settings", key, nil)
c.JSON(http.StatusOK, gin.H{"message": "setting updated"})
}
func (h *AdminHandler) PublicSettings(c *gin.Context) {
// Banner config, branding, etc. — safe subset for non-admin users
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
// Only tell the user whether admin prompt exists — don't expose content
hasAdminPrompt := false
if content, ok := systemPrompt["content"].(string); ok && content != "" {
hasAdminPrompt = true
}
c.JSON(http.StatusOK, gin.H{
"banner": banner,
"branding": branding,
"has_admin_prompt": hasAdminPrompt,
"storage_configured": storageConfigured,
"policies": gin.H{
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
"allow_user_personas": policies["allow_user_personas"],
},
})
}
// ── Vault Status ────────────────────────────
func (h *AdminHandler) VaultStatus(c *gin.Context) {
hasKey := h.vault != nil && h.vault.HasEnvKey()
keyStr := ""
if hasKey {
keyStr = "set" // non-empty signals to VaultStatus that key is configured
}
status, err := crypto.VaultStatus(database.DB, keyStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"})
return
}
c.JSON(http.StatusOK, status)
}
// ── Provider Configs (Global) ───────────────
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
// Redact API keys but expose has_key flag for UI
type configWithKey struct {
models.ProviderConfig
HasKey bool `json:"has_key"`
}
out := make([]configWithKey, len(cfgs))
for i, cfg := range cfgs {
out[i] = configWithKey{
ProviderConfig: cfg,
HasKey: cfg.HasKey(),
}
}
c.JSON(http.StatusOK, gin.H{"configs": out})
}
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
// Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-"
// so ShouldBindJSON would silently drop the api_key field.
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]interface{} `json:"headers,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := providers.Get(req.Provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported provider: " + req.Provider,
"supported_providers": providers.List(),
})
return
}
cfg := &models.ProviderConfig{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config),
Headers: models.JSONMap(req.Headers),
Settings: models.JSONMap(req.Settings),
Scope: models.ScopeGlobal,
KeyScope: models.ScopeGlobal,
IsActive: true,
IsPrivate: req.IsPrivate,
}
// Encrypt the API key
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
// No vault configured — store raw bytes (unencrypted fallback)
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
c.JSON(http.StatusCreated, gin.H{
"id": cfg.ID,
"scope": cfg.Scope,
"name": cfg.Name,
"provider": cfg.Provider,
"endpoint": cfg.Endpoint,
"model_default": cfg.ModelDefault,
"config": cfg.Config,
"headers": cfg.Headers,
"settings": cfg.Settings,
"is_active": cfg.IsActive,
"is_private": cfg.IsPrivate,
"has_key": cfg.HasKey(),
"created_at": cfg.CreatedAt,
"updated_at": cfg.UpdatedAt,
})
}
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
id := c.Param("id")
// Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-"
var req struct {
models.ProviderConfigPatch
APIKey *string `json:"api_key,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := req.ProviderConfigPatch
// Transfer api_key → encrypted fields
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
}
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
h.auditLog(c, "provider.update", "provider_config", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
}
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
id := c.Param("id")
// Delete associated catalog entries first
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
h.auditLog(c, "provider.delete", "provider_config", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
}
// ── Model Catalog ───────────────────────────
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"models": entries})
}
func (h *AdminHandler) FetchModels(c *gin.Context) {
var req struct {
ProviderConfigID string `json:"provider_config_id"`
}
c.ShouldBindJSON(&req)
// If no specific provider, fetch from ALL global providers
if req.ProviderConfigID == "" {
configs, err := h.stores.Providers.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"})
return
}
if len(configs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"})
return
}
totalAdded, totalUpdated, totalFetched := 0, 0, 0
var errs []string
for _, cfg := range configs {
if !cfg.IsActive {
continue
}
added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg)
if err != nil {
errs = append(errs, cfg.Provider+": "+err.Error())
continue
}
totalAdded += added
totalUpdated += updated
totalFetched += fetched
}
result := gin.H{
"message": "models synced",
"added": totalAdded,
"updated": totalUpdated,
"total": totalFetched,
}
if len(errs) > 0 {
result["errors"] = errs
}
c.JSON(http.StatusOK, result)
return
}
// Single provider fetch
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"})
return
}
added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "models synced",
"added": added,
"updated": updated,
"total": fetched,
})
}
// fetchModelsForProvider fetches and syncs models for a single provider config.
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
// Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil {
return 0, 0, 0, err
}
h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{
"added": result.Added, "updated": result.Updated, "total": result.Total,
})
return result.Added, result.Updated, result.Total, nil
}
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
id := c.Param("id")
var req struct {
Visibility *string `json:"visibility"`
DisplayName *string `json:"display_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Visibility != nil {
if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
}
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
var req struct {
ProviderConfigID string `json:"provider_config_id"`
Visibility string `json:"visibility" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var err error
if req.ProviderConfigID != "" {
err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility)
} else {
err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"})
}
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
}
// ── Stats ───────────────────────────────────
func (h *AdminHandler) GetStats(c *gin.Context) {
ctx := c.Request.Context()
stats := gin.H{}
var userCount, channelCount, messageCount int
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount)
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount)
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount)
stats["users"] = userCount
stats["channels"] = channelCount
stats["messages"] = messageCount
c.JSON(http.StatusOK, stats)
}
// ── Helpers ─────────────────────────────────
// NOTE: ListAuditLog and ListAuditActions are in audit.go
func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) {
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
var meta models.JSONMap
if metadata != nil {
b, _ := json.Marshal(metadata)
json.Unmarshal(b, &meta)
}
entry := &models.AuditEntry{
ActorID: &uid,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Metadata: meta,
IPAddress: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
}
if err := h.stores.Audit.Log(context.Background(), entry); err != nil {
log.Printf("audit log error: %v", err)
}
}

636
api.js
View File

@@ -1,636 +0,0 @@
// ==========================================
// Chat Switchboard API Client
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
//
// BASE_PATH: injected via index.html <script>
// at container startup. All API paths are
// prefixed automatically.
// ==========================================
const BASE = window.__BASE__ || '';
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
const API = {
accessToken: null,
refreshToken: null,
user: null,
_refreshing: null,
// ── Bootstrap ────────────────────────────
loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
this.user = saved.user || null;
// Unknown age — refresh soon to get a fresh token
if (this.refreshToken) this._scheduleRefresh(60);
}
} catch (e) { /* corrupt storage */ }
},
saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
user: this.user
}));
},
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem(_storageKey);
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
},
get isAuthed() { return !!this.accessToken; },
get isAdmin() { return this.user?.role === 'admin'; },
// ── Auth ─────────────────────────────────
async health() {
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return this._parseJSON(resp, '/api/v1/health');
},
// Detect proxy interception: HTTP 200 but HTML instead of JSON
async _parseJSON(resp, context) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
// Proxy returned an HTML page (block page, auth wall, etc.)
let title = 'unknown';
try {
const html = await resp.clone().text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* best effort */ }
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
return resp.json();
},
async login(login, password) {
const data = await this._post('/api/v1/auth/login', { login, password }, true);
this._setAuth(data);
return data;
},
async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
if (!data.pending) this._setAuth(data);
return data;
},
async logout() {
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
catch (e) { /* best effort */ }
this.clearTokens();
},
async refresh() {
if (this._refreshing) return this._refreshing;
this._refreshing = (async () => {
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
return true;
} catch (e) {
this.clearTokens();
return false;
} finally {
this._refreshing = null;
}
})();
return this._refreshing;
},
_setAuth(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.user = data.user;
this.saveTokens();
this._scheduleRefresh(data.expires_in || 900);
},
_refreshTimer: null,
_scheduleRefresh(expiresIn) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh at 80% of expiry (e.g., 12min for 15min token)
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
this._refreshTimer = setTimeout(async () => {
if (!this.refreshToken) return;
console.debug('🔄 Proactive token refresh');
await this.refresh();
}, ms);
},
// ── Channels ──────────────────────────────
listChannels(page = 1, perPage = 100, type = '') {
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
if (type) url += `&type=${type}`;
return this._get(url);
},
createChannel(title, model, systemPrompt, type = 'direct') {
const body = { title, type };
if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/channels', body);
},
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
// ── Messages ─────────────────────────────
listMessages(channelId, page = 1, perPage = 200) {
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
},
// ── Message Tree (forking) ───────────────
getActivePath(channelId) {
return this._get(`/api/v1/channels/${channelId}/path`);
},
editMessage(channelId, messageId, content) {
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
},
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
const body = {};
if (model) body.model = model;
if (presetId) body.preset_id = presetId;
if (apiConfigId) body.provider_config_id = apiConfigId;
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the regeneration request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the regeneration stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
updateCursor(channelId, activeLeafId) {
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
},
listSiblings(channelId, messageId) {
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
},
// ── Summarize & Continue ────────────────
summarizeChannel(channelId) {
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
},
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
const body = { channel_id: channelId, content, stream: true };
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.provider_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the completion request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
// Also check 200 OK but HTML (proxy returning block page with 200)
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the completion stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
// ── Models ───────────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
// User presets
listUserPresets() { return this._get('/api/v1/presets'); },
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
listAllModels() { return this._get('/api/v1/models'); },
// ── API Configs (user providers) ─────────
listConfigs() { return this._get('/api/v1/api-configs'); },
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
createConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/api-configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
// ── Profile & Settings ───────────────────
async getProfile() {
const data = await this._get('/api/v1/profile');
// Sync user object with profile data (including avatar)
if (data && this.user) {
this.user.avatar = data.avatar || null;
this.user.display_name = data.display_name;
this.saveTokens();
}
return data;
},
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
changePassword(current, newPw) {
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
},
getSettings() { return this._get('/api/v1/settings'); },
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
// ── Admin ────────────────────────────────
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
if (teamIds?.length) body.team_ids = teamIds;
if (teamRole) body.team_role = teamRole;
return this._put(`/api/v1/admin/users/${id}/active`, body);
},
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
getPublicSettings() { return this._get('/api/v1/settings/public'); },
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); },
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
return this._post('/api/v1/admin/configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(visibility) {
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
return this._put('/api/v1/admin/models/bulk', {
visibility,
is_enabled: visibility === 'enabled'
});
},
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── Admin Presets ────────────────────────
adminListPresets() { return this._get('/api/v1/admin/presets'); },
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
// ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/admin/teams'); },
// ── Admin Audit ─────────────────────────
adminListAudit(params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/admin/audit?${q}`);
},
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
// ── Team Admin Self-Service ─────────────
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
// ── Team Providers ──────────────────────
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
teamListAudit(teamId, params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
},
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
// ── 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}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── Attachments ─────────────────────────
/**
* Upload a file to a channel. Uses multipart/form-data (not JSON).
* Returns the created attachment object with id, metadata, etc.
*/
async uploadAttachment(channelId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
// No Content-Type — browser sets multipart boundary automatically
body: form,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
},
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
async downloadAttachmentBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
headers: { 'Authorization': `Bearer ${this.accessToken}` },
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
}
return resp.blob();
},
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
// ── Admin Storage ───────────────────────
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
// Vault
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
// ── Admin Usage & Pricing ───────────────
adminGetUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.since) q.set('since', params.since);
if (params?.until) q.set('until', params.until);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage?${q}`);
},
adminGetUserUsage(userId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
},
adminGetTeamUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
},
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
adminDeletePricing(providerConfigId, modelId) {
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/usage?${q}`);
},
// ── Team Roles ──────────────────────────
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
// ── Team Usage ──────────────────────────
teamGetUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
},
// ── HTTP Internals ───────────────────────
_esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
_authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
};
},
async _get(path) { return this._authed(path); },
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
async _put(path, body) { return this._authed(path, 'PUT', body); },
async _del(path) { return this._authed(path, 'DELETE'); },
async _authed(path, method = 'GET', body) {
try {
return await this._raw(path, method, body, true);
} catch (e) {
if (e.status === 401 && this.refreshToken) {
if (await this.refresh()) {
return this._raw(path, method, body, true);
}
}
throw e;
}
},
async _raw(path, method = 'GET', body, auth = false) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(BASE + path, opts);
if (!resp.ok) {
// Check if the error response is also a proxy page
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
let title = 'unknown';
try {
const html = await resp.text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* */ }
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
err.status = resp.status;
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, path);
}
};

View File

@@ -1,458 +0,0 @@
package handlers
import (
"database/sql"
"encoding/json"
"math"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Request / Response types ────────────────
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), group, channel
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type updateChannelRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
}
type channelResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
Description *string `json:"description"`
Model *string `json:"model"`
APIConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type paginatedResponse struct {
Data interface{} `json:"data"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}
// ChannelHandler holds dependencies for channel endpoints.
type ChannelHandler struct{}
// NewChannelHandler creates a new channel handler.
func NewChannelHandler() *ChannelHandler {
return &ChannelHandler{}
}
// channelDeleteHook is called after a channel is successfully deleted.
// Set at startup by SetChannelDeleteHook to clean up storage files.
var channelDeleteHook func(channelID string)
// SetChannelDeleteHook registers a callback invoked after channel deletion.
// Used to clean up attachment files on the storage backend.
func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
// ── Helpers ─────────────────────────────────
// getUserID extracts the authenticated user's ID from context.
func getUserID(c *gin.Context) string {
uid, _ := c.Get("user_id")
s, _ := uid.(string)
return s
}
// parsePagination reads page and per_page from query params with defaults.
func parsePagination(c *gin.Context) (page, perPage, offset int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 100 {
perPage = 100
}
offset = (page - 1) * perPage
return
}
// ── List Channels ───────────────────────────
func (h *ChannelHandler) ListChannels(c *gin.Context) {
userID := getUserID(c)
page, perPage, offset := parsePagination(c)
// Optional filters
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types
search := strings.TrimSpace(c.Query("search"))
// Count total
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
countArgs := []interface{}{userID, archived == "true"}
argN := 3
if channelType != "" {
countQuery += ` AND type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
}
if folder != "" {
countQuery += ` AND folder = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folder)
argN++
}
if search != "" {
countQuery += ` AND title ILIKE $` + strconv.Itoa(argN)
countArgs = append(countArgs, "%"+search+"%")
argN++
}
var total int
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
return
}
// Fetch channels with message count
query := `
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.user_id = $1 AND c.is_archived = $2`
args := []interface{}{userID, archived == "true"}
argN = 3
if channelType != "" {
query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType)
argN++
}
if folder != "" {
query += ` AND c.folder = $` + strconv.Itoa(argN)
args = append(args, folder)
argN++
}
if search != "" {
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
args = append(args, "%"+search+"%")
argN++
}
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
args = append(args, perPage, offset)
rows, err := database.DB.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
defer rows.Close()
channels := make([]channelResponse, 0)
for rows.Next() {
var ch channelResponse
var tags []string
err := rows.Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
channels = append(channels, ch)
}
c.JSON(http.StatusOK, paginatedResponse{
Data: channels,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
}
// ── Create Channel ──────────────────────────
func (h *ChannelHandler) CreateChannel(c *gin.Context) {
userID := getUserID(c)
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Tags == nil {
req.Tags = []string{}
}
// Default type is "direct" (1:1 AI chat)
channelType := req.Type
if channelType == "" {
channelType = "direct"
}
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
ch.MessageCount = 0
// Auto-create channel_member for the creator
_, _ = database.DB.Exec(`
INSERT INTO channel_members (channel_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT DO NOTHING
`, ch.ID, userID)
// Auto-create channel_model if model specified
if req.Model != "" {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
`, ch.ID, req.Model, req.APIConfigID)
}
c.JSON(http.StatusCreated, ch)
}
// ── Get Channel ─────────────────────────────
func (h *ChannelHandler) GetChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
FROM channels c
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.id = $1 AND c.user_id = $2
`, channelID, userID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
pq.Array(&tags), &ch.Settings,
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"})
return
}
if tags == nil {
tags = []string{}
}
ch.Tags = tags
c.JSON(http.StatusOK, ch)
}
// ── Update Channel ──────────────────────────
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if database.DB == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"})
return
}
var req updateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
var ownerID string
err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// Build dynamic UPDATE
setClauses := []string{}
args := []interface{}{}
argN := 1
addClause := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
args = append(args, val)
argN++
}
if req.Title != nil {
addClause("title", *req.Title)
}
if req.Description != nil {
addClause("description", *req.Description)
}
if req.Model != nil {
addClause("model", *req.Model)
}
if req.SystemPrompt != nil {
addClause("system_prompt", *req.SystemPrompt)
}
if req.APIConfigID != nil {
addClause("provider_config_id", *req.APIConfigID)
}
if req.IsArchived != nil {
addClause("is_archived", *req.IsArchived)
}
if req.IsPinned != nil {
addClause("is_pinned", *req.IsPinned)
}
if req.Folder != nil {
addClause("folder", *req.Folder)
}
if req.Tags != nil {
addClause("tags", pq.Array(req.Tags))
}
if req.Settings != nil {
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
args = append(args, []byte(*req.Settings))
argN++
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
query := "UPDATE channels SET "
for i, clause := range setClauses {
if i > 0 {
query += ", "
}
query += clause
}
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
args = append(args, channelID, userID)
_, err = database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
// Return updated channel
h.GetChannel(c)
}
// ── Delete Channel ──────────────────────────
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
result, err := database.DB.Exec(
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
channelID, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Clean up storage files (CASCADE already removed PG attachment rows)
if channelDeleteHook != nil {
go channelDeleteHook(channelID)
}
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}

632
chat.js
View File

@@ -1,632 +0,0 @@
// ==========================================
// Chat Switchboard Chat Operations
// ==========================================
// Chat management, send, stream, regenerate, edit, branch,
// summarize, per-chat model persistence.
// ── Summarize & Continue ──────────────────
async function summarizeAndContinue() {
const channelId = App.currentChatId;
if (!channelId) return;
const btn = document.getElementById('summarizeBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Summarizing…';
}
try {
const result = await API.summarizeChannel(channelId);
UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success');
// Reload the active path to get the updated message tree
const resp = await API.getActivePath(channelId);
const chat = App.chats.find(c => c.id === channelId);
if (chat && resp) {
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
UI.renderMessages(chat.messages);
updateContextWarning();
updateInputTokens();
}
} catch (err) {
console.error('Summarize failed:', err);
UI.toast(err.message || 'Summarization failed', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '📝 Summarize & Continue';
}
}
}
// ── Chat Management ──────────────────────────
async function loadChats() {
try {
const resp = await API.listChannels(1, 100, 'direct');
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
type: c.type || 'direct',
model: c.model || '',
messageCount: c.message_count || 0,
messages: [],
updatedAt: c.updated_at,
settings: c.settings || {},
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
UI.toast('Failed to load chats', 'error');
}
}
async function selectChat(chatId) {
clearStaged(); // Discard any staged attachments from previous chat
App.currentChatId = chatId;
UI.renderChatList();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
if (chat.messages.length === 0 && chat.messageCount > 0) {
try {
const resp = await API.getActivePath(chatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
} catch (e) {
console.error('Failed to load messages:', e.message);
UI.toast('Failed to load messages', 'error');
}
}
// Restore the last-used model/preset for this chat
_restoreChatModel(chatId, chat);
// Load attachments for message rendering (non-blocking, best-effort)
await loadChannelAttachments(chatId);
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
}
// ── Per-Chat Model/Preset Persistence ────────
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
// localStorage used as write-through cache for instant restore without
// waiting for the channel list API call.
function _saveChatModel(chatId) {
if (!chatId) return;
const selectorId = UI.getModelValue();
if (!selectorId) return;
// Write-through: localStorage for instant restore on next visit
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
map[chatId] = selectorId;
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* quota exceeded, etc */ }
// Update in-memory chat object
const chat = App.chats.find(c => c.id === chatId);
if (chat) {
if (!chat.settings) chat.settings = {};
chat.settings.last_selector_id = selectorId;
}
// Persist to server (fire-and-forget — non-critical)
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
.catch(e => console.debug('Failed to persist model selection:', e.message));
}
function _restoreChatModel(chatId, chat) {
// Priority: server settings → localStorage fallback → channel base model → keep current
let targetId = null;
// 1. Server-side settings (most authoritative, roams across devices)
if (chat?.settings?.last_selector_id) {
targetId = chat.settings.last_selector_id;
}
// 2. localStorage fallback (covers race where settings haven't loaded yet)
if (!targetId) {
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
if (map[chatId]) targetId = map[chatId];
} catch (e) { /* */ }
}
// 3. Verify the stored ID still exists in available models
if (targetId) {
const found = App.models.find(m => m.id === targetId);
if (found) {
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
return;
}
// Stored model removed — clean up stale entries
_cleanStaleChatModel(chatId);
}
// 4. Fall back to channel's base model ID (match by baseModelId)
if (chat?.model) {
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
return;
}
}
// 4. Stored model gone — fall back to admin default → first visible
const visible = App.models.filter(m => !m.hidden);
const def = App.defaultModel
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
: null;
const fallback = def || visible[0];
if (fallback) {
UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : ''));
}
}
function _cleanStaleChatModel(chatId) {
// Remove from localStorage
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
// Clear from in-memory settings (server cleanup is fire-and-forget)
const chat = App.chats.find(c => c.id === chatId);
if (chat?.settings?.last_selector_id) {
delete chat.settings.last_selector_id;
}
}
async function newChat() {
clearStaged(); // Discard any staged attachments
App.currentChatId = null;
UI.renderChatList();
UI.showEmptyState();
UI.showRegenerate(false);
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
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) {
if (!await showConfirm('Delete this chat?')) return;
try {
await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);
// Clean up stored model preference
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
UI.renderChatList();
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
}
// ── Send Message ─────────────────────────────
async function sendMessage() {
const input = document.getElementById('messageInput');
const text = input.value.trim();
const hasAttachments = hasStagedAttachments();
// Need text or attachments, not generating, not blocked by uploads
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
input.value = '';
input.style.height = 'auto';
// Snapshot attachment IDs before clearing staged state
const attachmentIds = getStagedAttachmentIds();
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
// For presets, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
if (!App.currentChatId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
// Optimistic: show user message immediately
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : '');
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
UI.renderMessages(chat.messages);
// Clear staged attachments (they're now part of this message)
if (hasAttachments) consumeStaged();
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
// Reload active path from server to get proper IDs and sibling metadata
await reloadActivePath();
UI.showRegenerate(true);
// Persist the model/preset selection for this chat
_saveChatModel(App.currentChatId);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Completion error:', e);
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning');
} else if (msg.includes('no API config') || msg.includes('no model')) {
UI.toast('No provider configured — add one in Settings', 'error');
} else {
UI.toast(msg, 'error');
}
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
// ── Reload Active Path from Server ──────────
async function reloadActivePath() {
if (!App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
try {
const resp = await API.getActivePath(App.currentChatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
await loadChannelAttachments(App.currentChatId);
UI.renderMessages(chat.messages);
updateContextWarning();
} catch (e) {
console.error('Failed to reload path:', e.message);
}
}
// ── Regenerate (tree-aware: creates sibling) ─
async function regenerateMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
// Truncate display to the parent of the message being regenerated.
// This makes the stream appear in the correct position — as a fresh
// response to the parent user message, not appended at the bottom.
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) {
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
if (msgIdx > 0) {
const truncated = chat.messages.slice(0, msgIdx);
UI.renderMessages(truncated);
}
}
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamRegenerate(
App.currentChatId, messageId, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Reload from server to get proper tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, 'error'); }
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
// Legacy: bottom-bar regenerate button targets the last assistant message
async function regenerate() {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || chat.messages.length === 0) return;
// Find the last assistant message with an ID
for (let i = chat.messages.length - 1; i >= 0; i--) {
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
return regenerateMessage(chat.messages[i].id);
}
}
UI.toast('No assistant message to regenerate', 'warning');
}
// ── Edit Message (creates sibling, triggers completion) ──
async function editMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
const msg = chat.messages.find(m => m.id === messageId);
if (!msg || msg.role !== 'user') return;
// Show inline edit UI
UI.showEditInline(messageId, msg.content);
}
async function submitEdit(messageId, newContent) {
if (App.isGenerating || !App.currentChatId) return;
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const chat = App.chats.find(c => c.id === App.currentChatId);
// Step 1: Create sibling user message via edit endpoint
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
// Step 2: Reload path to show the new edited message
await reloadActivePath();
// Step 3: Generate assistant response as child of the new edit.
// Uses regenerate endpoint (which handles user messages by creating
// a child response, not a sibling). This avoids the duplicate user
// message that streamCompletion would create.
const resp = await API.streamRegenerate(
App.currentChatId, editResp.id, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Step 4: Final reload to get the complete tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Edit error:', e);
UI.toast(e.message || 'Edit failed', 'error');
await reloadActivePath();
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function cancelEdit() {
// Re-render to remove the edit form
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) UI.renderMessages(chat.messages);
}
// ── Switch Branch (sibling navigation) ──────
async function switchSibling(messageId, direction) {
if (App.isGenerating || !App.currentChatId) return;
try {
// Get siblings for this message
const data = await API.listSiblings(App.currentChatId, messageId);
const siblings = data.siblings || [];
const currentIdx = data.current_index ?? 0;
const targetIdx = currentIdx + direction;
if (targetIdx < 0 || targetIdx >= siblings.length) return;
const targetSibling = siblings[targetIdx];
// Update cursor to the target sibling (backend walks to leaf)
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
// Update local state with the new path
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat && resp.path) {
chat.messages = resp.path.map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
}
} catch (e) {
console.error('Branch switch failed:', e.message);
UI.toast('Failed to switch branch', 'error');
}
}
// ── Chat Listeners (extracted from initListeners) ──
function _initChatListeners() {
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', newChat);
// Split button dropdown
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('newChatDropdown').classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.split-btn')) {
document.getElementById('newChatDropdown').classList.remove('open');
}
});
// User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
e.stopPropagation();
UI.toggleUserMenu();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
});
// Flyout items
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Sidebar chat search
const _chatSearchInput = document.getElementById('chatSearchInput');
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
});
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
// Model selector (custom dropdown)
UI.initModelDropdown();
UI.initAppearance();
// Mobile hamburger
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('sidebarOverlay').style.display = 'none';
localStorage.setItem('sb_sidebar', '1');
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModels();
const visible = App.models.filter(m => !m.hidden).length;
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(overlay.id);
});
});
}

View File

@@ -1,931 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<base href="%%BASE_HREF%%">
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</script>
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
<title>Chat Switchboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=%%APP_VERSION%%">
<link rel="icon" type="image/x-icon" href="favicon.ico?v=%%APP_VERSION%%">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=%%APP_VERSION%%">
<link rel="icon" type="image/png" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
<link rel="apple-touch-icon" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
<link rel="manifest" href="manifest.json?v=%%APP_VERSION%%">
<meta name="theme-color" content="#0e0e10">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
</head>
<body>
<!-- ── App Shell ───────────────────────────── -->
<div id="appContainer" class="app" style="display:none">
<!-- Environment Banner (top) -->
<div class="banner banner-top" id="bannerTop"></div>
<div class="app-body">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-top">
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
<span class="brand-logo" id="brandSidebarLogo"><img src="favicon-32.png" class="brand-logo-img" alt=""></span>
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span class="sb-label brand-text" id="brandSidebarText">Chat Switchboard</span>
</button>
<div class="split-btn" style="position:relative">
<button class="split-btn-main" id="newChatBtn" title="New Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
<span class="sb-label">New Chat</span>
</button>
<button class="split-btn-drop" id="newChatDropBtn" title="More options">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="split-dropdown" id="newChatDropdown">
<button class="split-dropdown-item" onclick="newChat()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
New Chat
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Group Chat <span class="dd-hint">soon</span>
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
New Channel <span class="dd-hint">soon</span>
</button>
</div>
</div>
</div>
<div class="sidebar-search" id="sidebarSearch">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
<button class="sidebar-search-clear" id="chatSearchClear" title="Clear"></button>
</div>
<div class="sidebar-chats" id="chatHistory"></div>
<!-- Notes shortcut -->
<div class="sidebar-notes-btn">
<button class="sb-notes" id="notesBtn" title="Notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span class="sb-label">Notes</span>
</button>
</div>
<!-- User area -->
<div class="sidebar-bottom">
<button class="user-btn" id="userMenuBtn">
<div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span>
<span class="avatar-bug" title="Debug available">🐛</span>
</div>
<span class="sb-label user-name" id="userName">User</span>
</button>
<div class="user-flyout" id="userFlyout">
<button class="flyout-item" id="menuSettings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
<span>Settings</span>
</button>
<button class="flyout-item" id="menuAdmin" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin Panel</span>
</button>
<button class="flyout-item" id="menuTeamAdmin" style="display:none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Team Management</span>
</button>
<button class="flyout-item" id="menuDebug">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><path d="M4 14h4"/><path d="M16 14h4"/><path d="M12 18v-6"/><circle cx="12" cy="14" r="6"/></svg>
<span>Debug Log</span>
</button>
<div class="flyout-divider"></div>
<button class="flyout-item flyout-danger" id="menuSignout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>Sign Out</span>
</button>
</div>
</div>
</aside>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Main Chat Area -->
<main class="chat-area">
<div class="model-bar">
<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">
<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>
<div class="model-caps" id="modelCaps"></div>
</div>
<div class="messages" id="chatMessages">
<div class="empty-state">
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
<h2>Chat Switchboard</h2>
<p>Select a model and start chatting</p>
</div>
</div>
<div class="input-area">
<div class="context-warning" id="contextWarning" style="display:none">
<span class="context-warning-icon">⚠️</span>
<span class="context-warning-text" id="contextWarningText"></span>
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss"></button>
</div>
<div class="attachment-strip" id="attachmentStrip" style="display:none"></div>
<div class="input-wrap">
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="fileInput" multiple style="display:none">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions">
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button class="action-btn send-btn" id="sendBtn" title="Send">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
<div class="input-meta" id="inputMeta">
<span class="input-token-count" id="inputTokenCount"></span>
</div>
</div>
</main>
<!-- Side Panel (preview + notes) -->
<aside class="side-panel" id="sidePanel">
<div class="side-panel-resize" id="sidePanelResize"></div>
<div class="side-panel-header">
<div class="side-panel-tabs" id="sidePanelTabs">
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
</div>
<div class="side-panel-actions">
<button class="side-panel-action-btn" id="sidePanelClearBtn" onclick="clearPreview()" title="Clear preview" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</button>
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel"></button>
</div>
</div>
<div class="side-panel-body">
<!-- Preview tab -->
<div class="side-panel-page" id="sidePanelPreview">
<div class="side-panel-empty" id="previewEmpty">
<p>Click <strong>Preview</strong> on any HTML code block to render it here.</p>
</div>
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts" style="display:none"></iframe>
</div>
<!-- Notes tab -->
<div class="side-panel-page" id="sidePanelNotes" style="display:none">
<div class="notes-toolbar">
<div class="notes-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
</div>
<select id="notesFolderFilter" class="notes-filter-select">
<option value="">All folders</option>
</select>
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
<option value="updated_desc">Updated ↓</option>
<option value="updated_asc">Updated ↑</option>
<option value="created_desc">Created ↓</option>
<option value="created_asc">Created ↑</option>
<option value="title_asc">Title AZ</option>
<option value="title_desc">Title ZA</option>
</select>
</div>
<div class="notes-actions-bar">
<button class="btn-small" id="notesSelectModeBtn">Select</button>
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
</div>
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
</div>
<!-- List view (default) -->
<div id="notesListView">
<div id="notesList" class="notes-list">
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
</div>
</div>
<!-- Editor view (hidden by default) -->
<div id="notesEditorView" style="display:none">
<div class="notes-editor">
<div class="notes-editor-toolbar">
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
<div class="notes-mode-toggle">
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
</div>
</div>
<!-- Read mode: rendered markdown -->
<div id="noteReadMode" style="display:none">
<h3 class="note-read-title" id="noteReadTitle"></h3>
<div class="note-read-meta" id="noteReadMeta"></div>
<div class="note-read-content msg-text" id="noteReadContent"></div>
<div class="notes-editor-actions">
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
</div>
</div>
<!-- Edit mode: form fields -->
<div id="noteEditMode">
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
<div class="form-row">
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
<div class="notes-editor-actions">
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
</div><!-- .app-body -->
<!-- Environment Banner (bottom) -->
<div class="banner banner-bottom" id="bannerBottom"></div>
</div>
<!-- ── Auth Splash ─────────────────────────── -->
<div class="splash" id="splashGate">
<div class="splash-hero">
<div class="hero-content">
<div class="hero-logo-row">
<div class="hero-logo-mark" id="brandLogo">
<img src="favicon-256.png" alt="Chat Switchboard" style="width:100%;height:100%;border-radius:12px">
</div>
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
</div>
<h1 class="hero-headline" id="brandHeadline">One interface.<br>Every AI model.</h1>
<p class="hero-sub" id="brandTagline">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
<div class="hero-features" id="brandPills">
<div class="hero-pill accent"><span class="pill-icon"></span> Streaming responses</div>
<div class="hero-pill purple"><span class="pill-icon"><img src="favicon-32.png" style="width:14px;height:14px;vertical-align:middle" alt=""></span> Multi-provider routing</div>
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
<div class="hero-pill"><span class="pill-icon">🧠</span> Thinking blocks</div>
</div>
<div class="hero-version">v%%APP_VERSION%%</div>
</div>
</div>
<div class="splash-auth">
<div class="auth-card">
<div class="auth-card-header">
<h2>Welcome back</h2>
<p>Sign in to continue to your workspace</p>
</div>
<div class="auth-tabs">
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
</div>
<div id="authLoginForm">
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username" placeholder="you@example.com"></div>
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" placeholder="••••••••" onkeydown="if(event.key==='Enter')handleLogin()"></div>
</div>
<div id="authRegisterForm" style="display:none">
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username" placeholder="Choose a username"></div>
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email" placeholder="you@example.com"></div>
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" placeholder="Min 8 characters" onkeydown="if(event.key==='Enter')handleRegister()"></div>
</div>
<div class="auth-error" id="authError"></div>
<div class="splash-error" id="splashError"></div>
<div class="auth-actions">
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
</div>
<div class="auth-footer">
<p id="brandAuthFooter">Self-hosted AI chat &middot; Your keys, your data</p>
</div>
</div>
</div>
</div>
<!-- ── Settings Modal ──────────────────────── -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn"></button></div>
<div class="modal-tabs settings-tabs">
<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')" id="settingsProvidersTabBtn">Providers</button>
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
<button class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')" id="settingsPersonasTabBtn">Personas</button>
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
</div>
<div class="modal-body">
<!-- General Tab -->
<div class="settings-tab-content" id="settingsGeneralTab">
<section class="settings-section">
<h3>Profile</h3>
<div class="avatar-upload-row">
<div class="avatar-preview" id="avatarPreview">
<span id="avatarPreviewLetter">?</span>
</div>
<div class="avatar-actions">
<button class="btn-small" id="avatarUploadBtn">Upload Photo</button>
<button class="btn-small btn-danger" id="avatarRemoveBtn" style="display:none">Remove</button>
<input type="file" id="avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
<div class="form-hint">Auto-resized to 128×128</div>
</div>
</div>
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
<button class="btn-small" id="profileChangePwBtn">Change Password</button>
<div id="profileChangePwForm" style="display:none">
<div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div>
<div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div>
<button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
</div>
</section>
<section class="settings-section" id="settingsTeamsSection" style="display:none">
<h3>My Teams</h3>
<div id="settingsTeamsList"></div>
</section>
<section class="settings-section">
<h3>Chat</h3>
<div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
<div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
<div class="form-row">
<div class="form-group">
<label>Max Tokens <span class="form-hint" id="settingsMaxHint"></span></label>
<input type="number" id="settingsMaxTokens" placeholder="Auto (from model)">
</div>
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
</div>
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Auto-expand thinking blocks</label>
</section>
</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="150" step="5" value="100" class="range-input">
<div class="range-labels"><span>80%</span><span>150%</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>20px</span></div>
</div>
</section>
</div>
<!-- Providers Tab -->
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
<section class="settings-section">
<div id="providerList"></div>
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
<div id="providerAddForm" style="display:none">
</div>
</section>
<div id="userProvidersDisabled" class="settings-notice" style="display:none">
<span>⚠️</span> User-configured providers have been disabled by the administrator.
</div>
</div>
<!-- Models Tab -->
<div class="settings-tab-content" id="settingsModelsTab" style="display:none">
<section class="settings-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<h3 style="font-size:14px;margin:0">Available Models</h3>
<div>
<button class="btn-small" onclick="bulkSetUserModelVisibility(true)" title="Show all models in selector">Show All</button>
<button class="btn-small" onclick="bulkSetUserModelVisibility(false)" title="Hide all models from selector">Hide All</button>
</div>
</div>
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through presets.</p>
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
</section>
</div>
<!-- Personas Tab (hidden if admin disables user presets) -->
<div class="settings-tab-content" id="settingsPersonasTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:4px">My Personas</h3>
<p class="section-hint" style="margin-bottom:8px">Personal model presets with custom system prompts and settings.</p>
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
</section>
</div>
<!-- Usage Tab -->
<div class="settings-tab-content" id="settingsUsageTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:6px">My Usage</h3>
<p class="section-hint" style="margin-bottom:8px">Token consumption and estimated costs across all your conversations.</p>
<div class="form-row" style="gap:6px;margin-bottom:8px">
<select id="myUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
</select>
<select id="myUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
<option value="model">By Model</option>
<option value="day">By Day</option>
</select>
</div>
<div id="myUsageTotals"></div>
<div id="myUsageResults"></div>
</section>
</div>
<!-- Model Roles Tab (BYOK users — personal role overrides) -->
<div class="settings-tab-content" id="settingsRolesTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:4px">Personal Model Roles</h3>
<p class="section-hint" style="margin-bottom:8px">Override the org's default utility and embedding models with your own providers. Uses your personal API keys — no org cost.</p>
<div id="userRolesConfig"></div>
</section>
<div id="userRolesDisabled" class="settings-notice" style="display:none">
<span></span> Add a personal provider first to configure role overrides.
</div>
</div>
</div>
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
</div>
</div>
<!-- ── Team Management Modal ─────────────────── -->
<div class="modal-overlay" id="teamAdminModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2 id="teamAdminTitle">Team Management</h2>
<button class="modal-close" id="teamAdminCloseBtn"></button>
</div>
<!-- Team picker (shown when user admins multiple teams) -->
<div id="teamAdminPicker" style="display:none">
<div class="modal-body" id="teamAdminPickerList"></div>
</div>
<!-- Tabbed management (shown after selecting a team) -->
<div id="teamAdminContent" style="display:none">
<div class="modal-tabs admin-tabs" id="teamAdminTabs">
<button class="admin-tab active" data-ttab="members" onclick="UI.switchTeamTab('members')">Members</button>
<button class="admin-tab" data-ttab="providers" onclick="UI.switchTeamTab('providers')">Providers</button>
<button class="admin-tab" data-ttab="presets" onclick="UI.switchTeamTab('presets')">Presets</button>
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
</div>
<div class="modal-body">
<!-- Members tab -->
<div class="team-tab-content" id="teamTabMembers">
<div id="settingsTeamMembers"></div>
<div id="settingsTeamAddMember" style="display:none;margin-top:8px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>User</label><select id="settingsTeamMemberUser"></select></div>
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button><button class="btn-small" id="settingsTeamCancelMember">Cancel</button></div>
</div>
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
</div>
<!-- Providers tab -->
<div class="team-tab-content" id="teamTabProviders" style="display:none">
<div id="settingsTeamProviders"></div>
<div id="settingsTeamProviderForm" style="display:none;margin-top:8px" class="admin-inline-form">
</div>
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
</div>
<!-- Presets tab -->
<div class="team-tab-content" id="teamTabPresets" style="display:none">
<div id="settingsTeamPresets"></div>
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
</div>
<!-- Usage tab -->
<div class="team-tab-content" id="teamTabUsage" style="display:none">
<div class="form-row" style="gap:6px;margin-bottom:8px">
<select id="teamUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
</select>
<select id="teamUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
</select>
</div>
<div id="settingsTeamUsageTotals"></div>
<div id="settingsTeamUsageResults"></div>
</div>
<!-- Activity tab -->
<div class="team-tab-content" id="teamTabActivity" style="display:none">
<div class="audit-filters" style="margin-bottom:8px">
<select id="teamAuditFilterAction" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamAuditLog()">
<option value="">All actions</option>
</select>
</div>
<div id="settingsTeamAudit" style="max-height:400px;overflow-y:auto"></div>
<div id="teamAuditPagination" class="audit-pagination" style="display:none">
<button class="btn-small" id="teamAuditPrevBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage - 1)">← Prev</button>
<span id="teamAuditPageInfo" style="font-size:11px;color:var(--text-3)"></span>
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Admin Modal ─────────────────────────── -->
<div class="modal-overlay" id="adminModal">
<div class="modal modal-wide">
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn"></button></div>
<div class="modal-tabs admin-tabs">
<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="models">Models</button>
<button class="admin-tab" data-tab="presets">Presets</button>
<button class="admin-tab" data-tab="teams">Teams</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="roles">Roles</button>
<button class="admin-tab" data-tab="usage">Usage</button>
<button class="admin-tab" data-tab="extensions">Extensions</button>
<button class="admin-tab" data-tab="storage">Storage</button>
<button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div>
<div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddUserBtn">+ Add User</button>
</div>
<div id="adminAddUserForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>
<div class="form-group"><label>Email</label><input type="email" id="adminNewEmail" placeholder="email@example.com"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>
<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateUserBtn">Create</button><button class="btn-small" id="adminCancelUserBtn">Cancel</button></div>
</div>
<div id="adminUserList"></div>
</div>
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
</div>
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
</div>
<div id="adminProviderList"></div>
</div>
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
<button class="btn-small" onclick="bulkSetVisibility('enabled')">All Enabled</button>
<button class="btn-small" onclick="bulkSetVisibility('team')">All Team</button>
<button class="btn-small" onclick="bulkSetVisibility('disabled')">All Disabled</button>
<span class="admin-hint" id="adminModelsHint"></span>
</div>
<div id="adminModelList"></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>
<div id="adminPresetList"></div>
</div>
<div class="admin-tab-content" id="adminTeamsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddTeamBtn">+ New Team</button>
</div>
<div id="adminAddTeamForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Team Name</label><input type="text" id="adminTeamName" placeholder="Engineering"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Core engineering team"></div>
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateTeamBtn">Create</button><button class="btn-small" id="adminCancelTeamBtn">Cancel</button></div>
</div>
<div id="adminTeamList"></div>
<!-- Team detail / member management (shown when editing a team) -->
<div id="adminTeamDetail" style="display:none">
<button class="notes-back-btn" id="adminTeamBackBtn">← Back to teams</button>
<h3 id="adminTeamDetailName" style="margin:8px 0 12px;font-size:15px"></h3>
<section class="settings-section" style="margin-bottom:12px;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (data stays on-prem)</label>
<p class="section-hint" style="margin:4px 0 0">Team members can only use providers marked as "private".</p>
<label class="checkbox-label" style="margin-top:8px"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team admins to manage providers</label>
<p class="section-hint" style="margin:4px 0 0">When disabled, team admins cannot add or modify team providers.</p>
</section>
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddMemberBtn">+ Add Member</button>
</div>
<div id="adminAddMemberForm" style="display:none" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>User</label><select id="adminMemberUser"></select></div>
<div class="form-group"><label>Team Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
</div>
<div class="form-row"><button class="btn-small btn-primary" id="adminAddMemberSubmit">Add</button><button class="btn-small" id="adminCancelMemberBtn">Cancel</button></div>
</div>
<div id="adminMemberList"></div>
</div>
</div>
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<section class="settings-section">
<h3>System Prompt</h3>
<p class="section-hint" style="margin-bottom:6px">Injected into every conversation before user/preset prompts. Users cannot override or disable this.</p>
<div class="form-group">
<textarea id="adminSystemPrompt" rows="4" placeholder="e.g. You are a helpful assistant for Acme Corp. Always respond professionally."></textarea>
</div>
</section>
<section class="settings-section">
<h3>Registration</h3>
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow new user registration</label>
<div class="form-group" style="margin-top: 8px;">
<label>Default state for new accounts</label>
<select id="adminRegDefaultState">
<option value="active">Active — immediate access</option>
<option value="pending">Pending — require admin approval</option>
</select>
</div>
</section>
<section class="settings-section">
<h3>User Providers</h3>
<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle" checked> Allow users to configure their own API providers</label>
<p class="section-hint">When disabled, users can only use admin-configured global providers.</p>
</section>
<section class="settings-section">
<h3>User Presets</h3>
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal presets</label>
<p class="section-hint">When disabled, users can only use admin-created global presets.</p>
</section>
<section class="settings-section">
<h3>Default Model</h3>
<div class="form-group">
<select id="adminDefaultModel">
<option value="">— None (first visible) —</option>
</select>
</div>
<p class="section-hint">Model selected by default for new users or when a saved model is no longer available.</p>
</section>
<section class="settings-section">
<h3>Environment Banner</h3>
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>
<div id="bannerConfigFields" style="display:none">
<div class="form-group">
<label>Preset</label>
<select id="adminBannerPreset"><option value="">Custom</option></select>
</div>
<div class="form-group"><label>Banner Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>
<div class="form-row">
<div class="form-group"><label>Position</label>
<select id="adminBannerPosition"><option value="both">Top &amp; Bottom</option><option value="top">Top only</option><option value="bottom">Bottom only</option></select>
</div>
</div>
<div class="form-row">
<div class="form-group"><label>Background</label><div class="color-input-wrap"><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" class="color-hex" value="#007a33" maxlength="7"></div></div>
<div class="form-group"><label>Text Color</label><div class="color-input-wrap"><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" class="color-hex" value="#ffffff" maxlength="7"></div></div>
</div>
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
</div>
</section>
<section class="admin-section">
<h4>🔐 Encryption</h4>
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
</section>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div>
<!-- ── Roles Tab ─────────────────── -->
<div class="admin-tab-content" id="adminRolesTab" style="display:none">
<p style="color:var(--text-muted);margin:0 0 12px">Configure system model roles. Each role has a primary and optional fallback model.</p>
<div id="adminRolesContent">
<div class="loading">Loading roles...</div>
</div>
</div>
<!-- ── Usage Tab ─────────────────── -->
<div class="admin-tab-content" id="adminUsageTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:12px">
<select id="usagePeriod" style="min-width:100px">
<option value="7d">Last 7 days</option>
<option value="30d" selected>Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<select id="usageGroupBy" style="min-width:100px">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
<option value="provider">By Provider</option>
</select>
<button class="btn-secondary btn-small" id="usageRefreshBtn">Refresh</button>
</div>
<div id="adminUsageTotals"></div>
<div id="adminUsageResults"></div>
<h3 style="margin-top:16px;font-size:14px">Model Pricing</h3>
<div id="adminPricingTable"></div>
</div>
<div class="admin-tab-content" id="adminExtensionsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminInstallExtBtn">+ Install Extension</button>
</div>
<div id="adminExtensionsList"></div>
<div id="adminInstallExtForm" style="display:none;margin-top:12px" class="admin-inline-form">
<div class="form-row">
<div class="form-group"><label>Extension ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>
<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0" value="1.0.0"></div>
<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor"></div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="extInstallDesc"></div>
<div class="form-group"><label>Manifest JSON <span class="text-muted">(optional)</span></label><textarea id="extInstallManifest" rows="4" placeholder='{"tools":[], "permissions":[]}'></textarea></div>
<div class="form-group"><label>Script <span class="text-muted">(JavaScript source)</span></label><textarea id="extInstallScript" rows="6" placeholder="Extensions.register({ id: 'my-ext', init(ctx) { ... } });"></textarea></div>
<div class="form-row">
<label class="toggle-label"><input type="checkbox" id="extInstallSystem"> System (users can't disable)</label>
<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>
</div>
<div class="form-actions">
<button class="btn-small" onclick="document.getElementById('adminInstallExtForm').style.display='none'">Cancel</button>
<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>
</div>
</div>
</div>
<div class="admin-tab-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">
<select id="auditFilterAction" style="min-width:140px"><option value="">All actions</option></select>
<select id="auditFilterResource" style="min-width:120px">
<option value="">All types</option>
<option value="user">user</option>
<option value="team">team</option>
<option value="preset">preset</option>
<option value="channel">channel</option>
<option value="config">config</option>
</select>
<button class="btn-small" id="auditRefreshBtn">Refresh</button>
</div>
<div id="adminAuditList"></div>
<div class="pagination-row" id="auditPagination" style="display:none;margin-top:8px">
<button class="btn-small" id="auditPrevBtn">← Prev</button>
<span id="auditPageInfo" style="font-size:12px;color:var(--text-3)"></span>
<button class="btn-small" id="auditNextBtn">Next →</button>
</div>
</div>
</div>
</div>
</div>
<!-- ── Notes Modal ────────────────────────── -->
<!-- Debug Modal (Ctrl+Shift+L) -->
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">
<div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()"></button></div>
<div class="modal-tabs debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
</div>
<div class="modal-body debug-modal-body">
<div class="debug-tab-content" id="debugConsoleTab">
<div class="debug-toolbar">
<label class="debug-check"><input type="checkbox" id="debugFilterErrors"> Errors only</label>
<label class="debug-check"><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
</div>
<div id="debugConsoleContent" class="debug-content"></div>
</div>
<div class="debug-tab-content" id="debugNetworkTab" style="display:none"><div id="debugNetworkContent" class="debug-content"></div></div>
<div class="debug-tab-content" id="debugStateTab" style="display:none"><div id="debugStateContent" class="debug-content"></div></div>
</div>
<div class="modal-footer debug-footer">
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<button class="btn-small btn-danger" onclick="purgeCache()">Purge Cache</button>
<div style="margin-left:auto;display:flex;gap:0.5rem">
<button class="btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-small" onclick="copyDebugLog()">Copy</button>
<button class="btn-small" onclick="exportDebugLog()">Export</button>
</div>
</div>
</div>
</div>
<!-- Command Palette (Ctrl+K) -->
<div class="cmd-palette-overlay" id="cmdPalette">
<div class="cmd-palette">
<div class="cmd-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="cmdInput" placeholder="Type a command…" autocomplete="off" spellcheck="false">
<kbd class="cmd-kbd">esc</kbd>
</div>
<div class="cmd-results" id="cmdResults"></div>
<div class="cmd-footer">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd></kbd> run</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
<!-- Vendor libs -->
<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="js/debug.js?v=%%APP_VERSION%%"></script>
<script src="js/events.js?v=%%APP_VERSION%%"></script>
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
<script src="js/api.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-settings.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
<script src="js/app.js?v=%%APP_VERSION%%"></script>
<script>
// ── Service Worker Registration ─────────
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').then(reg => {
console.log('SW registered, scope:', reg.scope);
// Check for updates periodically
setInterval(() => reg.update(), 60 * 60 * 1000);
}).catch(err => console.warn('SW registration failed:', err));
});
}
// ── PWA Install Prompt ──────────────────
let _deferredInstallPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
_deferredInstallPrompt = e;
_showInstallBanner();
});
function _showInstallBanner() {
if (document.getElementById('pwaInstallBanner')) return;
const banner = document.createElement('div');
banner.id = 'pwaInstallBanner';
banner.className = 'pwa-install-banner';
banner.innerHTML =
'<span>Install Chat Switchboard for a better experience</span>' +
'<button class="btn-small btn-primary" onclick="_installPWA()">Install</button>' +
'<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>';
document.body.appendChild(banner);
}
function _installPWA() {
if (!_deferredInstallPrompt) return;
_deferredInstallPrompt.prompt();
_deferredInstallPrompt.userChoice.then(() => {
_deferredInstallPrompt = null;
document.getElementById('pwaInstallBanner')?.remove();
});
}
window.addEventListener('appinstalled', () => {
document.getElementById('pwaInstallBanner')?.remove();
_deferredInstallPrompt = null;
});
</script>
<!-- Image lightbox overlay -->
<div class="lightbox-overlay" id="lightbox">
<button class="lightbox-close" title="Close (Esc)"></button>
<img id="lightboxImg" alt="">
</div>
</body>
</html>

508
main.go
View File

@@ -1,508 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
)
func main() {
// ── Subcommand dispatch ──────────────────
// Usage: switchboard vault rekey
if len(os.Args) > 2 && os.Args[1] == "vault" {
runVaultCommand(os.Args[2])
return
}
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Println("switchboard", Version)
return
}
// ── Server startup ──────────────────────
cfg := config.Load()
// Register LLM providers
providers.Init()
var stores store.Stores
uekCache := crypto.NewUEKCache()
var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
log.Println(" Running in unmanaged mode (no persistence)")
} else {
// Schema check: init if fresh, upgrade if behind, proceed if current
if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err)
}
// Vault: enforce encryption key + backfill plaintext keys
if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault check failed: %v", err)
}
if cfg.EncryptionKey != "" {
if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault backfill failed: %v", err)
}
}
// Derive env key for key resolver (nil if not set)
var envKey []byte
if cfg.EncryptionKey != "" {
var err error
envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to derive encryption key: %v", err)
}
log.Println(" 🔐 API key encryption active")
}
keyResolver = crypto.NewKeyResolver(envKey, uekCache)
// Initialize store layer
stores = postgres.NewStores(database.DB)
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores)
// Seed additional users from env (dev/test only, skipped in production)
handlers.SeedUsers(cfg, stores)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
}
defer database.Close()
// ── File Storage ─────────────────────────
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
// overrides auto-detection. nil objStore = storage features disabled.
if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath); err != nil {
if cfg.StorageBackend != "" {
// Explicit backend requested but failed — fatal
log.Fatalf("❌ Storage init failed: %v", err)
}
log.Printf("⚠ Storage init failed: %v", err)
} else {
objStore = s
}
handlers.SetStorageConfigured(objStore != nil)
// ── Extraction Queue ────────────────────
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
// Nil if storage is disabled.
var extQueue *extraction.Queue
if objStore != nil && cfg.StoragePath != "" {
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
if err != nil {
log.Printf("⚠ Extraction queue init failed: %v", err)
} else {
extQueue = q
// Recover items stuck in "processing" from previous crash
if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
log.Printf("⚠ Extraction recovery failed: %v", err)
} else if recovered > 0 {
log.Printf(" 📋 Recovered %d stale extraction items", recovered)
}
}
}
// Role resolver for model role dispatch (needs stores + vault)
roleResolver := roles.NewResolver(stores, keyResolver)
r := gin.Default()
r.Use(middleware.CORS())
// ── Base path group ──────────────────────
base := r.Group(cfg.BasePath)
// ── EventBus + WebSocket Hub ─────────────
bus := events.NewBus()
hub := events.NewHub(bus)
// Health check (k8s probes hit this directly)
base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"version": Version,
"database": database.IsConnected(),
"schema_version": database.SchemaVersion(),
})
})
// WebSocket endpoint
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg, stores, uekCache)
authLimiter := middleware.NewRateLimiter(1, 5)
api := base.Group("/api/v1")
{
// Health (routable through ingress)
api.GET("/health", func(c *gin.Context) {
info := gin.H{
"status": "ok",
"version": Version,
"schema_version": database.SchemaVersion(),
"database": database.IsConnected(),
"providers": providers.List(),
}
if database.IsConnected() {
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
}
c.JSON(200, info)
})
authGroup := api.Group("/auth")
authGroup.Use(authLimiter.Limit())
{
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
authGroup.POST("/refresh", auth.Refresh)
authGroup.POST("/logout", auth.Logout)
}
// ── Public extension assets ────────────────
// Script tags can't send Authorization headers, so asset serving must be public.
// Extension JS is the same for all users — no user-specific data.
extH := handlers.NewExtensionHandler(stores)
api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)
// ── Protected routes ────────────────────
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
{
// Channels
channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
// Message tree (forking)
protected.GET("/channels/:id/path", msgs.GetActivePath)
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore)
protected.POST("/chat/completions", comp.Complete)
// Summarize & Continue
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
protected.POST("/channels/:id/summarize", summarize.Summarize)
// Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
protected.POST("/api-configs", provCfg.CreateConfig)
protected.GET("/api-configs/:id", provCfg.GetConfig)
protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", provCfg.ListModels)
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Models (unified resolver — replaces scattered endpoints)
modelH := handlers.NewModelHandler(stores)
protected.GET("/models/enabled", modelH.ListEnabledModels)
protected.GET("/models", modelH.ListEnabledModels) // alias
// Model Preferences
modelPrefs := handlers.NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User Settings & Profile
settings := handlers.NewSettingsHandler(uekCache)
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
protected.POST("/profile/avatar", settings.UploadAvatar)
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Usage (personal)
usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)
// Personas (replaces /presets)
personas := handlers.NewPersonaHandler(stores)
protected.GET("/presets", personas.ListUserPersonas) // backward compat
protected.POST("/presets", personas.CreateUserPersona)
protected.PUT("/presets/:id", personas.UpdateUserPersona)
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Notes
notes := handlers.NewNoteHandler()
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Attachments (file upload/download)
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
protected.POST("/channels/:id/attachments", attachH.Upload)
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
protected.GET("/attachments/:id", attachH.GetMetadata)
protected.GET("/attachments/:id/download", attachH.Download)
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
// Hook: clean up storage files when channels are deleted
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)
// Teams (user: my teams)
teams := handlers.NewTeamHandler(keyResolver)
protected.GET("/teams/mine", teams.MyTeams)
// Team admin self-service
teamScoped := protected.Group("/teams/:teamId")
teamScoped.Use(middleware.RequireTeamAdmin())
{
teamScoped.GET("/members", teams.ListMembers)
teamScoped.POST("/members", teams.AddMember)
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
teamScoped.GET("/models", teams.ListAvailableModels)
// Team providers
teamScoped.GET("/providers", teams.ListTeamProviders)
teamScoped.POST("/providers", teams.CreateTeamProvider)
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
// Team audit log (team admins only — RequireTeamAdmin on group)
teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
// Team usage (team admins only — usage against team-owned providers)
teamUsage := handlers.NewUsageHandler(stores)
teamScoped.GET("/usage", teamUsage.TeamUsage)
// Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
// Team role overrides
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
}
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
protected.GET("/settings/public", adm.PublicSettings)
// Extensions (user-facing)
protected.GET("/extensions", extH.ListUserExtensions)
protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
}
// ── Admin routes ────────────────────────
admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg))
admin.Use(middleware.RequireAdmin())
{
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
// User management
admin.GET("/users", adm.ListUsers)
admin.POST("/users", adm.CreateUser)
admin.PUT("/users/:id/role", adm.UpdateUserRole)
admin.PUT("/users/:id/active", adm.ToggleUserActive)
admin.POST("/users/:id/reset-password", adm.ResetPassword)
admin.DELETE("/users/:id", adm.DeleteUser)
// Global settings
admin.GET("/settings", adm.ListGlobalSettings)
admin.GET("/settings/:key", adm.GetGlobalSetting)
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
// Stats
admin.GET("/stats", adm.GetStats)
// Global Provider Configs
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
// Model Catalog
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/bulk", adm.BulkUpdateModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
// Personas (admin global)
personaAdm := handlers.NewPersonaHandler(stores)
admin.GET("/presets", personaAdm.ListAdminPersonas)
admin.POST("/presets", personaAdm.CreateAdminPersona)
admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona)
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Teams (admin)
teamAdm := handlers.NewTeamHandler(keyResolver)
admin.GET("/teams", teamAdm.ListTeams)
admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam)
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
admin.DELETE("/teams/:id", teamAdm.DeleteTeam)
admin.GET("/teams/:id/members", teamAdm.ListMembers)
admin.POST("/teams/:id/members", teamAdm.AddMember)
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
// Model Roles
rolesH := handlers.NewRolesHandler(stores, roleResolver)
admin.GET("/roles", rolesH.ListRoles)
admin.GET("/roles/:role", rolesH.GetRole)
admin.PUT("/roles/:role", rolesH.UpdateRole)
admin.POST("/roles/:role/test", rolesH.TestRole)
// Usage & Pricing
usageH := handlers.NewUsageHandler(stores)
admin.GET("/usage", usageH.AdminUsage)
admin.GET("/usage/users/:id", usageH.AdminUserUsage)
admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
admin.GET("/pricing", usageH.ListPricing)
admin.PUT("/pricing", usageH.UpsertPricing)
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
// Storage status
storageH := handlers.NewStorageHandler(objStore)
admin.GET("/storage/status", storageH.Status)
// Vault
admin.GET("/vault/status", adm.VaultStatus)
// Storage management (orphan cleanup)
attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue)
admin.GET("/storage/orphans", attachAdm.OrphanCount)
admin.POST("/storage/cleanup", attachAdm.CleanupOrphans)
admin.GET("/storage/extraction", attachAdm.ExtractionStatus)
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
admin.POST("/extensions", extAdm.AdminInstallExtension)
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
}
}
bp := cfg.BasePath
if bp == "" {
bp = "/"
}
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List())
if objStore != nil {
log.Printf(" Storage: %s", objStore.Backend())
if extQueue != nil {
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
} else {
log.Printf(" Extraction: disabled")
}
} else {
log.Printf(" Storage: disabled")
}
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
// ── Vault CLI Commands ──────────────────────
func runVaultCommand(subcmd string) {
cfg := config.Load()
switch strings.ToLower(subcmd) {
case "rekey":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
oldKey := os.Getenv("ENCRYPTION_KEY")
newKey := os.Getenv("NEW_ENCRYPTION_KEY")
if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
log.Fatalf("❌ Vault rekey failed: %v", err)
}
case "status":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to get vault status: %v", err)
}
fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet)
fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys)
fmt.Printf("Vault users (active): %d\n", status.VaultUsers)
default:
fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
os.Exit(1)
}
}

115
rekey.go
View File

@@ -1,115 +0,0 @@
package crypto
import (
"database/sql"
"fmt"
"log"
)
// Rekey re-encrypts all global/team API keys from oldKey to newKey.
// Personal BYOK keys are unaffected (they're keyed to the user's UEK,
// not the environment-derived key).
//
// Runs in a single transaction — all-or-nothing. On failure, no keys
// are modified.
//
// Usage:
//
// switchboard vault rekey
// ENCRYPTION_KEY = current/old key
// NEW_ENCRYPTION_KEY = new key to re-encrypt with
func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error {
if oldEnvKey == "" {
return fmt.Errorf("ENCRYPTION_KEY (current key) is required")
}
if newEnvKey == "" {
return fmt.Errorf("NEW_ENCRYPTION_KEY is required")
}
if oldEnvKey == newEnvKey {
return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do")
}
oldKey, err := DeriveKeyFromEnv(oldEnvKey)
if err != nil {
return fmt.Errorf("failed to derive old key: %w", err)
}
newKey, err := DeriveKeyFromEnv(newEnvKey)
if err != nil {
return fmt.Errorf("failed to derive new key: %w", err)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() // no-op after commit
// Find all global/team keys that are encrypted
rows, err := tx.Query(`
SELECT id, api_key_enc, key_nonce
FROM provider_configs
WHERE key_scope IN ('global', 'team')
AND api_key_enc IS NOT NULL
`)
if err != nil {
return fmt.Errorf("failed to query encrypted keys: %w", err)
}
type rekeyRow struct {
id string
ciphertext []byte
nonce []byte
}
var toRekey []rekeyRow
for rows.Next() {
var r rekeyRow
if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil {
rows.Close()
return fmt.Errorf("failed to scan row: %w", err)
}
toRekey = append(toRekey, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("row iteration error: %w", err)
}
if len(toRekey) == 0 {
log.Println("No global/team encrypted keys found — nothing to rekey.")
return nil
}
log.Printf("Rekeying %d provider config(s)...", len(toRekey))
// Decrypt with old key, re-encrypt with new key, update in place
for _, r := range toRekey {
plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey)
if err != nil {
return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err)
}
newCiphertext, newNonce, err := Encrypt(plaintext, newKey)
if err != nil {
return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err)
}
_, err = tx.Exec(`
UPDATE provider_configs
SET api_key_enc = $1, key_nonce = $2
WHERE id = $3
`, newCiphertext, newNonce, r.id)
if err != nil {
return fmt.Errorf("failed to update config %s: %w", r.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit rekey transaction: %w", err)
}
log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey))
log.Println("Update ENCRYPTION_KEY to the new value and restart the server.")
return nil
}

View File

@@ -176,6 +176,8 @@ func TruncateAll(t *testing.T) {
"teams",
"refresh_tokens",
"users",
"platform_policies",
"global_config",
}
for _, table := range tables {
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))

View File

@@ -6,6 +6,7 @@
========================================== */
:root {
/* ── Core palette ────────────────────────── */
--bg: #0e0e10;
--bg-surface: #18181b;
--bg-raised: #222227;
@@ -23,6 +24,27 @@
--warning: #eab308;
--purple: #a78bfa;
--purple-dim: rgba(167,139,250,0.10);
/* ── Contrast text for solid-color backgrounds ── */
--text-on-color: #fff;
--text-on-warning: #000;
/* ── Light variants (badge/pill text on dim backgrounds) ── */
--accent-light: #93c5fd;
--danger-light: #f87171;
--success-light: #4ade80;
--warning-light: #fbbf24;
/* ── Dim variants (badge/pill backgrounds) ── */
--danger-dim: rgba(239,68,68,0.2);
--success-dim: rgba(34,197,94,0.2);
--warning-dim: rgba(234,179,8,0.2);
/* ── Surfaces ────────────────────────────── */
--overlay: rgba(0,0,0,0.6);
--glass: rgba(255,255,255,0.03);
/* ── Layout ──────────────────────────────── */
--radius: 8px;
--radius-lg: 12px;
--sidebar-w: 260px;
@@ -406,7 +428,7 @@ a:hover { text-decoration: underline; }
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
.message { padding: 1.25rem 0; }
.message:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.03); }
.message:not(:last-child) { border-bottom: 1px solid var(--glass); }
.msg-inner { max-width: 768px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 1rem; }
@@ -506,7 +528,7 @@ a:hover { text-decoration: underline; }
.msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); }
.msg-edit-submit {
background: var(--accent);
color: #fff;
color: var(--text-on-color);
border-color: var(--accent);
}
.msg-edit-submit:hover { background: var(--accent-hover); }
@@ -701,7 +723,7 @@ a:hover { text-decoration: underline; }
/* Thinking blocks */
.thinking-block {
border: 1px solid rgba(108,159,255,0.12); border-radius: var(--radius);
border: 1px solid var(--accent-dim); border-radius: var(--radius);
margin: 0.5rem 0; background: rgba(108,159,255,0.03);
}
.thinking-block summary {
@@ -831,15 +853,15 @@ a:hover { text-decoration: underline; }
.context-warning-icon { flex-shrink: 0; }
.context-warning-text { flex: 1; }
.context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; }
.context-warning-dismiss:hover { color: var(--text-1); }
.context-warning-dismiss:hover { color: var(--text); }
.context-warning-action {
flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg-surface); color: var(--text-1); cursor: pointer;
background: var(--bg-surface); color: var(--text); cursor: pointer;
font-size: 0.75rem; white-space: nowrap; transition: all var(--transition);
}
.context-warning-action:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.context-warning-action:hover { background: var(--accent); color: var(--text-on-color); border-color: var(--accent); }
.context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; }
.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text-1); border-color: var(--border); }
.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text); border-color: var(--border); }
/* Summary message node */
.message-summary {
border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent);
@@ -855,7 +877,7 @@ a:hover { text-decoration: underline; }
background: none; border: none; color: var(--accent); cursor: pointer;
font-size: 0.75rem; padding: 0; text-decoration: underline;
}
.message-summary .summary-toggle:hover { color: var(--text-1); }
.message-summary .summary-toggle:hover { color: var(--text); }
.msg-dimmed { opacity: 0.5; }
.message-summary-divider {
text-align: center; padding: 8px 0; margin: 4px 0;
@@ -918,7 +940,7 @@ a:hover { text-decoration: underline; }
font-size: 12px; max-width: 220px; position: relative;
}
.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); }
.att-chip.att-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
.att-thumb {
@@ -1005,7 +1027,7 @@ a:hover { text-decoration: underline; }
}
.lightbox-close {
position: absolute; top: 16px; right: 20px;
background: rgba(255,255,255,0.15); border: none; color: #fff;
background: rgba(255,255,255,0.15); border: none; color: var(--text-on-color);
font-size: 22px; width: 40px; height: 40px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
@@ -1032,7 +1054,7 @@ a:hover { text-decoration: underline; }
button { font-family: var(--font); cursor: pointer; }
.btn-primary {
background: var(--accent); color: #fff; border: none;
background: var(--accent); color: var(--text-on-color); border: none;
padding: 8px 16px; border-radius: var(--radius);
font-weight: 500; font-size: 13px;
transition: background var(--transition);
@@ -1046,8 +1068,8 @@ button { font-family: var(--font); cursor: pointer; }
font-size: 12px; transition: all var(--transition);
}
.btn-small:hover { background: var(--bg-hover); color: var(--text); }
.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-danger { background: var(--danger); color: #fff; border: none; border-radius: var(--radius); }
.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-color); }
.btn-danger { background: var(--danger); color: var(--text-on-color); border: none; border-radius: var(--radius); }
/* ── Auth Splash ─────────────────────────── */
@@ -1147,7 +1169,7 @@ button { font-family: var(--font); cursor: pointer; }
.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; }
.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; }
.splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; }
.splash-error-hint { color: var(--text-muted); font-size: 0.72rem; }
.splash-error-hint { color: var(--text-3); font-size: 0.72rem; }
.splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; }
.auth-actions { margin-top: 1.5rem; }
.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; }
@@ -1200,14 +1222,26 @@ button { font-family: var(--font); cursor: pointer; }
/* ── Forms ────────────────────────────────── */
.form-group { margin-bottom: 0.75rem; }
.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea {
width: 100%; background: var(--bg-raised); border: 1px solid var(--border);
/* Base form element styling — ensures selects/inputs match the dark theme
even when not wrapped in .form-group (e.g. role config, inline toolbars) */
select, input[type="text"], input[type="email"], input[type="password"],
input[type="number"], input[type="search"], textarea {
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text); padding: 8px 12px; border-radius: var(--radius);
font-family: var(--font); font-size: 13px;
transition: border-color var(--transition);
}
select:focus, input[type="text"]:focus, input[type="email"]:focus,
input[type="password"]:focus, textarea:focus {
outline: none; border-color: var(--accent);
}
select option { background: var(--bg-surface); color: var(--text); }
.form-group { margin-bottom: 0.75rem; }
.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea {
width: 100%;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none; border-color: var(--accent);
}
@@ -1233,7 +1267,7 @@ button { font-family: var(--font); cursor: pointer; }
/* ── Modal ────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
position: fixed; inset: 0; background: var(--overlay);
display: none; align-items: center; justify-content: center;
z-index: 1000; backdrop-filter: blur(2px);
padding: 2rem; /* keeps modal away from viewport edges at any zoom */
@@ -1324,7 +1358,7 @@ button { font-family: var(--font); cursor: pointer; }
.settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.settings-notice {
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2);
background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim);
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
color: var(--warning); display: flex; align-items: center; gap: 8px;
margin-top: 1rem;
@@ -1340,17 +1374,66 @@ button { font-family: var(--font); cursor: pointer; }
.provider-actions { display: flex; align-items: center; gap: 8px; }
.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; }
/* Admin tabs */
.admin-tabs { }
/* Admin panel (fullscreen) */
.admin-panel {
position: fixed; z-index: 1000;
top: var(--banner-top-height); right: 0;
bottom: var(--banner-bottom-height); left: 0;
background: var(--bg); display: flex; flex-direction: column;
}
.admin-topbar {
display: flex; align-items: center; padding: 0 1rem; height: 48px;
border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0;
}
.admin-back {
background: none; border: none; color: var(--text-3); cursor: pointer;
font-size: 14px; padding: 4px 10px; border-radius: 4px; font-family: var(--font);
}
.admin-back:hover { color: var(--accent); background: var(--bg-raised); }
.admin-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; }
.admin-user { font-size: 12px; color: var(--text-3); }
.admin-categories {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--bg-surface); padding: 0 1rem; flex-shrink: 0;
}
.admin-cat {
background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-3); padding: 10px 20px; cursor: pointer;
font-size: 14px; font-weight: 500; font-family: var(--font);
transition: color 0.15s, border-color 0.15s; outline: none;
}
.admin-cat:hover { color: var(--text-2); }
.admin-cat.active { color: var(--accent); border-bottom-color: var(--accent); }
.admin-body { display: flex; flex: 1; overflow: hidden; }
.admin-sidebar {
width: 160px; flex-shrink: 0; border-right: 1px solid var(--border);
background: var(--bg-surface); padding: 0.75rem 0; overflow-y: auto;
}
.admin-sidebar button {
display: block; width: 100%; text-align: left; background: none; border: none;
padding: 8px 20px; color: var(--text-3); cursor: pointer;
font-size: 13px; font-family: var(--font); border-left: 3px solid transparent;
transition: color 0.1s, background 0.1s;
}
.admin-sidebar button:hover { color: var(--text-2); background: var(--bg-raised); }
.admin-sidebar button.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-raised); }
.admin-main { flex: 1; overflow-y: auto; padding: 1.5rem 2rem; }
.admin-section-content { /* replaces .admin-tab-content */ }
/* Admin shared */
/* admin-tab: still used by team admin modal */
.admin-tab {
padding: 10px 14px; background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
white-space: nowrap; flex-shrink: 0; transition: color var(--transition);
outline: none;
outline: none; font-family: var(--font);
}
.admin-tab:hover { color: var(--text-2); }
.admin-tab:focus { color: var(--text-3); }
.admin-tab.active, .admin-tab.active:focus { color: var(--accent); border-bottom-color: var(--accent); }
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; }
.admin-hint { font-size: 12px; color: var(--text-3); }
@@ -1387,7 +1470,7 @@ button { font-family: var(--font); cursor: pointer; }
.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; }
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); }
.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); }
.admin-model-toggle.team { border-color: #60a5fa; color: #60a5fa; }
.admin-model-toggle.team { border-color: var(--accent); color: var(--accent); }
.model-list-item.model-hidden { opacity: 0.5; }
.model-list-item.model-hidden .model-name { text-decoration: line-through; }
@@ -1411,7 +1494,7 @@ button { font-family: var(--font); cursor: pointer; }
.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); }
.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); }
/* Stats cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
@@ -1464,15 +1547,15 @@ button { font-family: var(--font); cursor: pointer; }
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
.model-list-item .model-caps-inline { display: flex; gap: 3px; }
.badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-admin { background: var(--accent); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-team { background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; }
.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: rgba(255,255,255,0.03); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); }
.badge-inactive { background: var(--danger); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-pending { background: var(--warning); color: var(--text-on-warning); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-team { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; }
.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); }
.admin-approve-form .checkbox-label { display: block; margin: 2px 0; }
.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; }
.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
.team-card { padding: 8px 10px; background: var(--glass); border-radius: 6px; margin-bottom: 6px; }
.team-card-info { display: flex; align-items: center; gap: 8px; }
.team-admin-back {
cursor: pointer; margin-right: 4px; opacity: 0.5;
@@ -1480,10 +1563,10 @@ button { font-family: var(--font); cursor: pointer; }
}
.team-admin-back:hover { opacity: 1; }
.team-tab-content { padding: 0; }
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-danger { background: rgba(239,68,68,0.2); color: #f87171; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-private { background: var(--purple); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-success { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-warning { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.badge-danger { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.admin-user-row.user-inactive { opacity: 0.7; }
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
@@ -1687,8 +1770,8 @@ button { font-family: var(--font); cursor: pointer; }
.notes-editor input, .notes-editor textarea {
width: 100%; background: var(--bg-surface); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 6px 10px; font-family: var(--font); outline: none;
transition: border-color var(--transition);
padding: 6px 10px; font-family: var(--font); font-size: var(--msg-font, 14px);
outline: none; transition: border-color var(--transition);
}
.notes-editor input:focus, .notes-editor textarea:focus {
border-color: var(--accent);
@@ -1730,7 +1813,7 @@ button { font-family: var(--font); cursor: pointer; }
/* ── Command Palette ─────────────────────── */
.cmd-palette-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
position: fixed; inset: 0; background: var(--overlay);
display: none; align-items: flex-start; justify-content: center;
z-index: 2000; backdrop-filter: blur(4px);
padding-top: min(20vh, 160px);
@@ -1817,7 +1900,7 @@ button { font-family: var(--font); cursor: pointer; }
.confirm-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
background: var(--overlay);
animation: fade-in 0.12s ease;
}
.confirm-dialog {
@@ -1876,7 +1959,7 @@ button { font-family: var(--font); cursor: pointer; }
.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);
background: var(--overlay);
}
@media (max-width: 768px) {
@@ -1891,4 +1974,9 @@ button { font-family: var(--font); cursor: pointer; }
.modal { width: 100%; }
.tab-arrow { width: 36px; font-size: 20px; } /* larger touch target */
.modal-tabs { padding: 0 12px; }
.admin-sidebar { width: 48px; padding: 0.5rem 0; }
.admin-sidebar button { padding: 8px 0; text-align: center; font-size: 11px; border-left: none; border-bottom: 2px solid transparent; }
.admin-sidebar button.active { border-bottom-color: var(--accent); border-left: none; }
.admin-main { padding: 1rem; }
.admin-cat { padding: 10px 12px; font-size: 13px; }
}

View File

@@ -425,7 +425,7 @@
<button class="btn-small" onclick="bulkSetUserModelVisibility(false)" title="Hide all models from selector">Hide All</button>
</div>
</div>
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through presets.</p>
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through personas.</p>
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
</section>
</div>
@@ -433,7 +433,7 @@
<div class="settings-tab-content" id="settingsPersonasTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:4px">My Personas</h3>
<p class="section-hint" style="margin-bottom:8px">Personal model presets with custom system prompts and settings.</p>
<p class="section-hint" style="margin-bottom:8px">Personal model personas with custom system prompts and settings.</p>
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
@@ -491,7 +491,7 @@
<div class="modal-tabs admin-tabs" id="teamAdminTabs">
<button class="admin-tab active" data-ttab="members" onclick="UI.switchTeamTab('members')">Members</button>
<button class="admin-tab" data-ttab="providers" onclick="UI.switchTeamTab('providers')">Providers</button>
<button class="admin-tab" data-ttab="presets" onclick="UI.switchTeamTab('presets')">Presets</button>
<button class="admin-tab" data-ttab="presets" onclick="UI.switchTeamTab('presets')">Personas</button>
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
</div>
@@ -558,25 +558,24 @@
</div>
<!-- ── Admin Modal ─────────────────────────── -->
<div class="modal-overlay" id="adminModal">
<div class="modal modal-wide">
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn"></button></div>
<div class="modal-tabs admin-tabs">
<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="models">Models</button>
<button class="admin-tab" data-tab="presets">Presets</button>
<button class="admin-tab" data-tab="teams">Teams</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="roles">Roles</button>
<button class="admin-tab" data-tab="usage">Usage</button>
<button class="admin-tab" data-tab="extensions">Extensions</button>
<button class="admin-tab" data-tab="storage">Storage</button>
<button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div>
<div class="modal-body">
<div class="admin-tab-content" id="adminUsersTab">
<!-- ── Admin Panel (fullscreen overlay) ──── -->
<div class="admin-panel" id="adminPanel" style="display:none">
<div class="admin-topbar">
<button class="admin-back" id="adminBackBtn">← Chat</button>
<h1 class="admin-title">Admin Panel</h1>
<span class="admin-user" id="adminCurrentUser"></span>
</div>
<div class="admin-categories" id="adminCategories">
<button class="admin-cat active" data-cat="people">People</button>
<button class="admin-cat" data-cat="ai">AI</button>
<button class="admin-cat" data-cat="system">System</button>
<button class="admin-cat" data-cat="monitoring">Monitoring</button>
</div>
<div class="admin-body">
<nav class="admin-sidebar" id="adminSidebar"></nav>
<main class="admin-main" id="adminMain">
<!-- People -->
<div class="admin-section-content" id="adminUsersTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddUserBtn">+ Add User</button>
</div>
@@ -593,32 +592,7 @@
</div>
<div id="adminUserList"></div>
</div>
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
</div>
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
</div>
<div id="adminProviderList"></div>
</div>
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
<button class="btn-small" onclick="bulkSetVisibility('enabled')">All Enabled</button>
<button class="btn-small" onclick="bulkSetVisibility('team')">All Team</button>
<button class="btn-small" onclick="bulkSetVisibility('disabled')">All Disabled</button>
<span class="admin-hint" id="adminModelsHint"></span>
</div>
<div id="adminModelList"></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>
<div id="adminPresetList"></div>
</div>
<div class="admin-tab-content" id="adminTeamsTab" style="display:none">
<div class="admin-section-content" id="adminTeamsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddTeamBtn">+ New Team</button>
</div>
@@ -630,7 +604,6 @@
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateTeamBtn">Create</button><button class="btn-small" id="adminCancelTeamBtn">Cancel</button></div>
</div>
<div id="adminTeamList"></div>
<!-- Team detail / member management (shown when editing a team) -->
<div id="adminTeamDetail" style="display:none">
<button class="notes-back-btn" id="adminTeamBackBtn">← Back to teams</button>
<h3 id="adminTeamDetailName" style="margin:8px 0 12px;font-size:15px"></h3>
@@ -653,7 +626,42 @@
<div id="adminMemberList"></div>
</div>
</div>
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<div class="admin-section-content" id="adminRolesTab" style="display:none">
<p style="color:var(--text-muted);margin:0 0 12px">Configure system model roles. Each role has a primary and optional fallback model.</p>
<div id="adminRolesContent">
<div class="loading">Loading roles...</div>
</div>
</div>
<!-- AI -->
<div class="admin-section-content" id="adminProvidersTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
</div>
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
</div>
<div id="adminProviderList"></div>
</div>
<div class="admin-section-content" id="adminModelsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
<button class="btn-small" onclick="bulkSetVisibility('enabled')">All Enabled</button>
<button class="btn-small" onclick="bulkSetVisibility('team')">All Team</button>
<button class="btn-small" onclick="bulkSetVisibility('disabled')">All Disabled</button>
<span class="admin-hint" id="adminModelsHint"></span>
</div>
<div id="adminModelList"></div>
</div>
<div class="admin-section-content" id="adminPresetsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Persona</button>
</div>
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
<div id="adminPresetList"></div>
</div>
<!-- System -->
<div class="admin-section-content" id="adminSettingsTab" style="display:none">
<section class="settings-section">
<h3>System Prompt</h3>
<p class="section-hint" style="margin-bottom:6px">Injected into every conversation before user/preset prompts. Users cannot override or disable this.</p>
@@ -678,9 +686,9 @@
<p class="section-hint">When disabled, users can only use admin-configured global providers.</p>
</section>
<section class="settings-section">
<h3>User Presets</h3>
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal presets</label>
<p class="section-hint">When disabled, users can only use admin-created global presets.</p>
<h3>User Personas</h3>
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal personas</label>
<p class="section-hint">When disabled, users can only use admin-created global personas.</p>
</section>
<section class="settings-section">
<h3>Default Model</h3>
@@ -718,38 +726,8 @@
</section>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div>
<!-- ── Roles Tab ─────────────────── -->
<div class="admin-tab-content" id="adminRolesTab" style="display:none">
<p style="color:var(--text-muted);margin:0 0 12px">Configure system model roles. Each role has a primary and optional fallback model.</p>
<div id="adminRolesContent">
<div class="loading">Loading roles...</div>
</div>
</div>
<!-- ── Usage Tab ─────────────────── -->
<div class="admin-tab-content" id="adminUsageTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:12px">
<select id="usagePeriod" style="min-width:100px">
<option value="7d">Last 7 days</option>
<option value="30d" selected>Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<select id="usageGroupBy" style="min-width:100px">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
<option value="provider">By Provider</option>
</select>
<button class="btn-secondary btn-small" id="usageRefreshBtn">Refresh</button>
</div>
<div id="adminUsageTotals"></div>
<div id="adminUsageResults"></div>
<h3 style="margin-top:16px;font-size:14px">Model Pricing</h3>
<div id="adminPricingTable"></div>
</div>
<div class="admin-tab-content" id="adminExtensionsTab" style="display:none">
<div class="admin-section-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
<div class="admin-section-content" id="adminExtensionsTab" style="display:none">
<div class="admin-toolbar">
<button class="btn-small btn-primary" id="adminInstallExtBtn">+ Install Extension</button>
</div>
@@ -776,9 +754,29 @@
</div>
</div>
</div>
<div class="admin-tab-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<!-- Monitoring -->
<div class="admin-section-content" id="adminUsageTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:12px">
<select id="usagePeriod" style="min-width:100px">
<option value="7d">Last 7 days</option>
<option value="30d" selected>Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<select id="usageGroupBy" style="min-width:100px">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
<option value="provider">By Provider</option>
</select>
<button class="btn-secondary btn-small" id="usageRefreshBtn">Refresh</button>
</div>
<div id="adminUsageTotals"></div>
<div id="adminUsageResults"></div>
<h3 style="margin-top:16px;font-size:14px">Model Pricing</h3>
<div id="adminPricingTable"></div>
</div>
<div class="admin-section-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">
<select id="auditFilterAction" style="min-width:140px"><option value="">All actions</option></select>
<select id="auditFilterResource" style="min-width:120px">
@@ -798,7 +796,8 @@
<button class="btn-small" id="auditNextBtn">Next →</button>
</div>
</div>
</div>
<div class="admin-section-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
</main>
</div>
</div>

View File

@@ -6,7 +6,7 @@
// ── Admin Actions ────────────────────────────
function _adminScroll() { return document.querySelector('#adminModal .modal-body'); }
function _adminScroll() { return document.getElementById('adminMain'); }
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
async function toggleUserActive(id, active) {
try {
@@ -403,10 +403,17 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
// ── Admin Listeners (extracted from initListeners) ──
function _initAdminListeners() {
// Admin modal (elements may not exist for non-admin users)
document.getElementById('adminCloseBtn')?.addEventListener('click', UI.closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
// Admin panel navigation (elements may not exist for non-admin users)
document.getElementById('adminBackBtn')?.addEventListener('click', UI.closeAdmin);
document.querySelectorAll('.admin-cat').forEach(btn => {
btn.addEventListener('click', () => UI.switchAdminCategory(btn.dataset.cat));
});
// Esc closes admin panel
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('adminPanel')?.style.display === 'flex') {
UI.closeAdmin();
e.stopPropagation();
}
});
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);

View File

@@ -1,14 +1,131 @@
// ==========================================
// Chat Switchboard UI Admin
// ==========================================
// Extends UI with admin modal tabs: users, stats, roles,
// usage, audit, providers, models, presets, teams, settings.
// Fullscreen admin panel with category + section navigation.
// All loadAdmin*() functions unchanged — same endpoints, same DOM IDs.
// ── Category → Section mapping ────────────
const ADMIN_SECTIONS = {
people: ['users', 'teams'],
ai: ['providers', 'models', 'presets', 'roles'],
system: ['settings', 'storage', 'extensions'],
monitoring: ['usage', 'audit', 'stats'],
};
const ADMIN_LABELS = {
users: 'Users', teams: 'Teams',
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
// Section → loader mapping (reuses all existing loadAdmin* functions)
const ADMIN_LOADERS = {
users: () => UI.loadAdminUsers(),
teams: () => UI.loadAdminTeams(),
roles: () => UI.loadAdminRoles(),
providers: () => UI.loadAdminProviders(),
models: () => UI.loadAdminModels(),
presets: () => UI.loadAdminPresets(),
settings: () => UI.loadAdminSettings(),
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
extensions: () => UI.loadAdminExtensions(),
usage: () => UI.loadAdminUsage(),
audit: () => UI.loadAuditLog(),
stats: () => UI.loadAdminStats(),
};
// Find which category a section belongs to
function _adminCatForSection(section) {
for (const [cat, sections] of Object.entries(ADMIN_SECTIONS)) {
if (sections.includes(section)) return cat;
}
return 'people';
}
Object.assign(UI, {
// ── Admin Modal ──────────────────────────
// ── Admin Panel State ─────────────────────
_adminCat: 'people',
_adminSection: 'users',
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
closeAdmin() { closeModal('adminModal'); },
// ── Open / Close ──────────────────────────
openAdmin(cat, section) {
UI._adminCat = cat || 'people';
UI._adminSection = section || ADMIN_SECTIONS[UI._adminCat][0];
const panel = document.getElementById('adminPanel');
panel.style.display = 'flex';
// Show current user email
const userEl = document.getElementById('adminCurrentUser');
if (userEl && API.user) userEl.textContent = API.user.email || API.user.username || '';
UI._renderAdminNav();
UI._switchAdminSection(UI._adminSection);
},
closeAdmin() {
document.getElementById('adminPanel').style.display = 'none';
},
// ── Navigate to specific section ──────────
// Called from command palette, deep links, etc.
openAdminSection(section) {
const cat = _adminCatForSection(section);
UI.openAdmin(cat, section);
},
// ── Category switch ───────────────────────
switchAdminCategory(cat) {
UI._adminCat = cat;
UI._adminSection = ADMIN_SECTIONS[cat][0];
UI._renderAdminNav();
UI._switchAdminSection(UI._adminSection);
},
// ── Render navigation state ───────────────
_renderAdminNav() {
// Highlight active category
document.querySelectorAll('.admin-cat').forEach(btn => {
btn.classList.toggle('active', btn.dataset.cat === UI._adminCat);
});
// Populate sidebar
const sidebar = document.getElementById('adminSidebar');
sidebar.innerHTML = ADMIN_SECTIONS[UI._adminCat].map(sec =>
`<button class="${sec === UI._adminSection ? 'active' : ''}" data-section="${sec}">${ADMIN_LABELS[sec]}</button>`
).join('');
// Wire sidebar clicks
sidebar.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => UI._switchAdminSection(btn.dataset.section));
});
},
// ── Section switch (shows content + lazy-loads) ──
async _switchAdminSection(section) {
UI._adminSection = section;
// Update sidebar highlight
document.querySelectorAll('.admin-sidebar button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.section === section);
});
// Hide all, show target
document.querySelectorAll('.admin-section-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${section.charAt(0).toUpperCase() + section.slice(1)}Tab`);
if (panel) panel.style.display = '';
// Lazy-load data
await ADMIN_LOADERS[section]?.();
},
// ── Backward compat: switchAdminTab still works ──
// (called by some inline onclick handlers and admin-handlers.js)
async switchAdminTab(tab) {
const cat = _adminCatForSection(tab);
if (document.getElementById('adminPanel').style.display !== 'flex') {
UI.openAdmin(cat, tab);
} else {
if (cat !== UI._adminCat) {
UI._adminCat = cat;
UI._renderAdminNav();
}
await UI._switchAdminSection(tab);
}
},
openTeamAdmin() {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
@@ -59,26 +176,6 @@ Object.assign(UI, {
if (tab === 'activity') UI.loadTeamAuditLog(1);
},
async switchAdminTab(tab) {
document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'audit') await this.loadAuditLog();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
if (tab === 'extensions') await this.loadAdminExtensions();
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
},
async loadAdminUsers(quiet) {
const el = document.getElementById('adminUserList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';

View File

@@ -441,14 +441,14 @@ function renderRoleConfig(containerEl, opts = {}) {
// Reload after short delay so dropdowns reflect saved state
setTimeout(() => refresh(), 500);
} catch (e) {
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; }
}
}
async function handleTest(roleId) {
if (!opts.onTest) return;
const statusEl = containerEl.querySelector(`[data-role-status="${roleId}"]`);
if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-muted)'; }
if (statusEl) { statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--text-3)'; }
try {
const result = await opts.onTest(roleId);
if (statusEl) {
@@ -457,7 +457,7 @@ function renderRoleConfig(containerEl, opts = {}) {
statusEl.style.color = 'var(--success)';
}
} catch (e) {
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--error)'; }
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.style.color = 'var(--danger)'; }
}
}

View File

@@ -40,8 +40,8 @@ Object.assign(UI, {
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas + modals (but NOT .app or banners)
document.querySelectorAll('.sidebar, .chat-area, .modal-overlay').forEach(el => el.style.zoom = z);
// Zoom content areas + modals + panels (but NOT .app or banners)
document.querySelectorAll('.sidebar, .chat-area, .modal-overlay, .side-panel, .admin-panel').forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +0,0 @@
package crypto
import (
"database/sql"
"fmt"
)
// VaultStatusInfo holds vault health information for admin display.
type VaultStatusInfo struct {
EncryptionKeySet bool `json:"encryption_key_set"`
EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc
VaultUsers int `json:"vault_users"` // users with vault_set = true
}
// VaultStatus gathers vault health metrics from the database.
// Used by both the CLI (`switchboard vault status`) and the admin API endpoint.
func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) {
if db == nil {
return nil, fmt.Errorf("database not available")
}
info := &VaultStatusInfo{
EncryptionKeySet: encryptionKey != "",
}
// Count encrypted provider keys (global + team + personal)
err := db.QueryRow(`
SELECT COUNT(*) FROM provider_configs
WHERE api_key_enc IS NOT NULL
`).Scan(&info.EncryptedKeys)
if err != nil {
return nil, fmt.Errorf("count encrypted keys: %w", err)
}
// Count users with active vaults
err = db.QueryRow(`
SELECT COUNT(*) FROM users
WHERE vault_set = true
`).Scan(&info.VaultUsers)
if err != nil {
// vault_set column might not exist on very old installs
info.VaultUsers = 0
}
return info, nil
}

1893
styles.css

File diff suppressed because it is too large Load Diff

View File

@@ -1,727 +0,0 @@
// ==========================================
// Chat Switchboard UI Admin
// ==========================================
// Extends UI with admin modal tabs: users, stats, roles,
// usage, audit, providers, models, presets, teams, settings.
Object.assign(UI, {
// ── Admin Modal ──────────────────────────
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
closeAdmin() { closeModal('adminModal'); },
openTeamAdmin() {
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
if (adminTeams.length === 0) {
UI.toast('You are not an admin of any teams', 'error');
return;
}
openModal('teamAdminModal');
if (adminTeams.length === 1) {
// Skip picker, go directly to the team
UI.openTeamManage(adminTeams[0].id, adminTeams[0].name);
} else {
// Show team picker
document.getElementById('teamAdminTitle').textContent = 'Team Management';
document.getElementById('teamAdminPicker').style.display = '';
document.getElementById('teamAdminContent').style.display = 'none';
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
<div class="team-card-info">
<strong>${esc(t.name)}</strong>
<span class="badge-admin">admin</span>
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
</div>
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
}
},
closeTeamAdmin() { closeModal('teamAdminModal'); },
switchTeamTab(tab) {
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
tabs.forEach(t => {
t.classList.remove('active');
if (t.dataset.ttab === tab) t.classList.add('active');
});
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
if (panel) panel.style.display = '';
// Lazy-load tab data
const teamId = UI._managingTeamId;
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
},
async switchAdminTab(tab) {
document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
if (panel) panel.style.display = '';
if (tab === 'users') await this.loadAdminUsers();
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'audit') await this.loadAuditLog();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
if (tab === 'extensions') await this.loadAdminExtensions();
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
},
async loadAdminUsers(quiet) {
const el = document.getElementById('adminUserList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListUsers();
const users = resp.users || resp.data || [];
el.innerHTML = users.map(u => {
const teamBadges = (u.teams || []).map(t =>
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
).join(' ');
const isPending = !u.is_active;
return `
<div class="admin-user-row${isPending ? ' user-inactive' : ''}">
<div class="admin-user-info">
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
${isPending ? '<span class="badge-pending">pending</span>' : ''}
${teamBadges}</div>
<div class="admin-user-email">${esc(u.email)}</div>
</div>
<div class="admin-user-actions">
${isPending
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
}
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none">
<div class="approve-teams" id="approveTeams-${u.id}"></div>
<div class="form-row" style="margin-top:8px">
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button>
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No users</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminStats() {
const el = document.getElementById('adminStats');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const s = await API.adminGetStats();
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
el.innerHTML = '<div class="stats-grid">' +
Object.entries(s).map(([k, v]) => `
<div class="stat-card">
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
</div>`).join('') +
'</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Roles ────────────────────────
async loadAdminRoles() {
const el = document.getElementById('adminRolesContent');
if (!el) return;
if (!UI._adminRoleConfig) {
UI._adminRoleConfig = renderRoleConfig(el, {
scope: 'admin',
showFallback: true,
modelIdField: 'model_id',
modelNameField: 'display_name',
providerIdField: 'provider_config_id',
fetchProviders: async () => {
const configs = await API.adminListGlobalConfigs();
return configs.configs || configs || [];
},
fetchModels: async () => {
const models = await API.adminListModels();
return models.models || models.data || models || [];
},
fetchRoles: () => API.adminListRoles(),
onSave: async (roleId, config) => {
await API.adminUpdateRole(roleId, config);
},
onTest: (roleId) => API.adminTestRole(roleId),
});
}
await UI._adminRoleConfig.refresh();
},
// ── Admin: Usage ────────────────────────
async loadAdminUsage() {
// Usage stats via primitive
const totalsEl = document.getElementById('adminUsageTotals');
if (!totalsEl) return;
// Wrap totals+results in a container for the primitive (first time only)
let usageContainer = document.getElementById('_adminUsageContainer');
if (!usageContainer) {
usageContainer = document.createElement('div');
usageContainer.id = '_adminUsageContainer';
const resultsEl = document.getElementById('adminUsageResults');
totalsEl.parentElement.insertBefore(usageContainer, totalsEl);
usageContainer.appendChild(totalsEl);
if (resultsEl) usageContainer.appendChild(resultsEl);
}
if (!UI._adminUsageDash) {
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
apiFetch: (p) => API.adminGetUsage(p),
periodElId: 'usagePeriod',
groupByElId: 'usageGroupBy',
compact: false,
showUserColumn: true,
});
}
await UI._adminUsageDash.refresh();
// Pricing table (admin-only, separate from usage primitive)
const pricingEl = document.getElementById('adminPricingTable');
if (pricingEl) {
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
}
},
async loadAdminExtensions() {
const el = document.getElementById('adminExtensionsList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API._get('/api/v1/admin/extensions');
const exts = resp.data || [];
this._adminExtensions = exts; // store for edit access
if (exts.length === 0) {
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
return;
}
el.innerHTML = exts.map(ext => {
const statusBadge = ext.is_enabled
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
const systemBadge = ext.is_system
? ' <span class="badge-user" style="font-size:11px">system</span>'
: '';
return `
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
<div class="admin-user-info" style="flex:1">
<div style="display:flex;align-items:center;gap:8px">
<strong>${esc(ext.name)}</strong>
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
${statusBadge}${systemBadge}
</div>
<div class="text-muted" style="font-size:12px;margin-top:2px">
${esc(ext.description || 'No description')}
${ext.author ? ` · by ${esc(ext.author)}` : ''}
· v${esc(ext.version)} · ${ext.tier}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
Edit
</button>
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
${ext.is_enabled ? 'Disable' : 'Enable'}
</button>
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
Uninstall
</button>
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
}
},
_auditPage: 1,
_auditPerPage: 30,
async loadAuditLog(page) {
if (page) UI._auditPage = page;
const el = document.getElementById('adminAuditList');
el.innerHTML = '<div class="loading">Loading...</div>';
// Populate action filter on first load
const actionSel = document.getElementById('auditFilterAction');
if (actionSel && actionSel.options.length <= 1) {
try {
const resp = await API.adminListAuditActions();
(resp.actions || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
actionSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
const params = {
page: UI._auditPage,
per_page: UI._auditPerPage,
action: document.getElementById('auditFilterAction')?.value || '',
resource_type: document.getElementById('auditFilterResource')?.value || '',
};
try {
const resp = await API.adminListAudit(params);
const entries = resp.data || [];
const total = resp.total || 0;
const totalPages = Math.ceil(total / UI._auditPerPage);
if (entries.length === 0) {
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
} else {
el.innerHTML = entries.map(e => {
const ts = new Date(e.created_at);
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const actor = e.actor_name || 'system';
const meta = UI._formatAuditMeta(e.metadata);
return `<div class="audit-row">
<div class="audit-main">
<span class="audit-action">${esc(e.action)}</span>
<span class="audit-actor">${esc(actor)}</span>
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
</div>
<div class="audit-meta">
${meta ? `<span class="audit-details">${meta}</span>` : ''}
<span class="audit-time">${timeStr}</span>
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
</div>
</div>`;
}).join('');
}
// Pagination
const pgEl = document.getElementById('auditPagination');
if (totalPages > 1) {
pgEl.style.display = 'flex';
document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`;
document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1;
document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages;
} else {
pgEl.style.display = 'none';
}
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
_formatAuditMeta(metaStr) {
try {
const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
if (!m || Object.keys(m).length === 0) return '';
return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · ');
} catch { return ''; }
},
async loadAdminProviders(quiet) {
const el = document.getElementById('adminProviderList');
const formEl = document.getElementById('adminAddProviderForm');
// Initialize admin provider form primitive (once)
if (!UI._adminProvForm && formEl) {
UI._adminProvForm = renderProviderForm(formEl, {
prefix: 'adminProv',
showPrivate: true,
showDefaultModel: true,
onSubmit: async (vals, isEdit) => {
try {
if (isEdit) {
const updates = {};
if (vals.name) updates.name = vals.name;
if (vals.endpoint) updates.endpoint = vals.endpoint;
if (vals.api_key) updates.api_key = vals.api_key;
updates.model_default = vals.model_default;
updates.is_private = vals.is_private || false;
await API.adminUpdateGlobalConfig(vals._editId, updates);
UI.toast('Provider updated', 'success');
} else {
if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
UI.toast('Provider added', 'success');
}
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => {
formEl.style.display = 'none';
UI._adminProvForm.setCreateMode();
},
});
}
// Initialize admin provider list primitive (once)
if (!UI._adminProvList) {
UI._adminProvList = renderProviderList(el, {
apiFetch: () => API.adminListGlobalConfigs(),
showEndpoint: true,
showPrivate: true,
emptyMsg: 'No global providers — add one above',
onEdit: (prov) => {
if (UI._adminProvForm) {
UI._adminProvForm.setEditMode(prov.id, prov);
formEl.style.display = '';
}
},
onDelete: async (prov) => {
if (!await showConfirm('Delete this global provider?')) return;
try {
await API.adminDeleteGlobalConfig(prov.id);
UI.toast('Provider deleted', 'success');
UI._adminProvList.refresh();
} catch (e) { UI.toast(e.message, 'error'); }
},
});
}
await UI._adminProvList.refresh(quiet);
},
async loadAdminModels(quiet) {
const el = document.getElementById('adminModelList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListModels();
const list = data.models || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(m => {
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
// Support both old is_enabled (bool) and new visibility (string)
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
return `<div class="admin-model-row">
<span class="model-name">${esc(m.model_id || m.id)}</span>
<span class="model-caps-inline">${badges}</span>
<span class="provider-meta">${esc(m.provider_name || '')}</span>
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
</div>`;
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</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 || [];
// Ensure form is initialized for dropdown population
ensureAdminPresetForm();
// Populate model dropdown from admin model list (all synced models)
const modelSel = _adminPresetForm?.getModelSelect();
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 = _adminPresetForm?.getConfigSelect();
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 avatarEl = p.avatar
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
: '';
// Scope badge with attribution
let scopeBadge = '';
if (p.scope === 'global') {
scopeBadge = '<span class="badge-admin">global</span>';
} else if (p.scope === 'team' && p.team_name) {
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
} else if (p.scope === 'personal') {
scopeBadge = '<span class="badge-user">personal</span>';
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
<div class="preset-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${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>`; }
},
// ── Teams Admin ─────────────────────────
_teamEditId: null,
async loadAdminTeams(quiet) {
const el = document.getElementById('adminTeamList');
const detail = document.getElementById('adminTeamDetail');
if (!quiet) {
el.innerHTML = '<div class="loading">Loading...</div>';
detail.style.display = 'none';
el.style.display = '';
document.getElementById('adminAddTeamForm').style.display = 'none';
}
try {
const resp = await API.adminListTeams();
const teams = resp.data || [];
el.innerHTML = teams.map(t => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(t.name)}</strong>
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
</div>
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
<div class="admin-user-actions">
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async openTeamDetail(teamId, teamName) {
this._teamEditId = teamId;
document.getElementById('adminTeamList').style.display = 'none';
document.getElementById('adminAddTeamForm').style.display = 'none';
const detail = document.getElementById('adminTeamDetail');
detail.style.display = '';
document.getElementById('adminTeamDetailName').textContent = teamName;
document.getElementById('adminAddMemberForm').style.display = 'none';
// Load team settings for policy checkboxes
try {
const team = await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
// allow_team_providers defaults to true if not explicitly set
document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
} catch (e) { /* proceed with defaults */ }
await this.loadTeamMembers(teamId);
},
async loadTeamMembers(teamId) {
const el = document.getElementById('adminMemberList');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListMembers(teamId);
const members = resp.data || [];
el.innerHTML = members.map(m => `
<div class="admin-user-row">
<div class="admin-user-info">
<div><strong>${esc(m.display_name || m.email)}</strong>
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
</div>
<div class="text-muted">${esc(m.email)}</div>
</div>
<div class="admin-user-actions">
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
</select>
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
</div>
</div>
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMemberUserDropdown(teamId) {
const sel = document.getElementById('adminMemberUser');
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.users || resp.data || [];
// Also get existing members to exclude
const memberResp = await API.adminListMembers(teamId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
sel.innerHTML = '<option value="">Select user...</option>' +
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
const settings = data.settings || {};
const policies = data.policies || {};
// Helper to read from settings map (JSONB values)
const getSetting = (key, fallback) => {
const v = settings[key];
if (v === undefined || v === null) return fallback;
// Unwrap {value: X} wrapper if present
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
};
// Registration (policy: allow_registration)
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
// Admin system prompt (global_settings)
const sysPrompt = getSetting('system_prompt', {}) || {};
document.getElementById('adminSystemPrompt').value = sysPrompt.content || '';
// Registration default state (policy: default_user_active → 'true' means auto-active)
const defaultActive = policies.default_user_active === 'true';
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
// User providers / BYOK (policy: allow_user_byok)
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
// User presets / personas (policy: allow_user_personas)
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
// Default model (policy: default_model) — populate from current model list
const defModelSel = document.getElementById('adminDefaultModel');
if (defModelSel) {
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
defModelSel.appendChild(opt);
});
defModelSel.value = policies.default_model || '';
}
// Banner (global_settings)
const banner = getSetting('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
document.getElementById('adminBannerText').value = banner.text || '';
document.getElementById('adminBannerPosition').value = banner.position || 'both';
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
UI.updateBannerPreview();
// Load banner presets (global_settings)
const presets = getSetting('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
});
UI._bannerPresets = presets;
// Vault / Encryption status
const vaultEl = document.getElementById('adminVaultStatus');
if (vaultEl) {
try {
const vault = await API.adminGetVaultStatus();
const keyBadge = vault.encryption_key_set
? '<span class="badge badge-success">Active</span>'
: '<span class="badge badge-warning">Not Set</span>';
vaultEl.innerHTML = `
<div class="admin-storage-grid" style="margin-top:6px">
<div class="admin-storage-item">
<span class="admin-storage-label">Encryption</span>
${keyBadge}
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Encrypted Keys</span>
<span class="admin-storage-value">${vault.encrypted_keys}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Vault Users</span>
<span class="admin-storage-value">${vault.vault_users}</span>
</div>
</div>`;
} catch (e) {
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
}
}
} catch (e) { console.debug('Failed to load admin settings:', e); }
},
_bannerPresets: {},
updateBannerPreview() {
const prev = document.getElementById('bannerPreview');
if (!prev) return;
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
const bg = document.getElementById('adminBannerBg').value;
const fg = document.getElementById('adminBannerFg').value;
prev.textContent = text;
prev.style.background = bg;
prev.style.color = fg;
},
});