This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/pages/pages.go
Jeffrey Smith 3cc3360624
Some checks failed
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Failing after 5s
CI/CD / test-go-pg (push) Failing after 2m34s
CI/CD / test-sqlite (push) Successful in 2m39s
CI/CD / build-and-deploy (push) Has been skipped
Feat v0.2.5 ui polish dead code (#9)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-27 14:16:36 +00:00

956 lines
27 KiB
Go

// Package pages provides server-side HTML rendering via Go templates.
//
// Templates are embedded in the binary via //go:embed and organized as:
//
// templates/base.html — outer shell (banner + surface block + scripts)
// templates/login.html — standalone login page
// templates/components/*.html — reusable partials (model-select, team-select, etc.)
// templates/surfaces/*.html — one per surface (admin, settings, team-admin, extension)
package pages
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"strings"
"sync"
"github.com/gin-gonic/gin"
"switchboard-core/config"
"switchboard-core/store"
)
//go:embed templates/*.html templates/components/*.html templates/surfaces/*.html
var templateFS embed.FS
// Engine holds parsed templates and rendering state.
type Engine struct {
mu sync.RWMutex
templates *template.Template
cfg *config.Config
stores store.Stores
loaders map[string]DataLoaderFunc
surfaces []SurfaceManifest // v0.25.0: registered surface definitions
devMode bool
}
// SurfaceManifest describes a surface's registration. Core surfaces are
// registered in Go at startup. Extension surfaces will be registered from
// manifest files (future).
type SurfaceManifest struct {
ID string `json:"id"` // unique identifier: "admin", "settings", "my-dashboard"
Route string `json:"route"` // primary URL pattern: "/", "/admin"
AltRoutes []string `json:"alt_routes"` // additional URL patterns
Title string `json:"title"` // human-readable: "Admin", "Settings"
Template string `json:"template"` // Go template name: "surface-admin", "surface-extension"
Components []string `json:"components"` // component IDs used: ["file-tree"]
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
Styles []string `json:"styles"` // CSS files (surface-specific)
Auth string `json:"auth"` // "authenticated", "public", "session", "admin"
Layout string `json:"layout"` // default pane layout preset: "single", "editor"
Source string `json:"source"` // "core" or "extension"
}
// BannerConfig holds environment banner settings.
type BannerConfig struct {
Text string `json:"text"`
Color string `json:"color"`
Background string `json:"background"`
Visible bool `json:"visible"`
}
// MessageConfig holds dismissible message bar settings.
type MessageConfig struct {
Text string `json:"text"`
Variant string `json:"variant"` // info, warn, error, success
Visible bool `json:"visible"`
}
// FooterConfig holds optional footer bar settings.
type FooterConfig struct {
Text string `json:"text"`
Visible bool `json:"visible"`
}
// PageData is passed to every template render.
type PageData struct {
Banner BannerConfig
Message MessageConfig
Footer FooterConfig
Surface string // active surface ID
Section string // sub-section (for admin pages)
CSPNonce string
BasePath string
Version string
Environment string
User *UserContext
Data any // surface-specific data from loader
// v0.22.7: Theme + settings injection
Theme string // "dark", "light", or "" (= use default)
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
// v0.25.0: Surface manifest — layout preset, components, etc.
Manifest *SurfaceManifest `json:"manifest,omitempty"`
// v0.25.0: Enabled surface IDs for conditional nav rendering.
EnabledSurfaces []string `json:"-"`
// v0.27.0: Extension surfaces for sidebar nav rendering.
ExtensionSurfaces []ExtensionNavItem `json:"-"`
// v0.22.7: Login/splash page fields
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
Tagline string // branding: tagline under instance name
RegistrationOpen bool // whether self-registration is enabled
AuthMode string // v0.24.1: "builtin", "mtls", "oidc"
}
// SurfaceEnabled returns true if the given surface ID is in the EnabledSurfaces list.
// Used by templates: {{if .SurfaceEnabled "editor"}}
func (pd PageData) SurfaceEnabled(id string) bool {
for _, s := range pd.EnabledSurfaces {
if s == id {
return true
}
}
return false
}
// ExtensionNavItem holds the minimal info needed to render an extension
// surface link in the sidebar nav.
type ExtensionNavItem struct {
ID string
Title string
Route string
}
// ConfigSectionEntry describes a package-declared config section for
// lazy-loading in Settings, Admin, or Team Admin surfaces.
// v0.38.3: manifest-driven discovery — no new tables or endpoints.
type ConfigSectionEntry struct {
PackageID string `json:"package_id"` // section key & asset path prefix
Label string `json:"label"` // nav link text
Icon string `json:"icon"` // optional SVG path data (admin CatIcon format)
Component string `json:"component"` // relative asset path, e.g. "js/config.js"
Category string `json:"category"` // admin only: category tab (default "system")
}
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
// Queries the DB at render time so newly installed surfaces appear immediately.
func (e *Engine) extensionNavItems() []ExtensionNavItem {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
surfaces, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
return nil
}
var items []ExtensionNavItem
for _, sr := range surfaces {
if sr.Source == "core" || !sr.Enabled {
continue
}
// Only surface and full types have routes — extensions are headless
if sr.Type != "surface" && sr.Type != "full" {
continue
}
route := ""
if sr.Manifest != nil {
route, _ = sr.Manifest["route"].(string)
}
if route == "" {
route = "/s/" + sr.ID
}
items = append(items, ExtensionNavItem{
ID: sr.ID,
Title: sr.Title,
Route: route,
})
}
return items
}
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Email string `json:"email"`
}
// DataLoaderFunc loads surface-specific data for template rendering.
type DataLoaderFunc func(c *gin.Context, stores store.Stores) (any, error)
// New creates a template engine. Call after config and stores are ready.
func New(cfg *config.Config, stores store.Stores) *Engine {
e := &Engine{
cfg: cfg,
stores: stores,
loaders: make(map[string]DataLoaderFunc),
devMode: gin.Mode() == gin.DebugMode,
}
e.parseTemplates()
e.registerLoaders()
e.registerCoreSurfaces()
e.SeedSurfaces() // v0.25.0: Write core manifests to DB (preserves admin toggle state)
return e
}
// registerCoreSurfaces populates the surface manifest list with the kernel
// surfaces. Extension surfaces are appended from DB at startup.
func (e *Engine) registerCoreSurfaces() {
e.surfaces = []SurfaceManifest{
{
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
Title: "Admin", Template: "surface-admin", Auth: "admin",
DataRequires: []string{"admin"},
Layout: "single", Source: "core",
},
{
ID: "settings", Route: "/settings/:section", AltRoutes: []string{"/settings"},
Title: "Settings", Template: "surface-settings", Auth: "authenticated",
DataRequires: []string{"settings"},
Layout: "single", Source: "core",
},
{
ID: "team-admin", Route: "/team-admin/:section", AltRoutes: []string{"/team-admin"},
Title: "Team Admin", Template: "surface-team-admin", Auth: "authenticated",
DataRequires: []string{"team-admin"},
Layout: "single", Source: "core",
},
{
ID: "workflow", Route: "/w/:id",
Title: "Workflow", Template: "workflow", Auth: "session",
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},
{
ID: "workflow-landing", Route: "/w/:id/:slug",
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
Layout: "single", Source: "core",
},
{
ID: "welcome", Route: "/welcome",
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
Layout: "single", Source: "core",
},
}
}
// GetSurface returns the manifest for a surface by ID, or nil if not found.
func (e *Engine) GetSurface(id string) *SurfaceManifest {
for i := range e.surfaces {
if e.surfaces[i].ID == id {
return &e.surfaces[i]
}
}
return nil
}
// Surfaces returns all registered surface manifests.
func (e *Engine) Surfaces() []SurfaceManifest {
return e.surfaces
}
// parseTemplates compiles all embedded templates with the custom FuncMap.
func (e *Engine) parseTemplates() {
funcMap := template.FuncMap{
"toJSON": toJSON,
"eq": func(a, b string) bool { return a == b },
"contains": stringContains,
"roleFilterType": roleFilterType,
"esc": template.HTMLEscapeString,
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"join": strings.Join,
"dict": dict,
"or": tmplOr,
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
"templates/*.html",
"templates/components/*.html",
"templates/surfaces/*.html",
)
if err != nil {
log.Fatalf("❌ Failed to parse templates: %v", err)
}
e.mu.Lock()
e.templates = tmpl
e.mu.Unlock()
var names []string
for _, t := range tmpl.Templates() {
if t.Name() != "" {
names = append(names, t.Name())
}
}
log.Printf("[pages] Parsed %d templates", len(names))
}
// RegisterLoader adds a named data loader for a surface.
func (e *Engine) RegisterLoader(name string, fn DataLoaderFunc) {
e.loaders[name] = fn
}
// Render renders a named template into the response.
func (e *Engine) Render(c *gin.Context, name string, data PageData) {
data.BasePath = e.cfg.BasePath
data.Version = Version
data.Environment = e.cfg.Environment
data.CSPNonce = generateNonce()
data.Banner = e.loadBanner()
data.Message = e.loadMessage()
data.Footer = e.loadFooter()
// v0.22.7: Default theme if not set
if data.Theme == "" {
data.Theme = "dark"
}
e.mu.RLock()
tmpl := e.templates
e.mu.RUnlock()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
log.Printf("[pages] Template render error (%s): %v", name, err)
}
}
// RenderSurface is a Gin handler factory for surface routes.
func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
return func(c *gin.Context) {
user := e.getUserContext(c)
var data any
if loader, ok := e.loaders[surfaceID]; ok {
var err error
data, err = loader(c, e.stores)
if err != nil {
log.Printf("[pages] Data loader error (%s): %v", surfaceID, err)
c.String(http.StatusInternalServerError, "Failed to load page data")
return
}
}
section := c.Param("section")
if section == "" && surfaceID == "admin" {
section = "users"
}
if section == "" && surfaceID == "settings" {
section = "general"
}
if section == "" && surfaceID == "team-admin" {
section = "members"
}
e.Render(c, "base.html", PageData{
Surface: surfaceID,
Section: section,
User: user,
Data: data,
Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
})
}
}
// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
// No restart required after installing a surface via admin API.
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
return func(c *gin.Context) {
slug := c.Param("slug")
if slug == "" {
c.String(http.StatusNotFound, "Surface not found")
return
}
if e.stores.Packages == nil {
c.String(http.StatusNotFound, "Surface registry not available")
return
}
// Look up by ID (slug == surface ID)
sr, err := e.stores.Packages.Get(c.Request.Context(), slug)
if err != nil || sr == nil {
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
if !sr.Enabled {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
return
}
if sr.Source == "core" {
// Core surfaces have their own routes — don't serve them here
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
user := e.getUserContext(c)
// Build manifest from DB record
route, _ := sr.Manifest["route"].(string)
if route == "" {
route = "/s/" + sr.ID
}
layout, _ := sr.Manifest["layout"].(string)
if layout == "" {
layout = "single"
}
manifest := &SurfaceManifest{
ID: sr.ID,
Route: route,
Title: sr.Title,
Template: "surface-extension",
Layout: layout,
Source: "extension",
}
e.Render(c, "base.html", PageData{
Surface: sr.ID,
User: user,
Manifest: manifest,
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
})
}
}
// PageRouteMiddleware holds middleware handlers for each auth level.
// Passed to RegisterPageRoutes by main.go.
type PageRouteMiddleware struct {
Authenticated gin.HandlerFunc // AuthOrRedirect — for authenticated surfaces
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
Session gin.HandlerFunc // AuthOrSession — for workflow
}
// RegisterPageRoutes generates Gin routes from the registered surface manifests.
// Replaces manual per-surface route blocks in main.go.
//
// Groups surfaces by auth level to avoid redundant middleware chains.
// Workflow surfaces use RenderWorkflow() (separate template + data model).
// All other surfaces use RenderSurface().
func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddleware) {
// Group surfaces by auth type
byAuth := map[string][]SurfaceManifest{}
for _, s := range e.surfaces {
byAuth[s.Auth] = append(byAuth[s.Auth], s)
}
// Authenticated surfaces — single middleware group
if surfaces, ok := byAuth["authenticated"]; ok && mw.Authenticated != nil {
group := base.Group("")
group.Use(mw.Authenticated)
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
// Disabled surface → redirect to chat
registerRoutes(group, s, e.disabledRedirect())
continue
}
handler := e.RenderSurface(s.ID)
registerRoutes(group, s, handler)
}
// v0.27.0: Extension surface catch-all — DB lookup at request time.
// No restart needed after installing new surfaces via admin API.
group.GET("/s/:slug", e.RenderExtensionSurface())
}
// Admin surfaces — auth + admin role middleware
if surfaces, ok := byAuth["admin"]; ok && len(mw.Admin) > 0 {
for _, s := range surfaces {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
for _, m := range mw.Admin {
group.Use(m)
}
// Admin surfaces are always enabled (needed to manage other surfaces)
handler := e.RenderSurface(s.ID)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Session surfaces (workflow) — session middleware
if surfaces, ok := byAuth["session"]; ok && mw.Session != nil {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
registerRoutesRelative(group, prefix, s, e.disabledRedirect())
continue
}
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
handler := e.surfaceHandler(s)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Public surfaces — no middleware
if surfaces, ok := byAuth["public"]; ok {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
registerRoutes(base, s, e.disabledRedirect())
continue
}
handler := e.surfaceHandler(s)
registerRoutes(base, s, handler)
}
}
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
}
// surfaceHandler returns the appropriate Gin handler for a surface.
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
if s.ID == "workflow" {
return e.RenderWorkflow()
}
if s.ID == "workflow-landing" {
return e.RenderWorkflowLanding()
}
return e.RenderSurface(s.ID)
}
// registerRoutes adds the primary route and alt routes for a surface.
// Used for surfaces in the root group (authenticated).
func registerRoutes(group *gin.RouterGroup, s SurfaceManifest, handler gin.HandlerFunc) {
group.GET(s.Route, handler)
for _, alt := range s.AltRoutes {
if alt != s.Route {
group.GET(alt, handler)
}
}
}
// registerRoutesRelative adds routes relative to a group prefix.
// Used for admin/session surfaces with their own prefix group.
// "/admin/:section" with prefix "/admin" → "/:section"
func registerRoutesRelative(group *gin.RouterGroup, prefix string, s SurfaceManifest, handler gin.HandlerFunc) {
rel := stripPrefix(s.Route, prefix)
group.GET(rel, handler)
for _, alt := range s.AltRoutes {
r := stripPrefix(alt, prefix)
if r != rel {
group.GET(r, handler)
}
}
}
// routePrefix extracts the first path segment as a group prefix.
// "/admin/:section" → "/admin", "/w/:id" → "/w", "/" → ""
func routePrefix(route string) string {
trimmed := strings.TrimPrefix(route, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) == 0 || parts[0] == "" || strings.HasPrefix(parts[0], ":") {
return ""
}
return "/" + parts[0]
}
// stripPrefix removes a prefix from a route path. Returns "" if the
// entire path IS the prefix (which Gin interprets as the group root).
func stripPrefix(route, prefix string) string {
if prefix == "" {
return route
}
rel := strings.TrimPrefix(route, prefix)
if rel == "" {
rel = ""
}
return rel
}
// RenderLogin serves the standalone login page.
func (e *Engine) RenderLogin() gin.HandlerFunc {
return func(c *gin.Context) {
// v0.22.7: Populate splash/login fields from global config
instanceName, logoURL, tagline := e.loadBranding()
regOpen := e.isRegistrationOpen()
e.Render(c, "login.html", PageData{
Surface: "login",
InstanceName: instanceName,
LogoURL: logoURL,
Tagline: tagline,
RegistrationOpen: regOpen,
AuthMode: e.cfg.AuthMode,
})
}
}
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
type WorkflowPageData struct {
ChannelID string
ChannelTitle string
ChannelDescription string
SessionID string
SessionName string
StageMode string // custom | form_only | form_chat | review
StageName string
FormTemplateJSON string // typed form template JSON (empty if custom)
TotalStages int
CurrentStage int
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
BrandingJSON string // v0.35.0: workflow branding JSON (accent_color, logo_url, tagline)
}
// WorkflowLandingPageData is passed to workflow-landing.html.
type WorkflowLandingPageData struct {
WorkflowID string
Scope string
Slug string
Name string
Description string
Branding struct {
AccentColor string `json:"accent_color"`
LogoURL string `json:"logo_url"`
Tagline string `json:"tagline"`
}
PersonaName string
PersonaIcon string
StageCount int
FirstStageMode string // custom | form_only | form_chat (v0.29.3)
ResumeURL string // non-empty if visitor has an active session
}
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
return func(c *gin.Context) {
channelID := c.GetString("channel_id")
sessionID := c.GetString("session_id")
// Load channel metadata
var title, description string
if title == "" {
title = "Workflow"
}
// Load session display name
sessionName := "Visitor"
// Load workflow stage info for form rendering (v0.29.3)
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
var totalStages, currentStage int
stageMode = "custom" // default
instanceName, _, _ := e.loadBranding()
e.Render(c, "workflow.html", PageData{
Surface: "workflow",
InstanceName: instanceName,
Data: WorkflowPageData{
ChannelID: channelID,
ChannelTitle: title,
ChannelDescription: description,
SessionID: sessionID,
SessionName: sessionName,
StageMode: stageMode,
StageName: stageName,
FormTemplateJSON: formTplJSON,
TotalStages: totalStages,
CurrentStage: currentStage,
SurfacePkgID: surfacePkgID,
BrandingJSON: brandingJSON,
},
})
}
}
// RenderWorkflowLanding renders the public landing page for a workflow.
// Visitors see the workflow name, description, persona info, and a Start button.
func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
// Route: /w/:id/:slug — :id is scope (team-id or "global"),
// :slug is the workflow slug. Same :id param name as /w/:id
// (chat surface) to avoid Gin wildcard conflicts.
scope := c.Param("id")
slug := c.Param("slug")
// Resolve scope to team_id
var teamID *string
if scope != "global" {
teamID = &scope
}
if e.stores.Workflows == nil {
c.String(http.StatusNotFound, "Workflows not available")
return
}
wf, err := e.stores.Workflows.GetBySlug(ctx, teamID, slug)
if err != nil || wf == nil {
c.String(http.StatusNotFound, "Workflow not found")
return
}
if !wf.IsActive {
c.String(http.StatusNotFound, "Workflow not available")
return
}
data := WorkflowLandingPageData{
WorkflowID: wf.ID,
Scope: scope,
Slug: slug,
Name: wf.Name,
Description: wf.Description,
}
// Parse branding
if len(wf.Branding) > 0 {
_ = json.Unmarshal(wf.Branding, &data.Branding)
}
// Load stages for count + first persona info + stage mode
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
data.StageCount = len(stages)
if len(stages) > 0 {
data.FirstStageMode = stages[0].StageMode
if data.FirstStageMode == "" {
data.FirstStageMode = "custom"
}
// Persona lookup deferred — personas are extensions now
}
instanceName, _, _ := e.loadBranding()
e.Render(c, "workflow-landing.html", PageData{
Surface: "workflow-landing",
InstanceName: instanceName,
Data: data,
})
}
}
// getUserContext extracts user info from gin context (set by auth middleware)
// and enriches it with display name, username, and email from the database.
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
userID, exists := c.Get("user_id")
if !exists {
return nil
}
uid := userID.(string)
uc := &UserContext{
ID: uid,
Email: c.GetString("email"),
Role: c.GetString("role"),
}
// Enrich from DB — username, display_name, and email may not be in JWT claims.
if e.stores.Users != nil {
if u, err := e.stores.Users.GetByID(c.Request.Context(), uid); err == nil && u != nil {
uc.Username = u.Username
uc.DisplayName = u.DisplayName
if uc.Email == "" {
uc.Email = u.Email
}
}
}
return uc
}
// loadBanner reads banner config from global settings.
func (e *Engine) loadBanner() BannerConfig {
if e.stores.GlobalConfig == nil {
return BannerConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "banner")
if err != nil || raw == nil {
return BannerConfig{}
}
b := BannerConfig{}
if v, ok := raw["enabled"].(bool); ok {
b.Visible = v
}
if v, ok := raw["text"].(string); ok {
b.Text = v
}
if v, ok := raw["fg"].(string); ok {
b.Color = v
}
if v, ok := raw["bg"].(string); ok {
b.Background = v
}
return b
}
// loadMessage reads dismissible message bar config from global settings.
func (e *Engine) loadMessage() MessageConfig {
if e.stores.GlobalConfig == nil {
return MessageConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "message")
if err != nil || raw == nil {
return MessageConfig{}
}
m := MessageConfig{}
if v, ok := raw["enabled"].(bool); ok {
m.Visible = v
}
if v, ok := raw["text"].(string); ok {
m.Text = v
}
if v, ok := raw["variant"].(string); ok {
m.Variant = v
}
if m.Variant == "" {
m.Variant = "info"
}
return m
}
// loadFooter reads optional footer bar config from global settings.
func (e *Engine) loadFooter() FooterConfig {
if e.stores.GlobalConfig == nil {
return FooterConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "footer")
if err != nil || raw == nil {
return FooterConfig{}
}
f := FooterConfig{}
if v, ok := raw["enabled"].(bool); ok {
f.Visible = v
}
if v, ok := raw["text"].(string); ok {
f.Text = v
}
return f
}
// loadBranding reads instance branding from global settings.
func (e *Engine) loadBranding() (name, logoURL, tagline string) {
name = "Switchboard Core" // default
if e.stores.GlobalConfig == nil {
return
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "branding")
if err != nil || raw == nil {
return
}
if v, ok := raw["instance_name"].(string); ok && v != "" {
name = v
}
if v, ok := raw["logo_url"].(string); ok {
logoURL = v
}
if v, ok := raw["tagline"].(string); ok {
tagline = v
}
return
}
// isRegistrationOpen checks if self-registration is enabled.
func (e *Engine) isRegistrationOpen() bool {
if e.stores.GlobalConfig == nil {
return false
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "registration")
if err != nil || raw == nil {
return false
}
if v, ok := raw["enabled"].(bool); ok {
return v
}
return false
}
// Version is injected at build time via ldflags.
var Version = "dev"
// SetVersion allows main.go to inject the build version.
func SetVersion(v string) {
Version = v
}
// WriteError writes an error page directly to the response.
func (e *Engine) WriteError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Error</title></head><body><h1>%d</h1><p>%s</p></body></html>`, status, template.HTMLEscapeString(msg))
}
// ── Template FuncMap helpers ─────────────────
func toJSON(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b)
}
func stringContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// roleFilterType returns the model_type filter for a given role name.
func roleFilterType(role string) string {
switch role {
case "embedding":
return "embedding"
case "utility":
return "chat"
default:
return ""
}
}
// dict creates a map from alternating key/value pairs for template use.
func dict(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs)-1; i += 2 {
key, ok := pairs[i].(string)
if !ok {
continue
}
m[key] = pairs[i+1]
}
return m
}
// tmplOr returns the first non-empty string argument.
func tmplOr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "fallback-nonce"
}
return hex.EncodeToString(b)
}