Skip to content

Commit b1a9943

Browse files
authored
feat(branding): admin-configurable instance name, tagline, and assets (mudler#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>
1 parent 7325046 commit b1a9943

35 files changed

Lines changed: 1508 additions & 17 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package application
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
func TestApplication(t *testing.T) {
11+
RegisterFailHandler(Fail)
12+
RunSpecs(t, "Application test suite")
13+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package application
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"time"
7+
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
11+
"github.com/mudler/LocalAI/core/config"
12+
)
13+
14+
// seedSettings writes the given JSON fragment to runtime_settings.json
15+
// under a fresh temp DynamicConfigsDir and returns the directory path.
16+
func seedSettings(json string) string {
17+
dir := GinkgoT().TempDir()
18+
Expect(os.WriteFile(filepath.Join(dir, "runtime_settings.json"), []byte(json), 0o600)).To(Succeed())
19+
return dir
20+
}
21+
22+
var _ = Describe("loadRuntimeSettingsFromFile", func() {
23+
// Reproduces the "settings revert after restart" report: an admin
24+
// sets a branding instance name + uploads a logo, the values are
25+
// persisted to runtime_settings.json, but on the next startup
26+
// loadRuntimeSettingsFromFile() did not read those fields back so
27+
// appConfig.Branding stayed zero and the public /api/branding
28+
// endpoint fell back to LocalAI defaults.
29+
Describe("branding fields", func() {
30+
It("loads instance name, tagline, and asset basenames", func() {
31+
dir := seedSettings(`{
32+
"instance_name": "Acme AI",
33+
"instance_tagline": "Private inference",
34+
"logo_file": "logo.png",
35+
"logo_horizontal_file": "logo_horizontal.svg",
36+
"favicon_file": "favicon.ico"
37+
}`)
38+
39+
cfg := &config.ApplicationConfig{DynamicConfigsDir: dir}
40+
loadRuntimeSettingsFromFile(cfg)
41+
42+
Expect(cfg.Branding).To(Equal(config.BrandingConfig{
43+
InstanceName: "Acme AI",
44+
InstanceTagline: "Private inference",
45+
LogoFile: "logo.png",
46+
LogoHorizontalFile: "logo_horizontal.svg",
47+
FaviconFile: "favicon.ico",
48+
}))
49+
})
50+
})
51+
52+
// Adjacent fields exercise the other classes of settings that
53+
// previously silently reverted on restart. Each spec pairs a
54+
// runtime_settings.json fragment with the expected ApplicationConfig
55+
// state after the loader runs. A regression in any one means a
56+
// UI-saved setting will not survive a process restart — same shape as
57+
// the branding bug, different field.
58+
//
59+
// Where a field has a non-zero default (set by NewApplicationConfig),
60+
// the spec seeds the post-AppOptions state the loader would observe
61+
// at boot. Without that setup the "if at default" gate would either
62+
// always pass or always fail and the spec wouldn't reflect the real
63+
// call site.
64+
Describe("adjacent restart-loss fields", func() {
65+
It("loads auto_upgrade_backends", func() {
66+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"auto_upgrade_backends": true}`)}
67+
loadRuntimeSettingsFromFile(cfg)
68+
Expect(cfg.AutoUpgradeBackends).To(BeTrue())
69+
})
70+
71+
It("loads prefer_development_backends", func() {
72+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"prefer_development_backends": true}`)}
73+
loadRuntimeSettingsFromFile(cfg)
74+
Expect(cfg.PreferDevelopmentBackends).To(BeTrue())
75+
})
76+
77+
It("disables the LocalAI Assistant when localai_assistant_enabled=false", func() {
78+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"localai_assistant_enabled": false}`)}
79+
loadRuntimeSettingsFromFile(cfg)
80+
Expect(cfg.DisableLocalAIAssistant).To(BeTrue())
81+
})
82+
83+
It("loads open_responses_store_ttl as a duration", func() {
84+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"open_responses_store_ttl": "1h"}`)}
85+
loadRuntimeSettingsFromFile(cfg)
86+
Expect(cfg.OpenResponsesStoreTTL).To(Equal(time.Hour))
87+
})
88+
})
89+
90+
// The Agent Pool block has a mix of zero and non-zero defaults
91+
// (Enabled=true, EmbeddingModel="granite-...", MaxChunkingSize=400,
92+
// VectorEngine="chromem", AgentHubURL="https://agenthub.localai.io").
93+
// Each spec seeds the appropriate startup state so the loader's
94+
// "at default" check observes what New() would.
95+
Describe("agent pool fields", func() {
96+
It("loads agent_pool_enabled=false against the default-true", func() {
97+
cfg := &config.ApplicationConfig{
98+
DynamicConfigsDir: seedSettings(`{"agent_pool_enabled": false}`),
99+
AgentPool: config.AgentPoolConfig{Enabled: true},
100+
}
101+
loadRuntimeSettingsFromFile(cfg)
102+
Expect(cfg.AgentPool.Enabled).To(BeFalse())
103+
})
104+
105+
It("loads agent_pool_default_model", func() {
106+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_default_model": "qwen2.5-7b"}`)}
107+
loadRuntimeSettingsFromFile(cfg)
108+
Expect(cfg.AgentPool.DefaultModel).To(Equal("qwen2.5-7b"))
109+
})
110+
111+
It("overrides the granite embedding default", func() {
112+
cfg := &config.ApplicationConfig{
113+
DynamicConfigsDir: seedSettings(`{"agent_pool_embedding_model": "all-minilm"}`),
114+
AgentPool: config.AgentPoolConfig{EmbeddingModel: "granite-embedding-107m-multilingual"},
115+
}
116+
loadRuntimeSettingsFromFile(cfg)
117+
Expect(cfg.AgentPool.EmbeddingModel).To(Equal("all-minilm"))
118+
})
119+
120+
It("overrides the 400 max chunking size default", func() {
121+
cfg := &config.ApplicationConfig{
122+
DynamicConfigsDir: seedSettings(`{"agent_pool_max_chunking_size": 800}`),
123+
AgentPool: config.AgentPoolConfig{MaxChunkingSize: 400},
124+
}
125+
loadRuntimeSettingsFromFile(cfg)
126+
Expect(cfg.AgentPool.MaxChunkingSize).To(Equal(800))
127+
})
128+
129+
It("loads agent_pool_chunk_overlap", func() {
130+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_chunk_overlap": 50}`)}
131+
loadRuntimeSettingsFromFile(cfg)
132+
Expect(cfg.AgentPool.ChunkOverlap).To(Equal(50))
133+
})
134+
135+
It("loads agent_pool_enable_logs", func() {
136+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_enable_logs": true}`)}
137+
loadRuntimeSettingsFromFile(cfg)
138+
Expect(cfg.AgentPool.EnableLogs).To(BeTrue())
139+
})
140+
141+
It("loads agent_pool_collection_db_path", func() {
142+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_collection_db_path": "/var/lib/localai/collections.db"}`)}
143+
loadRuntimeSettingsFromFile(cfg)
144+
Expect(cfg.AgentPool.CollectionDBPath).To(Equal("/var/lib/localai/collections.db"))
145+
})
146+
147+
It("overrides the chromem vector_engine default", func() {
148+
cfg := &config.ApplicationConfig{
149+
DynamicConfigsDir: seedSettings(`{"agent_pool_vector_engine": "postgres"}`),
150+
AgentPool: config.AgentPoolConfig{VectorEngine: "chromem"},
151+
}
152+
loadRuntimeSettingsFromFile(cfg)
153+
Expect(cfg.AgentPool.VectorEngine).To(Equal("postgres"))
154+
})
155+
156+
It("loads agent_pool_database_url", func() {
157+
cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"agent_pool_database_url": "postgres://user:pass@db:5432/localai"}`)}
158+
loadRuntimeSettingsFromFile(cfg)
159+
Expect(cfg.AgentPool.DatabaseURL).To(Equal("postgres://user:pass@db:5432/localai"))
160+
})
161+
162+
It("overrides the agenthub.localai.io agent_hub_url default", func() {
163+
cfg := &config.ApplicationConfig{
164+
DynamicConfigsDir: seedSettings(`{"agent_pool_agent_hub_url": "https://hub.acme.io"}`),
165+
AgentPool: config.AgentPoolConfig{AgentHubURL: "https://agenthub.localai.io"},
166+
}
167+
loadRuntimeSettingsFromFile(cfg)
168+
Expect(cfg.AgentPool.AgentHubURL).To(Equal("https://hub.acme.io"))
169+
})
170+
})
171+
})

core/application/startup.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,109 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
548548
}
549549
}
550550

551+
// Branding / whitelabeling. There are no env vars for these — the file is
552+
// the only source — so apply unconditionally. Without this block a server
553+
// restart silently drops the configured instance name, tagline, and asset
554+
// filenames.
555+
if settings.InstanceName != nil {
556+
options.Branding.InstanceName = *settings.InstanceName
557+
}
558+
if settings.InstanceTagline != nil {
559+
options.Branding.InstanceTagline = *settings.InstanceTagline
560+
}
561+
if settings.LogoFile != nil {
562+
options.Branding.LogoFile = *settings.LogoFile
563+
}
564+
if settings.LogoHorizontalFile != nil {
565+
options.Branding.LogoHorizontalFile = *settings.LogoHorizontalFile
566+
}
567+
if settings.FaviconFile != nil {
568+
options.Branding.FaviconFile = *settings.FaviconFile
569+
}
570+
571+
// Backend upgrade flags
572+
if settings.AutoUpgradeBackends != nil {
573+
if !options.AutoUpgradeBackends {
574+
options.AutoUpgradeBackends = *settings.AutoUpgradeBackends
575+
}
576+
}
577+
if settings.PreferDevelopmentBackends != nil {
578+
if !options.PreferDevelopmentBackends {
579+
options.PreferDevelopmentBackends = *settings.PreferDevelopmentBackends
580+
}
581+
}
582+
583+
// LocalAI Assistant — file-stored as the negation (LocalAIAssistantEnabled).
584+
// Default is enabled (DisableLocalAIAssistant=false). Apply the file value
585+
// unless env explicitly disabled the assistant (DisableLocalAIAssistant=true).
586+
if settings.LocalAIAssistantEnabled != nil {
587+
if !options.DisableLocalAIAssistant {
588+
options.DisableLocalAIAssistant = !*settings.LocalAIAssistantEnabled
589+
}
590+
}
591+
592+
// Open Responses TTL. Default is 0 (no expiration). Treat the on-disk
593+
// "0"/empty as "no expiration" — a no-op since options is already 0 —
594+
// and parse anything else as a duration.
595+
if settings.OpenResponsesStoreTTL != nil && options.OpenResponsesStoreTTL == 0 {
596+
v := *settings.OpenResponsesStoreTTL
597+
if v != "0" && v != "" {
598+
if dur, err := time.ParseDuration(v); err == nil {
599+
options.OpenResponsesStoreTTL = dur
600+
} else {
601+
xlog.Warn("invalid open_responses_store_ttl in runtime_settings.json", "error", err, "ttl", v)
602+
}
603+
}
604+
}
605+
606+
// Agent Pool. NewApplicationConfig seeds non-zero defaults for some of
607+
// these fields (Enabled=true, EmbeddingModel="granite-embedding-107m-
608+
// multilingual", MaxChunkingSize=400). The "if at default, apply file"
609+
// gate uses each field's actual default literal so file values can
610+
// override the bootstrap default while still letting an env-set value
611+
// (e.g. WithAgentPoolEmbeddingModel from a flag) win.
612+
if settings.AgentPoolEnabled != nil && options.AgentPool.Enabled {
613+
options.AgentPool.Enabled = *settings.AgentPoolEnabled
614+
}
615+
if settings.AgentPoolDefaultModel != nil && options.AgentPool.DefaultModel == "" {
616+
options.AgentPool.DefaultModel = *settings.AgentPoolDefaultModel
617+
}
618+
if settings.AgentPoolEmbeddingModel != nil {
619+
if options.AgentPool.EmbeddingModel == "" || options.AgentPool.EmbeddingModel == "granite-embedding-107m-multilingual" {
620+
options.AgentPool.EmbeddingModel = *settings.AgentPoolEmbeddingModel
621+
}
622+
}
623+
if settings.AgentPoolMaxChunkingSize != nil {
624+
if options.AgentPool.MaxChunkingSize == 0 || options.AgentPool.MaxChunkingSize == 400 {
625+
options.AgentPool.MaxChunkingSize = *settings.AgentPoolMaxChunkingSize
626+
}
627+
}
628+
if settings.AgentPoolChunkOverlap != nil && options.AgentPool.ChunkOverlap == 0 {
629+
options.AgentPool.ChunkOverlap = *settings.AgentPoolChunkOverlap
630+
}
631+
if settings.AgentPoolEnableLogs != nil && !options.AgentPool.EnableLogs {
632+
options.AgentPool.EnableLogs = *settings.AgentPoolEnableLogs
633+
}
634+
if settings.AgentPoolCollectionDBPath != nil && options.AgentPool.CollectionDBPath == "" {
635+
options.AgentPool.CollectionDBPath = *settings.AgentPoolCollectionDBPath
636+
}
637+
if settings.AgentPoolVectorEngine != nil {
638+
// Default is "chromem"; treat both that and empty as "not env-set".
639+
if options.AgentPool.VectorEngine == "" || options.AgentPool.VectorEngine == "chromem" {
640+
options.AgentPool.VectorEngine = *settings.AgentPoolVectorEngine
641+
}
642+
}
643+
if settings.AgentPoolDatabaseURL != nil && options.AgentPool.DatabaseURL == "" {
644+
options.AgentPool.DatabaseURL = *settings.AgentPoolDatabaseURL
645+
}
646+
if settings.AgentPoolAgentHubURL != nil {
647+
// Default is "https://agenthub.localai.io"; treat both that and empty
648+
// as "not env-set".
649+
if options.AgentPool.AgentHubURL == "" || options.AgentPool.AgentHubURL == "https://agenthub.localai.io" {
650+
options.AgentPool.AgentHubURL = *settings.AgentPoolAgentHubURL
651+
}
652+
}
653+
551654
xlog.Debug("Runtime settings loaded from runtime_settings.json")
552655
}
553656

0 commit comments

Comments
 (0)