Changeset 0.13.0 (#64)
This commit is contained in:
668
admin.go
668
admin.go
@@ -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
636
api.js
@@ -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);
|
||||
}
|
||||
};
|
||||
458
channels.go
458
channels.go
@@ -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
632
chat.js
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
931
index.html
931
index.html
@@ -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 A–Z</option>
|
||||
<option value="title_desc">Title Z–A</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 · 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 & 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
508
main.go
@@ -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
115
rekey.go
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
177
src/index.html
177
src/index.html
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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)'; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
2582
src/js/ui.js
2582
src/js/ui.js
File diff suppressed because it is too large
Load Diff
46
status.go
46
status.go
@@ -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
1893
styles.css
File diff suppressed because it is too large
Load Diff
727
ui-admin.js
727
ui-admin.js
@@ -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;
|
||||
},
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user