mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 04:56:52 -04:00
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>
284 lines
9.9 KiB
Go
284 lines
9.9 KiB
Go
package localai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
|
|
"github.com/mudler/LocalAI/core/p2p"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// GetSettingsEndpoint returns current settings with precedence (env > file > defaults)
|
|
func GetSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
appConfig := app.ApplicationConfig()
|
|
settings := appConfig.ToRuntimeSettings()
|
|
return c.JSON(http.StatusOK, settings)
|
|
}
|
|
}
|
|
|
|
// UpdateSettingsEndpoint updates settings, saves to file, and applies immediately
|
|
func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
appConfig := app.ApplicationConfig()
|
|
startupConfig := app.StartupConfig()
|
|
|
|
if startupConfig == nil {
|
|
startupConfig = appConfig
|
|
}
|
|
|
|
body, err := io.ReadAll(c.Request().Body)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to read request body: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
var settings config.RuntimeSettings
|
|
if err := json.Unmarshal(body, &settings); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to parse JSON: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
// Validate timeouts if provided
|
|
if settings.WatchdogIdleTimeout != nil {
|
|
if _, err := time.ParseDuration(*settings.WatchdogIdleTimeout); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid watchdog_idle_timeout format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.WatchdogBusyTimeout != nil {
|
|
if _, err := time.ParseDuration(*settings.WatchdogBusyTimeout); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid watchdog_busy_timeout format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.WatchdogInterval != nil {
|
|
if _, err := time.ParseDuration(*settings.WatchdogInterval); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid watchdog_interval format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.LRUEvictionRetryInterval != nil {
|
|
if _, err := time.ParseDuration(*settings.LRUEvictionRetryInterval); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid lru_eviction_retry_interval format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
if settings.OpenResponsesStoreTTL != nil {
|
|
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
|
|
if _, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err != nil {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Invalid open_responses_store_ttl format: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate P2P token before saving so the real token is persisted (not "0")
|
|
if settings.P2PToken != nil && *settings.P2PToken == "0" {
|
|
token := p2p.GenerateToken(60, 60)
|
|
settings.P2PToken = &token
|
|
}
|
|
|
|
// Save to file
|
|
if appConfig.DynamicConfigsDir == "" {
|
|
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "DynamicConfigsDir is not set",
|
|
})
|
|
}
|
|
|
|
// 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
|
|
// the incoming list before we persist or re-merge, otherwise each save
|
|
// duplicates the env keys on top of the previous merge (#9071).
|
|
if settings.ApiKeys != nil {
|
|
envKeys := startupConfig.ApiKeys
|
|
envSet := make(map[string]struct{}, len(envKeys))
|
|
for _, k := range envKeys {
|
|
envSet[k] = struct{}{}
|
|
}
|
|
runtimeOnly := make([]string, 0, len(*settings.ApiKeys))
|
|
for _, k := range *settings.ApiKeys {
|
|
if _, fromEnv := envSet[k]; fromEnv {
|
|
continue
|
|
}
|
|
runtimeOnly = append(runtimeOnly, k)
|
|
}
|
|
settings.ApiKeys = &runtimeOnly
|
|
}
|
|
|
|
settingsFile := filepath.Join(appConfig.DynamicConfigsDir, "runtime_settings.json")
|
|
settingsJSON, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to marshal settings: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
if err := os.WriteFile(settingsFile, settingsJSON, 0600); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Failed to write settings file: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
// Apply settings using centralized method
|
|
watchdogChanged := appConfig.ApplyRuntimeSettings(&settings)
|
|
|
|
// Handle API keys specially (merge with startup keys)
|
|
if settings.ApiKeys != nil {
|
|
envKeys := startupConfig.ApiKeys
|
|
runtimeKeys := *settings.ApiKeys
|
|
appConfig.ApiKeys = append(envKeys, runtimeKeys...)
|
|
}
|
|
|
|
// Update backend logging dynamically
|
|
if settings.EnableBackendLogging != nil {
|
|
app.ModelLoader().SetBackendLoggingEnabled(*settings.EnableBackendLogging)
|
|
xlog.Info("Updated backend logging setting", "enableBackendLogging", *settings.EnableBackendLogging)
|
|
}
|
|
|
|
// Update watchdog dynamically for settings that don't require restart
|
|
if settings.ForceEvictionWhenBusy != nil {
|
|
currentWD := app.ModelLoader().GetWatchDog()
|
|
if currentWD != nil {
|
|
currentWD.SetForceEvictionWhenBusy(*settings.ForceEvictionWhenBusy)
|
|
xlog.Info("Updated watchdog force eviction when busy setting", "forceEvictionWhenBusy", *settings.ForceEvictionWhenBusy)
|
|
}
|
|
}
|
|
|
|
// Update ModelLoader LRU eviction retry settings dynamically
|
|
maxRetries := appConfig.LRUEvictionMaxRetries
|
|
retryInterval := appConfig.LRUEvictionRetryInterval
|
|
if settings.LRUEvictionMaxRetries != nil {
|
|
maxRetries = *settings.LRUEvictionMaxRetries
|
|
}
|
|
if settings.LRUEvictionRetryInterval != nil {
|
|
if dur, err := time.ParseDuration(*settings.LRUEvictionRetryInterval); err == nil {
|
|
retryInterval = dur
|
|
}
|
|
}
|
|
if settings.LRUEvictionMaxRetries != nil || settings.LRUEvictionRetryInterval != nil {
|
|
app.ModelLoader().SetLRUEvictionRetrySettings(maxRetries, retryInterval)
|
|
xlog.Info("Updated LRU eviction retry settings", "maxRetries", maxRetries, "retryInterval", retryInterval)
|
|
}
|
|
|
|
// Update Open Responses store TTL dynamically
|
|
if settings.OpenResponsesStoreTTL != nil {
|
|
ttl := time.Duration(0)
|
|
if *settings.OpenResponsesStoreTTL != "0" && *settings.OpenResponsesStoreTTL != "" {
|
|
if dur, err := time.ParseDuration(*settings.OpenResponsesStoreTTL); err == nil {
|
|
ttl = dur
|
|
} else {
|
|
xlog.Warn("Invalid Open Responses store TTL format", "ttl", *settings.OpenResponsesStoreTTL, "error", err)
|
|
}
|
|
}
|
|
// Import the store package
|
|
store := openresponses.GetGlobalStore()
|
|
store.SetTTL(ttl)
|
|
xlog.Info("Updated Open Responses store TTL", "ttl", ttl)
|
|
}
|
|
|
|
// Check if agent job retention changed
|
|
agentJobChanged := settings.AgentJobRetentionDays != nil
|
|
|
|
// Restart watchdog if settings changed
|
|
if watchdogChanged {
|
|
if settings.WatchdogEnabled != nil && !*settings.WatchdogEnabled {
|
|
if err := app.StopWatchdog(); err != nil {
|
|
xlog.Error("Failed to stop watchdog", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to stop watchdog: " + err.Error(),
|
|
})
|
|
}
|
|
} else {
|
|
if err := app.RestartWatchdog(); err != nil {
|
|
xlog.Error("Failed to restart watchdog", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to restart watchdog: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restart agent job service if retention days changed
|
|
if agentJobChanged {
|
|
if err := app.RestartAgentJobService(); err != nil {
|
|
xlog.Error("Failed to restart agent job service", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to restart agent job service: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Restart P2P if P2P settings changed
|
|
p2pChanged := settings.P2PToken != nil || settings.P2PNetworkID != nil || settings.Federated != nil
|
|
if p2pChanged {
|
|
if settings.P2PToken != nil && *settings.P2PToken == "" {
|
|
if err := app.StopP2P(); err != nil {
|
|
xlog.Error("Failed to stop P2P", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to stop P2P: " + err.Error(),
|
|
})
|
|
}
|
|
} else {
|
|
if err := app.RestartP2P(); err != nil {
|
|
xlog.Error("Failed to restart P2P", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{
|
|
Success: false,
|
|
Error: "Settings saved but failed to restart P2P: " + err.Error(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, schema.SettingsResponse{
|
|
Success: true,
|
|
Message: "Settings updated successfully",
|
|
})
|
|
}
|
|
}
|