Files
Loki/internal/loki/web_engine.go
T
MichaelandClaude Opus 5.5 c24472b91f Moteur : encart « version recommandée », retour à la version précédente, garde du rendu du raisonnement
Le lot 1 savait dire qu'un moteur officiel antérieur à b10864 évince trop tôt
les points de reprise des hybrides, mais laissait l'utilisateur chercher seul
quelle version prendre. Le panneau Moteur propose maintenant la version
recommandée, sur clic seulement, et garde un chemin de retour sans réseau.

La mise à jour elle-même n'est pas neutre pour le prompt : depuis b10763,
llama-server active preserve_reasoning par défaut, et un gabarit qui retirait
la réflexion des tours passés (Qwen3.6, clear_thinking) la rend alors — vide,
puisque Loki ne la renvoie pas sans REASONING_ECHO. Avant la bascule, Loki lit
le gabarit chargé et le verdict de la sonde ; si le rendu va changer, la
confirmation le dit et propose REASONING_PRESERVE=off (case cochée seulement
quand c'est établi : sur Qwen3.8, off changerait à son tour le rendu).
L'utilisateur choisit. Après la bascule, le rendu de conversations
synthétiques est comparé via /apply-template entre l'ancien et le nouveau
moteur, et le premier écart est signalé.

- encart sans réseau (build de confiance < b10864) : gains cités (points de
  reprise, MTP rapide b11009, MTP qwen4exp b11331), SPEC reste off
- POST /api/engine/plan, sur clic : dernier build publié, vérifié ≥ b10864 et
  tag figé existant ; sans réseau, une erreur et rien d'installé
- POST /api/engine/rollback + fichier engine/previous-bin : la version quittée
  (gardée par le ménage du lot 1) ; le retour la rend « précédente » à son tour
- REASONING_PRESERVE=off posée dans le preset actif, seulement si le nouveau
  moteur connaît --no-reasoning-preserve et que la clé est vide
- comparaison du rendu en tâche de fond, après chargement et changement de
  build_info (un ancien moteur survivant ne fausse pas le verdict)
- aucune clé nouvelle ; rien au démarrage ; aucune mise à jour sans clic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:12:19 +02:00

313 lines
11 KiB
Go

// web_engine.go — panneau « Moteur de l'image » : voir la version de llama.cpp
// qui tourne, savoir s'il en existe une plus récente, l'installer, et revenir
// en arrière si elle ne convient pas.
//
// GET /api/engine → version courante, versions installées, variante
// POST /api/engine/check → dernier build publié vs celui qui tourne
// POST /api/engine/update → job : téléchargement + test à blanc + bascule
// POST /api/engine/use → bascule vers une version déjà installée
// POST /api/engine/remove → supprime une version installée
//
// La version recommandée, le retour à la version précédente et la garde du
// rendu des gabarits vivent dans web_engine_reco.go.
//
// Jusqu'ici, mettre à jour llama.cpp imposait de reconstruire l'image de Loki —
// donc un rebuild complet et un redéploiement pour un composant qui publie
// plusieurs versions par jour. Le panneau ne le disait même pas : il annonçait
// « rien à mettre à jour ici ». Ces quatre routes suppriment ce détour.
package loki
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// engineUseImage est la valeur de `tag` qui désigne le moteur d'origine, celui
// que l'image Docker fournit dans /app. C'est le retour arrière garanti : il ne
// dépend d'aucun téléchargement et ne peut pas manquer.
const engineUseImage = "image"
// handleEngineStatus décrit le moteur courant et ce qui est installé à côté.
func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
bin := currentEngineBin()
build, commit := engineBuildOf(bin)
imageBin := providedEngineBin()
source := engineSource(bin)
var installed []map[string]any
for _, tag := range engineInstalled() {
p := engineBinFor(tag)
installed = append(installed, map[string]any{
"tag": tag,
"bin": p,
"build": engineTagBuild(tag),
"in_use": samePath(p, bin),
})
}
sendJSON(w, 200, map[string]any{
"supported": engineOCISupported(),
"variant": engineVariant(),
"repo": ociHost() + "/" + ociEngineRepo,
"bin": bin,
"build": build,
"commit": commit,
"source": source,
"image_bin": imageBin,
"dir": engineDir(),
"installed": installed,
"job": lcJobSnapshot(0, false),
// Avis « moteur trop ancien » : seulement pour un build digne de foi
// (voir engineBuildTrust), jamais pour un compilé ou un fork.
"notice": engineBuildNotice(engineBuildTrust(source, build)),
// L'encart « version recommandée » : décidé sans réseau, sur le même
// build de confiance. Le choix du tag n'a lieu qu'au clic (/plan).
"recommend": engineRecommendation(engineBuildTrust(source, build), engineOCISupported()),
"previous": enginePrevious(bin),
// Rendu du gabarit comparé avant/après la dernière bascule.
"render_check": engineRenderSnapshot(),
})
}
// handleEngineCheck interroge le registre : quel est le dernier build publié
// pour cette variante, et sommes-nous en retard ? Synchrone, quelques secondes.
func handleEngineCheck(w http.ResponseWriter, r *http.Request) {
if !engineOCISupported() {
sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
return
}
variant := engineVariant()
build, tag, err := engineOCILatest(variant)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
cur, _ := engineBuildOf(currentEngineBin())
sendJSON(w, 200, map[string]any{
"ok": true,
"variant": variant,
"latest": build,
"tag": tag,
"current": cur,
// Un build courant inconnu (0) ne prouve pas qu'on est à jour : on
// propose alors la mise à jour plutôt que d'affirmer le contraire.
"update": build == 0 || cur == 0 || build > cur,
})
}
// handleEngineUpdate lance le job de mise à jour. {tag} permet d'épingler une
// version précise ; sans lui, on prend la dernière publiée pour la variante.
// {preserve_off:true} : l'utilisateur a choisi de poser REASONING_PRESERVE=off
// avant la bascule (voir reasoningPreserveRisk).
func handleEngineUpdate(w http.ResponseWriter, r *http.Request) {
if !engineOCISupported() {
sendJSON(w, 400, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
return
}
var req struct {
Tag string `json:"tag"`
PreserveOff bool `json:"preserve_off"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := strings.TrimSpace(req.Tag)
if err := startLcJob("engine", func() { engineRunUpdate(tag, req.PreserveOff) }); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
func engineUnsupportedWhy() string {
return "les images officielles llama.cpp n'existent que pour Linux x86-64 et arm64 ; " +
"sur cette machine, utilise « llama.cpp précompilé » ou « llama.cpp compilé »."
}
// engineRunUpdate : corps du job. Télécharger, ESSAYER, puis seulement basculer.
// L'ordre compte — le seul vrai risque ici est un moteur plus récent que le
// runtime CUDA de l'image, et on le découvre au test à blanc, moteur courant
// encore intact.
func engineRunUpdate(tag string, preserveOff bool) {
variant := engineVariant()
ancien := currentEngineBin()
if tag == "" {
lcPhase("recherche de la dernière version publiée…")
build, latest, err := engineOCILatest(variant)
if err != nil {
lcFail(err)
return
}
tag = latest
if build > 0 {
if cur, _ := engineBuildOf(currentEngineBin()); cur == build {
lcDone(fmt.Sprintf("déjà à jour (build %d)", build))
return
}
}
}
lcAppend("variante : " + variant + " — version visée : " + tag)
bin, err := engineOCIInstall(tag, lcAppend, lcPhase)
if err != nil {
lcFail(err)
return
}
lcPhase("essai du nouveau moteur…")
if err := engineSmokeTest(bin, ancien); err != nil {
// On ne garde pas un moteur inutilisable : il encombrerait la liste et
// inviterait à le sélectionner.
_ = os.RemoveAll(filepath.Dir(bin))
lcFail(fmt.Errorf("%w\n\nLe moteur courant n'a PAS été touché. Cette version demande sans doute un runtime CUDA plus récent que celui de l'image : reconstruis l'image avec un LLAMACPP_IMAGE récent, ou épingle un build antérieur", err))
return
}
newBuild, commit := engineBuildOf(bin)
if newBuild > 0 {
lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", newBuild, commit))
}
// Rendu du raisonnement (b10763) : la décision vient de l'utilisateur,
// prise avant le clic ; ici on l'applique, ou on rappelle l'avertissement.
if preserveOff {
msg, err := enginePreserveOff(bin)
if err != nil {
lcFail(fmt.Errorf("REASONING_PRESERVE=off demandée mais pas posée : %w — moteur courant inchangé ; le nouveau reste installé", err))
return
}
lcAppend(msg)
} else {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
risk := reasoningPreserveRisk(enginePreserveInputs(ctx, newBuild))
cancel()
if risk.Risk != "no" {
lcAppend("[warn] rendu du raisonnement : " + risk.Why)
}
}
// Rendu de référence sur le moteur qui tourne encore, pour comparer après.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ref, haveBefore := engineRenderBefore(ctx)
cancel()
if err := engineSwitchTo(bin); err != nil {
lcFail(err)
return
}
engineRememberPrevious(ancien, bin)
enginePrune(engineKeepAfterUpdate(bin, ancien, lcAppend), lcAppend)
if haveBefore {
lcAppend("vérification du rendu du gabarit une fois le modèle chargé (panneau Moteur, journal [moteur])")
go engineRenderAfter(ref, tag)
}
lcDone("moteur mis à jour (" + tag + ")")
}
// engineKeepAfterUpdate : les versions téléchargées que le ménage épargne après
// une bascule. La nouvelle, et celle qui tournait juste avant si c'en était une
// : le moteur de l'image n'est pas forcément l'ancien build (il dépend de
// LLAMACPP_IMAGE), et revenir sur la version d'hier ne doit demander ni réseau
// ni que le tag soit encore publié. Les plus anciennes partent (~450 Mo chacune).
func engineKeepAfterUpdate(bin, ancien string, logf func(string)) map[string]bool {
keep := map[string]bool{filepath.Base(filepath.Dir(bin)): true}
if prev := engineTagOf(ancien); prev != "" && !keep[prev] {
keep[prev] = true
if logf != nil {
logf("version précédente gardée pour revenir en arrière : " + prev)
}
}
return keep
}
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
func engineSwitchTo(bin string) error {
if err := tuneGuard(); err != nil {
return err
}
wasUp := serviceIsActive()
if wasUp {
lcPhase("arrêt du moteur…")
if err := serviceAction("stop"); err != nil {
lcAppend("[warn] arrêt du moteur : " + err.Error())
}
}
if err := SetConfigKey("BIN", bin); err != nil {
if wasUp {
_ = serviceAction("start")
}
return fmt.Errorf("moteur installé mais échec d'écriture de BIN : %w", err)
}
lcAppend("BIN → " + bin)
if wasUp {
lcPhase("redémarrage du moteur…")
if err := serviceAction("start"); err != nil {
lcAppend("[warn] redémarrage du moteur : " + err.Error())
}
}
return nil
}
// handleEngineUse bascule vers une version DÉJÀ installée, ou revient au moteur
// de l'image ({tag:"image"}). Aucun réseau : c'est la sortie de secours quand une
// mise à jour s'avère décevante.
func handleEngineUse(w http.ResponseWriter, r *http.Request) {
if tuneDenyHTTP(w) {
return
}
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := strings.TrimSpace(req.Tag)
var bin string
if tag == engineUseImage {
if bin = providedEngineBin(); bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "cette installation n'a pas de moteur d'image (LOKI_ENGINE_BIN)"})
return
}
} else if bin = engineBinFor(tag); bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
return
}
left := currentEngineBin()
if err := SetConfigKey("BIN", bin); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
engineRememberPrevious(left, bin)
if serviceIsActive() {
_ = serviceAction("restart")
}
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
}
// handleEngineRemove supprime une version téléchargée. On refuse de retirer
// celle qui tourne : ça laisserait une configuration pointant dans le vide, et
// le moteur ne redémarrerait plus.
func handleEngineRemove(w http.ResponseWriter, r *http.Request) {
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := filepath.Base(strings.TrimSpace(req.Tag))
bin := engineBinFor(tag)
if bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
return
}
if samePath(bin, currentEngineBin()) {
sendJSON(w, 409, map[string]any{"ok": false, "error": "c'est le moteur utilisé — bascule d'abord sur une autre version"})
return
}
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}