Changeset 0.22.5 (#147)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
412
server/pages/loaders.go
Normal file
412
server/pages/loaders.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Types for template data ──────────────────
|
||||
|
||||
// ModelOption is a flat model for template dropdowns.
|
||||
type ModelOption struct {
|
||||
ProviderConfigID string `json:"provider_config_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ModelID string `json:"model_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ModelType string `json:"model_type"`
|
||||
}
|
||||
|
||||
// ProviderOption for template dropdowns.
|
||||
type ProviderOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// TeamOption for template dropdowns.
|
||||
type TeamOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MemberCount int `json:"member_count,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ProviderDetail is an enriched provider for the providers table.
|
||||
type ProviderDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Scope string `json:"scope"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ModelCount int `json:"model_count"`
|
||||
}
|
||||
|
||||
// ProviderTypeOption for the provider type dropdown.
|
||||
type ProviderTypeOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// UserRow is a user for the admin users table.
|
||||
type UserRow struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// RoleConfig holds a role's current settings.
|
||||
type RoleConfig struct {
|
||||
Name string `json:"name"`
|
||||
Primary *RoleSelection `json:"primary"`
|
||||
Fallback *RoleSelection `json:"fallback"`
|
||||
}
|
||||
|
||||
// RoleSelection is provider + model pair for a role slot.
|
||||
type RoleSelection struct {
|
||||
ProviderID string `json:"provider_config_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
|
||||
// ── Page data structs ────────────────────────
|
||||
|
||||
// AdminPageData is what the admin surface templates receive.
|
||||
type AdminPageData struct {
|
||||
Section string `json:"section"`
|
||||
Category string `json:"category"`
|
||||
Providers []ProviderOption `json:"providers"`
|
||||
ProviderDetails []ProviderDetail `json:"provider_details,omitempty"`
|
||||
ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"`
|
||||
Models []ModelOption `json:"models"`
|
||||
Teams []TeamOption `json:"teams"`
|
||||
Users []UserRow `json:"users,omitempty"`
|
||||
Roles []RoleConfig `json:"roles"`
|
||||
Policies any `json:"policies,omitempty"`
|
||||
}
|
||||
|
||||
// ChatPageData is what the chat surface templates receive.
|
||||
type ChatPageData struct {
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
// EditorPageData provides workspace context for the editor surface shell.
|
||||
// editor-mode.js builds CodeMirror, file tree, etc. inside the template containers.
|
||||
type EditorPageData struct {
|
||||
WorkspaceID string `json:"WorkspaceID"`
|
||||
WorkspaceName string `json:"WorkspaceName"`
|
||||
}
|
||||
|
||||
// NotesPageData provides context for the notes surface shell.
|
||||
type NotesPageData struct {
|
||||
NoteID string `json:"NoteID,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsPageData is what the settings surface templates receive.
|
||||
type SettingsPageData struct {
|
||||
Section string `json:"section"`
|
||||
}
|
||||
|
||||
// ── Loader registration ──────────────────────
|
||||
|
||||
func (e *Engine) registerLoaders() {
|
||||
e.RegisterLoader("admin", e.adminLoader)
|
||||
e.RegisterLoader("chat", e.chatLoader)
|
||||
e.RegisterLoader("editor", e.editorLoader)
|
||||
e.RegisterLoader("notes", e.notesLoader)
|
||||
e.RegisterLoader("settings", e.settingsLoader)
|
||||
}
|
||||
|
||||
// ── Admin loader ─────────────────────────────
|
||||
// Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope).
|
||||
|
||||
func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
ctx := context.Background()
|
||||
section := c.Param("section")
|
||||
if section == "" {
|
||||
section = "overview"
|
||||
}
|
||||
|
||||
data := &AdminPageData{
|
||||
Section: section,
|
||||
Category: sectionCategory(section),
|
||||
}
|
||||
|
||||
// ── Providers (global scope) ─────────────
|
||||
if s.Providers != nil {
|
||||
configs, err := s.Providers.ListGlobal(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list providers: %v", err)
|
||||
} else {
|
||||
for _, pc := range configs {
|
||||
name := pc.Name
|
||||
if name == "" {
|
||||
name = pc.Provider
|
||||
}
|
||||
data.Providers = append(data.Providers, ProviderOption{
|
||||
ID: pc.ID,
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Teams ────────────────────────────────
|
||||
if s.Teams != nil {
|
||||
teams, err := s.Teams.List(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list teams: %v", err)
|
||||
} else {
|
||||
for _, t := range teams {
|
||||
data.Teams = append(data.Teams, TeamOption{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
MemberCount: t.MemberCount,
|
||||
IsActive: t.IsActive,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full model catalog with model_type ───
|
||||
// This is what fixes bug #1: all models from all providers,
|
||||
// including embedding models, with their model_type intact.
|
||||
if s.Catalog != nil {
|
||||
entries, err := s.Catalog.ListAllGlobal(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list catalog: %v", err)
|
||||
} else {
|
||||
provNames := make(map[string]string, len(data.Providers))
|
||||
for _, p := range data.Providers {
|
||||
provNames[p.ID] = p.Name
|
||||
}
|
||||
for _, entry := range entries {
|
||||
mt := entry.ModelType
|
||||
if mt == "" {
|
||||
mt = "chat"
|
||||
}
|
||||
dn := entry.DisplayName
|
||||
if dn == "" {
|
||||
dn = entry.ModelID
|
||||
}
|
||||
data.Models = append(data.Models, ModelOption{
|
||||
ProviderConfigID: entry.ProviderConfigID,
|
||||
ProviderName: provNames[entry.ProviderConfigID],
|
||||
ModelID: entry.ModelID,
|
||||
DisplayName: dn,
|
||||
ModelType: mt,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section-specific data ────────────────
|
||||
switch section {
|
||||
case "roles":
|
||||
data.Roles = e.loadRolesConfig(ctx, s)
|
||||
case "routing":
|
||||
if s.RoutingPolicies != nil {
|
||||
policies, err := s.RoutingPolicies.ListAll(ctx)
|
||||
if err == nil {
|
||||
data.Policies = policies
|
||||
}
|
||||
}
|
||||
case "providers":
|
||||
data.ProviderTypes = loadProviderTypes()
|
||||
data.ProviderDetails = e.loadProviderDetails(ctx, s, data.Providers, data.Models)
|
||||
case "users":
|
||||
data.Users = e.loadUsers(ctx, s)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// loadRolesConfig reads current role assignments.
|
||||
func (e *Engine) loadRolesConfig(ctx context.Context, s store.Stores) []RoleConfig {
|
||||
roleNames := []string{"utility", "embedding"}
|
||||
roles := make([]RoleConfig, 0, len(roleNames))
|
||||
|
||||
if s.GlobalConfig == nil {
|
||||
for _, name := range roleNames {
|
||||
roles = append(roles, RoleConfig{Name: name})
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
raw, err := s.GlobalConfig.Get(ctx, "model_roles")
|
||||
if err != nil || raw == nil {
|
||||
for _, name := range roleNames {
|
||||
roles = append(roles, RoleConfig{Name: name})
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
rolesMap, ok := raw["roles"].(map[string]any)
|
||||
if !ok {
|
||||
rolesMap = raw
|
||||
}
|
||||
|
||||
for _, name := range roleNames {
|
||||
rc := RoleConfig{Name: name}
|
||||
if roleData, ok := rolesMap[name].(map[string]any); ok {
|
||||
if primary, ok := roleData["primary"].(map[string]any); ok {
|
||||
rc.Primary = &RoleSelection{
|
||||
ProviderID: fmt.Sprintf("%v", primary["provider_config_id"]),
|
||||
ModelID: fmt.Sprintf("%v", primary["model_id"]),
|
||||
}
|
||||
}
|
||||
if fallback, ok := roleData["fallback"].(map[string]any); ok {
|
||||
rc.Fallback = &RoleSelection{
|
||||
ProviderID: fmt.Sprintf("%v", fallback["provider_config_id"]),
|
||||
ModelID: fmt.Sprintf("%v", fallback["model_id"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
roles = append(roles, rc)
|
||||
}
|
||||
|
||||
return roles
|
||||
}
|
||||
|
||||
// ── Chat loader ──────────────────────────────
|
||||
|
||||
func (e *Engine) chatLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
chatID := c.Param("chatID")
|
||||
return &ChatPageData{ChatID: chatID}, nil
|
||||
}
|
||||
|
||||
// ── Admin helpers ────────────────────────────
|
||||
|
||||
// sectionCategory maps an admin section to its category tab.
|
||||
func sectionCategory(section string) string {
|
||||
switch section {
|
||||
case "users", "teams", "groups":
|
||||
return "people"
|
||||
case "providers", "models", "presets", "roles", "knowledgeBases", "memory":
|
||||
return "ai"
|
||||
case "health", "routing", "capabilities":
|
||||
return "routing"
|
||||
case "settings", "storage", "extensions":
|
||||
return "system"
|
||||
case "usage", "audit", "stats":
|
||||
return "monitoring"
|
||||
default:
|
||||
return "ai" // default landing
|
||||
}
|
||||
}
|
||||
|
||||
// loadProviderTypes returns the registered provider type metadata.
|
||||
func loadProviderTypes() []ProviderTypeOption {
|
||||
types := providers.ListTypes()
|
||||
out := make([]ProviderTypeOption, 0, len(types))
|
||||
for _, t := range types {
|
||||
out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadProviderDetails enriches providers with model counts.
|
||||
func (e *Engine) loadProviderDetails(ctx context.Context, s store.Stores, provs []ProviderOption, models []ModelOption) []ProviderDetail {
|
||||
// Count models per provider
|
||||
counts := make(map[string]int)
|
||||
for _, m := range models {
|
||||
counts[m.ProviderConfigID]++
|
||||
}
|
||||
|
||||
// Get full provider configs for endpoint/scope
|
||||
if s.Providers == nil {
|
||||
return nil
|
||||
}
|
||||
configs, err := s.Providers.ListGlobal(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list provider details: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]ProviderDetail, 0, len(configs))
|
||||
for _, pc := range configs {
|
||||
name := pc.Name
|
||||
if name == "" {
|
||||
name = pc.Provider
|
||||
}
|
||||
out = append(out, ProviderDetail{
|
||||
ID: pc.ID,
|
||||
Name: name,
|
||||
Provider: pc.Provider,
|
||||
Scope: pc.Scope,
|
||||
Endpoint: pc.Endpoint,
|
||||
ModelCount: counts[pc.ID],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadUsers returns the user list for the admin users table.
|
||||
func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
|
||||
if s.Users == nil {
|
||||
return nil
|
||||
}
|
||||
users, _, err := s.Users.List(ctx, store.ListOptions{Limit: 500, Sort: "username", Order: "asc"})
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list users: %v", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]UserRow, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, UserRow{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
IsActive: u.IsActive,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
// ── Editor loader ────────────────────────────
|
||||
// Resolves workspace from URL param. The heavy lifting (file tree, CodeMirror)
|
||||
// is done client-side by editor-mode.js.
|
||||
|
||||
func (e *Engine) editorLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
wsID := c.Param("wsId")
|
||||
data := &EditorPageData{WorkspaceID: wsID}
|
||||
|
||||
if wsID != "" && s.Workspaces != nil {
|
||||
ws, err := s.Workspaces.GetByID(context.Background(), wsID)
|
||||
if err == nil && ws != nil {
|
||||
data.WorkspaceName = ws.Name
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ── Notes loader ─────────────────────────────
|
||||
|
||||
func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
noteID := c.Param("noteId")
|
||||
return &NotesPageData{NoteID: noteID}, nil
|
||||
}
|
||||
|
||||
// ── Settings loader ──────────────────────────
|
||||
// Most settings sections are populated client-side by existing JS.
|
||||
// Server just passes the section name for navigation highlighting.
|
||||
|
||||
func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
section := c.Param("section")
|
||||
if section == "" {
|
||||
section = "general"
|
||||
}
|
||||
return &SettingsPageData{Section: section}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user