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

+1
View File
@@ -230,6 +230,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go |
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
@@ -0,0 +1,44 @@
# Lot 10 — réglages partagés tenus par la façade
## Contexte
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
tous les appareils :
| Route | Port de | Ce qu'elle règle |
|---|---|---|
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<id>`), repli sur le global (`state/sysprompt`) |
| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) |
| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » |
Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`,
expérimental ; sans la variable rien ne change).
Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là
où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se
périme sur l'horodatage du fichier de base, qu'une écriture de la façade
modifie. `facade_settings.go` remet les routes du tunnel à la façade.
## Écarts assumés
- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une
valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`.
- Préférences réécrites clé par clé au lieu d'une transaction.
- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait
par `/etc/services`.
## Parité
`internal/loki/parity_settings_test.go` tire du Go :
- 19 adresses publiques normalisées ou refusées ;
- 11 valeurs d'intensité ;
- 29 appels de route, avec le contenu de la base après chacun :
- fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ;
- prompt par preset avec repli, effacement, méthode refusée ;
- intensité acceptée ou refusée ;
- adresse publique.
`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom
tronqué à 25 caractères) fait échouer le rejeu.
+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)
+8
View File
@@ -32,11 +32,19 @@ pub struct Ctx {
pub models: Option<std::sync::Arc<crate::models::Models>>,
/// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) ; `None` : au Go.
pub dictate: Option<std::sync::Arc<crate::dictate::Dictation>>,
/// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`).
pub settings: bool,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
impl Ctx {
/// La façade tient-elle au moins un lot (le Go lui remet alors le trafic
/// de ce lot qui lui arrive par le tunnel) ?
pub fn holds_any(&self) -> bool {
self.chat.is_some() || self.models.is_some() || self.dictate.is_some() || self.settings
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
/// lui (tunnel du relais, redistribution E2E).
pub fn from_go(&self, headers: &HeaderMap) -> bool {
+1
View File
@@ -26,6 +26,7 @@ pub mod oai;
pub mod paths;
pub mod presets;
pub mod proxy;
pub mod settings;
pub mod store;
pub mod sysinfo;
pub mod tools;
+8 -1
View File
@@ -124,6 +124,9 @@ fn serve() -> ExitCode {
// LOKI_DICTATE=rust : la dictée vocale aussi (le Go lui demande alors
// d'arrêter son serveur quand il libère la VRAM).
let dictate_rust = held_by_facade("LOKI_DICTATE");
// LOKI_SETTINGS=rust : les réglages partagés (préférences, prompt
// système, intensité du raisonnement, adresse publique).
let settings_rust = held_by_facade("LOKI_SETTINGS");
let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())];
if chat_rust {
env.push(("LOKI_CHAT".into(), "rust".into()));
@@ -134,7 +137,10 @@ fn serve() -> ExitCode {
if dictate_rust {
env.push(("LOKI_DICTATE".into(), "rust".into()));
}
if chat_rust || models_rust || dictate_rust {
if settings_rust {
env.push(("LOKI_SETTINGS".into(), "rust".into()));
}
if chat_rust || models_rust || dictate_rust || settings_rust {
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
}
let sup = child::Supervisor::spawn(go_bin, go_port, env);
@@ -196,6 +202,7 @@ fn serve() -> ExitCode {
chat,
models,
dictate,
settings: settings_rust,
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
+9 -5
View File
@@ -69,6 +69,8 @@ pub struct Config {
/// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) : servie ici ;
/// `None` : relayée au Go.
pub dictate: Option<Arc<crate::dictate::Dictation>>,
/// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`).
pub settings: bool,
}
struct Proxy {
@@ -98,6 +100,7 @@ pub async fn serve(
chat: cfg.chat,
models: cfg.models,
dictate: cfg.dictate,
settings: cfg.settings,
internal_token: cfg.internal_token.clone(),
},
});
@@ -137,11 +140,9 @@ pub async fn serve(
impl Proxy {
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
// Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la
// façade le trafic du chat, des modèles et de la dictée qui lui arrive
// sans passer par elle.
let from_go =
(self.ctx.chat.is_some() || self.ctx.models.is_some() || self.ctx.dictate.is_some())
&& self.ctx.from_go(req.headers());
// façade le trafic des lots qu'elle tient et qui lui arrive sans passer
// par elle.
let from_go = self.ctx.holds_any() && self.ctx.from_go(req.headers());
// Jamais de confiance accordée sur la foi d'un en-tête du client :
// ni « je viens du tunnel », ni « je suis la façade ».
req.headers_mut().remove(VIA_TUNNEL);
@@ -181,6 +182,9 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if self.ctx.settings && crate::settings::handles(path) {
return crate::settings::serve(&self.ctx, req, from_go).await;
}
if let Some(d) = &self.ctx.dictate
&& crate::dictate::handles(path)
{
+490
View File
@@ -0,0 +1,490 @@
//! Réglages partagés entre appareils — port de `web_prefs.go`,
//! `web_sysprompt.go`, `handleReasoningEffort` et `handlePublicAddress`
//! (lot 10 de la migration), servis par la façade quand
//! `LOKI_SETTINGS=rust` (expérimental ; le Go par défaut) :
//!
//! - `GET|POST /api/prefs` : thème et affichage, prénom, avatars ;
//! - `GET|POST /api/sysprompt` : prompt système du preset actif (repli sur le
//! prompt global) ;
//! - `GET|POST /api/reasoning-effort` : intensité du raisonnement
//! (`REASONING_EFFORT` de la configuration vivante) ;
//! - `GET|POST /api/oai/public` : adresse publique de l'instance.
//!
//! Tout vit dans la base du Go (mêmes clés) : le Go relit ces valeurs là où il
//! s'en sert (tour de chat, accès OpenAI).
//!
//! Écarts assumés : message d'un corps JSON syntaxiquement illisible (serde) ;
//! préférences réécrites clé par clé au lieu d'une transaction (le Go
//! remplaçait le bucket d'un bloc) ; port d'adresse publique nommé
//! (`:https`) refusé — le Go le résolvait par /etc/services.
use std::collections::{BTreeMap, HashMap};
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::chat::wire::{reasoning_active, reasoning_effort_value};
use crate::models::body_json;
use crate::presets;
use crate::proxy::Body;
use crate::store::BK_STATE;
const BK_PREFS: &str = "prefs";
const BK_CONFIG: &str = "config";
const SYSPROMPT_GLOBAL: &str = "sysprompt";
const SYSPROMPT_PREFIX: &str = "sysprompt:";
const PUBLIC_URL_KEY: &str = "public_url";
/// Valeurs proposées pour `REASONING_EFFORT` (`ReasoningEfforts`).
pub const REASONING_EFFORTS: [&str; 5] = ["none", "low", "medium", "high", "xhigh"];
/// Avatars proposés (liste fermée).
pub const AVATARS: [&str; 16] = [
"🙂", "😎", "🧑", "👤", "🦊", "🐱", "🐼", "🦉", "🚀", "⚡", "🌙", "🔥", "🌿", "🎯", "🧠", "🤖",
];
/// Routes servies ici.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/prefs" | "/api/sysprompt" | "/api/reasoning-effort" | "/api/oai/public"
)
}
// ---------------------------------------------------------------------------
// Préférences
// ---------------------------------------------------------------------------
/// `webPrefsAllowed` : clés à valeurs fermées.
fn allowed(key: &str) -> Option<&'static [&'static str]> {
Some(match key {
"theme" => &["light", "dark"],
"hide_reasoning" | "hide_tools" | "fold_tools" | "hide_side" | "hide_stats" => &["0", "1"],
_ => return None,
})
}
/// `strings.TrimSpace` (espaces Unicode).
fn go_trim(s: &str) -> &str {
s.trim_matches(char::is_whitespace)
}
/// `webPrefsText` : nettoyage des préférences libres ; `None` : refusée.
fn clean_text(key: &str, v: &str) -> Option<Option<String>> {
match key {
"user_name" => {
let v: String = v
.chars()
.filter(|&c| c >= '\u{20}' && c != '\u{7f}')
.collect();
let mut v = go_trim(&v).to_string();
if v.chars().count() > 24 {
v = go_trim(&v.chars().take(24).collect::<String>()).to_string();
}
Some(Some(v))
}
"avatar_user" | "avatar_ai" => {
let v = go_trim(v);
if v.is_empty() || AVATARS.contains(&v) {
Some(Some(v.to_string()))
} else {
Some(None)
}
}
_ => None,
}
}
/// `saveWebPrefs` : fusionne les valeurs valides de `req` dans `prefs`.
pub fn merge_prefs(prefs: &mut BTreeMap<String, String>, req: &Value) {
let Some(obj) = req.as_object() else {
return;
};
for (k, v) in obj {
// map[string]string du Go : une valeur non textuelle est sautée.
let Some(v) = v.as_str() else { continue };
if let Some(res) = clean_text(k, v) {
if let Some(val) = res {
prefs.insert(k.clone(), val);
}
continue;
}
let Some(ok) = allowed(k) else { continue };
let v = go_trim(v).to_lowercase();
if ok.contains(&v.as_str()) {
prefs.insert(k.clone(), v);
}
}
}
// ---------------------------------------------------------------------------
// Adresse publique
// ---------------------------------------------------------------------------
/// Caractères refusés dans un nom d'hôte par `url.Parse` (`InvalidHostError`).
fn host_char_ok(c: char) -> bool {
!c.is_ascii() || c.is_ascii_alphanumeric() || "-_.~!$&'()*+,;=:[]<>\"%".contains(c)
}
/// `url.Parse` du Go, réduit à ce que l'adresse publique examine.
struct GoUrl {
scheme: String,
user: Option<String>,
host: String,
path: String,
query: String,
fragment: String,
}
fn go_url_parse(raw: &str) -> Option<GoUrl> {
let (rest, fragment) = match raw.split_once('#') {
Some((a, b)) => (a, b.to_string()),
None => (raw, String::new()),
};
let (scheme, rest) = rest.split_once("://")?;
let mut sc = scheme.chars();
if !sc.next().is_some_and(|c| c.is_ascii_alphabetic())
|| !sc.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
{
return None;
}
let (rest, query) = match rest.split_once('?') {
Some((a, b)) => (a, b.to_string()),
None => (rest, String::new()),
};
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
let (user, host) = match authority.rfind('@') {
Some(i) => {
let info = &authority[..i];
let name = info.split(':').next().unwrap_or("");
(Some(percent_decode(name)?), &authority[i + 1..])
}
None => (None, authority),
};
// Port : des chiffres seulement après le dernier « : » (hors crochets).
if let Some(stripped) = host.strip_prefix('[') {
let end = stripped.find(']')?;
let after = &stripped[end + 1..];
if !after.is_empty()
&& !(after.starts_with(':') && after[1..].bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
} else if let Some(i) = host.rfind(':')
&& !host[i + 1..].bytes().all(|b| b.is_ascii_digit())
{
return None;
}
if !host.chars().all(host_char_ok) {
return None;
}
Some(GoUrl {
scheme: scheme.to_string(),
user,
host: host.to_string(),
path: percent_decode(path)?,
query,
fragment,
})
}
fn percent_decode(s: &str) -> Option<String> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
let h = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?;
out.push(u8::from_str_radix(h, 16).ok()?);
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
/// `net.SplitHostPort`, réduit : (hôte, port) ou rien.
fn split_host_port(host: &str) -> Option<(&str, &str)> {
if let Some(rest) = host.strip_prefix('[') {
let end = rest.find(']')?;
let after = &rest[end + 1..];
return after.strip_prefix(':').map(|p| (&rest[..end], p));
}
let i = host.rfind(':')?;
let h = &host[..i];
if h.contains(':') {
return None; // trop de « : »
}
Some((h, &host[i + 1..]))
}
/// `normalizePublicURL` : « schéma://hôte[:port] », sans slash final.
pub fn normalize_public_url(raw: &str) -> Result<String, String> {
let raw = go_trim(raw);
if raw.is_empty() {
return Ok(String::new());
}
let raw = if raw.contains("://") {
raw.to_string()
} else {
format!("https://{raw}")
};
let u = go_url_parse(&raw).ok_or_else(|| format!("adresse illisible : {raw}"))?;
let scheme = u.scheme.to_lowercase();
if scheme != "http" && scheme != "https" {
return Err(format!(
"adresse en {scheme}:// — seuls http:// et https:// sont acceptés"
));
}
if u.host.is_empty() {
return Err("adresse sans nom de machine".into());
}
if let Some(name) = u.user {
return Err(format!(
"l'adresse ne doit pas porter d'identifiants ({name}@…)"
));
}
if !u.query.is_empty() || !u.fragment.is_empty() {
return Err("l'adresse ne doit porter ni paramètre ni ancre".into());
}
match u.path.strip_suffix('/').unwrap_or(&u.path) {
"" | "/v1" => {}
_ => {
return Err(format!(
"l'adresse ne doit pas contenir de chemin ({}) : Loki sert /v1 à la racine de son port",
u.path
));
}
}
match split_host_port(&u.host) {
Some((h, port)) => {
if h.is_empty() {
return Err("adresse sans nom de machine".into());
}
if !port.parse::<u32>().is_ok_and(|n| n > 0 && n <= 65535) {
return Err(format!("port invalide : {port}"));
}
}
None if u.host.contains(':') && !u.host.starts_with('[') => {
return Err(format!(
"adresse IPv6 à écrire entre crochets : [{}]",
u.host
));
}
None => {}
}
Ok(format!("{scheme}://{}", u.host))
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/// Sérialise les écritures de préférences (lecture, fusion, écriture).
static PREFS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn load_prefs(ctx: &Ctx) -> Result<BTreeMap<String, String>, String> {
Ok(ctx.store.all(BK_PREFS).await?.into_iter().collect())
}
/// `putStr` : une chaîne vide efface la clé.
async fn put_str(ctx: &Ctx, bucket: &str, key: &str, val: &str) -> Result<(), String> {
if val.is_empty() {
ctx.store.del(bucket, key).await
} else {
ctx.store.put(bucket, key, val.as_bytes().to_vec()).await
}
}
/// Erreur de type d'`encoding/json` pour un champ texte attendu.
fn go_type_error(field: &str, v: &Value) -> Option<String> {
let kind = match v {
Value::Null | Value::String(_) => return None,
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::Array(_) => "array",
Value::Object(_) => "object",
};
Some(format!(
"json: cannot unmarshal {kind} into Go struct field .{field} of type string"
))
}
async fn active_preset(ctx: &Ctx) -> String {
let cfg: HashMap<String, String> = ctx.store.config().await;
let order = presets::parse_preset_order(
ctx.store
.get(BK_STATE, presets::PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
let home = ctx.engine.home.clone();
tokio::task::spawn_blocking(move || presets::active_preset_id(&home, &cfg, &order))
.await
.unwrap_or_default()
}
/// `readSysPrompt` : celui du preset actif, sinon le global.
async fn read_sysprompt(ctx: &Ctx) -> String {
let id = active_preset(ctx).await;
if !id.is_empty() {
let s = ctx
.store
.get_str(BK_STATE, &format!("{SYSPROMPT_PREFIX}{id}"))
.await;
if !go_trim(&s).is_empty() {
return go_trim(&s).to_string();
}
}
ctx.store.get_str(BK_STATE, SYSPROMPT_GLOBAL).await
}
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(ctx: &Ctx, req: Request<Incoming>, from_go: bool) -> Response<Body> {
let (parts, body) = req.into_parts();
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
return *refus;
}
let post = parts.method == Method::POST;
match parts.uri.path() {
"/api/prefs" if post => {
let req = body_json(body).await.unwrap_or(Value::Null);
let _guard = PREFS_LOCK.lock().await;
let old = match load_prefs(ctx).await {
Ok(p) => p,
Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e),
};
let mut prefs = old.clone();
merge_prefs(&mut prefs, &req);
// replaceKV : les valeurs vides ne sont pas gardées.
for (k, v) in &prefs {
let res = if v.is_empty() {
if old.contains_key(k) {
ctx.store.del(BK_PREFS, k).await
} else {
Ok(())
}
} else if old.get(k) != Some(v) {
ctx.store.put(BK_PREFS, k, v.as_bytes().to_vec()).await
} else {
Ok(())
};
if let Err(e) = res {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
}
send_json(StatusCode::OK, &json!({ "ok": true, "prefs": prefs }))
}
"/api/prefs" => match load_prefs(ctx).await {
Ok(p) => send_json(
StatusCode::OK,
&json!({ "ok": true, "prefs": p, "avatars": AVATARS }),
),
Err(_) => send_json(
StatusCode::OK,
&json!({ "ok": true, "prefs": {}, "avatars": AVATARS }),
),
},
"/api/sysprompt" => match parts.method {
Method::GET => send_json(
StatusCode::OK,
&json!({ "ok": true, "text": read_sysprompt(ctx).await }),
),
Method::POST => {
let v = match body_json(body).await {
Ok(v) => v,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
let raw = v.get("text").cloned().unwrap_or(Value::Null);
if let Some(e) = go_type_error("text", &raw) {
return fail(StatusCode::BAD_REQUEST, e);
}
let text = go_trim(raw.as_str().unwrap_or("")).to_string();
let id = active_preset(ctx).await;
let key = if id.is_empty() {
SYSPROMPT_GLOBAL.to_string()
} else {
format!("{SYSPROMPT_PREFIX}{id}")
};
if let Err(e) = put_str(ctx, BK_STATE, &key, &text).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "text": read_sysprompt(ctx).await }),
)
}
_ => fail(StatusCode::METHOD_NOT_ALLOWED, "méthode non autorisée"),
},
"/api/reasoning-effort" => {
if post {
let v = body_json(body).await.unwrap_or(Value::Null);
let v =
go_trim(v.get("value").and_then(Value::as_str).unwrap_or("")).to_lowercase();
if !v.is_empty() && !REASONING_EFFORTS.contains(&v.as_str()) {
return fail(StatusCode::BAD_REQUEST, format!("valeur inconnue : {v}"));
}
if let Err(e) = put_str(ctx, BK_CONFIG, "REASONING_EFFORT", &v).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
}
let cfg = ctx.store.config().await;
let get = |k: &str| cfg.get(k).map(String::as_str).unwrap_or("");
send_json(
StatusCode::OK,
&json!({
"ok": true,
"value": reasoning_effort_value(get("REASONING_EFFORT")),
"on": reasoning_active(get("REASONING")),
"choices": REASONING_EFFORTS,
}),
)
}
"/api/oai/public" if post => {
let v = match body_json(body).await {
Ok(Value::Object(o)) => o,
_ => return fail(StatusCode::BAD_REQUEST, "requête illisible"),
};
let raw = v.get("url").cloned().unwrap_or(Value::Null);
if go_type_error("url", &raw).is_some() {
return fail(StatusCode::BAD_REQUEST, "requête illisible");
}
let clean = match normalize_public_url(raw.as_str().unwrap_or("")) {
Ok(c) => c,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
if let Err(e) = put_str(ctx, BK_STATE, PUBLIC_URL_KEY, &clean).await {
return fail(StatusCode::BAD_REQUEST, e);
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "url": clean, "set": !clean.is_empty() }),
)
}
"/api/oai/public" => {
let u = ctx.store.get_str(BK_STATE, PUBLIC_URL_KEY).await;
send_json(
StatusCode::OK,
&json!({ "ok": true, "url": u, "set": !u.is_empty() }),
)
}
_ => send_json(
StatusCode::NOT_FOUND,
&json!({ "error": "404 page not found" }),
),
}
}
+2
View File
@@ -465,6 +465,7 @@ pub struct Opts {
pub engine_port: Option<u16>,
pub models_hf: Option<String>,
pub dictate: Option<Arc<loki_rs::dictate::Dictation>>,
pub settings: bool,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -556,6 +557,7 @@ pub async fn start_with(
})
}),
dictate: opts.dictate,
settings: opts.settings,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -96,6 +96,7 @@ async fn banc(cle: Option<&str>) -> Banc {
chat: None,
models: None,
dictate: None,
settings: false,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
@@ -0,0 +1,731 @@
{
"public_url": [
{
"in": ""
},
{
"in": " "
},
{
"in": "exemple.fr",
"out": "https://exemple.fr"
},
{
"in": "https://ia.exemple.fr/",
"out": "https://ia.exemple.fr"
},
{
"in": "http://10.0.0.5:8090/v1/",
"out": "http://10.0.0.5:8090"
},
{
"in": "HTTPS://Exemple.FR:443",
"out": "https://Exemple.FR:443"
},
{
"in": "ftp://x.fr",
"err": "adresse en ftp:// — seuls http:// et https:// sont acceptés"
},
{
"in": "https://",
"err": "adresse sans nom de machine"
},
{
"in": "https://moi:secret@x.fr",
"err": "l'adresse ne doit pas porter d'identifiants (moi@…)"
},
{
"in": "https://x.fr/?a=1",
"err": "l'adresse ne doit porter ni paramètre ni ancre"
},
{
"in": "https://x.fr/#ancre",
"err": "l'adresse ne doit porter ni paramètre ni ancre"
},
{
"in": "https://x.fr/api",
"err": "l'adresse ne doit pas contenir de chemin (/api) : Loki sert /v1 à la racine de son port"
},
{
"in": "https://x.fr:0",
"err": "port invalide : 0"
},
{
"in": "https://x.fr:99999",
"err": "port invalide : 99999"
},
{
"in": "https://x.fr:abc",
"err": "adresse illisible : https://x.fr:abc"
},
{
"in": "https://[::1]:8090",
"out": "https://[::1]:8090"
},
{
"in": "https://::1",
"err": "adresse IPv6 à écrire entre crochets : [::1]"
},
{
"in": "https://:8080",
"err": "adresse sans nom de machine"
},
{
"in": "exemple.fr:8090/v1",
"out": "https://exemple.fr:8090"
}
],
"effort_value": {
"": "",
" High ": "high",
"0": "none",
"Default": "",
"NONE": "none",
"auto": "",
"bizarre": "bizarre",
"low": "low",
"medium": "medium",
"off": "none",
"xhigh": "xhigh"
},
"steps": [
{
"method": "GET",
"path": "/api/prefs",
"status": 200,
"out": {
"avatars": [
"🙂",
"😎",
"🧑",
"👤",
"🦊",
"🐱",
"🐼",
"🦉",
"🚀",
"⚡",
"🌙",
"🔥",
"🌿",
"🎯",
"🧠",
"🤖"
],
"ok": true,
"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\":\"💩\"}",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "🦊",
"hide_tools": "1",
"theme": "dark",
"user_name": "Marie"
}
},
"prefs": {
"avatar_user": "🦊",
"hide_tools": "1",
"theme": "dark",
"user_name": "Marie"
}
},
{
"method": "POST",
"path": "/api/prefs",
"body": "{\"user_name\":\"Une très très longue signature éèà\",\"fold_tools\":1,\"hide_side\":\"0\"}",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
{
"method": "POST",
"path": "/api/prefs",
"body": "pas du json",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
{
"method": "POST",
"path": "/api/prefs",
"body": "{\"user_name\":\" \",\"avatar_user\":\"\"}",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": ""
}
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
{
"method": "GET",
"path": "/api/prefs",
"status": 200,
"out": {
"avatars": [
"🙂",
"😎",
"🧑",
"👤",
"🦊",
"🐱",
"🐼",
"🦉",
"🚀",
"⚡",
"🌙",
"🔥",
"🌿",
"🎯",
"🧠",
"🤖"
],
"ok": true,
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
{
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\" Réponds en français. \"}",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":5}",
"status": 400,
"out": {
"error": "json: cannot unmarshal number into Go struct field .text of type string",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "PUT",
"path": "/api/sysprompt",
"status": 405,
"out": {
"error": "méthode non autorisée",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"config": {
"CTX": "8192",
"MODEL": "a.gguf"
},
"preset": {
"alpha": "# Alpha\nMODEL=a.gguf\nCTX=8192\n",
"beta": "# Beta\nMODEL=b.gguf\n"
},
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\"Prompt d'Alpha\"}",
"status": 200,
"out": {
"ok": true,
"text": "Prompt d'Alpha"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Prompt d'Alpha"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"config": {
"MODEL": "b.gguf"
},
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\"\"}",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"config": {
"CTX": "8192",
"MODEL": "a.gguf"
},
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\" \"}",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"config": {
"MODEL": "a.gguf"
},
"method": "GET",
"path": "/api/reasoning-effort",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": false,
"value": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/reasoning-effort",
"body": "{\"value\":\" HIGH \"}",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": false,
"value": "high"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "high",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/reasoning-effort",
"body": "{\"value\":\"auto\"}",
"status": 400,
"out": {
"error": "valeur inconnue : auto",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "high",
"sysprompt": "Réponds en français."
}
},
{
"config": {
"MODEL": "a.gguf",
"REASONING": "on",
"REASONING_EFFORT": "off"
},
"method": "GET",
"path": "/api/reasoning-effort",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": true,
"value": "none"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "off",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/reasoning-effort",
"body": "{\"value\":\"\"}",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": true,
"value": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"config": {
"REASONING": "off",
"REASONING_EFFORT": "bizarre"
},
"method": "GET",
"path": "/api/reasoning-effort",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": false,
"value": "bizarre"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "GET",
"path": "/api/oai/public",
"status": 200,
"out": {
"ok": true,
"set": false,
"url": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "{\"url\":\"ftp://x\"}",
"status": 400,
"out": {
"error": "adresse en ftp:// — seuls http:// et https:// sont acceptés",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "pas du json",
"status": 400,
"out": {
"error": "requête illisible",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "{\"url\":\" ia.exemple.fr/v1 \"}",
"status": 200,
"out": {
"ok": true,
"set": true,
"url": "https://ia.exemple.fr"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"public_url": "https://ia.exemple.fr",
"sysprompt": "Réponds en français."
}
},
{
"method": "GET",
"path": "/api/oai/public",
"status": 200,
"out": {
"ok": true,
"set": true,
"url": "https://ia.exemple.fr"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"public_url": "https://ia.exemple.fr",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "{\"url\":\"\"}",
"status": 200,
"out": {
"ok": true,
"set": false,
"url": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
}
]
}
+1
View File
@@ -102,6 +102,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
chat: None,
models: None,
dictate: None,
settings: false,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
+146
View File
@@ -0,0 +1,146 @@
//! Lot 10 : réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_settings_test.go` →
//! `tests/parity_settings/settings.json`) est rejoué : normalisation de
//! l'adresse publique, intensité du raisonnement, puis 29 appels de route
//! par la façade, avec le contenu de la base après chacun.
mod common;
use std::collections::BTreeMap;
use common::fakego;
use loki_rs::settings;
use serde_json::Value;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_settings/settings.json")).expect("journal")
}
#[test]
fn parite_des_fonctions_pures() {
let fx = fixture();
for c in fx["public_url"].as_array().unwrap() {
let input = c["in"].as_str().unwrap();
match settings::normalize_public_url(input) {
Ok(o) => {
assert!(
c.get("err").is_none(),
"{input:?} : erreur attendue {}",
c["err"]
);
assert_eq!(o, c["out"].as_str().unwrap_or(""), "{input:?}");
}
Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or("<ok attendu>"), "{input:?}"),
}
}
for (v, want) in fx["effort_value"].as_object().unwrap() {
assert_eq!(
loki_rs::chat::wire::reasoning_effort_value(v),
want.as_str().unwrap(),
"{v:?}"
);
}
}
#[tokio::test]
async fn parite_des_routes() {
let fx = fixture();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let opts = fakego::Opts {
settings: true,
..Default::default()
};
let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await;
let keys = [
"sysprompt",
"sysprompt:alpha",
"sysprompt:beta",
"public_url",
];
for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() {
if let Some(cfg) = st.get("config").and_then(Value::as_object) {
let mut s = go.store.lock().unwrap();
let b = s.entry("config".into()).or_default();
b.clear();
for (k, v) in cfg {
b.insert(k.clone(), v.as_str().unwrap().as_bytes().to_vec());
}
}
if let Some(p) = st.get("preset").and_then(Value::as_object) {
std::fs::create_dir_all(home.join("presets")).unwrap();
for (id, content) in p {
std::fs::write(
home.join("presets").join(format!("{id}.env")),
content.as_str().unwrap(),
)
.unwrap();
}
}
let (m, p) = (st["method"].as_str().unwrap(), st["path"].as_str().unwrap());
let body = st.get("body").and_then(Value::as_str).unwrap_or("");
let (code, b) = fakego::send(facade, m, p, &[], body).await;
let ctx = format!("étape {i} : {m} {p} {body}");
assert_eq!(
code.as_u16() as u64,
st["status"].as_u64().unwrap(),
"{ctx}"
);
let got: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(got, st["out"], "{ctx}");
let prefs: BTreeMap<String, String> = go
.store
.lock()
.unwrap()
.get("prefs")
.map(|b| {
b.iter()
.map(|(k, v)| (k.clone(), String::from_utf8_lossy(v).into_owned()))
.collect()
})
.unwrap_or_default();
let want_prefs: BTreeMap<String, String> = serde_json::from_value(
st.get("prefs")
.cloned()
.unwrap_or(Value::Object(Default::default())),
)
.unwrap();
assert_eq!(prefs, want_prefs, "{ctx} (préférences en base)");
let mut state = BTreeMap::new();
for k in keys {
if let Some(v) = go.get("state", k).filter(|v| !v.is_empty()) {
state.insert(k.to_string(), v);
}
}
if let Some(v) = go
.get("config", "REASONING_EFFORT")
.filter(|v| !v.is_empty())
{
state.insert("config:REASONING_EFFORT".into(), v);
}
let want_state: BTreeMap<String, String> = serde_json::from_value(
st.get("state")
.cloned()
.unwrap_or(Value::Object(Default::default())),
)
.unwrap();
assert_eq!(state, want_state, "{ctx} (base)");
}
}
#[tokio::test]
async fn sans_la_variable_les_reglages_restent_au_go() {
let tmp = tempfile::tempdir().unwrap();
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
for p in [
"/api/prefs",
"/api/sysprompt",
"/api/reasoning-effort",
"/api/oai/public",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
}
}