Lot 10 : réglages partagés tenus par la façade (LOKI_SETTINGS=rust)

La façade sert /api/prefs, /api/sysprompt, /api/reasoning-effort et
/api/oai/public quand LOKI_SETTINGS=rust (expérimental ; le Go reste le
défaut), sur les mêmes clés de la base : préférences fusionnées et
nettoyées (prénom borné, avatars en liste fermée, valeurs vides
effacées), prompt système du preset actif avec repli sur le global,
intensité du raisonnement en liste blanche, adresse publique normalisée.

Côté Go, facade_settings.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_settings_test.go tire du Go 19 adresses, 11 intensités et
29 appels de route avec le contenu de la base après chacun, rejoués par
tests/settings.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 16:25:51 +00:00
1 parent 46bfab3343
commit 28f90543de
17 files changed
+1689 -9

No files matched your search

+4 -2
View File
@@ -141,8 +141,8 @@ var facadeChatRoutes = map[string]bool{
}
// withFacadeChat remet la requête à la façade quand elle tient la
// conversation — ou les routes des modèles (facade_models.go), ou la dictée
// (facade_dictate.go).
// conversation — ou les routes des modèles (facade_models.go), la dictée
// (facade_dictate.go), les réglages partagés (facade_settings.go).
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
// jeton interne qui en tient lieu là-bas.
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
@@ -153,6 +153,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeModels, "modèles indisponibles : "
case facadeDictateRoutes[path]:
held, unavailable = facadeDictate, "dictée indisponible : "
case facadeSettingsRoutes[path]:
held, unavailable = facadeSettings, "réglages indisponibles : "
default:
return h
}
+26
View File
@@ -412,3 +412,29 @@ func TestFacadeDictee(t *testing.T) {
t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n)
}
}
// LOKI_SETTINGS=rust : les réglages partagés partent à la façade ; sans la
// variable, ils restent ici.
func TestFacadeReglages(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeSettingsEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeSettingsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeSettingsRoutes) {
t.Fatalf("%d appels reçus par la façade", n)
}
t.Setenv(facadeSettingsEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/prefs", local)(w, httptest.NewRequest("GET", "/api/prefs", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
+32
View File
@@ -0,0 +1,32 @@
package loki
import (
"os"
"strings"
)
// facade_settings.go — réglages partagés tenus par la façade Rust
// (LOKI_SETTINGS=rust, lot 10 de la migration ; expérimental, le Go reste le
// défaut) : préférences d'affichage, prompt système, intensité du
// raisonnement, adresse publique.
//
// Les valeurs restent dans la base, aux mêmes clés : ce process les relit là
// où il s'en sert (tour de chat, accès OpenAI). Seules les routes changent de
// main ; celles qui arrivent ici par le tunnel du relais sont remises à la
// façade (withFacadeChat).
const facadeSettingsEnv = "LOKI_SETTINGS"
// facadeSettings : la façade tient-elle les réglages partagés ?
func facadeSettings() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeSettingsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeSettingsRoutes : les routes que la façade sert alors (settings.rs).
var facadeSettingsRoutes = map[string]bool{
"/api/prefs": true,
"/api/sysprompt": true,
"/api/reasoning-effort": true,
"/api/oai/public": true,
}
+183
View File
@@ -0,0 +1,183 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// parity_settings_test.go — journal de référence des réglages partagés entre
// appareils (lot 10 de la migration Rust) : préférences d'affichage, prompt
// système (par preset, repli global), intensité du raisonnement, adresse
// publique. Rangé dans rust/loki-rs/tests/parity_settings/settings.json ;
// tests/settings.rs le rejoue par la façade.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteReglages
const paritySettingsFile = "../../rust/loki-rs/tests/parity_settings/settings.json"
type paritySettingsStep struct {
// Avant l'appel : configuration remplacée, preset écrit.
Config map[string]string `json:"config,omitempty"`
Preset map[string]string `json:"preset,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Host string `json:"host,omitempty"`
Status int `json:"status"`
Out any `json:"out"`
// Après l'appel : le contenu des clés touchées en base.
Prefs map[string]string `json:"prefs,omitempty"`
State map[string]string `json:"state,omitempty"`
}
type paritySettingsFixture struct {
PublicURL []struct {
In string `json:"in"`
Out string `json:"out,omitempty"`
Err string `json:"err,omitempty"`
} `json:"public_url"`
EffortValue map[string]string `json:"effort_value"`
Steps []paritySettingsStep `json:"steps"`
}
func TestPariteReglages(t *testing.T) {
testHome(t)
var fx paritySettingsFixture
for _, in := range []string{"", " ", "exemple.fr", "https://ia.exemple.fr/", "http://10.0.0.5:8090/v1/",
"HTTPS://Exemple.FR:443", "ftp://x.fr", "https://", "https://moi:secret@x.fr", "https://x.fr/?a=1",
"https://x.fr/#ancre", "https://x.fr/api", "https://x.fr:0", "https://x.fr:99999", "https://x.fr:abc",
"https://[::1]:8090", "https://::1", "https://:8080", "exemple.fr:8090/v1"} {
c := struct {
In string `json:"in"`
Out string `json:"out,omitempty"`
Err string `json:"err,omitempty"`
}{In: in}
out, err := normalizePublicURL(in)
if err != nil {
c.Err = err.Error()
} else {
c.Out = out
}
fx.PublicURL = append(fx.PublicURL, c)
}
fx.EffortValue = map[string]string{}
for _, v := range []string{"", "auto", "Default", "off", "NONE", "0", "low", " High ", "medium", "xhigh", "bizarre"} {
fx.EffortValue[v] = reasoningEffortValue(v)
}
keys := []string{"sysprompt", "sysprompt:alpha", "sysprompt:beta", "public_url"}
run := func(st paritySettingsStep) paritySettingsStep {
if st.Config != nil {
if err := WriteConfig(st.Config); err != nil {
t.Fatal(err)
}
}
for id, content := range st.Preset {
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
w := httptest.NewRecorder()
r := httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body))
if st.Host != "" {
r.Host = st.Host
}
switch st.Path {
case "/api/prefs":
handleWebPrefs(w, r)
case "/api/sysprompt":
handleSysPrompt(w, r)
case "/api/reasoning-effort":
handleReasoningEffort(w, r)
case "/api/oai/public":
handlePublicAddress(w, r)
default:
t.Fatalf("route inattendue %s", st.Path)
}
st.Status = w.Code
if err := json.Unmarshal(w.Body.Bytes(), &st.Out); err != nil {
t.Fatalf("%s %s : %v (%s)", st.Method, st.Path, err, w.Body.String())
}
st.Prefs = loadWebPrefs()
st.State = map[string]string{}
for _, k := range keys {
if v := getStr(bkState, k); v != "" {
st.State[k] = v
}
}
if v := ReadConfig()["REASONING_EFFORT"]; v != "" {
st.State["config:REASONING_EFFORT"] = v
}
return st
}
for _, st := range []paritySettingsStep{
{Method: "GET", Path: "/api/prefs"},
{Method: "POST", Path: "/api/prefs", Body: `{"theme":" DARK ","hide_tools":"1","hide_stats":"2","inconnue":"x","user_name":" Ma\u0007rie ","avatar_user":"🦊","avatar_ai":"💩"}`},
{Method: "POST", Path: "/api/prefs", Body: `{"user_name":"Une très très longue signature éèà","fold_tools":1,"hide_side":"0"}`},
{Method: "POST", Path: "/api/prefs", Body: `pas du json`},
{Method: "POST", Path: "/api/prefs", Body: `{"user_name":" ","avatar_user":""}`},
{Method: "GET", Path: "/api/prefs"},
// Prompt système : sans preset actif, le global.
{Method: "GET", Path: "/api/sysprompt"},
{Method: "POST", Path: "/api/sysprompt", Body: `{"text":" Réponds en français. "}`},
{Method: "POST", Path: "/api/sysprompt", Body: `{"text":5}`},
{Method: "PUT", Path: "/api/sysprompt"},
// Preset alpha actif : son propre prompt, repli sur le global tant qu'il
// n'en a pas.
{Config: map[string]string{"MODEL": "a.gguf", "CTX": "8192"},
Preset: map[string]string{"alpha": "# Alpha\nMODEL=a.gguf\nCTX=8192\n", "beta": "# Beta\nMODEL=b.gguf\n"},
Method: "GET", Path: "/api/sysprompt"},
{Method: "POST", Path: "/api/sysprompt", Body: `{"text":"Prompt d'Alpha"}`},
{Method: "GET", Path: "/api/sysprompt"},
{Config: map[string]string{"MODEL": "b.gguf"}, Method: "GET", Path: "/api/sysprompt"},
{Method: "POST", Path: "/api/sysprompt", Body: `{"text":""}`},
{Config: map[string]string{"MODEL": "a.gguf", "CTX": "8192"}, Method: "POST", Path: "/api/sysprompt", Body: `{"text":" "}`},
{Method: "GET", Path: "/api/sysprompt"},
// Intensité du raisonnement.
{Config: map[string]string{"MODEL": "a.gguf"}, Method: "GET", Path: "/api/reasoning-effort"},
{Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":" HIGH "}`},
{Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":"auto"}`},
{Config: map[string]string{"MODEL": "a.gguf", "REASONING": "on", "REASONING_EFFORT": "off"}, Method: "GET", Path: "/api/reasoning-effort"},
{Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":""}`},
{Config: map[string]string{"REASONING": "off", "REASONING_EFFORT": "bizarre"}, Method: "GET", Path: "/api/reasoning-effort"},
// Adresse publique.
{Method: "GET", Path: "/api/oai/public"},
{Method: "POST", Path: "/api/oai/public", Body: `{"url":"ftp://x"}`},
{Method: "POST", Path: "/api/oai/public", Body: `pas du json`},
{Method: "POST", Path: "/api/oai/public", Body: `{"url":" ia.exemple.fr/v1 "}`},
{Method: "GET", Path: "/api/oai/public"},
{Method: "POST", Path: "/api/oai/public", Body: `{"url":""}`},
} {
fx.Steps = append(fx.Steps, run(st))
}
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(paritySettingsFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paritySettingsFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(paritySettingsFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("les réglages ne produisent plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteReglages,\n"+
"puis cargo test --test settings dit si le port Rust suit.\n--- obtenu ---\n%s", paritySettingsFile, got)
}
}
+2 -1
View File
@@ -221,7 +221,8 @@ func newWebMux() *http.ServeMux {
// withFacadeChat : routes du chat remises à la façade quand elle tient la
// conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand
// elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée
// (LOKI_DICTATE=rust, facade_dictate.go) ; sans effet sinon.
// (LOKI_DICTATE=rust, facade_dictate.go) et des réglages partagés
// (LOKI_SETTINGS=rust, facade_settings.go) ; sans effet sinon.
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) }
api("/api/ping", handlePing)
api("/api/status", handleStatus)