mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 20:43:27 -05:00
Signed-off-by: Karl-Gustav Kallasmaa <29797627+KGKallasmaa@users.noreply.github.com>
(cherry picked from commit a93be7cc10)
(cherry picked from commit 4c46293b151e03779e8ad580de9303da91afdc27)
277 lines
8.2 KiB
Go
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
|
|
}
|