Changeset 0.4.3 (#32)
This commit is contained in:
12
migrations/004_model_configs.sql
Normal file
12
migrations/004_model_configs.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Model configurations: admin-curated list of available models
|
||||||
|
CREATE TABLE IF NOT EXISTS model_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
is_enabled BOOLEAN DEFAULT true,
|
||||||
|
capabilities JSONB DEFAULT '{"tool": false, "thinking": false, "vision": false, "code": false}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(api_config_id, model_id)
|
||||||
|
);
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────
|
// ── Types ───────────────────────────────────
|
||||||
@@ -384,3 +385,283 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
c.JSON(http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Global API Configs ──────────────────────
|
||||||
|
|
||||||
|
type adminGlobalConfigResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
ModelDefault *string `json:"model_default"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
HasKey bool `json:"has_key"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
||||||
|
rows, err := database.DB.Query(`
|
||||||
|
SELECT id, name, provider, endpoint, model_default, is_active,
|
||||||
|
(api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key,
|
||||||
|
created_at
|
||||||
|
FROM api_configs
|
||||||
|
WHERE user_id IS NULL
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
configs := make([]adminGlobalConfigResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var cfg adminGlobalConfigResponse
|
||||||
|
if err := rows.Scan(
|
||||||
|
&cfg.ID, &cfg.Name, &cfg.Provider, &cfg.Endpoint,
|
||||||
|
&cfg.ModelDefault, &cfg.IsActive, &cfg.HasKey, &cfg.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configs = append(configs, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"configs": configs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||||
|
var req createAPIConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NULL)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": id, "message": "global config created"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
|
||||||
|
configID := c.Param("id")
|
||||||
|
|
||||||
|
result, err := database.DB.Exec(
|
||||||
|
`DELETE FROM api_configs WHERE id = $1 AND user_id IS NULL`,
|
||||||
|
configID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "global config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "global config deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model Configs ───────────────────────────
|
||||||
|
|
||||||
|
type modelConfigResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
APIConfigID string `json:"api_config_id"`
|
||||||
|
ProviderName string `json:"provider_name"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
IsEnabled bool `json:"is_enabled"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateModelConfigRequest struct {
|
||||||
|
IsEnabled *bool `json:"is_enabled"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
||||||
|
rows, err := database.DB.Query(`
|
||||||
|
SELECT mc.id, mc.api_config_id, ac.name, mc.model_id, mc.display_name,
|
||||||
|
mc.is_enabled, mc.capabilities, mc.created_at, mc.updated_at
|
||||||
|
FROM model_configs mc
|
||||||
|
JOIN api_configs ac ON mc.api_config_id = ac.id
|
||||||
|
WHERE ac.user_id IS NULL
|
||||||
|
ORDER BY ac.name, mc.model_id
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
models := make([]modelConfigResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var m modelConfigResponse
|
||||||
|
var capsJSON []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.ID, &m.APIConfigID, &m.ProviderName, &m.ModelID, &m.DisplayName,
|
||||||
|
&m.IsEnabled, &capsJSON, &m.CreatedAt, &m.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(capsJSON, &m.Capabilities)
|
||||||
|
models = append(models, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) FetchModels(c *gin.Context) {
|
||||||
|
// Load all global api_configs
|
||||||
|
rows, err := database.DB.Query(`
|
||||||
|
SELECT id, provider, endpoint, api_key_encrypted
|
||||||
|
FROM api_configs
|
||||||
|
WHERE user_id IS NULL AND is_active = true
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type fetchResult struct {
|
||||||
|
ConfigID string `json:"config_id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Added int `json:"added"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
results := make([]fetchResult, 0)
|
||||||
|
totalAdded := 0
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var cfgID, providerID, endpoint string
|
||||||
|
var apiKey *string
|
||||||
|
if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prov, err := providers.Get(providerID)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := ""
|
||||||
|
if apiKey != nil {
|
||||||
|
key = *apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
APIKey: key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fr := fetchResult{ConfigID: cfgID, Provider: providerID}
|
||||||
|
for _, m := range models {
|
||||||
|
result, err := database.DB.Exec(`
|
||||||
|
INSERT INTO model_configs (api_config_id, model_id, display_name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (api_config_id, model_id) DO NOTHING
|
||||||
|
`, cfgID, m.ID, m.Name)
|
||||||
|
if err != nil {
|
||||||
|
fr.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected > 0 {
|
||||||
|
fr.Added++
|
||||||
|
totalAdded++
|
||||||
|
} else {
|
||||||
|
fr.Skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, fr)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"total_added": totalAdded,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
||||||
|
modelID := c.Param("id")
|
||||||
|
|
||||||
|
var req updateModelConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic update
|
||||||
|
if req.IsEnabled != nil {
|
||||||
|
_, err := database.DB.Exec(
|
||||||
|
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
*req.IsEnabled, modelID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DisplayName != nil {
|
||||||
|
_, err := database.DB.Exec(
|
||||||
|
`UPDATE model_configs SET display_name = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
*req.DisplayName, modelID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Capabilities != nil {
|
||||||
|
capsJSON, err := json.Marshal(req.Capabilities)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capabilities"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = database.DB.Exec(
|
||||||
|
`UPDATE model_configs SET capabilities = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
string(capsJSON), modelID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update capabilities"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
|
||||||
|
modelID := c.Param("id")
|
||||||
|
result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -411,6 +411,47 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"models": allModels})
|
c.JSON(http.StatusOK, gin.H{"models": allModels})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List Enabled Models (from model_configs) ─
|
||||||
|
|
||||||
|
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
||||||
|
type enabledModel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ProviderName string `json:"provider_name"`
|
||||||
|
ConfigID string `json:"config_id"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := database.DB.Query(`
|
||||||
|
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
|
||||||
|
FROM model_configs mc
|
||||||
|
JOIN api_configs ac ON mc.api_config_id = ac.id
|
||||||
|
WHERE mc.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL
|
||||||
|
ORDER BY ac.name, mc.model_id
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"models": []interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
models := make([]enabledModel, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var m enabledModel
|
||||||
|
var capsJSON []byte
|
||||||
|
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Capabilities = make(map[string]interface{})
|
||||||
|
_ = json.Unmarshal(capsJSON, &m.Capabilities)
|
||||||
|
models = append(models, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
// ── Helpers ─────────────────────────────────
|
||||||
|
|
||||||
type scannable interface {
|
type scannable interface {
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ func main() {
|
|||||||
// Models (per-config and aggregate)
|
// Models (per-config and aggregate)
|
||||||
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
|
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
|
||||||
protected.GET("/models", apiCfg.ListAllModels)
|
protected.GET("/models", apiCfg.ListAllModels)
|
||||||
|
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
|
||||||
|
|
||||||
// User Settings & Profile
|
// User Settings & Profile
|
||||||
settings := handlers.NewSettingsHandler()
|
settings := handlers.NewSettingsHandler()
|
||||||
@@ -131,6 +132,17 @@ func main() {
|
|||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
admin.GET("/stats", adm.GetStats)
|
admin.GET("/stats", adm.GetStats)
|
||||||
|
|
||||||
|
// Global API Configs
|
||||||
|
admin.GET("/configs", adm.ListGlobalConfigs)
|
||||||
|
admin.POST("/configs", adm.CreateGlobalConfig)
|
||||||
|
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
||||||
|
|
||||||
|
// Model Configs
|
||||||
|
admin.GET("/models", adm.ListModelConfigs)
|
||||||
|
admin.POST("/models/fetch", adm.FetchModels)
|
||||||
|
admin.PUT("/models/:id", adm.UpdateModelConfig)
|
||||||
|
admin.DELETE("/models/:id", adm.DeleteModelConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func TestAllRoutesRegistered(t *testing.T) {
|
|||||||
protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig)
|
protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig)
|
||||||
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
|
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
|
||||||
protected.GET("/models", apiCfg.ListAllModels)
|
protected.GET("/models", apiCfg.ListAllModels)
|
||||||
|
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
|
||||||
|
|
||||||
// User Settings & Profile
|
// User Settings & Profile
|
||||||
protected.GET("/profile", settings.GetProfile)
|
protected.GET("/profile", settings.GetProfile)
|
||||||
@@ -118,6 +119,13 @@ func TestAllRoutesRegistered(t *testing.T) {
|
|||||||
admin.GET("/settings/:key", adm.GetGlobalSetting)
|
admin.GET("/settings/:key", adm.GetGlobalSetting)
|
||||||
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
|
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
|
||||||
admin.GET("/stats", adm.GetStats)
|
admin.GET("/stats", adm.GetStats)
|
||||||
|
admin.GET("/configs", adm.ListGlobalConfigs)
|
||||||
|
admin.POST("/configs", adm.CreateGlobalConfig)
|
||||||
|
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
||||||
|
admin.GET("/models", adm.ListModelConfigs)
|
||||||
|
admin.POST("/models/fetch", adm.FetchModels)
|
||||||
|
admin.PUT("/models/:id", adm.UpdateModelConfig)
|
||||||
|
admin.DELETE("/models/:id", adm.DeleteModelConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
routes := r.Routes()
|
routes := r.Routes()
|
||||||
@@ -153,6 +161,7 @@ func TestAllRoutesRegistered(t *testing.T) {
|
|||||||
"GET /api/v1/api-configs/:id/models",
|
"GET /api/v1/api-configs/:id/models",
|
||||||
// Models
|
// Models
|
||||||
"GET /api/v1/models",
|
"GET /api/v1/models",
|
||||||
|
"GET /api/v1/models/enabled",
|
||||||
// Profile & Settings
|
// Profile & Settings
|
||||||
"GET /api/v1/profile",
|
"GET /api/v1/profile",
|
||||||
"PUT /api/v1/profile",
|
"PUT /api/v1/profile",
|
||||||
@@ -170,6 +179,13 @@ func TestAllRoutesRegistered(t *testing.T) {
|
|||||||
"GET /api/v1/admin/settings/:key",
|
"GET /api/v1/admin/settings/:key",
|
||||||
"PUT /api/v1/admin/settings/:key",
|
"PUT /api/v1/admin/settings/:key",
|
||||||
"GET /api/v1/admin/stats",
|
"GET /api/v1/admin/stats",
|
||||||
|
"GET /api/v1/admin/configs",
|
||||||
|
"POST /api/v1/admin/configs",
|
||||||
|
"DELETE /api/v1/admin/configs/:id",
|
||||||
|
"GET /api/v1/admin/models",
|
||||||
|
"POST /api/v1/admin/models/fetch",
|
||||||
|
"PUT /api/v1/admin/models/:id",
|
||||||
|
"DELETE /api/v1/admin/models/:id",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range expected {
|
for _, e := range expected {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ body {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -87,6 +88,7 @@ body {
|
|||||||
.main-container {
|
.main-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +172,14 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Messages */
|
/* Messages */
|
||||||
@@ -386,6 +390,7 @@ body {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-container { max-width: 800px; margin: 0 auto; }
|
.input-container { max-width: 800px; margin: 0 auto; }
|
||||||
@@ -1199,3 +1204,211 @@ body {
|
|||||||
#profileChangePwForm .form-group {
|
#profileChangePwForm .form-group {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── API Providers List ──────────────────── */
|
||||||
|
|
||||||
|
.provider-list {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-add-toggle {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-add-form {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-add-form .form-group {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-add-form .form-group:last-of-type {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admin Models Tab ────────────────────── */
|
||||||
|
|
||||||
|
.admin-models-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-header .admin-hint {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-group-header {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label-compact input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-id {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-caps {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
background: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-inactive {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-badge:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-model-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings Mode Badge ─────────────────── */
|
||||||
|
|
||||||
|
.settings-mode-badge {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-managed {
|
||||||
|
background: #10b98115;
|
||||||
|
border: 1px solid #10b98133;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-unmanaged {
|
||||||
|
background: #f59e0b15;
|
||||||
|
border: 1px solid #f59e0b33;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|||||||
111
src/index.html
111
src/index.html
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Chat Switchboard</title>
|
<title>Chat Switchboard</title>
|
||||||
<link rel="stylesheet" href="css/styles.css?v=0.3.4">
|
<link rel="stylesheet" href="css/styles.css?v=0.4.2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="appContainer" class="app-hidden" style="display:none">
|
<div id="appContainer" class="app-hidden" style="display:none">
|
||||||
@@ -117,6 +117,9 @@
|
|||||||
<button class="modal-close" id="closeModalBtn">✕</button>
|
<button class="modal-close" id="closeModalBtn">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<!-- Mode indicator -->
|
||||||
|
<div class="settings-mode-badge" id="settingsModeBadge"></div>
|
||||||
|
|
||||||
<!-- Profile (managed mode only) -->
|
<!-- Profile (managed mode only) -->
|
||||||
<div class="settings-section" id="profileSection" style="display:none">
|
<div class="settings-section" id="profileSection" style="display:none">
|
||||||
<h3 class="settings-section-title">Profile</h3>
|
<h3 class="settings-section-title">Profile</h3>
|
||||||
@@ -160,6 +163,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div> <!-- /unmanagedSettings -->
|
</div> <!-- /unmanagedSettings -->
|
||||||
|
|
||||||
|
<!-- API Providers (managed mode only) -->
|
||||||
|
<div id="managedProviders" style="display:none">
|
||||||
|
<h3 class="settings-section-title">API Providers</h3>
|
||||||
|
<div id="providerList" class="provider-list"></div>
|
||||||
|
<div class="provider-add-toggle">
|
||||||
|
<button class="btn btn-secondary btn-small" id="providerShowAddBtn">+ Add Provider</button>
|
||||||
|
</div>
|
||||||
|
<div class="provider-add-form" id="providerAddForm" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="providerName">Name</label>
|
||||||
|
<input type="text" id="providerName" placeholder="e.g. My OpenAI Key">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="providerType">Provider</label>
|
||||||
|
<select id="providerType">
|
||||||
|
<option value="openai">OpenAI-compatible</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="providerEndpoint">Endpoint</label>
|
||||||
|
<input type="text" id="providerEndpoint" placeholder="https://api.openai.com/v1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="providerApiKey">API Key</label>
|
||||||
|
<input type="password" id="providerApiKey" placeholder="sk-...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="providerDefaultModel">Default Model (optional)</label>
|
||||||
|
<input type="text" id="providerDefaultModel" placeholder="gpt-4o">
|
||||||
|
</div>
|
||||||
|
<div class="admin-form-row">
|
||||||
|
<button class="btn btn-primary btn-small" id="providerCreateBtn">Save</button>
|
||||||
|
<button class="btn btn-secondary btn-small" id="providerCancelBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="settings-divider">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="model">Default Model</label>
|
<label for="model">Default Model</label>
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
@@ -243,6 +285,8 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button class="admin-tab active" data-tab="users">Users</button>
|
<button class="admin-tab active" data-tab="users">Users</button>
|
||||||
|
<button class="admin-tab" data-tab="models">Models</button>
|
||||||
|
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +317,7 @@
|
|||||||
<div id="adminUserList" class="admin-user-list">Loading...</div>
|
<div id="adminUserList" class="admin-user-list">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reset Password Dialog (inline) -->
|
<!-- Reset Password Dialog (inline overlay) -->
|
||||||
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none">
|
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none">
|
||||||
<div class="admin-reset-card">
|
<div class="admin-reset-card">
|
||||||
<h3>Reset Password</h3>
|
<h3>Reset Password</h3>
|
||||||
@@ -286,6 +330,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Models Tab -->
|
||||||
|
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
|
||||||
|
<div class="admin-models-header">
|
||||||
|
<p class="admin-hint">Manage which models are available to users. Fetch from configured providers, then enable/disable and tag capabilities.</p>
|
||||||
|
<button class="btn btn-primary btn-small" id="adminFetchModelsBtn">🔄 Fetch Models</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminModelList" class="admin-model-list">
|
||||||
|
<div class="admin-empty">No models configured. Add a provider first, then fetch models.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Providers Tab -->
|
||||||
|
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
|
||||||
|
<p class="admin-hint">Global providers are available to all users. Users can also add their own.</p>
|
||||||
|
<div id="adminProviderList" class="provider-list"></div>
|
||||||
|
<div class="provider-add-toggle">
|
||||||
|
<button class="btn btn-secondary btn-small" id="adminProviderShowAddBtn">+ Add Global Provider</button>
|
||||||
|
</div>
|
||||||
|
<div class="provider-add-form" id="adminProviderAddForm" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminProviderName">Name</label>
|
||||||
|
<input type="text" id="adminProviderName" placeholder="e.g. Shared OpenAI">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminProviderType">Provider</label>
|
||||||
|
<select id="adminProviderType">
|
||||||
|
<option value="openai">OpenAI-compatible</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminProviderEndpoint">Endpoint</label>
|
||||||
|
<input type="text" id="adminProviderEndpoint" placeholder="https://api.openai.com/v1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminProviderApiKey">API Key</label>
|
||||||
|
<input type="password" id="adminProviderApiKey" placeholder="sk-...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminProviderDefaultModel">Default Model (optional)</label>
|
||||||
|
<input type="text" id="adminProviderDefaultModel" placeholder="gpt-4o">
|
||||||
|
</div>
|
||||||
|
<div class="admin-form-row">
|
||||||
|
<button class="btn btn-primary btn-small" id="adminProviderCreateBtn">Save</button>
|
||||||
|
<button class="btn btn-secondary btn-small" id="adminProviderCancelBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -365,12 +458,12 @@
|
|||||||
|
|
||||||
<div class="toast-container" id="toastContainer"></div>
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
<script src="js/storage.js?v=0.3.4"></script>
|
<script src="js/storage.js?v=0.4.2"></script>
|
||||||
<script src="js/backend.js?v=0.3.4"></script>
|
<script src="js/backend.js?v=0.4.2"></script>
|
||||||
<script src="js/state.js?v=0.3.4"></script>
|
<script src="js/state.js?v=0.4.2"></script>
|
||||||
<script src="js/api.js?v=0.3.4"></script>
|
<script src="js/api.js?v=0.4.2"></script>
|
||||||
<script src="js/ui.js?v=0.3.4"></script>
|
<script src="js/ui.js?v=0.4.2"></script>
|
||||||
<script src="js/admin.js?v=0.3.4"></script>
|
<script src="js/admin.js?v=0.4.2"></script>
|
||||||
<script src="js/app.js?v=0.3.4"></script>
|
<script src="js/app.js?v=0.4.2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
214
src/js/admin.js
214
src/js/admin.js
@@ -40,6 +40,21 @@ function initAdminListeners() {
|
|||||||
document.getElementById('adminResetCancelBtn').addEventListener('click', closeResetDialog);
|
document.getElementById('adminResetCancelBtn').addEventListener('click', closeResetDialog);
|
||||||
document.getElementById('adminResetConfirmBtn').addEventListener('click', handleResetPassword);
|
document.getElementById('adminResetConfirmBtn').addEventListener('click', handleResetPassword);
|
||||||
|
|
||||||
|
// Admin providers
|
||||||
|
document.getElementById('adminProviderShowAddBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('adminProviderAddForm').style.display = '';
|
||||||
|
document.getElementById('adminProviderShowAddBtn').style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('adminProviderCancelBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('adminProviderAddForm').style.display = 'none';
|
||||||
|
document.getElementById('adminProviderShowAddBtn').style.display = '';
|
||||||
|
clearAdminProviderForm();
|
||||||
|
});
|
||||||
|
document.getElementById('adminProviderCreateBtn').addEventListener('click', handleCreateAdminProvider);
|
||||||
|
|
||||||
|
// Admin models
|
||||||
|
document.getElementById('adminFetchModelsBtn').addEventListener('click', handleAdminFetchModels);
|
||||||
|
|
||||||
document.getElementById('adminResetNewPw').addEventListener('keydown', function(e) {
|
document.getElementById('adminResetNewPw').addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') handleResetPassword();
|
if (e.key === 'Enter') handleResetPassword();
|
||||||
});
|
});
|
||||||
@@ -50,10 +65,14 @@ function switchAdminTab(tab) {
|
|||||||
t.classList.toggle('active', t.dataset.tab === tab);
|
t.classList.toggle('active', t.dataset.tab === tab);
|
||||||
});
|
});
|
||||||
document.getElementById('adminUsersTab').style.display = tab === 'users' ? '' : 'none';
|
document.getElementById('adminUsersTab').style.display = tab === 'users' ? '' : 'none';
|
||||||
|
document.getElementById('adminModelsTab').style.display = tab === 'models' ? '' : 'none';
|
||||||
|
document.getElementById('adminProvidersTab').style.display = tab === 'providers' ? '' : 'none';
|
||||||
document.getElementById('adminSettingsTab').style.display = tab === 'settings' ? '' : 'none';
|
document.getElementById('adminSettingsTab').style.display = tab === 'settings' ? '' : 'none';
|
||||||
document.getElementById('adminStatsTab').style.display = tab === 'stats' ? '' : 'none';
|
document.getElementById('adminStatsTab').style.display = tab === 'stats' ? '' : 'none';
|
||||||
document.getElementById('adminResetDialog').style.display = 'none';
|
document.getElementById('adminResetDialog').style.display = 'none';
|
||||||
|
|
||||||
|
if (tab === 'models') loadAdminModels();
|
||||||
|
if (tab === 'providers') loadAdminProviders();
|
||||||
if (tab === 'settings') loadAdminSettings();
|
if (tab === 'settings') loadAdminSettings();
|
||||||
if (tab === 'stats') loadAdminStats();
|
if (tab === 'stats') loadAdminStats();
|
||||||
}
|
}
|
||||||
@@ -271,3 +290,198 @@ function escapeHtml(str) {
|
|||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Admin Models ────────────────────────────
|
||||||
|
|
||||||
|
const CAPS = ['tool', 'thinking', 'vision', 'code'];
|
||||||
|
|
||||||
|
async function loadAdminModels() {
|
||||||
|
const container = document.getElementById('adminModelList');
|
||||||
|
container.innerHTML = '<div class="admin-loading">Loading models...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await Backend.adminListModels();
|
||||||
|
const models = data.models || [];
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
container.innerHTML = '<div class="admin-empty">No models configured. Add a provider first, then click "Fetch Models".</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by provider
|
||||||
|
const grouped = {};
|
||||||
|
for (const m of models) {
|
||||||
|
const key = m.provider_name || 'Unknown';
|
||||||
|
if (!grouped[key]) grouped[key] = [];
|
||||||
|
grouped[key].push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const [provider, provModels] of Object.entries(grouped)) {
|
||||||
|
html += '<div class="admin-model-group">';
|
||||||
|
html += '<div class="admin-model-group-header">' + escapeHtml(provider) + '</div>';
|
||||||
|
for (const m of provModels) {
|
||||||
|
const caps = m.capabilities || {};
|
||||||
|
const name = m.display_name || m.model_id;
|
||||||
|
html += '<div class="admin-model-row">';
|
||||||
|
html += ' <div class="admin-model-toggle">';
|
||||||
|
html += ' <label class="toggle-label-compact">';
|
||||||
|
html += ' <input type="checkbox" ' + (m.is_enabled ? 'checked' : '') +
|
||||||
|
' onchange="toggleModelEnabled(\'' + m.id + '\', this.checked)">';
|
||||||
|
html += ' </label>';
|
||||||
|
html += ' </div>';
|
||||||
|
html += ' <div class="admin-model-info">';
|
||||||
|
html += ' <span class="admin-model-name">' + escapeHtml(name) + '</span>';
|
||||||
|
html += ' <span class="admin-model-id">' + escapeHtml(m.model_id) + '</span>';
|
||||||
|
html += ' </div>';
|
||||||
|
html += ' <div class="admin-model-caps">';
|
||||||
|
for (const cap of CAPS) {
|
||||||
|
const active = caps[cap] === true;
|
||||||
|
html += ' <button class="cap-badge ' + (active ? 'cap-active' : 'cap-inactive') + '"' +
|
||||||
|
' onclick="toggleModelCap(\'' + m.id + '\', \'' + cap + '\', ' + !active + ', \'' + escapeHtml(JSON.stringify(caps)) + '\')"' +
|
||||||
|
' title="' + cap + '">' + cap + '</button>';
|
||||||
|
}
|
||||||
|
html += ' </div>';
|
||||||
|
html += ' <div class="admin-model-actions">';
|
||||||
|
html += ' <button class="btn btn-small btn-danger" onclick="deleteAdminModel(\'' + m.id + '\', \'' + escapeHtml(m.model_id) + '\')">✕</button>';
|
||||||
|
html += ' </div>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="admin-error">Failed to load models: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminFetchModels() {
|
||||||
|
const btn = document.getElementById('adminFetchModelsBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Fetching...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await Backend.adminFetchModels();
|
||||||
|
const added = data.total_added || 0;
|
||||||
|
const results = data.results || [];
|
||||||
|
const errors = results.filter(r => r.error);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
showToast('⚠️ Fetched with ' + errors.length + ' error(s). ' + added + ' new models.', 'warning');
|
||||||
|
} else {
|
||||||
|
showToast('✅ Fetched ' + added + ' new models', 'success');
|
||||||
|
}
|
||||||
|
loadAdminModels();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🔄 Fetch Models';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleModelEnabled(modelId, enabled) {
|
||||||
|
try {
|
||||||
|
await Backend.adminUpdateModel(modelId, { is_enabled: enabled });
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
loadAdminModels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleModelCap(modelId, cap, value, capsJson) {
|
||||||
|
try {
|
||||||
|
const caps = JSON.parse(capsJson);
|
||||||
|
caps[cap] = value;
|
||||||
|
await Backend.adminUpdateModel(modelId, { capabilities: caps });
|
||||||
|
loadAdminModels();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
loadAdminModels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAdminModel(modelId, modelName) {
|
||||||
|
if (!confirm('Remove model "' + modelName + '"? It can be re-fetched later.')) return;
|
||||||
|
try {
|
||||||
|
await Backend.adminDeleteModel(modelId);
|
||||||
|
showToast('✅ Model removed', 'success');
|
||||||
|
loadAdminModels();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminProviders() {
|
||||||
|
const container = document.getElementById('adminProviderList');
|
||||||
|
container.innerHTML = '<div class="admin-loading">Loading...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await Backend.adminListGlobalConfigs();
|
||||||
|
const configs = data.configs || [];
|
||||||
|
|
||||||
|
if (configs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="provider-empty">No global providers. Users must add their own.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = configs.map(c => `
|
||||||
|
<div class="provider-row">
|
||||||
|
<div class="provider-info">
|
||||||
|
<span class="provider-name">${escapeHtml(c.name)}</span>
|
||||||
|
<span class="provider-meta">${c.provider} · ${c.model_default || 'no default'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-actions">
|
||||||
|
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
|
||||||
|
<button class="btn btn-small btn-danger" onclick="deleteAdminProvider('${c.id}', '${escapeHtml(c.name)}')">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAdminProviderForm() {
|
||||||
|
document.getElementById('adminProviderName').value = '';
|
||||||
|
document.getElementById('adminProviderType').value = 'openai';
|
||||||
|
document.getElementById('adminProviderEndpoint').value = '';
|
||||||
|
document.getElementById('adminProviderApiKey').value = '';
|
||||||
|
document.getElementById('adminProviderDefaultModel').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateAdminProvider() {
|
||||||
|
const name = document.getElementById('adminProviderName').value.trim();
|
||||||
|
const provider = document.getElementById('adminProviderType').value;
|
||||||
|
const endpoint = document.getElementById('adminProviderEndpoint').value.trim();
|
||||||
|
const apiKey = document.getElementById('adminProviderApiKey').value.trim();
|
||||||
|
const modelDefault = document.getElementById('adminProviderDefaultModel').value.trim();
|
||||||
|
|
||||||
|
if (!name || !endpoint || !apiKey) {
|
||||||
|
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Backend.adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault || null);
|
||||||
|
showToast('✅ Global provider added', 'success');
|
||||||
|
clearAdminProviderForm();
|
||||||
|
document.getElementById('adminProviderAddForm').style.display = 'none';
|
||||||
|
document.getElementById('adminProviderShowAddBtn').style.display = '';
|
||||||
|
loadAdminProviders();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAdminProvider(configId, name) {
|
||||||
|
if (!confirm('Remove global provider "' + name + '"?')) return;
|
||||||
|
try {
|
||||||
|
await Backend.adminDeleteGlobalConfig(configId);
|
||||||
|
showToast('✅ Global provider removed', 'success');
|
||||||
|
loadAdminProviders();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,13 +16,22 @@ async function fetchModels(showInModal = true) {
|
|||||||
let models = [];
|
let models = [];
|
||||||
|
|
||||||
if (Backend.isManaged) {
|
if (Backend.isManaged) {
|
||||||
// Managed mode: fetch from backend aggregated models endpoint
|
// Managed mode: fetch admin-curated enabled models
|
||||||
const data = await Backend.listAllModels();
|
const data = await Backend.listEnabledModels();
|
||||||
models = (data.models || []).map(m => ({
|
models = (data.models || []).map(m => ({
|
||||||
|
id: m.model_id || m.id,
|
||||||
|
owned_by: m.provider_name || m.provider || null,
|
||||||
|
config_id: m.config_id || null
|
||||||
|
}));
|
||||||
|
// Fallback: if no curated models, try raw aggregation
|
||||||
|
if (models.length === 0) {
|
||||||
|
const raw = await Backend.listAllModels();
|
||||||
|
models = (raw.models || []).map(m => ({
|
||||||
id: m.id || m.name,
|
id: m.id || m.name,
|
||||||
owned_by: m.owned_by || m.provider || null,
|
owned_by: m.owned_by || m.provider || null,
|
||||||
config_id: m.config_id || null
|
config_id: m.config_id || null
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unmanaged mode: direct provider call
|
// Unmanaged mode: direct provider call
|
||||||
const endpoint = showInModal
|
const endpoint = showInModal
|
||||||
|
|||||||
@@ -39,12 +39,19 @@ async function init() {
|
|||||||
|
|
||||||
// Already authed or no backend → go straight to app
|
// Already authed or no backend → go straight to app
|
||||||
hideSplashGate();
|
hideSplashGate();
|
||||||
|
await loadSettingsFromBackend();
|
||||||
await initApp();
|
await initApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
await loadChats();
|
await loadChats();
|
||||||
initializeEventListeners();
|
initializeEventListeners();
|
||||||
|
|
||||||
|
// In managed mode, fetch models from backend (ignore localStorage cache)
|
||||||
|
if (Backend.isManaged) {
|
||||||
|
fetchModels(false); // async, non-blocking
|
||||||
|
}
|
||||||
|
|
||||||
updateQuickModelSelector();
|
updateQuickModelSelector();
|
||||||
renderChatHistory();
|
renderChatHistory();
|
||||||
updateConnectionStatus();
|
updateConnectionStatus();
|
||||||
@@ -80,8 +87,9 @@ function hideSplashGate() {
|
|||||||
|
|
||||||
async function authSuccess() {
|
async function authSuccess() {
|
||||||
hideSplashGate();
|
hideSplashGate();
|
||||||
|
await loadSettingsFromBackend();
|
||||||
await initApp();
|
await initApp();
|
||||||
showToast(`✅ Welcome, ${Backend.user?.username || 'user'}!`, 'success');
|
showToast(`✅ Welcome, ${Backend.user?.display_name || Backend.user?.username || 'user'}!`, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeEventListeners() {
|
function initializeEventListeners() {
|
||||||
@@ -99,6 +107,12 @@ function initializeEventListeners() {
|
|||||||
});
|
});
|
||||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||||||
|
|
||||||
|
// Providers (managed mode)
|
||||||
|
document.getElementById('providerShowAddBtn').addEventListener('click', showProviderForm);
|
||||||
|
document.getElementById('providerCancelBtn').addEventListener('click', hideProviderForm);
|
||||||
|
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||||
|
document.getElementById('providerType').addEventListener('change', updateProviderEndpointHint);
|
||||||
|
|
||||||
// Chat controls
|
// Chat controls
|
||||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||||
|
|||||||
@@ -294,6 +294,57 @@ const Backend = {
|
|||||||
return this._authedFetch('/api/v1/admin/stats');
|
return this._authedFetch('/api/v1/admin/stats');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Admin Global Configs
|
||||||
|
async adminListGlobalConfigs() {
|
||||||
|
return this._authedFetch('/api/v1/admin/configs');
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||||
|
return this._authedFetch('/api/v1/admin/configs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name, provider, endpoint,
|
||||||
|
api_key: apiKey,
|
||||||
|
model_default: modelDefault
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminDeleteGlobalConfig(configId) {
|
||||||
|
return this._authedFetch(`/api/v1/admin/configs/${configId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin Model Configs
|
||||||
|
async adminListModels() {
|
||||||
|
return this._authedFetch('/api/v1/admin/models');
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminFetchModels() {
|
||||||
|
return this._authedFetch('/api/v1/admin/models/fetch', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminUpdateModel(modelId, updates) {
|
||||||
|
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminDeleteModel(modelId) {
|
||||||
|
return this._authedFetch(`/api/v1/admin/models/${modelId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enabled models (for quick selector in managed mode)
|
||||||
|
async listEnabledModels() {
|
||||||
|
return this._authedFetch('/api/v1/models/enabled');
|
||||||
|
},
|
||||||
|
|
||||||
// ── Models ──────────────────────────────
|
// ── Models ──────────────────────────────
|
||||||
|
|
||||||
async listAllModels() {
|
async listAllModels() {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const State = {
|
|||||||
abortController: null
|
abortController: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Settings (always localStorage) ──────────
|
// ── Settings (localStorage + backend sync) ──
|
||||||
|
|
||||||
function loadSettings() {
|
function loadSettings() {
|
||||||
const saved = Storage.get('chatSwitchboard_settings');
|
const saved = Storage.get('chatSwitchboard_settings');
|
||||||
@@ -37,6 +37,47 @@ function loadSettings() {
|
|||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
Storage.set('chatSwitchboard_settings', State.settings);
|
Storage.set('chatSwitchboard_settings', State.settings);
|
||||||
|
// Fire-and-forget sync to backend
|
||||||
|
syncSettingsToBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSettingsToBackend() {
|
||||||
|
if (!Backend.isManaged) return;
|
||||||
|
try {
|
||||||
|
// Only sync preferences, not apiEndpoint/apiKey (those live in api_configs)
|
||||||
|
const prefs = {
|
||||||
|
model: State.settings.model,
|
||||||
|
stream: State.settings.stream,
|
||||||
|
saveHistory: State.settings.saveHistory,
|
||||||
|
showThinking: State.settings.showThinking,
|
||||||
|
systemPrompt: State.settings.systemPrompt,
|
||||||
|
maxTokens: State.settings.maxTokens,
|
||||||
|
temperature: State.settings.temperature,
|
||||||
|
topP: State.settings.topP,
|
||||||
|
presencePenalty: State.settings.presencePenalty
|
||||||
|
};
|
||||||
|
await Backend.updateSettings(prefs);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to sync settings to backend:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettingsFromBackend() {
|
||||||
|
if (!Backend.isManaged) return;
|
||||||
|
try {
|
||||||
|
const remote = await Backend.getSettings();
|
||||||
|
if (remote && Object.keys(remote).length > 0) {
|
||||||
|
// Merge remote prefs over local, but keep apiEndpoint/apiKey local
|
||||||
|
const localEndpoint = State.settings.apiEndpoint;
|
||||||
|
const localKey = State.settings.apiKey;
|
||||||
|
State.settings = { ...State.settings, ...remote };
|
||||||
|
State.settings.apiEndpoint = localEndpoint;
|
||||||
|
State.settings.apiKey = localKey;
|
||||||
|
Storage.set('chatSwitchboard_settings', State.settings);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load settings from backend:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Models (always localStorage) ────────────
|
// ── Models (always localStorage) ────────────
|
||||||
|
|||||||
122
src/js/ui.js
122
src/js/ui.js
@@ -89,22 +89,27 @@ function updateQuickModelSelector() {
|
|||||||
function openSettings() {
|
function openSettings() {
|
||||||
updateSettingsUI();
|
updateSettingsUI();
|
||||||
updateProfileSection();
|
updateProfileSection();
|
||||||
|
updateProviderSection();
|
||||||
document.getElementById('settingsModal').classList.add('active');
|
document.getElementById('settingsModal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProfileSection() {
|
function updateProfileSection() {
|
||||||
const profileSection = document.getElementById('profileSection');
|
const profileSection = document.getElementById('profileSection');
|
||||||
const unmanagedSettings = document.getElementById('unmanagedSettings');
|
const unmanagedSettings = document.getElementById('unmanagedSettings');
|
||||||
|
const modeBadge = document.getElementById('settingsModeBadge');
|
||||||
|
|
||||||
if (Backend.isManaged) {
|
if (Backend.isManaged) {
|
||||||
profileSection.style.display = '';
|
profileSection.style.display = '';
|
||||||
unmanagedSettings.style.display = 'none';
|
unmanagedSettings.style.display = 'none';
|
||||||
|
modeBadge.textContent = '🔗 Managed Mode — API keys are configured by your admin or in Providers below';
|
||||||
|
modeBadge.className = 'settings-mode-badge mode-managed';
|
||||||
loadProfileIntoSettings();
|
loadProfileIntoSettings();
|
||||||
} else {
|
} else {
|
||||||
profileSection.style.display = 'none';
|
profileSection.style.display = 'none';
|
||||||
unmanagedSettings.style.display = '';
|
unmanagedSettings.style.display = '';
|
||||||
|
modeBadge.textContent = '📱 Offline Mode — Configure your own API endpoint and key';
|
||||||
|
modeBadge.className = 'settings-mode-badge mode-unmanaged';
|
||||||
}
|
}
|
||||||
// Always reset password form
|
|
||||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +175,121 @@ async function handleChangePassword() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── API Providers (managed mode) ────────────
|
||||||
|
|
||||||
|
function updateProviderSection() {
|
||||||
|
const managed = document.getElementById('managedProviders');
|
||||||
|
if (Backend.isManaged) {
|
||||||
|
managed.style.display = '';
|
||||||
|
loadProviderList();
|
||||||
|
} else {
|
||||||
|
managed.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviderList() {
|
||||||
|
const container = document.getElementById('providerList');
|
||||||
|
container.innerHTML = '<div class="admin-loading">Loading...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await Backend.listConfigs();
|
||||||
|
const configs = data.configs || data.data || data || [];
|
||||||
|
const list = Array.isArray(configs) ? configs : [];
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
container.innerHTML = '<div class="provider-empty">No providers configured. Add one to start chatting.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = list.map(c => `
|
||||||
|
<div class="provider-row">
|
||||||
|
<div class="provider-info">
|
||||||
|
<span class="provider-name">${escapeHtmlUI(c.name)}</span>
|
||||||
|
<span class="provider-meta">${c.provider} · ${c.model_default || 'no default'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="provider-actions">
|
||||||
|
<span class="admin-badge admin-badge-active">${c.has_key ? '🔑' : '⚠️'}</span>
|
||||||
|
${c.user_id ? `<button class="btn btn-small btn-danger" onclick="deleteProvider('${c.id}', '${escapeHtmlUI(c.name)}')">Remove</button>` : '<span class="admin-badge admin-badge-moderator">global</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<div class="admin-error">Failed to load providers: ' + e.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProviderForm() {
|
||||||
|
document.getElementById('providerAddForm').style.display = '';
|
||||||
|
document.getElementById('providerShowAddBtn').style.display = 'none';
|
||||||
|
// Set default endpoint based on provider type
|
||||||
|
updateProviderEndpointHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideProviderForm() {
|
||||||
|
document.getElementById('providerAddForm').style.display = 'none';
|
||||||
|
document.getElementById('providerShowAddBtn').style.display = '';
|
||||||
|
clearProviderForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProviderForm() {
|
||||||
|
document.getElementById('providerName').value = '';
|
||||||
|
document.getElementById('providerType').value = 'openai';
|
||||||
|
document.getElementById('providerEndpoint').value = '';
|
||||||
|
document.getElementById('providerApiKey').value = '';
|
||||||
|
document.getElementById('providerDefaultModel').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProviderEndpointHint() {
|
||||||
|
const type = document.getElementById('providerType').value;
|
||||||
|
const endpoint = document.getElementById('providerEndpoint');
|
||||||
|
if (!endpoint.value) {
|
||||||
|
endpoint.placeholder = type === 'anthropic'
|
||||||
|
? 'https://api.anthropic.com'
|
||||||
|
: 'https://api.openai.com/v1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateProvider() {
|
||||||
|
const name = document.getElementById('providerName').value.trim();
|
||||||
|
const provider = document.getElementById('providerType').value;
|
||||||
|
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||||
|
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||||
|
const modelDefault = document.getElementById('providerDefaultModel').value.trim();
|
||||||
|
|
||||||
|
if (!name || !endpoint || !apiKey) {
|
||||||
|
showToast('⚠️ Name, endpoint, and API key are required', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Backend.createConfig(name, provider, endpoint, apiKey, modelDefault || null);
|
||||||
|
showToast('✅ Provider added', 'success');
|
||||||
|
hideProviderForm();
|
||||||
|
loadProviderList();
|
||||||
|
fetchModels(false); // Refresh model list
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProvider(configId, name) {
|
||||||
|
if (!confirm('Remove provider "' + name + '"?')) return;
|
||||||
|
try {
|
||||||
|
await Backend.deleteConfig(configId);
|
||||||
|
showToast('✅ Provider removed', 'success');
|
||||||
|
loadProviderList();
|
||||||
|
fetchModels(false);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('❌ ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlUI(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
document.getElementById('settingsModal').classList.remove('active');
|
document.getElementById('settingsModal').classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user