mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-18 13:38:49 -04:00
feat(branding): admin-configurable instance name, tagline, and assets (#9635)
Adds a whitelabeling feature so an operator can replace the LocalAI
instance name, tagline, square logo, horizontal logo, and favicon from
the admin Settings page. Defaults fall back to the bundled assets so
existing installs are unaffected.
The public GET /api/branding endpoint is reachable pre-auth so the
login screen can render the configured branding before sign-in.
Mutating routes (POST/DELETE /api/branding/asset/:kind) remain
admin-only. Text fields (instance_name, instance_tagline) ride the
existing /api/settings flow; binary assets get a dedicated multipart
upload route that persists files under DynamicConfigsDir/branding/.
To prevent the Settings page's stale local state from clobbering an
upload on save, UpdateSettingsEndpoint preserves whatever the on-disk
asset filename fields are regardless of the body — /api/branding/asset/*
are the sole writers for those fields.
The MCP catalog gains get_branding and set_branding tools (text fields
only; file upload stays UI-only) plus a configure_branding skill prompt.
While wiring this up, the same restart-loss class of bug surfaced for
several existing fields whose RuntimeSettings entries were never read
by the startup loader. Fix loadRuntimeSettingsFromFile() to load:
- branding (instance_name, instance_tagline, *_file basenames)
- auto_upgrade_backends, prefer_development_backends
- localai_assistant_enabled
- open_responses_store_ttl
- the 7 existing AgentPool fields (enabled, default/embedding model,
chunking sizes, enable_logs, collection_db_path)
Also exposes 3 new AgentPool runtime settings (vector_engine,
database_url, agent_hub_url) via /api/settings + the Settings UI, with
the same load-on-startup wiring. The file watcher's manual-edit path
is intentionally not changed — the in-process API endpoints already
update appConfig directly, so the watcher is redundant for supported
flows and a separate refactor for everything else.
15 TDD specs cover the loader behaviour (1 branding + 11 adjacent + 3
new agent-pool); 2 specs cover the persistence helpers and the
clobber-prevention contract.
Assisted-by: claude-code:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
7325046650
commit
b1a99436c7
13
core/application/application_suite_test.go
Normal file
13
core/application/application_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestApplication(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Application test suite")
|
||||
}
|
||||
171
core/application/runtime_settings_branding_test.go
Normal file
171
core/application/runtime_settings_branding_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
)
|
||||
|
||||
// seedSettings writes the given JSON fragment to runtime_settings.json
|
||||
// under a fresh temp DynamicConfigsDir and returns the directory path.
|
||||
func seedSettings(json string) string {
|
||||
dir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(dir, "runtime_settings.json"), []byte(json), 0o600)).To(Succeed())
|
||||
return dir
|
||||
}
|
||||
|
||||
var _ = Describe("loadRuntimeSettingsFromFile", func() {
|
||||
// Reproduces the "settings revert after restart" report: an admin
|
||||
// sets a branding instance name + uploads a logo, the values are
|
||||
// persisted to runtime_settings.json, but on the next startup
|
||||
// loadRuntimeSettingsFromFile() did not read those fields back so
|
||||
// appConfig.Branding stayed zero and the public /api/branding
|
||||
// endpoint fell back to LocalAI defaults.
|
||||
Describe("branding fields", func() {
|
||||
It("loads instance name, tagline, and asset basenames", func() {
|
||||
dir := seedSettings(`{
|
||||
"instance_name": "Acme AI",
|
||||
"instance_tagline": "Private inference",
|
||||
"logo_file": "logo.png",
|
||||
"logo_horizontal_file": "logo_horizontal.svg",
|
||||
"favicon_file": "favicon.ico"
|
||||
}`)
|
||||
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: dir}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
|
||||
Expect(cfg.Branding).To(Equal(config.BrandingConfig{
|
||||
InstanceName: "Acme AI",
|
||||
InstanceTagline: "Private inference",
|
||||
LogoFile: "logo.png",
|
||||
LogoHorizontalFile: "logo_horizontal.svg",
|
||||
FaviconFile: "favicon.ico",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// Adjacent fields exercise the other classes of settings that
|
||||
// previously silently reverted on restart. Each spec pairs a
|
||||
// runtime_settings.json fragment with the expected ApplicationConfig
|
||||
// state after the loader runs. A regression in any one means a
|
||||
// UI-saved setting will not survive a process restart — same shape as
|
||||
// the branding bug, different field.
|
||||
//
|
||||
// Where a field has a non-zero default (set by NewApplicationConfig),
|
||||
// the spec seeds the post-AppOptions state the loader would observe
|
||||
// at boot. Without that setup the "if at default" gate would either
|
||||
// always pass or always fail and the spec wouldn't reflect the real
|
||||
// call site.
|
||||
Describe("adjacent restart-loss fields", func() {
|
||||
It("loads auto_upgrade_backends", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"auto_upgrade_backends": true}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AutoUpgradeBackends).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads prefer_development_backends", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"prefer_development_backends": true}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.PreferDevelopmentBackends).To(BeTrue())
|
||||
})
|
||||
|
||||
It("disables the LocalAI Assistant when localai_assistant_enabled=false", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"localai_assistant_enabled": false}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.DisableLocalAIAssistant).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads open_responses_store_ttl as a duration", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"open_responses_store_ttl": "1h"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.OpenResponsesStoreTTL).To(Equal(time.Hour))
|
||||
})
|
||||
})
|
||||
|
||||
// The Agent Pool block has a mix of zero and non-zero defaults
|
||||
// (Enabled=true, EmbeddingModel="granite-...", MaxChunkingSize=400,
|
||||
// VectorEngine="chromem", AgentHubURL="https://agenthub.localai.io").
|
||||
// Each spec seeds the appropriate startup state so the loader's
|
||||
// "at default" check observes what New() would.
|
||||
Describe("agent pool fields", func() {
|
||||
It("loads agent_pool_enabled=false against the default-true", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_enabled": false}`),
|
||||
AgentPool: config.AgentPoolConfig{Enabled: true},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.Enabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("loads agent_pool_default_model", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_default_model": "qwen2.5-7b"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.DefaultModel).To(Equal("qwen2.5-7b"))
|
||||
})
|
||||
|
||||
It("overrides the granite embedding default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_embedding_model": "all-minilm"}`),
|
||||
AgentPool: config.AgentPoolConfig{EmbeddingModel: "granite-embedding-107m-multilingual"},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.EmbeddingModel).To(Equal("all-minilm"))
|
||||
})
|
||||
|
||||
It("overrides the 400 max chunking size default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_max_chunking_size": 800}`),
|
||||
AgentPool: config.AgentPoolConfig{MaxChunkingSize: 400},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.MaxChunkingSize).To(Equal(800))
|
||||
})
|
||||
|
||||
It("loads agent_pool_chunk_overlap", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_chunk_overlap": 50}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.ChunkOverlap).To(Equal(50))
|
||||
})
|
||||
|
||||
It("loads agent_pool_enable_logs", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_enable_logs": true}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.EnableLogs).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads agent_pool_collection_db_path", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_collection_db_path": "/var/lib/localai/collections.db"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.CollectionDBPath).To(Equal("/var/lib/localai/collections.db"))
|
||||
})
|
||||
|
||||
It("overrides the chromem vector_engine default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_vector_engine": "postgres"}`),
|
||||
AgentPool: config.AgentPoolConfig{VectorEngine: "chromem"},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.VectorEngine).To(Equal("postgres"))
|
||||
})
|
||||
|
||||
It("loads agent_pool_database_url", func() {
|
||||
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_database_url": "postgres://user:pass@db:5432/localai"}`)}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.DatabaseURL).To(Equal("postgres://user:pass@db:5432/localai"))
|
||||
})
|
||||
|
||||
It("overrides the agenthub.localai.io agent_hub_url default", func() {
|
||||
cfg := &config.ApplicationConfig{
|
||||
DynamicConfigsDir: seedSettings(`{"agent_pool_agent_hub_url": "https://hub.acme.io"}`),
|
||||
AgentPool: config.AgentPoolConfig{AgentHubURL: "https://agenthub.localai.io"},
|
||||
}
|
||||
loadRuntimeSettingsFromFile(cfg)
|
||||
Expect(cfg.AgentPool.AgentHubURL).To(Equal("https://hub.acme.io"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -548,6 +548,109 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// Branding / whitelabeling. There are no env vars for these — the file is
|
||||
// the only source — so apply unconditionally. Without this block a server
|
||||
// restart silently drops the configured instance name, tagline, and asset
|
||||
// filenames.
|
||||
if settings.InstanceName != nil {
|
||||
options.Branding.InstanceName = *settings.InstanceName
|
||||
}
|
||||
if settings.InstanceTagline != nil {
|
||||
options.Branding.InstanceTagline = *settings.InstanceTagline
|
||||
}
|
||||
if settings.LogoFile != nil {
|
||||
options.Branding.LogoFile = *settings.LogoFile
|
||||
}
|
||||
if settings.LogoHorizontalFile != nil {
|
||||
options.Branding.LogoHorizontalFile = *settings.LogoHorizontalFile
|
||||
}
|
||||
if settings.FaviconFile != nil {
|
||||
options.Branding.FaviconFile = *settings.FaviconFile
|
||||
}
|
||||
|
||||
// Backend upgrade flags
|
||||
if settings.AutoUpgradeBackends != nil {
|
||||
if !options.AutoUpgradeBackends {
|
||||
options.AutoUpgradeBackends = *settings.AutoUpgradeBackends
|
||||
}
|
||||
}
|
||||
if settings.PreferDevelopmentBackends != nil {
|
||||
if !options.PreferDevelopmentBackends {
|
||||
options.PreferDevelopmentBackends = *settings.PreferDevelopmentBackends
|
||||
}
|
||||
}
|
||||
|
||||
// LocalAI Assistant — file-stored as the negation (LocalAIAssistantEnabled).
|
||||
// Default is enabled (DisableLocalAIAssistant=false). Apply the file value
|
||||
// unless env explicitly disabled the assistant (DisableLocalAIAssistant=true).
|
||||
if settings.LocalAIAssistantEnabled != nil {
|
||||
if !options.DisableLocalAIAssistant {
|
||||
options.DisableLocalAIAssistant = !*settings.LocalAIAssistantEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// Open Responses TTL. Default is 0 (no expiration). Treat the on-disk
|
||||
// "0"/empty as "no expiration" — a no-op since options is already 0 —
|
||||
// and parse anything else as a duration.
|
||||
if settings.OpenResponsesStoreTTL != nil && options.OpenResponsesStoreTTL == 0 {
|
||||
v := *settings.OpenResponsesStoreTTL
|
||||
if v != "0" && v != "" {
|
||||
if dur, err := time.ParseDuration(v); err == nil {
|
||||
options.OpenResponsesStoreTTL = dur
|
||||
} else {
|
||||
xlog.Warn("invalid open_responses_store_ttl in runtime_settings.json", "error", err, "ttl", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agent Pool. NewApplicationConfig seeds non-zero defaults for some of
|
||||
// these fields (Enabled=true, EmbeddingModel="granite-embedding-107m-
|
||||
// multilingual", MaxChunkingSize=400). The "if at default, apply file"
|
||||
// gate uses each field's actual default literal so file values can
|
||||
// override the bootstrap default while still letting an env-set value
|
||||
// (e.g. WithAgentPoolEmbeddingModel from a flag) win.
|
||||
if settings.AgentPoolEnabled != nil && options.AgentPool.Enabled {
|
||||
options.AgentPool.Enabled = *settings.AgentPoolEnabled
|
||||
}
|
||||
if settings.AgentPoolDefaultModel != nil && options.AgentPool.DefaultModel == "" {
|
||||
options.AgentPool.DefaultModel = *settings.AgentPoolDefaultModel
|
||||
}
|
||||
if settings.AgentPoolEmbeddingModel != nil {
|
||||
if options.AgentPool.EmbeddingModel == "" || options.AgentPool.EmbeddingModel == "granite-embedding-107m-multilingual" {
|
||||
options.AgentPool.EmbeddingModel = *settings.AgentPoolEmbeddingModel
|
||||
}
|
||||
}
|
||||
if settings.AgentPoolMaxChunkingSize != nil {
|
||||
if options.AgentPool.MaxChunkingSize == 0 || options.AgentPool.MaxChunkingSize == 400 {
|
||||
options.AgentPool.MaxChunkingSize = *settings.AgentPoolMaxChunkingSize
|
||||
}
|
||||
}
|
||||
if settings.AgentPoolChunkOverlap != nil && options.AgentPool.ChunkOverlap == 0 {
|
||||
options.AgentPool.ChunkOverlap = *settings.AgentPoolChunkOverlap
|
||||
}
|
||||
if settings.AgentPoolEnableLogs != nil && !options.AgentPool.EnableLogs {
|
||||
options.AgentPool.EnableLogs = *settings.AgentPoolEnableLogs
|
||||
}
|
||||
if settings.AgentPoolCollectionDBPath != nil && options.AgentPool.CollectionDBPath == "" {
|
||||
options.AgentPool.CollectionDBPath = *settings.AgentPoolCollectionDBPath
|
||||
}
|
||||
if settings.AgentPoolVectorEngine != nil {
|
||||
// Default is "chromem"; treat both that and empty as "not env-set".
|
||||
if options.AgentPool.VectorEngine == "" || options.AgentPool.VectorEngine == "chromem" {
|
||||
options.AgentPool.VectorEngine = *settings.AgentPoolVectorEngine
|
||||
}
|
||||
}
|
||||
if settings.AgentPoolDatabaseURL != nil && options.AgentPool.DatabaseURL == "" {
|
||||
options.AgentPool.DatabaseURL = *settings.AgentPoolDatabaseURL
|
||||
}
|
||||
if settings.AgentPoolAgentHubURL != nil {
|
||||
// Default is "https://agenthub.localai.io"; treat both that and empty
|
||||
// as "not env-set".
|
||||
if options.AgentPool.AgentHubURL == "" || options.AgentPool.AgentHubURL == "https://agenthub.localai.io" {
|
||||
options.AgentPool.AgentHubURL = *settings.AgentPoolAgentHubURL
|
||||
}
|
||||
}
|
||||
|
||||
xlog.Debug("Runtime settings loaded from runtime_settings.json")
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,24 @@ type ApplicationConfig struct {
|
||||
// LocalAI Assistant chat modality. Hard-disable the in-process admin MCP
|
||||
// server with this flag; runtime-toggleable via /api/settings.
|
||||
DisableLocalAIAssistant bool
|
||||
|
||||
// Branding / whitelabeling — runtime-mutable via /api/settings (text) and
|
||||
// /api/branding/asset/:kind (binary uploads). All values optional; empty
|
||||
// strings fall back to bundled LocalAI defaults.
|
||||
Branding BrandingConfig
|
||||
}
|
||||
|
||||
// BrandingConfig holds the whitelabel/branding configuration of the instance.
|
||||
// Text fields are exposed via the public GET /api/branding endpoint so the
|
||||
// login page can read them before authentication. Binary asset filenames
|
||||
// (logo, horizontal logo, favicon) are stored as basenames; the actual files
|
||||
// live under {DynamicConfigsDir}/branding/.
|
||||
type BrandingConfig struct {
|
||||
InstanceName string
|
||||
InstanceTagline string
|
||||
LogoFile string
|
||||
LogoHorizontalFile string
|
||||
FaviconFile string
|
||||
}
|
||||
|
||||
// AuthConfig holds configuration for user authentication and authorization.
|
||||
@@ -180,6 +198,24 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
|
||||
"/healthz",
|
||||
"/api/auth/",
|
||||
"/assets/",
|
||||
// Branding read endpoint + public asset server. The login
|
||||
// screen renders before authentication completes, so it has
|
||||
// to be able to GET /api/branding and the configured logo.
|
||||
//
|
||||
// IMPORTANT: PathWithoutAuth uses a prefix match (see
|
||||
// auth.isExemptPath). The "/api/branding" entry therefore
|
||||
// also exempts POST/DELETE /api/branding/asset/:kind from
|
||||
// the *global* auth middleware. Those routes are still
|
||||
// admin-gated because they are registered with the
|
||||
// route-level adminMiddleware (auth.RequireAdmin) in
|
||||
// core/http/routes/ui_api.go — that's what keeps anonymous
|
||||
// uploads/deletes returning 401. Any new admin-only sub-route
|
||||
// added under /api/branding/* MUST also carry adminMiddleware
|
||||
// at the route registration site, otherwise it ships
|
||||
// unauthenticated. The TestBrandingRoutes_AdminGatingHolds
|
||||
// integration test in core/http/auth pins this contract.
|
||||
"/api/branding",
|
||||
"/branding/",
|
||||
},
|
||||
}
|
||||
for _, oo := range o {
|
||||
@@ -928,10 +964,20 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
agentPoolChunkOverlap := o.AgentPool.ChunkOverlap
|
||||
agentPoolEnableLogs := o.AgentPool.EnableLogs
|
||||
agentPoolCollectionDBPath := o.AgentPool.CollectionDBPath
|
||||
agentPoolVectorEngine := o.AgentPool.VectorEngine
|
||||
agentPoolDatabaseURL := o.AgentPool.DatabaseURL
|
||||
agentPoolAgentHubURL := o.AgentPool.AgentHubURL
|
||||
|
||||
// LocalAI Assistant settings
|
||||
localAIAssistantEnabled := !o.DisableLocalAIAssistant
|
||||
|
||||
// Branding settings
|
||||
instanceName := o.Branding.InstanceName
|
||||
instanceTagline := o.Branding.InstanceTagline
|
||||
logoFile := o.Branding.LogoFile
|
||||
logoHorizontalFile := o.Branding.LogoHorizontalFile
|
||||
faviconFile := o.Branding.FaviconFile
|
||||
|
||||
return RuntimeSettings{
|
||||
WatchdogEnabled: &watchdogEnabled,
|
||||
WatchdogIdleEnabled: &watchdogIdle,
|
||||
@@ -975,7 +1021,15 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||
AgentPoolChunkOverlap: &agentPoolChunkOverlap,
|
||||
AgentPoolEnableLogs: &agentPoolEnableLogs,
|
||||
AgentPoolCollectionDBPath: &agentPoolCollectionDBPath,
|
||||
AgentPoolVectorEngine: &agentPoolVectorEngine,
|
||||
AgentPoolDatabaseURL: &agentPoolDatabaseURL,
|
||||
AgentPoolAgentHubURL: &agentPoolAgentHubURL,
|
||||
LocalAIAssistantEnabled: &localAIAssistantEnabled,
|
||||
InstanceName: &instanceName,
|
||||
InstanceTagline: &instanceTagline,
|
||||
LogoFile: &logoFile,
|
||||
LogoHorizontalFile: &logoHorizontalFile,
|
||||
FaviconFile: &faviconFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,6 +1214,18 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
|
||||
o.AgentPool.CollectionDBPath = *settings.AgentPoolCollectionDBPath
|
||||
requireRestart = true
|
||||
}
|
||||
if settings.AgentPoolVectorEngine != nil {
|
||||
o.AgentPool.VectorEngine = *settings.AgentPoolVectorEngine
|
||||
requireRestart = true
|
||||
}
|
||||
if settings.AgentPoolDatabaseURL != nil {
|
||||
o.AgentPool.DatabaseURL = *settings.AgentPoolDatabaseURL
|
||||
requireRestart = true
|
||||
}
|
||||
if settings.AgentPoolAgentHubURL != nil {
|
||||
o.AgentPool.AgentHubURL = *settings.AgentPoolAgentHubURL
|
||||
requireRestart = true
|
||||
}
|
||||
|
||||
// LocalAI Assistant: read live at request entry by the chat handler, so
|
||||
// flipping the disable flag takes effect on the next request without a
|
||||
@@ -1168,6 +1234,24 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
|
||||
o.DisableLocalAIAssistant = !*settings.LocalAIAssistantEnabled
|
||||
}
|
||||
|
||||
// Branding: read live by the public /api/branding endpoint and asset
|
||||
// server, so changes apply on the next request without a restart.
|
||||
if settings.InstanceName != nil {
|
||||
o.Branding.InstanceName = *settings.InstanceName
|
||||
}
|
||||
if settings.InstanceTagline != nil {
|
||||
o.Branding.InstanceTagline = *settings.InstanceTagline
|
||||
}
|
||||
if settings.LogoFile != nil {
|
||||
o.Branding.LogoFile = *settings.LogoFile
|
||||
}
|
||||
if settings.LogoHorizontalFile != nil {
|
||||
o.Branding.LogoHorizontalFile = *settings.LogoHorizontalFile
|
||||
}
|
||||
if settings.FaviconFile != nil {
|
||||
o.Branding.FaviconFile = *settings.FaviconFile
|
||||
}
|
||||
|
||||
// Note: ApiKeys requires special handling (merging with startup keys) - handled in caller
|
||||
|
||||
return requireRestart
|
||||
|
||||
@@ -73,8 +73,20 @@ type RuntimeSettings struct {
|
||||
AgentPoolChunkOverlap *int `json:"agent_pool_chunk_overlap,omitempty"`
|
||||
AgentPoolEnableLogs *bool `json:"agent_pool_enable_logs,omitempty"`
|
||||
AgentPoolCollectionDBPath *string `json:"agent_pool_collection_db_path,omitempty"`
|
||||
AgentPoolVectorEngine *string `json:"agent_pool_vector_engine,omitempty"` // chromem | postgres
|
||||
AgentPoolDatabaseURL *string `json:"agent_pool_database_url,omitempty"` // PostgreSQL DSN when vector engine is postgres
|
||||
AgentPoolAgentHubURL *string `json:"agent_pool_agent_hub_url,omitempty"` // override the agenthub.localai.io endpoint
|
||||
|
||||
// LocalAI Assistant settings — read live by the chat handler at request
|
||||
// entry, so flipping the toggle takes effect on the next request.
|
||||
LocalAIAssistantEnabled *bool `json:"localai_assistant_enabled,omitempty"` // negation of DisableLocalAIAssistant for UI clarity
|
||||
|
||||
// Branding / whitelabeling. Text fields are user-facing; *File fields hold
|
||||
// just the basename of an uploaded asset under {DynamicConfigsDir}/branding/.
|
||||
// All optional — empty values fall back to bundled LocalAI defaults.
|
||||
InstanceName *string `json:"instance_name,omitempty"`
|
||||
InstanceTagline *string `json:"instance_tagline,omitempty"`
|
||||
LogoFile *string `json:"logo_file,omitempty"`
|
||||
LogoHorizontalFile *string `json:"logo_horizontal_file,omitempty"`
|
||||
FaviconFile *string `json:"favicon_file,omitempty"`
|
||||
}
|
||||
|
||||
49
core/config/runtime_settings_persist.go
Normal file
49
core/config/runtime_settings_persist.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// runtimeSettingsFile is the on-disk filename inside DynamicConfigsDir.
|
||||
const runtimeSettingsFile = "runtime_settings.json"
|
||||
|
||||
// ReadPersistedSettings loads runtime_settings.json from DynamicConfigsDir.
|
||||
// A missing file is not an error — the zero RuntimeSettings is returned.
|
||||
// This lets callers update only the field they own (e.g. one branding
|
||||
// asset filename) without clobbering unrelated settings already on disk.
|
||||
func (o *ApplicationConfig) ReadPersistedSettings() (RuntimeSettings, error) {
|
||||
var settings RuntimeSettings
|
||||
if o.DynamicConfigsDir == "" {
|
||||
return settings, errors.New("DynamicConfigsDir is not set")
|
||||
}
|
||||
path := filepath.Join(o.DynamicConfigsDir, runtimeSettingsFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return settings, nil
|
||||
}
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// WritePersistedSettings serialises the given RuntimeSettings to
|
||||
// runtime_settings.json with restricted permissions (it may carry API
|
||||
// keys and P2P tokens).
|
||||
func (o *ApplicationConfig) WritePersistedSettings(settings RuntimeSettings) error {
|
||||
if o.DynamicConfigsDir == "" {
|
||||
return errors.New("DynamicConfigsDir is not set")
|
||||
}
|
||||
path := filepath.Join(o.DynamicConfigsDir, runtimeSettingsFile)
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
105
core/config/runtime_settings_persist_test.go
Normal file
105
core/config/runtime_settings_persist_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
var _ = Describe("RuntimeSettings persistence helpers", func() {
|
||||
var (
|
||||
dir string
|
||||
cfg *config.ApplicationConfig
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
dir = GinkgoT().TempDir()
|
||||
cfg = &config.ApplicationConfig{DynamicConfigsDir: dir}
|
||||
})
|
||||
|
||||
// ReadPersistedSettings + WritePersistedSettings is the round-trip the
|
||||
// /api/branding/asset/:kind upload handler relies on: the upload writes
|
||||
// the basename to runtime_settings.json via these helpers, and the next
|
||||
// reader (loadRuntimeSettingsFromFile, the file watcher, or the next
|
||||
// upload) must observe that basename. A regression here would break
|
||||
// asset persistence.
|
||||
Describe("BrandingFiles round trip", func() {
|
||||
It("preserves instance_name, tagline, and basenames across read/write", func() {
|
||||
tagline := "Private inference"
|
||||
logo := "logo.png"
|
||||
settings := config.RuntimeSettings{
|
||||
InstanceName: strPtr("Acme AI"),
|
||||
InstanceTagline: &tagline,
|
||||
LogoFile: &logo,
|
||||
}
|
||||
Expect(cfg.WritePersistedSettings(settings)).To(Succeed())
|
||||
|
||||
got, err := cfg.ReadPersistedSettings()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(got.InstanceName).ToNot(BeNil())
|
||||
Expect(*got.InstanceName).To(Equal("Acme AI"))
|
||||
Expect(got.LogoFile).ToNot(BeNil())
|
||||
Expect(*got.LogoFile).To(Equal("logo.png"))
|
||||
})
|
||||
})
|
||||
|
||||
// PreserveOnSaveDoesNotClobberAssets reproduces the user-reported
|
||||
// regression: an admin uploads a logo, then clicks Save on the
|
||||
// Settings page. The Save body still has the stale pre-upload
|
||||
// logo_file (empty string) because the React state was loaded
|
||||
// before the upload. UpdateSettingsEndpoint must protect the
|
||||
// on-disk basename — branding asset filenames are owned by the
|
||||
// /api/branding/asset/:kind endpoints, not by /api/settings.
|
||||
//
|
||||
// This spec exercises what UpdateSettingsEndpoint does: read the
|
||||
// existing persisted settings, override the asset filename fields
|
||||
// from disk, then write the merged settings. The fix lives in
|
||||
// core/http/endpoints/localai/settings.go; this spec pins the
|
||||
// contract that ReadPersistedSettings exposes the basenames so the
|
||||
// handler can preserve them.
|
||||
Describe("Save preservation prevents asset clobber", func() {
|
||||
It("keeps the on-disk logo basename when /api/settings posts an empty string", func() {
|
||||
existing := "logo.png"
|
||||
Expect(cfg.WritePersistedSettings(config.RuntimeSettings{LogoFile: &existing})).To(Succeed())
|
||||
|
||||
// Simulate the body the React Settings page POSTs on Save:
|
||||
// stale empty-string logo_file, plus an unrelated user change
|
||||
// (instance_name).
|
||||
emptyLogo := ""
|
||||
newName := "Acme AI"
|
||||
body := config.RuntimeSettings{
|
||||
InstanceName: &newName,
|
||||
LogoFile: &emptyLogo,
|
||||
}
|
||||
|
||||
// Apply the same preservation step UpdateSettingsEndpoint performs.
|
||||
persisted, err := cfg.ReadPersistedSettings()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
body.LogoFile = persisted.LogoFile
|
||||
body.LogoHorizontalFile = persisted.LogoHorizontalFile
|
||||
body.FaviconFile = persisted.FaviconFile
|
||||
|
||||
Expect(cfg.WritePersistedSettings(body)).To(Succeed())
|
||||
|
||||
// On-disk runtime_settings.json must still have the uploaded
|
||||
// basename, AND the unrelated change must have landed.
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "runtime_settings.json"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var ondisk config.RuntimeSettings
|
||||
Expect(json.Unmarshal(raw, &ondisk)).To(Succeed())
|
||||
|
||||
Expect(ondisk.LogoFile).ToNot(BeNil(), "logo_file pointer was dropped")
|
||||
Expect(*ondisk.LogoFile).To(Equal("logo.png"), "logo_file was clobbered by Save")
|
||||
Expect(ondisk.InstanceName).ToNot(BeNil())
|
||||
Expect(*ondisk.InstanceName).To(Equal("Acme AI"))
|
||||
})
|
||||
})
|
||||
})
|
||||
108
core/http/auth/branding_routes_test.go
Normal file
108
core/http/auth/branding_routes_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newBrandingTestApp mirrors how core/http/routes/ui_api.go registers
|
||||
// the branding endpoints: GET endpoints public, POST/DELETE gated by
|
||||
// the route-level admin middleware. PathWithoutAuth is left to its
|
||||
// NewApplicationConfig defaults so the "/api/branding" + "/branding/"
|
||||
// exempt entries (added in this PR) participate in the test.
|
||||
func newBrandingTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
|
||||
adminMw := auth.RequireAdmin()
|
||||
|
||||
// Public read + asset server.
|
||||
e.GET("/api/branding", ok)
|
||||
e.GET("/branding/asset/:kind", ok)
|
||||
|
||||
// Admin-only mutations.
|
||||
e.POST("/api/branding/asset/:kind", ok, adminMw)
|
||||
e.DELETE("/api/branding/asset/:kind", ok, adminMw)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// These specs pin a contract that's easy to break by accident: the
|
||||
// "/api/branding" entry in PathWithoutAuth uses a prefix match, so it
|
||||
// also exempts POST/DELETE /api/branding/asset/:kind from the *global*
|
||||
// auth middleware. Those mutations only stay admin-only because the
|
||||
// route registration explicitly carries auth.RequireAdmin(). If that
|
||||
// route-level middleware is ever forgotten — or someone adds a new
|
||||
// admin sub-route under /api/branding/* without the gate — these
|
||||
// specs go red.
|
||||
var _ = Describe("Branding route admin gating", func() {
|
||||
var (
|
||||
db *gorm.DB
|
||||
appConfig *config.ApplicationConfig
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
db = testDB()
|
||||
appConfig = config.NewApplicationConfig()
|
||||
})
|
||||
|
||||
It("allows anonymous GET /api/branding (login screen reads it pre-auth)", func() {
|
||||
app := newBrandingTestApp(db, appConfig)
|
||||
rec := doRequest(app, http.MethodGet, "/api/branding")
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows anonymous GET /branding/asset/:kind (logo served pre-auth)", func() {
|
||||
app := newBrandingTestApp(db, appConfig)
|
||||
rec := doRequest(app, http.MethodGet, "/branding/asset/logo")
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 401 for anonymous POST /api/branding/asset/:kind", func() {
|
||||
app := newBrandingTestApp(db, appConfig)
|
||||
rec := doRequest(app, http.MethodPost, "/api/branding/asset/logo")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized),
|
||||
"PathWithoutAuth exempts the prefix from global auth, but the route-level adminMiddleware MUST still 401 anonymous mutations")
|
||||
})
|
||||
|
||||
It("returns 401 for anonymous DELETE /api/branding/asset/:kind", func() {
|
||||
app := newBrandingTestApp(db, appConfig)
|
||||
rec := doRequest(app, http.MethodDelete, "/api/branding/asset/logo")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 403 for non-admin POST /api/branding/asset/:kind", func() {
|
||||
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
app := newBrandingTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/api/branding/asset/logo", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("allows admin POST /api/branding/asset/:kind", func() {
|
||||
admin := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, admin.ID)
|
||||
app := newBrandingTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/api/branding/asset/logo", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows admin DELETE /api/branding/asset/:kind", func() {
|
||||
admin := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, admin.ID)
|
||||
app := newBrandingTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodDelete, "/api/branding/asset/logo", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
@@ -85,6 +85,12 @@ var instructionDefs = []instructionDef{
|
||||
Tags: []string{"voice-recognition"},
|
||||
Intro: "Voice (speaker) recognition — the audio analog to /v1/face/*. Use /v1/voice/verify for 1:1 speaker comparison, /v1/voice/identify for 1:N match against the registered store, /v1/voice/{register,forget} to manage that store, /v1/voice/embed for a raw speaker-encoder vector, and /v1/voice/analyze for age / gender / emotion inferred from speech. Registrations are in-memory by default and lost on restart. Audio inputs accept URL, base64, or data-URI; /v1/embeddings remains text-only.",
|
||||
},
|
||||
{
|
||||
Name: "branding",
|
||||
Description: "Whitelabel the instance: configure name, tagline, logo, and favicon",
|
||||
Tags: []string{"branding"},
|
||||
Intro: "GET /api/branding is public so the login screen can render the configured logo before authentication. Text fields are saved through POST /api/settings; binary assets (logo, horizontal logo, favicon) use multipart upload at /api/branding/asset/{kind} and are served back from /branding/asset/{kind}.",
|
||||
},
|
||||
}
|
||||
|
||||
// swaggerState holds parsed swagger spec data, initialised once.
|
||||
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("API Instructions Endpoints", func() {
|
||||
|
||||
instructions, ok := resp["instructions"].([]any)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(instructions).To(HaveLen(11))
|
||||
Expect(instructions).To(HaveLen(12))
|
||||
|
||||
// Verify each instruction has required fields and correct URL format
|
||||
for _, s := range instructions {
|
||||
|
||||
355
core/http/endpoints/localai/branding.go
Normal file
355
core/http/endpoints/localai/branding.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package localai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// brandingDirName is the subdirectory under DynamicConfigsDir that holds
|
||||
// uploaded branding assets (logo, horizontal logo, favicon).
|
||||
const brandingDirName = "branding"
|
||||
|
||||
// maxBrandingAssetBytes caps a single uploaded branding asset. Logos and
|
||||
// favicons are tiny in practice; this just keeps a misclick from filling
|
||||
// the disk.
|
||||
const maxBrandingAssetBytes = 5 * 1024 * 1024 // 5 MiB
|
||||
|
||||
// brandingAssetKinds enumerates the asset slots an admin may override.
|
||||
// The :kind path parameter must match one of these.
|
||||
var brandingAssetKinds = map[string]struct{}{
|
||||
"logo": {},
|
||||
"logo_horizontal": {},
|
||||
"favicon": {},
|
||||
}
|
||||
|
||||
// brandingAssetMimeTypes is the allow-list of Content-Type values the upload
|
||||
// handler accepts. Anything else is rejected with 400 — admins are trusted
|
||||
// but this keeps an HTML/JS payload from being served back as a "logo".
|
||||
var brandingAssetMimeTypes = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"image/x-icon": ".ico",
|
||||
"image/vnd.microsoft.icon": ".ico",
|
||||
}
|
||||
|
||||
// brandingDefaultURLs maps each asset kind to the bundled fallback URL the
|
||||
// React UI should use when the admin has not uploaded an override.
|
||||
var brandingDefaultURLs = map[string]string{
|
||||
"logo": "/static/logo.png",
|
||||
"logo_horizontal": "/static/logo_horizontal.png",
|
||||
"favicon": "/favicon.svg",
|
||||
}
|
||||
|
||||
// BrandingResponse is the JSON shape returned by GET /api/branding. It is
|
||||
// intentionally narrow — no other settings leak through this public endpoint.
|
||||
type BrandingResponse struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
InstanceTagline string `json:"instance_tagline"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
||||
FaviconURL string `json:"favicon_url"`
|
||||
}
|
||||
|
||||
// brandingAssetURL returns the URL the UI should use for a given asset kind:
|
||||
// the dynamic /branding/asset/:kind route when an upload is set, or the
|
||||
// bundled default otherwise.
|
||||
func brandingAssetURL(kind, file string) string {
|
||||
if file != "" {
|
||||
return "/branding/asset/" + kind
|
||||
}
|
||||
return brandingDefaultURLs[kind]
|
||||
}
|
||||
|
||||
// GetBrandingEndpoint exposes the public branding configuration. It is
|
||||
// intentionally unauthenticated so the React login page (rendered before
|
||||
// auth completes) can fetch the instance name and logo. Only the five
|
||||
// branding fields are returned — never API keys or other settings.
|
||||
//
|
||||
// @Summary Get instance branding
|
||||
// @Description Returns the configured instance name, tagline, and asset URLs. Public — no authentication required.
|
||||
// @Tags branding
|
||||
// @Produce json
|
||||
// @Success 200 {object} BrandingResponse
|
||||
// @Router /api/branding [get]
|
||||
func GetBrandingEndpoint(appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
b := appConfig.Branding
|
||||
return c.JSON(http.StatusOK, BrandingResponse{
|
||||
InstanceName: b.InstanceName,
|
||||
InstanceTagline: b.InstanceTagline,
|
||||
LogoURL: brandingAssetURL("logo", b.LogoFile),
|
||||
LogoHorizontalURL: brandingAssetURL("logo_horizontal", b.LogoHorizontalFile),
|
||||
FaviconURL: brandingAssetURL("favicon", b.FaviconFile),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// brandingDir resolves the directory that holds uploaded branding files,
|
||||
// creating it on demand. Returns an error if DynamicConfigsDir is unset.
|
||||
func brandingDir(appConfig *config.ApplicationConfig) (string, error) {
|
||||
if appConfig.DynamicConfigsDir == "" {
|
||||
return "", errors.New("DynamicConfigsDir is not set")
|
||||
}
|
||||
dir := filepath.Join(appConfig.DynamicConfigsDir, brandingDirName)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// removeExistingBrandingFiles deletes any prior asset files for the given
|
||||
// kind so a new upload doesn't leave a stale companion (e.g. logo.png and
|
||||
// logo.svg sitting side-by-side). Errors other than "not exist" are
|
||||
// returned so callers know a stale file is left behind.
|
||||
func removeExistingBrandingFiles(dir, kind string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := kind + "."
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(e.Name(), prefix) {
|
||||
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setBrandingFile updates appConfig.Branding for the given kind and writes
|
||||
// the change to runtime_settings.json. A nil-string-pointer would erase the
|
||||
// override; callers pass the new basename or "" to reset to default.
|
||||
func setBrandingFile(appConfig *config.ApplicationConfig, kind, basename string) error {
|
||||
switch kind {
|
||||
case "logo":
|
||||
appConfig.Branding.LogoFile = basename
|
||||
case "logo_horizontal":
|
||||
appConfig.Branding.LogoHorizontalFile = basename
|
||||
case "favicon":
|
||||
appConfig.Branding.FaviconFile = basename
|
||||
default:
|
||||
return errors.New("unknown branding asset kind: " + kind)
|
||||
}
|
||||
|
||||
settings, err := appConfig.ReadPersistedSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch kind {
|
||||
case "logo":
|
||||
settings.LogoFile = &basename
|
||||
case "logo_horizontal":
|
||||
settings.LogoHorizontalFile = &basename
|
||||
case "favicon":
|
||||
settings.FaviconFile = &basename
|
||||
}
|
||||
return appConfig.WritePersistedSettings(settings)
|
||||
}
|
||||
|
||||
// UploadBrandingAssetEndpoint accepts a multipart "file" field and stores it
|
||||
// as the override for the given asset kind. Admin-only.
|
||||
//
|
||||
// @Summary Upload a branding asset
|
||||
// @Description Upload a custom logo, horizontal logo, or favicon. The file replaces any previous override for that kind.
|
||||
// @Tags branding
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param kind path string true "Asset kind: logo, logo_horizontal, or favicon"
|
||||
// @Param file formData file true "Image file (png, jpeg, svg, webp, ico — up to 5MiB)"
|
||||
// @Success 200 {object} BrandingResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /api/branding/asset/{kind} [post]
|
||||
func UploadBrandingAssetEndpoint(appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
kind := c.Param("kind")
|
||||
if _, ok := brandingAssetKinds[kind]; !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid asset kind; expected one of logo, logo_horizontal, favicon",
|
||||
})
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "file is required"})
|
||||
}
|
||||
if fileHeader.Size > maxBrandingAssetBytes {
|
||||
return c.JSON(http.StatusRequestEntityTooLarge, map[string]string{
|
||||
"error": "file too large; max 5 MiB",
|
||||
})
|
||||
}
|
||||
|
||||
ct := fileHeader.Header.Get("Content-Type")
|
||||
ext, ok := brandingAssetMimeTypes[ct]
|
||||
if !ok {
|
||||
// Fall back to filename extension when the browser sent a generic
|
||||
// content-type (e.g. application/octet-stream for an .svg).
|
||||
ext = strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
if !isAllowedBrandingExt(ext) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "unsupported file type; expected png, jpeg, svg, webp, or ico",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open uploaded file"})
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(src, maxBrandingAssetBytes+1))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to read uploaded file"})
|
||||
}
|
||||
if len(data) > maxBrandingAssetBytes {
|
||||
return c.JSON(http.StatusRequestEntityTooLarge, map[string]string{
|
||||
"error": "file too large; max 5 MiB",
|
||||
})
|
||||
}
|
||||
|
||||
dir, err := brandingDir(appConfig)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := removeExistingBrandingFiles(dir, kind); err != nil {
|
||||
xlog.Warn("failed to clear previous branding asset", "kind", kind, "error", err)
|
||||
}
|
||||
|
||||
basename := kind + ext
|
||||
dest := filepath.Join(dir, basename)
|
||||
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to save asset: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := setBrandingFile(appConfig, kind, basename); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "asset saved but failed to persist setting: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, BrandingResponse{
|
||||
InstanceName: appConfig.Branding.InstanceName,
|
||||
InstanceTagline: appConfig.Branding.InstanceTagline,
|
||||
LogoURL: brandingAssetURL("logo", appConfig.Branding.LogoFile),
|
||||
LogoHorizontalURL: brandingAssetURL("logo_horizontal", appConfig.Branding.LogoHorizontalFile),
|
||||
FaviconURL: brandingAssetURL("favicon", appConfig.Branding.FaviconFile),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteBrandingAssetEndpoint removes the override for the given asset kind
|
||||
// and falls back to the bundled default. Admin-only.
|
||||
//
|
||||
// @Summary Reset a branding asset to default
|
||||
// @Description Remove a custom branding asset; the UI falls back to the bundled LocalAI default.
|
||||
// @Tags branding
|
||||
// @Produce json
|
||||
// @Param kind path string true "Asset kind: logo, logo_horizontal, or favicon"
|
||||
// @Success 200 {object} BrandingResponse
|
||||
// @Router /api/branding/asset/{kind} [delete]
|
||||
func DeleteBrandingAssetEndpoint(appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
kind := c.Param("kind")
|
||||
if _, ok := brandingAssetKinds[kind]; !ok {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid asset kind; expected one of logo, logo_horizontal, favicon",
|
||||
})
|
||||
}
|
||||
|
||||
dir, err := brandingDir(appConfig)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := removeExistingBrandingFiles(dir, kind); err != nil {
|
||||
xlog.Warn("failed to remove branding asset file(s)", "kind", kind, "error", err)
|
||||
}
|
||||
if err := setBrandingFile(appConfig, kind, ""); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to clear branding setting: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, BrandingResponse{
|
||||
InstanceName: appConfig.Branding.InstanceName,
|
||||
InstanceTagline: appConfig.Branding.InstanceTagline,
|
||||
LogoURL: brandingAssetURL("logo", appConfig.Branding.LogoFile),
|
||||
LogoHorizontalURL: brandingAssetURL("logo_horizontal", appConfig.Branding.LogoHorizontalFile),
|
||||
FaviconURL: brandingAssetURL("favicon", appConfig.Branding.FaviconFile),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ServeBrandingAssetEndpoint streams the uploaded asset for the given kind,
|
||||
// or 404 when no override is configured. Public — same accessibility as the
|
||||
// bundled /static/* assets it replaces.
|
||||
//
|
||||
// @Summary Serve a custom branding asset
|
||||
// @Description Serves the admin-uploaded logo, horizontal logo, or favicon. 404 when no override is set.
|
||||
// @Tags branding
|
||||
// @Produce image/*
|
||||
// @Param kind path string true "Asset kind: logo, logo_horizontal, or favicon"
|
||||
// @Success 200
|
||||
// @Failure 404
|
||||
// @Router /branding/asset/{kind} [get]
|
||||
func ServeBrandingAssetEndpoint(appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
kind := c.Param("kind")
|
||||
if _, ok := brandingAssetKinds[kind]; !ok {
|
||||
return c.NoContent(http.StatusNotFound)
|
||||
}
|
||||
|
||||
var file string
|
||||
switch kind {
|
||||
case "logo":
|
||||
file = appConfig.Branding.LogoFile
|
||||
case "logo_horizontal":
|
||||
file = appConfig.Branding.LogoHorizontalFile
|
||||
case "favicon":
|
||||
file = appConfig.Branding.FaviconFile
|
||||
}
|
||||
if file == "" || appConfig.DynamicConfigsDir == "" {
|
||||
return c.NoContent(http.StatusNotFound)
|
||||
}
|
||||
|
||||
// Prevent path traversal — the basename must be exactly what we
|
||||
// previously stored. Anything containing a separator is rejected.
|
||||
if strings.ContainsAny(file, "/\\") || file == "." || file == ".." {
|
||||
return c.NoContent(http.StatusNotFound)
|
||||
}
|
||||
|
||||
path := filepath.Join(appConfig.DynamicConfigsDir, brandingDirName, file)
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=300")
|
||||
return c.File(path)
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedBrandingExt reports whether ext (lowercase, with dot) is one of
|
||||
// the file extensions the upload handler will accept.
|
||||
func isAllowedBrandingExt(ext string) bool {
|
||||
switch ext {
|
||||
case ".png", ".jpg", ".jpeg", ".svg", ".webp", ".ico":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -110,6 +110,20 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// Branding asset filenames are owned exclusively by
|
||||
// /api/branding/asset/{kind} (upload/delete). The Settings page also
|
||||
// round-trips them via GET /api/settings, but its local state is stale
|
||||
// once an asset has been uploaded — clicking Save would otherwise
|
||||
// clobber the uploaded basename with the empty string the UI loaded
|
||||
// at page open. Replace whatever the body sent for these three fields
|
||||
// with the values currently on disk so /api/settings can never
|
||||
// regress them.
|
||||
if existing, err := appConfig.ReadPersistedSettings(); err == nil {
|
||||
settings.LogoFile = existing.LogoFile
|
||||
settings.LogoHorizontalFile = existing.LogoHorizontalFile
|
||||
settings.FaviconFile = existing.FaviconFile
|
||||
}
|
||||
|
||||
// The UI reads ApiKeys from GET /api/settings, which already returns the
|
||||
// merged env+runtime list. When the user clicks Save, the same merged
|
||||
// list comes back in the POST body. Strip the env-supplied keys from
|
||||
|
||||
@@ -68,6 +68,12 @@ func (stubClient) VRAMEstimate(_ context.Context, _ localaitools.VRAMEstimateReq
|
||||
}
|
||||
func (stubClient) ToggleModelState(_ context.Context, _ string, _ modeladmin.Action) error { return nil }
|
||||
func (stubClient) ToggleModelPinned(_ context.Context, _ string, _ modeladmin.Action) error { return nil }
|
||||
func (stubClient) GetBranding(_ context.Context) (*localaitools.Branding, error) {
|
||||
return &localaitools.Branding{InstanceName: "LocalAI"}, nil
|
||||
}
|
||||
func (stubClient) SetBranding(_ context.Context, _ localaitools.SetBrandingRequest) (*localaitools.Branding, error) {
|
||||
return &localaitools.Branding{InstanceName: "LocalAI"}, nil
|
||||
}
|
||||
|
||||
var _ = Describe("LocalAIAssistantHolder", func() {
|
||||
var ctx context.Context
|
||||
|
||||
@@ -2430,6 +2430,12 @@ select.input {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.login-tagline {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9375rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -5,6 +5,7 @@ import OperationsBar from './components/OperationsBar'
|
||||
import { ToastContainer, useToast } from './components/Toast'
|
||||
import { systemApi } from './utils/api'
|
||||
import { useTheme } from './contexts/ThemeContext'
|
||||
import { useBranding } from './contexts/BrandingContext'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
|
||||
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
|
||||
@@ -20,6 +21,7 @@ export default function App() {
|
||||
const navigate = useNavigate()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { authEnabled, user } = useAuth()
|
||||
const branding = useBranding()
|
||||
const hamburgerRef = useRef(null)
|
||||
const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/chat/)
|
||||
|
||||
@@ -87,7 +89,7 @@ export default function App() {
|
||||
>
|
||||
<i className="fas fa-bars" aria-hidden="true" />
|
||||
</button>
|
||||
<span className="mobile-title">LocalAI</span>
|
||||
<span className="mobile-title">{branding.instanceName}</span>
|
||||
<div className="mobile-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -125,7 +127,7 @@ export default function App() {
|
||||
<div className="app-footer-inner">
|
||||
{version && (
|
||||
<span className="app-footer-version">
|
||||
LocalAI <span style={{ fontWeight: 500 }}>{version}</span>
|
||||
{branding.instanceName} <span style={{ fontWeight: 500 }}>{version}</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="app-footer-links">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useBranding } from '../contexts/BrandingContext'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
|
||||
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
|
||||
@@ -105,6 +106,7 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||
})
|
||||
const [openSections, setOpenSections] = useState(loadSectionState)
|
||||
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
|
||||
const branding = useBranding()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const closeBtnRef = useRef(null)
|
||||
@@ -200,10 +202,10 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||
{/* Logo */}
|
||||
<div className="sidebar-header">
|
||||
<a href="./" className="sidebar-logo-link">
|
||||
<img src={apiUrl('/static/logo_horizontal.png')} alt="LocalAI" className="sidebar-logo-img" />
|
||||
<img src={apiUrl(branding.logoHorizontalUrl)} alt={branding.instanceName} className="sidebar-logo-img" />
|
||||
</a>
|
||||
<a href="./" className="sidebar-logo-icon" title="LocalAI">
|
||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="sidebar-logo-icon-img" />
|
||||
<a href="./" className="sidebar-logo-icon" title={branding.instanceName}>
|
||||
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="sidebar-logo-icon-img" />
|
||||
</a>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
|
||||
67
core/http/react-ui/src/contexts/BrandingContext.jsx
Normal file
67
core/http/react-ui/src/contexts/BrandingContext.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { brandingApi } from '../utils/api'
|
||||
|
||||
// Bundled defaults — used when the backend hasn't applied an override (or
|
||||
// when /api/branding is briefly unreachable on first load).
|
||||
const DEFAULT_BRANDING = {
|
||||
instanceName: 'LocalAI',
|
||||
instanceTagline: '',
|
||||
logoUrl: '/static/logo.png',
|
||||
logoHorizontalUrl: '/static/logo_horizontal.png',
|
||||
faviconUrl: '/favicon.svg',
|
||||
}
|
||||
|
||||
const BrandingContext = createContext(null)
|
||||
|
||||
// Reads /api/branding (public — works pre-auth so the login screen renders
|
||||
// the configured branding) and exposes the resolved values plus a refresh()
|
||||
// callback used by the Settings page after save/upload.
|
||||
export function BrandingProvider({ children }) {
|
||||
const [branding, setBranding] = useState(DEFAULT_BRANDING)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await brandingApi.get()
|
||||
setBranding({
|
||||
instanceName: data?.instance_name || DEFAULT_BRANDING.instanceName,
|
||||
instanceTagline: data?.instance_tagline || '',
|
||||
logoUrl: data?.logo_url || DEFAULT_BRANDING.logoUrl,
|
||||
logoHorizontalUrl: data?.logo_horizontal_url || DEFAULT_BRANDING.logoHorizontalUrl,
|
||||
faviconUrl: data?.favicon_url || DEFAULT_BRANDING.faviconUrl,
|
||||
})
|
||||
} catch (_e) {
|
||||
// /api/branding should always succeed (it's public and zero-side-effect).
|
||||
// If it doesn't, fall through to defaults so the UI still renders.
|
||||
} finally {
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
// Drive document.title and the favicon link from branding state. Bust the
|
||||
// favicon cache by appending a query so changes show up without forcing a
|
||||
// hard reload — most browsers respect the URL change.
|
||||
useEffect(() => {
|
||||
if (!loaded) return
|
||||
document.title = branding.instanceName
|
||||
const link = document.querySelector("link[rel='icon']") || document.querySelector("link[rel='shortcut icon']")
|
||||
if (link) {
|
||||
const href = branding.faviconUrl
|
||||
link.href = href.includes('?') ? href : `${href}?v=${Date.now()}`
|
||||
}
|
||||
}, [branding, loaded])
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={{ ...branding, loaded, refresh }}>
|
||||
{children}
|
||||
</BrandingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useBranding() {
|
||||
const ctx = useContext(BrandingContext)
|
||||
if (!ctx) throw new Error('useBranding must be used within a BrandingProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { BrandingProvider } from './contexts/BrandingContext'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { router } from './router'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
@@ -9,12 +10,17 @@ import './index.css'
|
||||
import './theme.css'
|
||||
import './App.css'
|
||||
|
||||
// BrandingProvider sits outside AuthProvider so the login screen — which
|
||||
// renders before authentication completes — can pick up the configured
|
||||
// instance name and logo from the public /api/branding endpoint.
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
<BrandingProvider>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</BrandingProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useBranding } from '../contexts/BrandingContext'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import { CAP_CHAT } from '../utils/capabilities'
|
||||
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
|
||||
@@ -20,6 +21,7 @@ export default function Home() {
|
||||
const navigate = useNavigate()
|
||||
const { addToast } = useOutletContext()
|
||||
const { isAdmin } = useAuth()
|
||||
const branding = useBranding()
|
||||
const { resources } = useResources()
|
||||
const [configuredModels, setConfiguredModels] = useState(null)
|
||||
const configuredModelsRef = useRef(configuredModels)
|
||||
@@ -293,7 +295,7 @@ export default function Home() {
|
||||
<>
|
||||
{/* Hero with logo */}
|
||||
<div className="home-hero">
|
||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
|
||||
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="home-logo" />
|
||||
</div>
|
||||
|
||||
{/* Resource monitor - prominent placement */}
|
||||
@@ -499,8 +501,8 @@ export default function Home() {
|
||||
/* No models installed - compact getting started */
|
||||
<div className="home-wizard">
|
||||
<div className="home-wizard-hero">
|
||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
|
||||
<h1>Get started with LocalAI</h1>
|
||||
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="home-logo" />
|
||||
<h1>Get started with {branding.instanceName}</h1>
|
||||
<p>Install your first model to begin. Browse the gallery or import your own.</p>
|
||||
</div>
|
||||
|
||||
@@ -544,7 +546,7 @@ export default function Home() {
|
||||
/* No models available (non-admin) */
|
||||
<div className="home-wizard">
|
||||
<div className="home-wizard-hero">
|
||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
|
||||
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="home-logo" />
|
||||
<h1>No Models Available</h1>
|
||||
<p>There are no models installed yet. Ask your administrator to set up models so you can start chatting.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useBranding } from '../contexts/BrandingContext'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import './auth.css'
|
||||
|
||||
@@ -9,6 +10,7 @@ export default function Login() {
|
||||
const { code: urlInviteCode } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { authEnabled, staticApiKeyRequired, user, loading: authLoading, refresh } = useAuth()
|
||||
const branding = useBranding()
|
||||
const [providers, setProviders] = useState([])
|
||||
const [hasUsers, setHasUsers] = useState(true)
|
||||
const [registrationMode, setRegistrationMode] = useState('open')
|
||||
@@ -182,7 +184,9 @@ export default function Login() {
|
||||
<div className="login-page">
|
||||
<div className="card login-card">
|
||||
<div className="login-header">
|
||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="login-logo" />
|
||||
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="login-logo" />
|
||||
<h1 className="login-title">{branding.instanceName}</h1>
|
||||
{branding.instanceTagline && <p className="login-tagline">{branding.instanceTagline}</p>}
|
||||
<p className="login-subtitle">Enter your API key to continue</p>
|
||||
</div>
|
||||
|
||||
@@ -230,7 +234,9 @@ export default function Login() {
|
||||
<div className="login-page">
|
||||
<div className="card login-card">
|
||||
<div className="login-header">
|
||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="login-logo" />
|
||||
<img src={apiUrl(branding.logoUrl)} alt={branding.instanceName} className="login-logo" />
|
||||
<h1 className="login-title">{branding.instanceName}</h1>
|
||||
{branding.instanceTagline && <p className="login-tagline">{branding.instanceTagline}</p>}
|
||||
<p className="login-subtitle">
|
||||
{!hasUsers ? 'Create your admin account' : mode === 'register' ? 'Create an account' : 'Sign in to continue'}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { settingsApi, resourcesApi } from '../utils/api'
|
||||
import { settingsApi, resourcesApi, brandingApi } from '../utils/api'
|
||||
import { useBranding } from '../contexts/BrandingContext'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import SearchableModelSelect from '../components/SearchableModelSelect'
|
||||
import { CAP_CHAT } from '../utils/capabilities'
|
||||
@@ -9,6 +10,7 @@ import SettingRow from '../components/SettingRow'
|
||||
import { formatBytes, percentColor } from '../utils/format'
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'branding', icon: 'fa-palette', color: 'var(--color-primary)', label: 'Branding' },
|
||||
{ id: 'watchdog', icon: 'fa-shield-halved', color: 'var(--color-primary)', label: 'Watchdog' },
|
||||
{ id: 'memory', icon: 'fa-memory', color: 'var(--color-accent)', label: 'Memory' },
|
||||
{ id: 'backends', icon: 'fa-cogs', color: 'var(--color-accent)', label: 'Backends' },
|
||||
@@ -24,6 +26,12 @@ const SECTIONS = [
|
||||
{ id: 'responses', icon: 'fa-database', color: 'var(--color-accent)', label: 'Responses' },
|
||||
]
|
||||
|
||||
const BRANDING_ASSETS = [
|
||||
{ kind: 'logo', label: 'Square Logo', description: 'Used as the icon-sized logo in the sidebar and on small screens.' },
|
||||
{ kind: 'logo_horizontal', label: 'Horizontal Logo', description: 'Wide logo shown in the sidebar header on desktop.' },
|
||||
{ kind: 'favicon', label: 'Favicon', description: 'Browser tab icon. PNG, SVG, or ICO. Browsers cache the favicon — a hard reload may be needed.' },
|
||||
]
|
||||
|
||||
export default function Settings() {
|
||||
const { addToast } = useOutletContext()
|
||||
const [settings, setSettings] = useState(null)
|
||||
@@ -31,7 +39,9 @@ export default function Settings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [resources, setResources] = useState(null)
|
||||
const [activeSection, setActiveSection] = useState('watchdog')
|
||||
const [activeSection, setActiveSection] = useState('branding')
|
||||
const branding = useBranding()
|
||||
const [brandingBusy, setBrandingBusy] = useState(null) // null | kind for asset ops in flight
|
||||
const contentRef = useRef(null)
|
||||
const sectionRefs = useRef({})
|
||||
|
||||
@@ -61,6 +71,9 @@ export default function Settings() {
|
||||
try {
|
||||
await settingsApi.save(settings)
|
||||
setInitialSettings(structuredClone(settings))
|
||||
// Refresh branding context so name/tagline updates propagate to the
|
||||
// sidebar, footer, and document title without a full reload.
|
||||
branding.refresh()
|
||||
addToast('Settings saved successfully', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Save failed: ${err.message}`, 'error')
|
||||
@@ -69,6 +82,42 @@ export default function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrandingUpload = async (kind, file) => {
|
||||
if (!file) return
|
||||
setBrandingBusy(kind)
|
||||
try {
|
||||
await brandingApi.uploadAsset(kind, file)
|
||||
await branding.refresh()
|
||||
addToast('Asset uploaded', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Upload failed: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setBrandingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrandingReset = async (kind) => {
|
||||
setBrandingBusy(kind)
|
||||
try {
|
||||
await brandingApi.deleteAsset(kind)
|
||||
await branding.refresh()
|
||||
addToast('Reset to default', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Reset failed: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setBrandingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const brandingAssetUrl = (kind) => {
|
||||
switch (kind) {
|
||||
case 'logo': return branding.logoUrl
|
||||
case 'logo_horizontal': return branding.logoHorizontalUrl
|
||||
case 'favicon': return branding.faviconUrl
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const update = (key, value) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
@@ -161,6 +210,81 @@ export default function Settings() {
|
||||
maxHeight: 'calc(100vh - 180px)',
|
||||
}}
|
||||
>
|
||||
{/* Branding / Whitelabeling */}
|
||||
<div ref={el => sectionRefs.current.branding = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
|
||||
<i className="fas fa-palette" style={{ color: 'var(--color-primary)' }} /> Branding
|
||||
</h3>
|
||||
<div className="card">
|
||||
<SettingRow label="Instance Name" description="Replaces "LocalAI" in the sidebar, footer, and browser tab. Visible on the login screen.">
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 240 }}
|
||||
value={settings.instance_name || ''}
|
||||
onChange={(e) => update('instance_name', e.target.value)}
|
||||
placeholder="LocalAI"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Tagline" description="Optional short subtitle shown beneath the instance name.">
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 240 }}
|
||||
value={settings.instance_tagline || ''}
|
||||
onChange={(e) => update('instance_tagline', e.target.value)}
|
||||
placeholder="(none)"
|
||||
/>
|
||||
</SettingRow>
|
||||
{BRANDING_ASSETS.map(asset => {
|
||||
const url = brandingAssetUrl(asset.kind)
|
||||
const isCustom = url && url.startsWith('/branding/asset/')
|
||||
const busy = brandingBusy === asset.kind
|
||||
return (
|
||||
<SettingRow key={asset.kind} label={asset.label} description={asset.description}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'var(--color-surface-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', overflow: 'hidden',
|
||||
}}>
|
||||
{url ? (
|
||||
<img src={url} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<i className="fas fa-image" style={{ color: 'var(--color-text-muted)' }} />
|
||||
)}
|
||||
</div>
|
||||
<label className="btn btn-secondary" style={{ cursor: busy ? 'wait' : 'pointer', margin: 0 }}>
|
||||
<i className="fas fa-upload" /> {busy ? 'Uploading…' : 'Upload'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp,image/x-icon,.ico"
|
||||
style={{ display: 'none' }}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (file) handleBrandingUpload(asset.kind, file)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleBrandingReset(asset.kind)}
|
||||
disabled={busy}
|
||||
title="Revert to bundled default"
|
||||
>
|
||||
<i className="fas fa-undo" /> Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watchdog */}
|
||||
<div ref={el => sectionRefs.current.watchdog = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
|
||||
@@ -458,6 +582,36 @@ export default function Settings() {
|
||||
<SettingRow label="Collection DB Path" description="Database path for agent collections">
|
||||
<input className="input" style={{ width: 280 }} value={settings.agent_pool_collection_db_path || ''} onChange={(e) => update('agent_pool_collection_db_path', e.target.value)} placeholder="Leave empty for default" />
|
||||
</SettingRow>
|
||||
<SettingRow label="Vector Engine" description="Backend store for collection embeddings. chromem is in-memory; postgres uses pgvector and requires Database URL.">
|
||||
<select
|
||||
className="input"
|
||||
style={{ width: 160 }}
|
||||
value={settings.agent_pool_vector_engine || 'chromem'}
|
||||
onChange={(e) => update('agent_pool_vector_engine', e.target.value)}
|
||||
>
|
||||
<option value="chromem">chromem</option>
|
||||
<option value="postgres">postgres</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="Database URL" description="PostgreSQL DSN used when Vector Engine is postgres (e.g. postgres://user:pass@host:5432/db).">
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 320 }}
|
||||
value={settings.agent_pool_database_url || ''}
|
||||
onChange={(e) => update('agent_pool_database_url', e.target.value)}
|
||||
placeholder="postgres://..."
|
||||
disabled={(settings.agent_pool_vector_engine || 'chromem') !== 'postgres'}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Agent Hub URL" description="Override the default https://agenthub.localai.io endpoint (custom or self-hosted hub).">
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 320 }}
|
||||
value={settings.agent_pool_agent_hub_url || ''}
|
||||
onChange={(e) => update('agent_pool_agent_hub_url', e.target.value)}
|
||||
placeholder="https://agenthub.localai.io"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
20
core/http/react-ui/src/utils/api.js
vendored
20
core/http/react-ui/src/utils/api.js
vendored
@@ -166,6 +166,26 @@ export const settingsApi = {
|
||||
save: (body) => postJSON(API_CONFIG.endpoints.settings, body),
|
||||
}
|
||||
|
||||
// Branding / whitelabeling
|
||||
// /api/branding is public (no auth) — the login page reads it before the
|
||||
// user signs in. Asset uploads/deletes still require admin privileges.
|
||||
export const brandingApi = {
|
||||
get: () => fetchJSON('/api/branding'),
|
||||
uploadAsset: (kind, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return fetch(apiUrl(`/api/branding/asset/${enc(kind)}`), {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'include',
|
||||
}).then(handleResponse)
|
||||
},
|
||||
deleteAsset: (kind) => fetch(apiUrl(`/api/branding/asset/${enc(kind)}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}).then(handleResponse),
|
||||
}
|
||||
|
||||
// Backend Logs API
|
||||
export const backendLogsApi = {
|
||||
listModels: () => fetchJSON(API_CONFIG.endpoints.backendLogs),
|
||||
|
||||
@@ -1508,5 +1508,14 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||
app.POST("/api/settings", localai.UpdateSettingsEndpoint(applicationInstance), adminMiddleware)
|
||||
}
|
||||
|
||||
// Branding / whitelabeling. The read endpoint and the asset server are
|
||||
// public so the login screen can render the configured logo and instance
|
||||
// name before authentication. Mutations are admin-only. See app.go where
|
||||
// "/api/branding" and "/branding/" are added to PathWithoutAuth.
|
||||
app.GET("/api/branding", localai.GetBrandingEndpoint(appConfig))
|
||||
app.GET("/branding/asset/:kind", localai.ServeBrandingAssetEndpoint(appConfig))
|
||||
app.POST("/api/branding/asset/:kind", localai.UploadBrandingAssetEndpoint(appConfig), adminMiddleware)
|
||||
app.DELETE("/api/branding/asset/:kind", localai.DeleteBrandingAssetEndpoint(appConfig), adminMiddleware)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -59,4 +59,12 @@ type LocalAIClient interface {
|
||||
ToggleModelState(ctx context.Context, name string, action modeladmin.Action) error
|
||||
// ToggleModelPinned accepts modeladmin.ActionPin / ActionUnpin.
|
||||
ToggleModelPinned(ctx context.Context, name string, action modeladmin.Action) error
|
||||
|
||||
// ---- Branding / whitelabeling ----
|
||||
// GetBranding returns the configured instance branding (name, tagline,
|
||||
// asset URLs).
|
||||
GetBranding(ctx context.Context) (*Branding, error)
|
||||
// SetBranding updates the text branding fields. Asset uploads are not
|
||||
// exposed over MCP — admins use the Settings UI for binary files.
|
||||
SetBranding(ctx context.Context, req SetBrandingRequest) (*Branding, error)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ var toolToHTTPRoute = map[string]string{
|
||||
ToolSystemInfo: "GET / (welcome JSON)",
|
||||
ToolListNodes: "GET /api/nodes",
|
||||
ToolVRAMEstimate: "POST /api/models/vram-estimate",
|
||||
ToolGetBranding: "GET /api/branding",
|
||||
|
||||
// Mutating tools.
|
||||
ToolInstallModel: "POST /models/apply",
|
||||
@@ -47,6 +48,7 @@ var toolToHTTPRoute = map[string]string{
|
||||
ToolUpgradeBackend: "POST /backends/upgrade/:name",
|
||||
ToolToggleModelState: "PUT /models/toggle-state/:name/:action",
|
||||
ToolToggleModelPinned: "PUT /models/toggle-pinned/:name/:action",
|
||||
ToolSetBranding: "POST /api/settings (instance_name, instance_tagline)",
|
||||
}
|
||||
|
||||
// allKnownTools is the union of expectedFullCatalog (defined in
|
||||
|
||||
@@ -118,6 +118,25 @@ type ImportModelURIResponse struct {
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Branding is the LLM-facing view of the instance's whitelabel settings.
|
||||
// Only the configurable text fields and the resolved asset URLs are
|
||||
// surfaced — the backing filenames on disk stay an implementation detail.
|
||||
type Branding struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
InstanceTagline string `json:"instance_tagline"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
||||
FaviconURL string `json:"favicon_url"`
|
||||
}
|
||||
|
||||
// SetBrandingRequest is the input for set_branding. Both fields are
|
||||
// optional; nil leaves the existing value untouched. Asset uploads are
|
||||
// deliberately excluded from MCP — admins use the Settings UI for that.
|
||||
type SetBrandingRequest struct {
|
||||
InstanceName *string `json:"instance_name,omitempty" jsonschema:"New instance display name (replaces \"LocalAI\" in headers, footers, and the browser tab). Pass an empty string to reset to default."`
|
||||
InstanceTagline *string `json:"instance_tagline,omitempty" jsonschema:"Optional short subtitle shown beneath the instance name. Pass an empty string to clear."`
|
||||
}
|
||||
|
||||
// VRAMEstimateRequest is the input for vram_estimate. The output type is
|
||||
// pkg/vram.EstimateResult — used directly via the LocalAIClient interface
|
||||
// so the LLM sees the same shape (size_bytes/size_display/vram_bytes/
|
||||
|
||||
@@ -43,6 +43,8 @@ type fakeClient struct {
|
||||
vramEstimate func(VRAMEstimateRequest) (*vram.EstimateResult, error)
|
||||
toggleModelState func(string, modeladmin.Action) error
|
||||
toggleModelPinned func(string, modeladmin.Action) error
|
||||
getBranding func() (*Branding, error)
|
||||
setBranding func(SetBrandingRequest) (*Branding, error)
|
||||
}
|
||||
|
||||
type fakeCall struct {
|
||||
@@ -218,5 +220,21 @@ func (f *fakeClient) ToggleModelPinned(_ context.Context, name string, action mo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) GetBranding(_ context.Context) (*Branding, error) {
|
||||
f.record("GetBranding", nil)
|
||||
if f.getBranding != nil {
|
||||
return f.getBranding()
|
||||
}
|
||||
return &Branding{InstanceName: "LocalAI"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeClient) SetBranding(_ context.Context, req SetBrandingRequest) (*Branding, error) {
|
||||
f.record("SetBranding", req)
|
||||
if f.setBranding != nil {
|
||||
return f.setBranding(req)
|
||||
}
|
||||
return &Branding{InstanceName: "LocalAI"}, nil
|
||||
}
|
||||
|
||||
// boom is a sentinel error used by tests that want a deterministic error string.
|
||||
var boom = fmt.Errorf("boom")
|
||||
|
||||
@@ -466,6 +466,46 @@ func (c *Client) ToggleModelPinned(ctx context.Context, name string, action mode
|
||||
return c.do(ctx, http.MethodPut, routeToggleModelPinned(name, string(action)), nil, nil)
|
||||
}
|
||||
|
||||
// ---- Branding ----
|
||||
|
||||
// brandingResponse mirrors the JSON shape emitted by GET /api/branding.
|
||||
// We don't import the server-side type here so the MCP HTTP client stays
|
||||
// independent of the localai endpoint package.
|
||||
type brandingResponse struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
InstanceTagline string `json:"instance_tagline"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
||||
FaviconURL string `json:"favicon_url"`
|
||||
}
|
||||
|
||||
func (c *Client) GetBranding(ctx context.Context) (*localaitools.Branding, error) {
|
||||
var raw brandingResponse
|
||||
if err := c.do(ctx, http.MethodGet, routeBranding, nil, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return (*localaitools.Branding)(&raw), nil
|
||||
}
|
||||
|
||||
func (c *Client) SetBranding(ctx context.Context, req localaitools.SetBrandingRequest) (*localaitools.Branding, error) {
|
||||
// Text fields ride the existing /api/settings POST, which maps the
|
||||
// pointer fields onto RuntimeSettings.InstanceName / InstanceTagline.
|
||||
body := map[string]any{}
|
||||
if req.InstanceName != nil {
|
||||
body["instance_name"] = *req.InstanceName
|
||||
}
|
||||
if req.InstanceTagline != nil {
|
||||
body["instance_tagline"] = *req.InstanceTagline
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return c.GetBranding(ctx)
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, routeSettings, body, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.GetBranding(ctx)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func contains(haystack, lowerNeedle string) bool {
|
||||
|
||||
@@ -22,6 +22,8 @@ const (
|
||||
routeBackendsApply = "/backends/apply"
|
||||
routeNodes = "/api/nodes"
|
||||
routeVRAMEstimate = "/api/models/vram-estimate"
|
||||
routeBranding = "/api/branding"
|
||||
routeSettings = "/api/settings"
|
||||
)
|
||||
|
||||
func routeJobStatus(jobID string) string {
|
||||
|
||||
@@ -413,6 +413,51 @@ func (c *Client) ToggleModelPinned(ctx context.Context, name string, action mode
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Branding ----
|
||||
|
||||
// brandingAssetURL returns the same URL shape the public REST endpoint
|
||||
// would emit so MCP and HTTP clients see identical wire output.
|
||||
func brandingAssetURL(kind, file, defaultURL string) string {
|
||||
if file != "" {
|
||||
return "/branding/asset/" + kind
|
||||
}
|
||||
return defaultURL
|
||||
}
|
||||
|
||||
func (c *Client) currentBranding() *localaitools.Branding {
|
||||
b := c.AppConfig.Branding
|
||||
return &localaitools.Branding{
|
||||
InstanceName: b.InstanceName,
|
||||
InstanceTagline: b.InstanceTagline,
|
||||
LogoURL: brandingAssetURL("logo", b.LogoFile, "/static/logo.png"),
|
||||
LogoHorizontalURL: brandingAssetURL("logo_horizontal", b.LogoHorizontalFile, "/static/logo_horizontal.png"),
|
||||
FaviconURL: brandingAssetURL("favicon", b.FaviconFile, "/favicon.svg"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetBranding(_ context.Context) (*localaitools.Branding, error) {
|
||||
return c.currentBranding(), nil
|
||||
}
|
||||
|
||||
func (c *Client) SetBranding(_ context.Context, req localaitools.SetBrandingRequest) (*localaitools.Branding, error) {
|
||||
settings, err := c.AppConfig.ReadPersistedSettings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.InstanceName != nil {
|
||||
c.AppConfig.Branding.InstanceName = *req.InstanceName
|
||||
settings.InstanceName = req.InstanceName
|
||||
}
|
||||
if req.InstanceTagline != nil {
|
||||
c.AppConfig.Branding.InstanceTagline = *req.InstanceTagline
|
||||
settings.InstanceTagline = req.InstanceTagline
|
||||
}
|
||||
if err := c.AppConfig.WritePersistedSettings(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.currentBranding(), nil
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// sendModelOp pushes op onto ch but bails if ctx is cancelled before the
|
||||
|
||||
13
pkg/mcp/localaitools/prompts/skills/configure_branding.md
Normal file
13
pkg/mcp/localaitools/prompts/skills/configure_branding.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Skill: Configure instance branding (whitelabeling)
|
||||
|
||||
Use this when the user wants to read or change how the instance presents itself — the visible "instance name", the tagline beneath it, or wants to know what custom branding is configured.
|
||||
|
||||
1. To inspect: call `get_branding` and report the resolved fields:
|
||||
- `instance_name` (what shows up in the sidebar, footer, and browser tab)
|
||||
- `instance_tagline` (optional subtitle)
|
||||
- `logo_url`, `logo_horizontal_url`, `favicon_url` — if any return a `/branding/asset/...` path the admin has uploaded a custom file; `/static/...` or `/favicon.svg` mean the bundled default.
|
||||
2. To change name/tagline: confirm with the user first ("I'll set the instance name to **`<x>`** and the tagline to **`<y>`** — confirm?"). On confirmation, call `set_branding` with only the field(s) they're changing. An empty string clears that field back to default.
|
||||
3. **Logo and favicon files are not changeable from chat.** If the user asks to upload a new logo or favicon, point them at the **Branding** section of the **Settings** page — file upload happens through the admin UI, not over MCP.
|
||||
4. After a successful `set_branding`, echo the new resolved values back to the user. The change applies on the next request without a restart.
|
||||
|
||||
Never call `set_branding` without explicit confirmation — branding is visible to every user of the instance, including unauthenticated visitors on the login page.
|
||||
@@ -47,6 +47,7 @@ func NewServer(client LocalAIClient, opts Options) *mcp.Server {
|
||||
registerConfigTools(srv, client, opts)
|
||||
registerSystemTools(srv, client, opts)
|
||||
registerStateTools(srv, client, opts)
|
||||
registerBrandingTools(srv, client, opts)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ var expectedFullCatalog = sortedStrings(
|
||||
ToolDeleteModel,
|
||||
ToolEditModelConfig,
|
||||
ToolGallerySearch,
|
||||
ToolGetBranding,
|
||||
ToolGetJobStatus,
|
||||
ToolGetModelConfig,
|
||||
ToolImportModelURI,
|
||||
@@ -87,6 +88,7 @@ var expectedFullCatalog = sortedStrings(
|
||||
ToolListKnownBackends,
|
||||
ToolListNodes,
|
||||
ToolReloadModels,
|
||||
ToolSetBranding,
|
||||
ToolSystemInfo,
|
||||
ToolToggleModelPinned,
|
||||
ToolToggleModelState,
|
||||
@@ -97,6 +99,7 @@ var expectedFullCatalog = sortedStrings(
|
||||
// expectedReadOnlyCatalog is the tool set when DisableMutating=true. Sorted.
|
||||
var expectedReadOnlyCatalog = sortedStrings(
|
||||
ToolGallerySearch,
|
||||
ToolGetBranding,
|
||||
ToolGetJobStatus,
|
||||
ToolGetModelConfig,
|
||||
ToolListBackends,
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
ToolSystemInfo = "system_info"
|
||||
ToolListNodes = "list_nodes"
|
||||
ToolVRAMEstimate = "vram_estimate"
|
||||
ToolGetBranding = "get_branding"
|
||||
|
||||
// Mutating tools — guarded by Options.DisableMutating and the
|
||||
// LLM-side safety prompt (see prompts/10_safety.md).
|
||||
@@ -30,6 +31,7 @@ const (
|
||||
ToolUpgradeBackend = "upgrade_backend"
|
||||
ToolToggleModelState = "toggle_model_state"
|
||||
ToolToggleModelPinned = "toggle_model_pinned"
|
||||
ToolSetBranding = "set_branding"
|
||||
)
|
||||
|
||||
// DefaultServerName is the MCP Implementation.Name surfaced when
|
||||
|
||||
38
pkg/mcp/localaitools/tools_branding.go
Normal file
38
pkg/mcp/localaitools/tools_branding.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package localaitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func registerBrandingTools(s *mcp.Server, client LocalAIClient, opts Options) {
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: ToolGetBranding,
|
||||
Description: "Read the configured instance branding (name, tagline, logo URLs, favicon URL).",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
|
||||
b, err := client.GetBranding(ctx)
|
||||
if err != nil {
|
||||
return errorResult(err), nil, nil
|
||||
}
|
||||
return jsonResult(b), nil, nil
|
||||
})
|
||||
|
||||
if opts.DisableMutating {
|
||||
return
|
||||
}
|
||||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: ToolSetBranding,
|
||||
Description: "Set the instance branding name and/or tagline. Both fields optional — nil leaves the existing value alone, an empty string resets to default. Requires user confirmation per safety rule 1. To replace the logo or favicon, point the user at the Branding section of the Settings page (file upload is intentionally not exposed over MCP).",
|
||||
}, func(ctx context.Context, _ *mcp.CallToolRequest, args SetBrandingRequest) (*mcp.CallToolResult, any, error) {
|
||||
if args.InstanceName == nil && args.InstanceTagline == nil {
|
||||
return errorResultf("at least one of instance_name or instance_tagline must be provided"), nil, nil
|
||||
}
|
||||
b, err := client.SetBranding(ctx, args)
|
||||
if err != nil {
|
||||
return errorResult(err), nil, nil
|
||||
}
|
||||
return jsonResult(b), nil, nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user