Files
LocalAI/addons/web/internal/settings/settings.go
T
Karl-Gustav Kallasmaa c7fb75bfcd feat(web): add Keenable as a search provider (works without a key)
Signed-off-by: Karl-Gustav Kallasmaa <29797627+KGKallasmaa@users.noreply.github.com>
(cherry picked from commit a93be7cc10)
(cherry picked from commit 4c46293b151e03779e8ad580de9303da91afdc27)
2026-11-03 19:29:02 +00:00

277 lines
8.2 KiB
Go

// SPDX-License-Identifier: MIT
// Package settings keeps the administrator's choices of the Web add-on: the
// order in which the search providers are tried, their keys, the Google
// search engine ID and the SearXNG address.
//
// The file is settings.json in the add-on's data folder, mode 0600, written
// atomically. A key is a secret: it is written there and handed to its
// provider, and nothing else. View, which is what the Settings page reads,
// says only whether a key is set.
package settings
import (
"encoding/json"
"errors"
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"github.com/mudler/LocalAI/addons/internal/atomicfile"
"github.com/mudler/LocalAI/addons/web/internal/providers"
)
// Settings are the saved choices.
type Settings struct {
// Order lists the providers to try, first to last. A provider that is not
// ready (no key, no address) is skipped.
Order []string `json:"order"`
// Keys maps a keyed provider to its key.
Keys map[string]string `json:"keys,omitempty"`
// GoogleEngine is the Programmable Search Engine ID (cx).
GoogleEngine string `json:"googleEngine,omitempty"`
// SearXNGURL is the address of a SearXNG instance.
SearXNGURL string `json:"searxngUrl,omitempty"`
}
// Defaults are the settings before any choice: every provider in the default
// order, so only the keyless entry is ready. Keenable, which is ready without
// a key, stays out of the default order until an administrator adds it, so
// the default search does not change.
func Defaults() Settings {
return Settings{Order: slices.DeleteFunc(slices.Clone(providers.Names), func(n string) bool { return n == providers.Keenable })}
}
func (s Settings) clone() Settings {
out := Settings{Order: slices.Clone(s.Order), GoogleEngine: s.GoogleEngine, SearXNGURL: s.SearXNGURL}
if len(s.Keys) > 0 {
out.Keys = make(map[string]string, len(s.Keys))
maps.Copy(out.Keys, s.Keys)
}
return out
}
// Ready reports whether a provider has what it needs, and if not, what is
// missing.
func (s Settings) Ready(name string) (bool, string) {
switch name {
case providers.Brave, providers.Tavily:
if s.Keys[name] == "" {
return false, "no key"
}
case providers.Google:
switch {
case s.Keys[name] == "" && s.GoogleEngine == "":
return false, "no key and no search engine ID"
case s.Keys[name] == "":
return false, "no key"
case s.GoogleEngine == "":
return false, "no search engine ID"
}
case providers.Keenable:
// Without a key it uses the keyless endpoint.
case providers.SearXNG:
if s.SearXNGURL == "" {
return false, "no address"
}
case providers.DuckDuckGo:
default:
return false, "unknown provider"
}
return true, ""
}
// Change is one save of the Settings page. A nil field keeps its value.
type Change struct {
Order []string `json:"order,omitempty"`
// Keys sets the key of each named provider. An empty value keeps the key.
Keys map[string]string `json:"keys,omitempty"`
// Clear removes the keys of the named providers.
Clear []string `json:"clear,omitempty"`
GoogleEngine *string `json:"googleEngine,omitempty"`
SearXNGURL *string `json:"searxngUrl,omitempty"`
}
// ErrInvalid wraps every refusal of a Change. The message never holds a key.
var ErrInvalid = errors.New("the settings are not valid")
func known(name string) bool { return slices.Contains(providers.Names, name) }
// apply returns s with c applied, or the reason c is refused.
func apply(s Settings, c Change) (Settings, error) {
out := s.clone()
if c.Order != nil {
if len(c.Order) == 0 {
return s, fmt.Errorf("%w: choose at least one provider", ErrInvalid)
}
seen := map[string]bool{}
for _, n := range c.Order {
if !known(n) || seen[n] {
return s, fmt.Errorf("%w: the order names an unknown or repeated provider", ErrInvalid)
}
seen[n] = true
}
out.Order = slices.Clone(c.Order)
}
for _, n := range c.Clear {
if !providers.Keyed(n) {
return s, fmt.Errorf("%w: %q has no key", ErrInvalid, n)
}
delete(out.Keys, n)
}
for n, k := range c.Keys {
if !providers.Keyed(n) {
return s, fmt.Errorf("%w: %q has no key", ErrInvalid, n)
}
k = strings.TrimSpace(k)
if k == "" {
continue
}
if !providers.ValidKey(k) {
// The key itself is never repeated in the answer.
return s, fmt.Errorf("%w: the key of %s must be 8 to 256 printable characters without spaces", ErrInvalid, n)
}
if out.Keys == nil {
out.Keys = map[string]string{}
}
out.Keys[n] = k
}
if len(out.Keys) == 0 {
out.Keys = nil
}
if c.GoogleEngine != nil {
v := strings.TrimSpace(*c.GoogleEngine)
if v != "" && !providers.ValidEngineID(v) {
return s, fmt.Errorf("%w: the search engine ID has letters, digits, colons, dashes and underscores only", ErrInvalid)
}
out.GoogleEngine = v
}
if c.SearXNGURL != nil {
v := strings.TrimSpace(*c.SearXNGURL)
if v != "" {
u, err := url.Parse(v)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
return s, fmt.Errorf("%w: the SearXNG address must be an http or https URL such as https://searx.example.org", ErrInvalid)
}
}
out.SearXNGURL = v
}
return out, nil
}
// Store holds the settings in memory and on disk. It is safe for concurrent
// use.
type Store struct {
path string
mu sync.Mutex
set Settings
rev int
note string
}
// Open reads the saved settings. A missing file gives the defaults; a broken
// one too, and the view says so.
func Open(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("settings: no data folder")
}
s := &Store{path: filepath.Join(dir, "settings.json"), set: Defaults()}
raw, err := os.ReadFile(s.path)
switch {
case errors.Is(err, os.ErrNotExist):
case err != nil:
return nil, err
default:
var f Settings
if json.Unmarshal(raw, &f) != nil {
s.note = "The saved settings could not be read, so the defaults apply."
break
}
// Check the file with the rules of a change, so a hand-edited file
// cannot hold what the page could not save.
got, err := apply(Defaults(), Change{Order: orNil(f.Order), Keys: f.Keys, GoogleEngine: &f.GoogleEngine, SearXNGURL: &f.SearXNGURL})
if err != nil {
s.note = "The saved settings are not valid, so the defaults apply."
break
}
s.set = got
}
return s, nil
}
func orNil(l []string) []string {
if len(l) == 0 {
return nil
}
return l
}
// Get returns a copy of the settings and their revision, which changes with
// every save.
func (s *Store) Get() (Settings, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.set.clone(), s.rev
}
// Apply saves a change. Nothing changes when it is refused or cannot be
// written.
func (s *Store) Apply(c Change) error {
s.mu.Lock()
defer s.mu.Unlock()
next, err := apply(s.set, c)
if err != nil {
return err
}
raw, err := json.Marshal(next)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return errors.New("the settings could not be saved")
}
if err := atomicfile.Write(s.path, raw, 0o600); err != nil {
return errors.New("the settings could not be saved")
}
s.set, s.note = next, ""
s.rev++
return nil
}
// ProviderView is one provider as the Settings page shows it.
type ProviderView struct {
Name string `json:"name"`
// Keyed says that the provider needs a key; KeySet that one is saved.
Keyed bool `json:"keyed"`
KeySet bool `json:"keySet"`
Ready bool `json:"ready"`
// Missing says what the provider still needs, when it is not ready.
Missing string `json:"missing,omitempty"`
}
// View is what the Settings page reads. It holds no key.
type View struct {
Order []string `json:"order"`
Providers []ProviderView `json:"providers"`
GoogleEngine string `json:"googleEngine"`
SearXNGURL string `json:"searxngUrl"`
Note string `json:"note,omitempty"`
}
// View returns the settings without the keys.
func (s *Store) View() View {
s.mu.Lock()
defer s.mu.Unlock()
v := View{Order: slices.Clone(s.set.Order), GoogleEngine: s.set.GoogleEngine, SearXNGURL: s.set.SearXNGURL, Note: s.note}
for _, n := range providers.Names {
ready, missing := s.set.Ready(n)
v.Providers = append(v.Providers, ProviderView{Name: n, Keyed: providers.Keyed(n), KeySet: s.set.Keys[n] != "", Ready: ready, Missing: missing})
}
return v
}