From 8a36c53a1c1fe048af83faab217462a089eba312 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Fri, 20 Mar 2026 11:14:59 +0000 Subject: [PATCH] Changeset 0.35.0.0 (#210) --- pages/loaders.go | 445 ---------------------------------------- server/pages/loaders.go | 2 +- 2 files changed, 1 insertion(+), 446 deletions(-) delete mode 100644 pages/loaders.go diff --git a/pages/loaders.go b/pages/loaders.go deleted file mode 100644 index 9244048..0000000 --- a/pages/loaders.go +++ /dev/null @@ -1,445 +0,0 @@ -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 ──────────────────────── -// -// v0.25.0: Each loader function is a "data provider" keyed by surface ID. -// The surface manifest's DataRequires field references these keys. -// Currently 1:1 (one loader per surface). Future: composite loaders -// that assemble data from multiple providers for dashboard-style surfaces. - -// 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"` - - // v0.22.7: Feature gates from admin config. - // These control which nav links/tabs are visible in the settings surface. - BYOKEnabled bool `json:"BYOKEnabled"` - UserPersonasEnabled bool `json:"UserPersonasEnabled"` -} - -// ── 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) -} - -// ListDataProviders returns the keys of all registered data providers. -// Used for manifest validation — DataRequires entries must match a key here. -func (e *Engine) ListDataProviders() []string { - keys := make([]string, 0, len(e.loaders)) - for k := range e.loaders { - keys = append(keys, k) - } - return keys -} - -// ── 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", "personas", "roles", "knowledgeBases", "memory": - return "ai" - case "health", "routing", "capabilities": - return "routing" - case "settings", "storage", "packages", "channels", "broadcast": - 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 ────────────────────────── -// v0.22.7: Reads feature gates from GlobalConfig to control -// which nav links/tabs are visible (BYOK, User Personas). - -func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) { - section := c.Param("section") - if section == "" { - section = "general" - } - - data := &SettingsPageData{Section: section} - - // Read feature gates from policies (where admin settings saves them). - // Keys: allow_user_byok, allow_user_personas - if s.Policies != nil { - ctx := context.Background() - policies, err := s.Policies.GetAll(ctx) - if err == nil { - data.BYOKEnabled = policies["allow_user_byok"] == "true" - data.UserPersonasEnabled = policies["allow_user_personas"] == "true" - } - } - - return data, nil -} diff --git a/server/pages/loaders.go b/server/pages/loaders.go index cc838a1..ee8cb9b 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -306,7 +306,7 @@ func sectionCategory(section string) string { return "ai" case "health", "routing", "capabilities": return "routing" - case "settings", "storage", "extensions", "channels", "surfaces": + case "settings", "storage", "packages", "channels", "broadcast": return "system" case "usage", "audit", "stats": return "monitoring"