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 }