mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Port de backend_strata.go (rust/loki-rs/src/strata) : la machine (cartes par nvidia-smi, Python 3.10+ et venv, RAM totale et disponible, RAM du moteur Strata en marche, place libre), ce qui y tient et la recommandation, la configuration de lancement en clair des modèles installés (placement des experts drop/ram/arena/disk, carte d'aide, réglages du preset), les réglages d'un modèle installé et l'installation (job des moteurs : paquet figé vérifié par SHA-256, installeur lancé et journalisé comme le sinkWriter du Go, preset écrit puis activé par le Go). Tenu seulement avec LOKI_BACKENDS=rust ; le lancement (loki serve) reste au Go, qui relaie les routes du tunnel. Correction du Go : /api/strata/settings décidait « en service » après avoir écrit les nouveaux réglages, donc jamais pour un réglage changé ; décidé désormais avant l'écriture, comme SavePresetApplying. Le port suit. Parité : parity_strata_test.go (crochets de test : release, meminfo, place libre, action de service) tire du Go un journal rejoué par tests/strata.rs ; les tests du Go portés dans strata/tests.rs ; TestFacadeStrata côté Go. Mutations : 71 tuées sur 71. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
403 lines
14 KiB
Go
403 lines
14 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). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa sonde
|
|
// de gabarit.
|
|
func facadeChatBlocker(cfg map[string]string) string {
|
|
switch {
|
|
case projSnapEnabled(cfg):
|
|
return "PROJ_SNAPSHOT"
|
|
case keepTurnImagesOn(cfg):
|
|
return "KEEP_TURN_IMAGES"
|
|
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 ""
|
|
}
|
|
|
|
// 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, chatx.rs). Le reste du mode Code (indice, travaux
|
|
// de fond, outil criteria) reste ici et lit les mêmes clés.
|
|
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,
|
|
// Lot 19 : export, mode Code et critères de la discussion ouverte.
|
|
"/api/chat/export": true,
|
|
"/api/chat/mode": true,
|
|
"/api/chat/criteria": true,
|
|
}
|
|
|
|
// withFacadeChat remet la requête à la façade quand elle tient la
|
|
// conversation — ou les routes des modèles (facade_models.go), la dictée
|
|
// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers
|
|
// de la discussion (facade_files.go), les notifications (facade_push.go), la
|
|
// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du
|
|
// moteur (facade_engine.go), les interrupteurs système (facade_system.go), les
|
|
// projets (facade_projects.go), les serveurs MCP (facade_mcp.go), la mise à
|
|
// jour (facade_update.go), les backends llama.cpp (facade_backends.go), le
|
|
// moteur de l'image (facade_engineimg.go), Strata (facade_strata.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 : "
|
|
case facadeDictateRoutes[path]:
|
|
held, unavailable = facadeDictate, "dictée indisponible : "
|
|
case facadeSettingsRoutes[path]:
|
|
held, unavailable = facadeSettings, "réglages indisponibles : "
|
|
case facadeFilesRoutes[path]:
|
|
held, unavailable = facadeFiles, "fichiers indisponibles : "
|
|
case facadePushRoutes[path]:
|
|
held, unavailable = facadePush, "notifications indisponibles : "
|
|
case facadeMemRoutes[path]:
|
|
held, unavailable = facadeMem, "mémoire indisponible : "
|
|
case facadePresetsRoutes[path]:
|
|
held, unavailable = facadePresets, "presets indisponibles : "
|
|
case facadeEngineRoutes[path]:
|
|
held, unavailable = facadeEngine, "pilotage du moteur indisponible : "
|
|
case facadeSystemRoutes[path]:
|
|
held, unavailable = facadeSystem, "réglages système indisponibles : "
|
|
case facadeProjectsRoutes[path]:
|
|
held, unavailable = facadeProjects, "projets indisponibles : "
|
|
case facadeMCPRoutes[path]:
|
|
held, unavailable = facadeMCP, "serveurs MCP indisponibles : "
|
|
case facadeUpdateRoutes[path]:
|
|
held, unavailable = facadeUpdate, "mise à jour indisponible : "
|
|
case facadeBackendsRoutes[path]:
|
|
held, unavailable = facadeBackends, "moteurs indisponibles : "
|
|
case facadeEngineImageRoutes[path]:
|
|
held, unavailable = facadeEngineImage, "moteur de l'image indisponible : "
|
|
case facadeStrataRoutes[path]:
|
|
held, unavailable = facadeStrata, "Strata indisponible : "
|
|
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()})
|
|
// L'hôte et le schéma du client, pas ceux du relais : la clé de
|
|
// complétion affiche l'adresse à coller dans un client tiers.
|
|
pr.Out.Host = pr.In.Host
|
|
if p := pr.In.Header.Get("X-Forwarded-Proto"); p != "" {
|
|
pr.Out.Header.Set("X-Forwarded-Proto", p)
|
|
}
|
|
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
|
|
}
|
|
|
|
// facadeTplCaps : le dernier verdict de la sonde de gabarit de la façade,
|
|
// quand elle tient la conversation (c'est elle qui sonde après ses
|
|
// complétions).
|
|
func facadeTplCaps() (tplProbeResult, bool) {
|
|
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/chat/tpl", strings.NewReader("{}"))
|
|
if err != nil {
|
|
return tplProbeResult{}, false
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(internalTokenHeader, internalToken())
|
|
resp, err := facadeClient.Do(req)
|
|
if err != nil {
|
|
return tplProbeResult{}, false
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
OK bool `json:"ok"`
|
|
Result tplProbeResult `json:"result"`
|
|
}
|
|
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil || !out.OK {
|
|
return tplProbeResult{}, false
|
|
}
|
|
return out.Result, true
|
|
}
|
|
|
|
// 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
|
|
}
|