Files
Loki/internal/loki/facade_chat.go
T
Claude a33e5ccffe Lot 8 : toutes les routes des modèles tenues par la façade (LOKI_MODELS=rust)
La variable LOKI_DOWNLOADS devient LOKI_MODELS et couvre, en plus des
téléchargements, la liste du sélecteur (avec qui référence chaque .gguf),
la suppression (refus si en service, 409 avec les presets sans force,
tranches emportées), les dossiers de modèles et le catalogue Hugging Face
(recherche, fichiers d'un dépôt avec verdict mémoire, jeton vérifié puis
enregistré, cache vidé à son changement).

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

Parité : parity_models_test.go tire du Go les fonctions pures du catalogue
et un scénario sur disque de 20 appels (liste, dossiers, suppressions),
rejoués à l'identique par tests/models.rs ; catalogue et routes testés de
bout en bout contre un faux Hugging Face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 13:25:45 +00:00

343 lines
11 KiB
Go

package loki
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"time"
)
// facade_chat.go — conversation tenue par la façade Rust (LOKI_CHAT=rust, lot 6).
//
// La façade sert elle-même les routes du chat sur le port public. Deux chemins
// arrivent pourtant ICI sans passer par elle : le tunnel du relais, qui sert ce
// mux nu (/api/e2e/*), et sa redistribution /api/e2e/req dans le mux local. Ils
// liraient la conversation du Go — périmée — et pourraient même l'écrire en
// base par-dessus celle de la façade. Quand la façade tient la conversation :
//
// - les routes du chat lui sont remises, authentifiées par le jeton interne
// (la requête l'a déjà été ici) ;
// - /api/e2e/chat lit son flux et le rechiffre événement par événement ;
// - les événements d'affichage nés ici (bascule du mode Code, critères) vont
// dans SON journal ;
// - l'export lit la discussion enregistrée ;
// - le déverrouillage de la mémoire lui fait recharger la discussion ;
// - changer de projet, ou en supprimer un, passe par elle : la discussion
// de ce process est périmée, l'enregistrer écraserait la sienne.
const (
facadeChatEnv = "LOKI_CHAT"
facadeAddrEnv = "LOKI_FACADE_ADDR"
)
// facadeChat : la façade tient-elle la conversation ? Il faut aussi son
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et
// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker).
func facadeChat() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == ""
}
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
// figé, images gardées, slots du moteur, préchauffage, motifs de relance
// personnalisés).
func facadeChatBlocker(cfg map[string]string) string {
switch {
case projSnapEnabled(cfg):
return "PROJ_SNAPSHOT"
case keepTurnImagesOn(cfg):
return "KEEP_TURN_IMAGES"
case cfgOn(cfg, "NUDGE_IN_TOOL"):
return "NUDGE_IN_TOOL"
case sideSlotOn(cfg):
return "SIDE_SLOT"
case slotPersistOn(cfg):
return "SLOT_PERSIST"
case prewarmMode(cfg) != "":
return "PREWARM"
case strings.TrimSpace(getStr(bkState, "retry_patterns")) != "":
return "retry_patterns"
}
return ""
}
// cfgOn : une clé booléenne posée (mêmes valeurs que les autres clés opt-in).
func cfgOn(cfg map[string]string, k string) bool {
switch strings.ToLower(strings.TrimSpace(cfg[k])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// Décision prise UNE fois par process : passer la conversation d'un détenteur
// à l'autre en cours de route laisserait chacun avec sa copie — un réglage
// changé vaut au redémarrage suivant. La façade la lit au démarrage
// (/internal/v1/chat/holder) et s'y range.
var (
facadeDecisionMu sync.Mutex
facadeDecisionDone bool
facadeDecisionWhy string
)
func facadeChatDecision() string {
facadeDecisionMu.Lock()
defer facadeDecisionMu.Unlock()
if !facadeDecisionDone {
facadeDecisionWhy = facadeChatBlocker(ReadConfig())
facadeDecisionDone = true
if facadeDecisionWhy != "" {
fmt.Fprintf(os.Stderr, "[chat] %s posé : la conversation reste tenue par le Go (LOKI_CHAT=rust ignoré)\n", facadeDecisionWhy)
}
}
return facadeDecisionWhy
}
// handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou
// {"holder":"go","reason":…}). La façade s'y range au démarrage.
func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
return
}
if facadeChat() {
sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"})
return
}
why := facadeChatDecision()
if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") {
why = facadeChatEnv + " absent"
}
sendJSON(w, http.StatusOK, map[string]any{"holder": "go", "reason": why})
}
func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) }
// facadeChatRoutes : les routes que la façade sert quand elle tient la
// conversation (chatapi.rs). Le reste (mode Code, critères, pièces jointes,
// fichiers, export) reste ici.
var facadeChatRoutes = map[string]bool{
"/api/chat": true,
"/api/chat/send": true,
"/api/chat/stop": true,
"/api/chat/reset": true,
"/api/chat/state": true,
"/api/chat/compact": true,
"/api/chat/tool-result": true,
"/api/conversations": true,
"/api/conversations/new": true,
"/api/conversations/switch": true,
"/api/conversations/rename": true,
"/api/conversations/delete": true,
}
// withFacadeChat remet la requête à la façade quand elle tient la
// conversation — ou les routes des modèles (facade_models.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 {
held, unavailable := facadeChat, "conversation indisponible : "
switch {
case facadeChatRoutes[path]:
case facadeModelRoutes[path]:
held, unavailable = facadeModels, "modèles indisponibles : "
default:
return h
}
return func(w http.ResponseWriter, r *http.Request) {
if !held() {
h(w, r)
return
}
facadeRelayAs(w, r, unavailable)
}
}
// facadeRelayAs : relais transparent (flux compris) vers la façade ;
// unavailable préfixe l'erreur si elle ne répond pas.
func facadeRelayAs(w http.ResponseWriter, r *http.Request, unavailable string) {
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: facadeAddr()})
pr.Out.Header.Set(internalTokenHeader, internalToken())
},
FlushInterval: -1, // SSE : chaque trame part aussitôt
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": unavailable + err.Error()})
},
}
rp.ServeHTTP(w, r)
}
// facadeClient : appels courts vers l'API interne de la façade.
var facadeClient = &http.Client{Timeout: 5 * time.Second}
// facadePost appelle l'API interne de la façade.
func facadePost(path string, body any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("façade : %s", resp.Status)
}
return nil
}
// facadeError : refus de la façade, avec son statut (409 : un tour tourne).
type facadeError struct {
status int
msg string
}
func (e *facadeError) Error() string { return e.msg }
// facadeCall appelle l'API interne de la façade et rend son refus éventuel
// tel quel (statut et message), pour les actions dont l'utilisateur attend
// la réponse.
func facadeCall(path string, body any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
// Délai large : enregistrer une discussion et en ouvrir une autre passe
// par le disque (et par le chiffrement).
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return fmt.Errorf("conversation indisponible : %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 300 {
return nil
}
var out struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
if out.Error == "" {
out.Error = "façade : " + resp.Status
}
return &facadeError{status: resp.StatusCode, msg: out.Error}
}
// facadeProjectSwitch : la façade enregistre la discussion du projet quitté
// (avec son projet), fait changer le projet actif ici
// (/internal/v1/project/activate), puis ouvre une discussion du nouveau.
func facadeProjectSwitch(slug string) error {
return facadeCall("/internal/facade/v1/project/switch", map[string]any{"slug": slug})
}
// facadeDropProject : la façade supprime les discussions d'un projet (index,
// contenu, et ce qui les entoure ici par /internal/v1/conv/dropped).
func facadeDropProject(slug string) error {
return facadeCall("/internal/facade/v1/project/drop", map[string]any{"slug": slug})
}
// facadeDelta : un événement d'affichage né ici, journalisé par la façade
// (tous les appareils le reçoivent). Rend false si la façade ne tient pas la
// conversation : l'appelant journalise alors comme avant.
func facadeDelta(delta map[string]any) bool {
if !facadeChat() {
return false
}
if err := facadePost("/internal/facade/v1/delta", delta); err != nil {
fmt.Fprintf(os.Stderr, "[chat] événement non transmis à la façade : %v\n", err)
}
return true
}
// facadeReload : la mémoire vient d'être déverrouillée ; la façade recharge la
// discussion si elle l'avait chargée vide.
func facadeReload() {
if !facadeChat() {
return
}
go func() {
if err := facadePost("/internal/facade/v1/reload", map[string]any{}); err != nil {
fmt.Fprintf(os.Stderr, "[chat] rechargement de la discussion par la façade : %v\n", err)
}
}()
}
// facadeChatStream : /api/e2e/chat quand la façade tient la conversation. Son
// flux est lu ligne par ligne et chaque événement repart par out (scellé, un
// sceau par événement). Les battements de la façade sont ignorés : ce flux a
// les siens (sseHeartbeat).
func facadeChatStream(ctx context.Context, body []byte, out *sseStream) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+facadeAddr()+"/api/chat", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := http.DefaultClient.Do(req) // sans délai : un flux dure ce qu'il dure
if err != nil {
if ctx.Err() == nil {
out.emit(map[string]any{"error": "conversation indisponible : " + err.Error()})
}
return
}
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64<<10), 64<<20)
for sc.Scan() {
data, ok := strings.CutPrefix(sc.Text(), "data: ")
if !ok {
continue
}
var ev struct {
Choices []struct {
Delta map[string]any `json:"delta"`
} `json:"choices"`
}
dec := json.NewDecoder(strings.NewReader(data))
dec.UseNumber() // les nombres repartent tels quels
if dec.Decode(&ev) != nil || len(ev.Choices) == 0 {
continue
}
if !out.emit(ev.Choices[0].Delta) {
return
}
}
}
// exportSource : la conversation à exporter. Tenue par la façade, celle du Go
// est périmée : on lit la discussion enregistrée (à jour à chaque fin de tour).
func exportSource() *Conversation {
if !facadeChat() {
return conv
}
c := &Conversation{}
c.cond = sync.NewCond(&c.mu)
if b := storeBytes(bkChat, convKey(convEnsureActive())); len(b) > 0 {
_ = json.Unmarshal(b, c)
}
return c
}